【Linux】进程(四)——进程虚拟地址空间
【Linux】进程(四)——进程虚拟地址空间
本文的知识点目前只是讲个大框架,在以后的文章中会反复进行深入讲解
在学习的过程中,我们总会产生一些疑惑:为什么堆内存地址向上增长、栈内存向下增长?普通变量函数结束就销毁,全局变量和static变量却能一直生效?字符串常量一碰修改就直接崩溃?
所有这些看似矛盾、难以理解的现象,根源全部指向一个非常重要的知识点:进程虚拟地址空间
我之前一直把这一块空间叫做程序内存分区,这其实是误区!根本没有程序内存分区/程序地址空间这一说法。程序只是磁盘上静态的普通文件,只有程序运行起来变成进程后,操作系统才会为会给它分配专属的逻辑空间,准确的叫法只有两个:进程地址空间/虚拟地址空间
之前的误解其实是当时知识掌握得不够,为了理解内容自己找补出来的说法,但是没关系,学习就是不断修正认知得过程。我们现在对进程有了一定的认识,可以重新来好好认识进程地址空间(虚拟地址空间)了
这个是我之前写的博客,叫程序内存分区是错误的,应该叫进程虚拟地址空间分区,之前那片文章只适合前期学习
文章目录
一、进程虚拟地址空间到底是啥?
1、程序VS进程VS物理地址VS虚拟地址
- 程序:存放在磁盘上的静态文件,无内存占用、不参与 CPU 调度。不运行的话,只是一堆代码和数据的集合。本身没有任何地址划分
- 进程:程序加载运行后的动态实体,拥有唯一PID,操作系统给它分配独立资源由操作系统管理,虚拟地址空间就是进程专属内存布局
- 物理地址:是真实的硬件地址,只有操作系统和硬件(MMU)才能碰,用户代码根本看不到也改不了
- 虚拟地址:这是操作系统层面的概念,是操作系统给每个进程虚构出的逻辑地址。我们代码中打印、使用的所有地址(如&a,malloc返回的指针),全部都是虚拟地址,和真实物理内存地址无关
2、直观查看虚拟地址空间的布局
下面这段代码,可以完整打印出进程内所有变量、代码、参数的虚拟地址

我们可以看到运行结果
原来低地址——>高地址,顺序是这样的:
-
正文代码段
存放程序的二进制指令,只读(防止代码被篡改),地址最低,对应图中的序号1 -
只读常量区
存放字符串常量、全局const常量等,紧跟代码段后面,也是只读,对应序号2 -
初始化数据段(.data)
存放已经初始化的全局变量,static静态变量。只要进程不退出,这片区域会持续存在。对应序号3 -
未初始化数据段(.bss)
存放未初始化的全局变量,进程启动时自动清零,只要把这个变量打印出来就会打印0,对应序号4 -
堆区(Heap)
malloc/new动态申请的内存在这里,地址由低向高向上增长
(越申请地址会越来越大),对应序号5 -
共享库/共享区
存放系统共享库(比如libc)的代码和数据,我并没有打印,它在堆和栈之间 -
栈区(Stack)
存放局部变量、函数栈帧,函数返回地址,地址由高向低向下增长
(越申请地址越来越小),对应序号6。函数执行完毕栈帧自动销毁,所以局部变量函数结束直接消失。 -
命令行参数&环境变量区
进程地址空间的最顶端,存放argv、env环境参数是整个用户空间地址数值最高的区域,对应下面输出的argv[0]、env[0] 到 env[24]

由此画出虚拟地址空间分布图

3、什么是虚拟地址空间
虚拟地址空间,是操作系统为每个进程分配的独立逻辑地址空间,依靠页表完成虚拟地址与物理内存的映射,以此实现进程内存隔离、统一管理内存
我们来打个比方:操作系统为每一个进程单独租了一间独立的“超大公寓”,这间公寓就是虚拟地址空间。对进程来说,它以为自己独占了一整间完整公寓,所有地址都是自己的,不受任何人干扰。但实际上,这间公寓是操作系统“画出来的”虚拟空间,不是真实物理内存
真实的物理内存,是楼下统一的“物理仓库”,所有进程共享。
操作系统通过页表,把每个进程的虚拟公寓房间号(虚拟地址),映射到物理仓库的真实位置(物理地址)
这么做的好处是隐藏物理内存、隔离进程、保护系统安全,避免进程随意篡改物理内存数据

如上图:
- 进程操作的全部是虚拟地址
- 依靠进程自带的页表,把虚拟地址转换为真实物理内存地址
- 程序代码只有需要用的时候,才会从磁盘加载进物理内存
如何理解堆、栈的动态变化呢?
整个虚拟公寓的区域划分是固定的,但内部可用空间是动态的
- 堆区:公寓中间的空闲区域,需要内存就向上“拓展房间”,malloc就是申请新房间,free就是退还房间
- 栈区:公寓顶层的固定区域,函数调用就向下开辟栈帧房间,函数结束就自动回收房间
所谓内存动态变化,本质就是操作系统动态调整虚拟地址空间的映射范围
栈从高处往下,堆从低处往上,中间留出一大片空闲虚拟地址区间,这么设计的好处:
1.充分利用虚拟地址空间,栈、堆各自按需向中间拓展
2.中间的空洞形成隔离,正常情况下栈和堆不会互相覆盖,只有栈溢出时才会发生碰撞,触发程序崩溃
二、虚拟地址空间详细分析
1、为什么fork父子进程地址相同、数值不同?
我们来运行下面的代码

