一、程序地址空间
如下,是我们在 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 程序验证
c
#include <stdio.h>
#include <stdlib.h>
// 全局:初始化 → data段
int g_init = 123;
// 全局:未初始化 → bss段
int g_uninit;
void test(void)
{
// 静态局部:初始化 → data
static int s_init = 456;
// 静态局部:未初始化 → bss
static int s_uninit;
// 普通局部自动变量 → 栈
int stack_var = 789;
// 常量字符串字面量 → rodata
char *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);
}
int main(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);
return 0;
}


1.4 问题
下面这个代码,我们在进程父子关系中提到过,同一个地址映射出两个不同的值,这就证明了:虚拟地址空间不是物理内存。
c
void test2()
{
pid_t id = 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;:
c
struct task_struct
{
/*...*/
struct mm_struct *mm;
// 对于普通的用户进程来说该字段指向
// 他的虚拟地址空间的用户空间部分,
// 对于内核线程来说这部分为 NULL。
struct mm_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)。
c
struct mm_struct {
unsigned long total_vm; // 进程总虚拟内存大小
// 区域边界描述
unsigned long start_code, end_code; // 代码段的开始与结束
unsigned long start_data, end_data; // 初始化数据段的开始与结束
unsigned long start_brk, brk; // 堆区的开始与当前边界
unsigned long start_stack; // 栈区的开始位置
unsigned long arg_start, arg_end; // 命令行参数区域
unsigned long env_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 创建出一个子进程时,已经说过为什么会有两个值的问题,我们继续研究,看这个代码:
c
int g_val = 1;
void test()
{
pid_t id = fork();
if(id == 0)
{
//子进程
int cnt = 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--;
}
}
else if(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 会拒绝。

比如:
c
// 尝试触发错误:
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 的主要定义如下:
c
struct vm_area_struct {
unsigned long vm_start; // 虚存区起始地址
unsigned long vm_end; // 虚存区结束地址
struct vm_area_struct *vm_next, *vm_prev; // 前后指针,构成双向链表
struct rb_node vm_rb; // 红黑树中的位置节点
unsigned long rb_subtree_gap;
struct mm_struct *vm_mm; // 所属的 mm_struct 结构体
pgprot_t vm_page_prot; // 保护位权限(如只读、可读写)
unsigned long vm_flags; // 标志位(如 VM_READ, VM_WRITE, VM_EXEC 等)
struct {
struct rb_node rb;
unsigned long rb_subtree_last;
} shared;
struct list_head anon_vma_chain;
struct anon_vma *anon_vma;
const struct vm_operations_struct *vm_ops; // VMA 对应的实际操作函数集
unsigned long vm_pgoff; // 文件映射偏移量
struct file *vm_file; // 映射的文件(若是匿名映射则为 NULL)
void *vm_private_data; // 私有数据
atomic_long_t swap_readahead_info;
#ifndef CONFIG_MMU
struct vm_region *vm_region; /* NOMMU mapping region */
#endif
#ifdef CONFIG_NUMA
struct mempolicy *vm_policy; /* NUMA policy for the VMA */
#endif
struct vm_userfaultfd_ctx vm_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 路线。