一、程序地址空间
如下,是我们在 C/C++ 学习时期所看到的程序地址空间布局情况。今天我们要更进一步的细化这个布局,方便我们后续的解释。
先看第一张图,它是最经典的"教科书版"布局:从低地址到高地址依次是代码段、已初始化数据段、未初始化数据段、堆、共享区、栈,最顶部是内核空间。这张图能帮我们建立一个整体印象,但它其实做了大量简化,很多细节被"合并"掉了。
再看第二张图,这是细化之后的版本。相比第一张图,它主要做了三处调整:
- 把原来笼统的"数据段"拆成了初始化数据区域和未初始化数据区域两部分,因为这两块在可执行文件中的存储方式和加载行为完全不同;
- 在栈区域的上面单独划分出了命令行参数和环境变量区域,它们由操作系统在程序启动时压入栈顶附近;
- 明确了栈和堆之间那一大块空白区域是共享区,动态库、mmap 映射的内存都落在这里。
至于最顶部的内核空间,它属于操作系统自己使用,用户态代码无法直接访问,本文不做展开。
为什么要先看这两张图?因为后面所有关于"虚拟地址"“页表”"写时拷贝"的讨论,都建立在这个布局之上。只有先把每一块区域"存什么、在哪、怎么变"搞清楚,后面才不会觉得抽象。
1.1 代码段与只读数据段
先抛出三个问题:
- 常量字符串(
char *str = "hello";)存在哪里? - 函数体编译出来的机器指令存在哪里?
- 为什么修改常量字符串会直接崩溃(段错误)?
常量字符串在编译的时候是被硬编码成为只读数据段,只读数据段和代码被放在了一个区域,所以大家都在代码段这个内存区域,但是进一步细分,只读数据段应该在代码段的上面。
这里再展开几点,帮助理解"代码段"这个区域:
- 代码段(.text):存放的是编译器把源码翻译出来的机器指令,以及函数入口地址。它是只读的,因为程序运行期间指令不应该被改写;同时它通常可以被多个进程共享,比如同一个可执行文件被启动多次,物理内存里的代码只有一份;
- 只读数据段(.rodata):存放字符串字面量、
const修饰的全局变量、以及编译器生成的常量表(如 switch 跳转表)。它同样只读,所以和代码段被放在相邻的位置,共享同一套只读权限; - 为什么修改会崩溃:
char *str = "hello"; str[0] = 'H';这行代码编译能通过,但运行时页表会把这一页标记为只读,写入操作会触发保护异常,最终表现为 Segmentation Fault。这也是为什么推荐写成const char *str = "hello";,让编译器提前帮你拦住这类错误。
一句话总结:代码段和只读数据段是"只读兄弟",一个放指令,一个放常量,物理上相邻,权限上一致。
1.2 初始化数据区域和未初始化数据区域
1. 已初始化数据区(.data 段)
- 全局变量:
int g_init = 123; - 静态全局变量:
static int sg_init = 1; - 静态局部变量:
static int s_init = 456;
存什么:存储在代码中明确赋了初值的全局变量和静态变量。
特点:这些初始值直接存储在可执行二进制文件中,程序加载时直接读入内存。
补充两点容易忽略的细节:
- 静态局部变量也在 .data:虽然它写在函数内部,但
static让它拥有静态存储期,生命周期贯穿整个程序,所以它不会随函数调用结束而销毁,自然也不能放在栈上; - 初值为 0 的变量会被"优化"到 .bss:如果你写
int g = 0;,编译器通常不会把它放进 .data,而是当作未初始化处理,因为存一堆 0 太浪费磁盘空间了。
2. 未初始化数据区(.bss 段)
存什么:存储在代码中没有赋初值(或赋初值为 0)的全局变量和静态变量。
特点:在可执行文件中不占据实际磁盘空间(只记录大小)。程序启动后,操作系统会自动将这块内存全部清零。
1.3 程序验证
#include<stdio.h>#include<stdlib.h>// 全局:初始化 → data段intg_init=123;// 全局:未初始化 → bss段intg_uninit;voidtest(void){// 静态局部:初始化 → datastaticints_init=456;// 静态局部:未初始化 → bssstaticints_uninit;// 普通局部自动变量 → 栈intstack_var=789;// 常量字符串字面量 → rodatachar*ro_str="const string";printf("【静态局部 初始化 data】 &s_init = %p\n",&s_init);printf("【静态局部 未初始化 bss】 &s_uninit = %p\n",&s_uninit);printf("【栈 stack】 &stack_var= %p\n",&stack_var);printf("【rodata 常量字符串】 ro_str = %p\n",ro_str);}intmain(void){void*heap_p=malloc(64);printf("【text 代码段】 test = %p\n",test);printf("【全局 初始化 data】 &g_init = %p\n",&g_init);printf("【全局 未初始化 bss】 &g_uninit = %p\n",&g_uninit);printf("【堆 heap】 heap_p = %p\n",heap_p);printf("\n");test();free(heap_p);return0;}1.4 问题
下面这个代码,我们在进程父子关系中提到过,同一个地址映射出两个不同的值,这就证明了:虚拟地址空间不是物理内存。
voidtest2(){pid_tid=fork();if(id==0){while(1){printf("子: gval: %d, &gval: %p, pid: %d, ppid: %d\n",g_init,&g_init,getpid(),getppid());sleep(1);g_init++;}}else{while(1){printf("父: gval: %d, &gval: %p, pid: %d, ppid: %d\n",g_init,&g_init,getpid(),getppid());sleep(1);}}}- 变量值不同,所以父子进程输出的变量绝对不是同一个变量;
- 但地址值是一样的,说明该地址绝对不是物理地址;
- 在 Linux 地址下,这种地址叫做虚拟地址;
- 我们在用 C/C++ 语言所看到的地址,全部都是虚拟地址!物理地址,用户一概看不到,由 OS 统一管理;
- OS 必须负责将虚拟地址转化成物理地址。
二、什么是进程地址空间
2.1 例子
究竟什么是进程地址空间?我举一个例子:
一个身价 10 个亿的大富翁,他有 4 个私生子。这 4 个私生子彼此之间互不相识,都以为自己是独生子。富翁对每个私生子都画了一个大饼:"儿子,我这 10 个亿的资产未来全是你的!"于是,每个私生子都认为自己拥有 10 个亿。当私生子 A 想向富翁要 50 万买车时,富翁随手就给了他;私生子 B 想要 100 万创业,富翁也满足了。
2.2 进程地址空间的概念
在上面的故事中,大富翁就是操作系统(OS),他手里的 10 亿资产就是物理内存。而那 4 个私生子就是操作系统中的一个个进程,富翁给他们画的大饼就是"虚拟地址空间"。
操作系统通过这种方式,让每一个进程都认为自己独占了整个系统的物理内存资源(在 32 位机器下就是 4GB)。进程可以任意规划它的内存分配(代码、栈、堆等布局),但实际上,只有当它真正需要物理内存时,操作系统才通过某种映射机制,在物理内存上切出来给他们。
2.3 进程的独立性
操作系统为什么要费尽心机搞这么一套虚拟地址空间呢?核心原因在于保证进程的独立性。
- 内核数据结构独立:每个进程都有自己专属的
task_struct等等; - 代码和数据独立:通过虚拟地址到物理地址的映射,不同进程的代码和数据被隔离开,加载进物理内存的不同区域。一个进程崩溃、乱写内存,绝对不会影响到另一个进程。
三、地址空间是如何被管理的
3.1 先描述,再组织和 mm_struct(内存描述符)
大富翁给私生子画的大饼,总不能只是口头说说,他必须在自己的本子上记录下每个私生子的情况。同样的,虚拟地址空间虽然是"虚拟"的,但操作系统也必须对其进行管理。
Linux 管理地址空间的内核设计哲学依然是:先描述,再组织。
在 Linux 内核中,进程地址空间本质上是一个内核数据结构,叫做struct mm_struct。每一个进程的进程控制块struct task_struct中,都包含一个指向其专属mm_struct的指针struct mm_struct *mm;:
structtask_struct{/*...*/structmm_struct*mm;// 对于普通的用户进程来说该字段指向// 他的虚拟地址空间的用户空间部分,// 对于内核线程来说这部分为 NULL。structmm_struct*active_mm;// 该字段是内核线程使用的。当该进程是内核线程时,// 它的 mm 字段为 NULL,表示没有内存地址空间,// 可它并不是真正的没有,这是因为所有进程关// 于内核的映射都是一样的,// 内核线程可以使用任意进程的地址空间。/*...*/}真正指向 mm_struct 的是后面那个指针。
可以说,mm_struct结构是对整个用户空间的描述。每一个进程都会有自己独立的mm_struct,这样每一个进程都会有自己独立的地址空间才能互不干扰。先来看看由task_struct到mm_struct,进程的地址空间的分布情况:
3.2 区域划分的本质
1. 引入
我们在前文看到的堆区、栈区、代码区,在内核中究竟是怎么划分的?
其实非常简单,就像你跟你女同桌给自己的桌子划分区域一样。如果一张桌子长 100cm,你同桌想和你五五分,只需要在心里或者用刻度定义:[0, 49] 是我的区域,[50, 100] 是同桌的区域。
区域划分的本质:“确认区域的开始和结束位置”
2. mm_struct 内部的实现原理
在struct mm_struct内部,就是通过大量的整数变量(刻度)来定义和调整各个区域的边界。在 32 位机器下,虚拟地址的范围是0x00000000到0xFFFFFFFF(4GB)。
structmm_struct{unsignedlongtotal_vm;// 进程总虚拟内存大小// 区域边界描述unsignedlongstart_code,end_code;// 代码段的开始与结束unsignedlongstart_data,end_data;// 初始化数据段的开始与结束unsignedlongstart_brk,brk;// 堆区的开始与当前边界unsignedlongstart_stack;// 栈区的开始位置unsignedlongarg_start,arg_end;// 命令行参数区域unsignedlongenv_start,env_end;// 环境变量区域// ... 后文会进一步扩展};区域调整的本质:比如当我们进行所谓的"堆区扩容"(例如 C 语言中的malloc或 C++ 中的new),在虚拟地址空间层面的操作,其实就是让 brk 边界指针向高地址方向移动,即brk += size;。这就是区域划分与调整的底层逻辑。
3. 区域划分的规则细节
以 32 位系统为例子,按理说区域划分的数值应该在(0, 3GB)的区间内。
- 顶部:stack_start
stack_start:确实在 3GB 附近,但它是"浮动"的。在 32 位 Linux 中,用户空间的最高地址确实是0xC0000000(3GB)。
理论上:栈底(起始点)应该就是0xC0000000。
现实中:为了防止黑客利用固定的内存地址进行攻击(这种攻击叫缓冲区溢出),Linux 引入了ASLR(地址空间布局随机化)。
结果:每次你运行程序,stack_start会在 3GB 往下的一小段范围内随机跳动。比如这次是0xBFFFFA00,下次可能是0xBFFFFC10。
- 底部:code_start
code_start:并不是从0x00000000开始,这可能出乎你的意料。在 Linux 中,虚拟地址 0 附近的一段空间通常是禁止访问的。
为什么:为了捕获空指针(NULL)错误。
逻辑:如果
code_start真的从 0 开始,那么当你写了int *p = NULL; *p = 1;时,程序可能会真的改写掉你的代码段。
现实:在经典的 32 位系统里,代码段通常从0x08048000开始。从 0 到这个地址之间是一片"禁区"。一旦你的 C++ 代码尝试访问这个区间的数值,就会立刻报错,Segmentation Fault。
3.3 页表
进程手里的mm_struct仅仅是一张蓝图,它给出的地址全是虚拟地址。当 CPU 需要真正执行指令、读取数据时,必须将虚拟地址转换为物理内存地址。
负责这个转换的核心是页表(Page Table)。
四、进程地址空间到物理地址空间的完整映射过程
4.1 前言
我们前面用 fork 创建出一个子进程时,已经说过为什么会有两个值的问题,我们继续研究,看这个代码:
intg_val=1;voidtest(){pid_tid=fork();if(id==0){//子进程intcnt=5;while(1){printf("pid:%d,ppid:%d,g_val:%d,g_val的地址%p:\n",getpid(),getppid(),g_val,&g_val);sleep(1);if(cnt==0){g_val=999;printf("子进程修改了g_val\n");}cnt--;}}elseif(id>0){//父进程while(1){printf("pid:%d,ppid:%d,g_val:%d,g_val的地址%p:\n",getpid(),getppid(),g_val,&g_val);sleep(1);}}else{printf("创建失败\n");}}我们发现他们地址一样但是值不一样,为什么呢?说明这个地址肯定不是物理地址,具体为什么接着往下看。
4.2 初始化虚拟地址空间和构建虚拟物理映射关系的全过程
如上图,进程创建后,操作系统会为你的进程创建三个东西:PCB、进程地址空间、页表。
必须先创建
task_struct、mm_struct等内核数据结构,然后再加载(映射)代码和数据。
- 申请物理内存,把进程的代码和数据拷贝到物理内存当中;
- 在虚拟地址空间中根据代码和数据的大小,给所有的指针(整型)赋值,让他们指向初始位置;
- 开始填表:将所有的代码和数据在虚拟地址空间中的地址填入页表的左侧,将所有代码和数据在物理内存中的地址填入页表的右侧;
- 访问:
g_val首先在虚拟地址空间中找到g_val的地址,然后到页表中去查找映射关系,对应到物理内存中的地址,找到数值返回。
补充细节:
一个整型有四个字节,于是有四个地址来存储,我们取得它的地址就是取得其中最小的那个地址。那么如何访问到数据?
首地址 + 变量类型(偏移量计算)= 实际取得的数据
所以类型是决定访问的偏移量。
4.3 子进程拷贝父进程与引发的写时拷贝
1. 子进程拷贝父进程
当子进程被创建时,它会拷贝父进程的 1. PCB 2. 进程地址空间 3. 页表(因此两者的代码和数据也基本一样)。
子进程也复制了父进程的页表,所以初始状态下,父子进程的虚拟地址映射到了同一块物理内存上。
2. 子进程写时拷贝
子进程对某个变量进行修改写入,子进程发生了写时拷贝。于是就出现了,同一个"数值一致"的虚拟地址映射出两个不同的物理地址。
五、为什么要有进程地址空间
我们不免会产生一个核心疑问:为什么不让进程直接访问物理内存,而一定要在中间横插一层虚拟地址空间?事实上,现代操作系统之所以大费周章地构建出这套虚拟地址体系,主要为了解决以下三大核心问题:
5.1 地址有序化
各种乱七八糟的进程加载进物理内存,然后通过页表映射成为有序的虚拟地址空间。
或者一个非常大的项目加载的时候分块加载,代码东一块西一块,也可以通过页表变成有序的。
5.2 保护物理地址空间
- 野指针访问拒止
地址转换的过程中,也可以对你的地址和操作进行合法性判定,进而保护物理内存!
- 页表权限位与只读访问拒止
在 X86 等架构的页表项中,除了存放物理页地址,还挤进了几个关键的标志位:
- R/W(Read/Write):读写位。如果该位为 0,表示这块内存只读;
- U/S(User/Supervisor):用户/特权位。区分这块内存是给用户进程用的(3GB 空间),还是给内核用的(1GB 空间);
- NX(No-Execute):不可执行位。现在的操作系统会把堆和栈标记为 NX。这意味着你可以在堆里存数据,但如果你尝试跳到堆里的地址去执行指令,CPU 会拒绝。
比如:
// 尝试触发错误:char*str="helloworld";*str='H';// 这是一个由栈中的一个指针指向的一个字符串常量为什么在字符常量区写入就会崩溃?原因是字符串常量在进程地址空间的常量区 —— 这是以前我们的说法。如今我们有了更加深刻的见解:原因是查找页表的时候,权限拦截了!
事实上我们不能去访问它,因为这种规则的限制、底层页表的制约。我们必须用const限制自己的手脚const char *str = "helloworld";,不让我们去做这件事情,这是一种编译器级别的保护(在《Effective C++》的第二条款中讲到过)。
5.3 操作系统的进程管理与内存管理解耦
如果没有页表来解耦,PCB 就必须伸出指针来直接指向物理内存,物理内存的 IO 操作会让 PCB 的指针操作异常复杂,同时也不利于维护。
六、缺页中断和再谈挂起
6.1 缺页中断
我们的物理内存是有限的,为了容纳这么多进程的代码和数据,于是操作系统会进行一种操作:把代码和数据(比如一共有 4GB)先全部开到虚拟地址空间里面,表示合法;然后在物理内存中只加载 500MB,并且在页表中也只建立前 500MB 的虚拟—物理映射。
于是当前 500MB 的代码跑完了,在第 501MB 段代码的时候,访问页表,发现没有对应的映射关系,但是由于其合法性,于是中断进程,OS 访问外设,重新加载第二部分的 500MB 到内存,然后重新在页表建立映射关系,进行映射,最后继续运行。这就是缺页中断。
6.2 更激进的缺页中断
更激进的,我们一个进程被加载的时候,实际上可以只创建 PCB 和进程地址空间,并在页表中只填写一半,不加载任何数据和代码到真实物理内存,可不可以?实际上是可以的。我们的页表除了权限标志位以外,还有一个映射标志位,表示我这个虚拟地址是否有对应的物理映射。
当我们的程序运行这个进程的时候,就必须停下来(因为缺页中断)等待磁盘内容自动加载并完成页表填充,然后再运行。
6.3 再谈挂起
- 程序物理内存空间严重告急;
- 操作系统找到部分状态为 S(阻塞)的进程;
- 将进程的 PCB 中状态值改为挂起;
- 在 PCB 中找到
mm_struct; - 保留它,并将页表中对应映射位:保留虚拟地址映射位,清空物理地址映射位;
- 将物理内存内容全部挂到磁盘中的指定位置。
七、Linux 虚拟地址空间的管理核心
7.1 零散虚拟内存空间的管理
当我们在 C/C++ 程序中调用malloc()或new时,内核会为进程在虚拟地址空间的堆区(Heap)分配对应的空间。随着程序的运行,频繁的内存申请和释放会导致虚拟内存空间不连续。
因此,内核有必要对这些虚拟内存区域进行精细化管理,我不卖关子了,直接说结论:
mm_struct内部维护着一个指针(mmap),该指针指向一个由vm_area_struct(VMA)构成的链表。
7.2 解决办法
1. vm_area_struct 结构体
vm_area_struct是 Linux 内核中用来描述一个独立的、连续的虚拟内存区域的核心结构体。由于每个不同质的虚拟内存区域功能和内部机制都不同,一个进程会使用多个vm_area_struct结构来分别表示不同类型的虚拟内存区域。
2. vm_area_struct 构成的链表
为了方便管理,内核将这些描述一块块虚拟内存区域的结构体链接起来,这就形成了链表。这正是操作系统中"先描述,再组织"的典型体现。我们每次malloc一块空间,底层都会创建一个vm_area_struct结构体来维护这块空间。
核心源码剖析
根据内核源码,vm_area_struct的主要定义如下:
structvm_area_struct{unsignedlongvm_start;// 虚存区起始地址unsignedlongvm_end;// 虚存区结束地址structvm_area_struct*vm_next,*vm_prev;// 前后指针,构成双向链表structrb_nodevm_rb;// 红黑树中的位置节点unsignedlongrb_subtree_gap;structmm_struct*vm_mm;// 所属的 mm_struct 结构体pgprot_tvm_page_prot;// 保护位权限(如只读、可读写)unsignedlongvm_flags;// 标志位(如 VM_READ, VM_WRITE, VM_EXEC 等)struct{structrb_noderb;unsignedlongrb_subtree_last;}shared;structlist_headanon_vma_chain;structanon_vma*anon_vma;conststructvm_operations_struct*vm_ops;// VMA 对应的实际操作函数集unsignedlongvm_pgoff;// 文件映射偏移量structfile*vm_file;// 映射的文件(若是匿名映射则为 NULL)void*vm_private_data;// 私有数据atomic_long_tswap_readahead_info;#ifndefCONFIG_MMUstructvm_region*vm_region;/* NOMMU mapping region */#endif#ifdefCONFIG_NUMAstructmempolicy*vm_policy;/* NUMA policy for the VMA */#endifstructvm_userfaultfd_ctxvm_userfaultfd_ctx;}__randomize_layout;除了堆区,其他区域也有vm_area_struct维护。
八、VMA 增长引发的问题与红黑树优化
8.1 VMA 增长引发的问题
那如果我们频繁进行大块内存申请,虚拟内存被频繁打碎,导致vm_area_struct节点的数量急剧增加。
8.2 红黑树优化
这个 mm 里面还有一个闲置的 rb 指针,当这个链表不断变长的时候,达到一个阈值,就会把这个链表重新组织成为一个红黑树。这样查找的效率就会高一点。这个和 Java 中的哈希桶的设计思路是非常相似的:桶下节点太多就挂红黑树。
内核并不是"达到阈值才变红黑树"。实际上,只要有 VMA 存在,内核就会同时维护这两者。当你只有 2-3 个 VMA 时,链表查询也很快。当 VMA 数量增加(比如几百个零散的堆块或映射),红黑树的 O(log n) 查找效率就远高于链表的 O(n)了。
8.3 vm_area_struct 与 mm_struct 的字段重复问题
有人可能会疑惑:mm_struct内部已经通过start_brk、brk等字段把各个空间的边界标明得很清楚了,这与 VMA 是否重复?
其实并不重复,两者分工不同:
- mm_struct 是整体描述:它负责从宏观上勾勒出整个进程地址空间的框架;
- vm_area_struct 是具体描述:它负责微观、精细化地管理每一个独立的小区域。当程序去寻址时,有时候就会直接走更具体的 VMA 路线。