神奇的事情发生了:
父子进程中,&g_val 的地址完全一致,但子进程不断修改 g_val(数值持续递增),父进程 g_val 始终保持初始值100。
变量内容不一样,说明父子进程输出的变量绝对不是同一个变量,但地址值是一样的,说明我们看到的是虚拟地址!
同一个物理地址,不可能同时存储两个不同的数值:由此证明:代码中所有打印、使用的地址(比如 &g_val),全部都是虚拟地址,不是真实的物理内存地址。物理地址由操作系统完全隐藏、统一管理,用户程序根本看不到
虽然父子进程虚拟地址相同,但映射到了不同的物理内存,所以值互不影响
2、底层原理:写时拷贝机制
Linux创建子进程,不会直接复制物理内存数据,而是优先浅拷贝:
-
子进程创建时,先拷贝父进程的PCB(task_struct)、虚拟地址空间(mm_struct)、页表,此时父子进程虚拟地址空间完全一致,共享同一块物理内存数据,节省内存开销
-
但操作系统有一套严格的规则:父子进程共享的数据,只读不写,一旦修改,就会触发拷贝。在上面展示的进程中,当子进程执行g_val++尝试修改数据时,就会触发写保护异常。
-
写时拷贝:操作系统立即为子进程单独开辟一块新物理内存,拷贝原有数据;同时更新子进程页表映射,相同的虚拟地址,映射到全新的物理内存上
最终实现虚拟地址一模一样,物理地址完全不同,实现进程数据独立
fork后父子共享数据,此时数据是互通的、不独立的
只有发生写时拷贝后,两个进程的数据才彻底隔离、真正独立。
只读状态下共享数据,修改状态下拷贝数据,兼顾效率与独立性

3、页表和MMU
我们前面提到了操作系统通过页表,把每个进程的虚拟地址和物理地址连接起来
-
那么什么是页表呢?
页表是每进程都有的独立映射表,记录着:虚拟地址 → 物理地址 的一一对应关系,同时记录内存权限(读、写、执行)。每个进程都有自己的页表和自己的虚拟地址空间 -
谁来查表?
查表工作不由软件、代码完成,由 CPU 硬件 MMU(内存管理单元) 自动完成,速度极快,用户是感知不到的 -
为什么代码段、字符串常量不可修改?
代码段、字符串常量区之所以不能修改,根本不是代码限制,而是页表限制
因为代码段、只读常量区对应的页表项,被操作系统统一设置为只读。当代码尝试写入这个区域数据,MMU硬件检测到写入操作和页面只读权限冲突,就会直接拦截程序,触发段错误 SIGSEGV 崩溃
本质不是 C 语言语法限制,而是操作系统通过页表硬件管控进程行为,防止进程篡改指令、常量,保障系统内存安全
4、内核底层结构:mm_struct
Linux内核中,struct mm_struct结构体专门用来完整描述一个进程的虚拟地址空间,每个进程的 PCB(task_struct)内部都存在一个*mm指针指向该结构体
mm_struct 里记录了虚拟地址空间的所有信息:
- 代码段、数据段、堆、栈等所有分区的的起始 / 结束地址
- 页表指针、所有页面的读写执行权限
- 共享库映射区域、文件 mmap 映射相关信息等
5、一些现象解释
(1) 内存挂起与swap交换区
当物理内存不足时,操作系统会将暂时不运行的进程数据,从物理内存转移到磁盘的Swap交换区
需要注意的是:
转移的只是物理内存里的数据,虚拟地址空间始终不变,只是映射的物理内存数据被暂存到磁盘。
当进程再次访问这片虚拟地址时,操作系统再把数据从磁盘换回物理内存
(2) Malloc/new底层本质:懒加载机制
我们调用 malloc、new 申请堆内存时,并不是直接占用物理内存,而是在进程虚拟地址空间的堆区,申请一段虚拟地址空间。
只有当我们真正读写这片虚拟内存时,才会触发缺页中断,操作系统才分配真实物理内存建立映射
-
懒加载机制:
进程启动时,进程刚创建时,仅构建 PCB、mm_struct、空页表,磁盘上的代码、常量、全局数据不会一次性全部加载进物理内存。只有进程首次访问对应虚拟地址,触发缺页中断,操作系统才将磁盘数据加载到物理内存。这么做可以大大提升进程启动速度,节省物理内存 -
懒加载的实际应用:
一款上百 G 的大型游戏,不需要启动时把全部资源加载进内存,仅加载启动界面、基础逻辑代码;切换地图、场景时动态加载对应资源,仅占用几 G 物理内存即可正常运行
6、设计虚拟地址空间的目的
- 保障内存安全,管控进程行为
借助页表的权限位,操作系统可以拦截进程的非法读写。不允许进程随意修改程序代码、只读常量,也禁止越界访问其他进程的内存。就算某一个进程崩溃,也只会销毁该进程自己的资源,不会造成整个操作系统宕机 - 解耦进程逻辑与物理内存
进程只使用规整连续的虚拟地址空间,完全感知不到物理内存的实际布局。物理内存的分配、回收、碎片整理全部交由操作系统处理
虚拟地址连续,并不要求对应的物理内存也连续。操作系统可以把内存中零散的物理碎片,映射成一块连续的虚拟地址给到进程,充分利用内存资源 - 懒加载,提升内存利用率与启动速度
程序刚开始运行时,不需要把全部代码和数据一次性加载到物理内存,采用按需加载的策略。只有当进程真正访问某块内存时,才分配对应的物理内存。既加快了程序启动速度,又避免内存被无效数据占用 - 完成进程之间的内存隔离
每一个进程都拥有属于自己的虚拟地址空间以及独立页表。结合 fork 的写时拷贝机制,各个进程的数据互相隔离,一个进程无法读取、篡改另一个进程的数据
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)