【Linux 系统】进程虚拟地址空间与写时拷贝
父进程创建子进程后,两边打印同一个全局变量,可能得到完全相同的地址;等子进程修改变量后,两边的地址仍然相同,值却不一样。
如果把程序打印的地址理解成物理内存地址,这个现象就解释不通。真正需要补上的一层,是进程虚拟地址空间:程序使用虚拟地址,内核通过内存区域和页表把它转换为实际的物理内存访问。fork() 的写时拷贝,也正是建立在这一层映射之上的。
抓住整条主线
这一部分可以先压缩成两条链。
程序访问内存时:
C 语言中的指针值
↓
进程虚拟地址
↓ 查询地址所在的虚拟内存区域
访问类型是否合法?
↓
页表完成地址转换并检查权限
↓
物理页中的实际数据
fork() 之后发生写入时:
父进程创建子进程
↓
父子拥有各自的虚拟地址空间
↓
初始页表可以映射同一批物理页
↓
双方暂时只读共享可写数据页
↓ 某一方第一次写入
触发页故障并进入内核
↓
确认这是合法的写时拷贝访问
↓
准备独立物理页并复制原有内容
↓
只修改写入方的页表映射
↓
重新执行写指令
因此,写时拷贝不是独立于虚拟内存的技巧。没有虚拟地址到物理页的映射关系,就无法在保持虚拟地址不变的同时,让父子进程逐渐使用不同的物理页。
1. 程序打印的地址是什么
在普通 Linux 用户程序中,指针保存的是进程虚拟地址。CPU 执行访存指令时,由内存管理单元结合当前地址空间的页表完成转换;用户程序通常不会直接得到普通内存对应的物理地址。
一个地址值必须放在所属进程的上下文中理解:
进程 A 中的 0x1234 ──► 可能映射物理页 X
进程 B 中的 0x1234 ──► 可能映射物理页 Y
数字相同,只能说明两个进程使用了相同的虚拟地址,不能说明它们一定访问同一个物理位置。反过来,不同虚拟地址也可能因为共享库、共享内存或文件映射而指向同一个物理页。
地址空间大小不能一律写成 4 GB
在 32 位地址模型中,地址宽度为 32 位,理论范围是 0~2^32-1,也就是 4 GiB 的虚拟地址范围。但这并不表示用户进程可以使用全部 4 GiB,更不表示机器真的安装了 4 GiB 物理内存。
现代 Linux 经常运行在 64 位平台上。虚拟地址宽度、用户空间和内核空间的划分、实际可用范围,都与处理器架构、内核配置和 ABI 有关,不能把 32 位平台的布局数值直接搬过来。
2. 常见的用户虚拟地址布局
从低地址到高地址,Linux 进程经常能观察到代码、只读数据、可写数据、堆、内存映射区和栈等区域。不过这只是帮助理解的典型布局,不是所有平台都必须遵守的固定刻度图。
较高虚拟地址
┌──────────────────────────────┐
│ 栈、启动参数和环境字符串 │
│ 通常按需扩展 │
├──────────────────────────────┤
│ 共享库、文件映射、匿名映射 │
│ 大块动态分配也可能出现在这里 │
├──────────────────────────────┤
│ 传统堆区域 │
│ 一部分动态分配可能来自这里 │
├──────────────────────────────┤
│ BSS:零初始化的静态存储期对象 │
├──────────────────────────────┤
│ 可写数据 │
├──────────────────────────────┤
│ 只读数据 │
├──────────────────────────────┤
│ 可执行代码 │
└──────────────────────────────┘
较低虚拟地址
几个名称容易混在一起:
| 名称 | 观察角度 | 例子 |
|---|---|---|
| ELF section | 编译、链接时组织目标文件 | .text、.rodata、.data、.bss |
| ELF segment | 程序装载时使用的视图 | 可执行、只读、可写的装载段 |
| VMA | 进程运行时由内核维护的连续虚拟区间 | 代码映射、堆、栈、共享库、匿名映射 |
它们之间有联系,但不是三个完全相同的概念。一个装载段可以包含多个 section;内核最终维护的是运行时映射及其权限,而不是照着教科书画出几个永远不变的方框。
静态变量放在哪里
全局变量和 static 变量都具有静态存储期。它们更常按“是否需要在可执行文件中保存非零初值”来组织:
- 带有非零初始值的对象通常进入可写数据区域;
- 没有显式初始化或初始化为零的对象通常可以放入 BSS;
- 具体 section 和排列顺序由编译器、链接器和目标格式决定。
所以,“静态变量固定放在初始化数据和未初始化数据之间”并不是可靠规则。static 说明的是生命周期和链接属性,不是一个独立的地址区。
3. 用地址观察运行时布局
下面只打印地址,不尝试读取物理页。递归调用用于观察当前平台上不同栈帧的地址变化。
#include <stdio.h>
#include <stdlib.h>
int global_initialized = 10;
int global_uninitialized;
static void show_stack_frames(int depth)
{
int marker = depth;
printf("stack frame %d : %p\n", depth, (void *)&marker);
if (depth < 3) {
show_stack_frames(depth + 1);
}
}
int main(int argc, char *argv[])
{
static int static_initialized = 20;
static int static_uninitialized;
const char *literal = "hello virtual memory";
int stack_local = 30;
void *heap_first = malloc(32);
void *heap_second = malloc(32);
if (heap_first == NULL || heap_second == NULL) {
free(heap_first);
free(heap_second);
perror("malloc");
return 1;
}
printf("global initialized : %p\n", (void *)&global_initialized);
printf("global uninitialized : %p\n", (void *)&global_uninitialized);
printf("static initialized : %p\n", (void *)&static_initialized);
printf("static uninitialized : %p\n", (void *)&static_uninitialized);
printf("string literal : %p\n", (const void *)literal);
printf("heap block 1 : %p\n", heap_first);
printf("heap block 2 : %p\n", heap_second);
printf("stack local : %p\n", (void *)&stack_local);
if (argc > 0) {
printf("argument string : %p\n", (void *)argv[0]);
}
show_stack_frames(1);
free(heap_second);
free(heap_first);
return 0;
}
运行结果:
global initialized : 0x60381fd83010
global uninitialized : 0x60381fd8301c
static initialized : 0x60381fd83014
static uninitialized : 0x60381fd83020
string literal : 0x60381fd81021
heap block 1 : 0x603854f90010
heap block 2 : 0x603854f90040
stack local : 0x7ffda731ac6c
argument string : 0x7ffda731cf84
stack frame 1 : 0x7ffda731ac34
stack frame 2 : 0x7ffda731ac04
stack frame 3 : 0x7ffda731abd4
这次运行可以观察到:
- 字符串字面量与可写的全局、静态对象不在同一小段地址范围;
- 两次小块动态分配地址接近;
- 栈和启动参数位于较高地址;
- 在当前 x86-64 环境中,递归变深时栈帧地址逐渐减小。
这些地址不能作为固定答案。地址空间布局随机化会让程序每次运行时的栈、堆、共享库以及位置无关可执行文件等映射发生变化。C 语言也没有规定栈必须向低地址增长,更没有保证连续两次 malloc() 一定按地址递增。
实验打印的是几个点,而不是整张内存映射。要查看一个 Linux 进程当前有哪些映射区域及其权限,可以观察 /proc/<pid>/maps;其中每一行表示一段虚拟地址范围,权限字段中的 r、w、x 分别表示读、写、执行,p 表示私有映射,s 表示共享映射。
4. 内核怎样描述一张地址空间
对普通用户进程来说,可以先记住三层关系:
task_struct
│
└──► mm_struct:描述一张完整的用户虚拟地址空间
│
├──► VMA:一段连续、属性一致的虚拟地址范围
├──► VMA:另一段范围
└──► 页表:把虚拟页映射到物理页并记录权限
mm_struct
task_struct 中的 mm 指针把任务与用户虚拟地址空间联系起来。同一进程中的线程共享地址空间,因此它们引用同一个 mm_struct;由 fork() 创建的父子进程则拥有不同的地址空间描述。
VMA
一个 VMA 通常描述一段 [vm_start, vm_end) 形式的连续虚拟区间,并记录读、写、执行、共享或私有、文件映射或匿名映射等属性。两个相邻区域如果权限或后端对象不同,就需要分开描述。
访问一个地址时,内核首先要判断它是否落在有效 VMA 中,以及此次访问是否符合该区域的逻辑权限。没有对应区域,或者对只读区域进行写入,都不能仅靠“页表中有一个地址”就放行。
内核数据结构会随版本演进
早期 Linux 资料常用 VMA 链表和红黑树解释区域管理,这与 Linux 2.6.32 一类旧内核背景相符。当前内核文档中,mm_struct 使用 Maple Tree 管理 VMA。
学习时真正需要保留的是稳定关系:mm_struct 描述地址空间,VMA 描述连续且属性一致的区域。链表、红黑树或 Maple Tree 属于具体版本为了查找、修改和并发性能作出的实现选择,不应背成永远不变的接口。
5. 页表怎样连接虚拟地址与物理内存
虚拟地址通常被拆成“虚拟页编号 + 页内偏移”。多级页表根据虚拟页编号逐层查找,在叶子项中找到物理页信息和权限位;页内偏移在转换前后保持不变。
虚拟地址
┌──────────────────────┬─────────────┐
│ 用于逐级查询页表的部分 │ 页内偏移 │
└──────────┬───────────┴──────┬──────┘
↓ │ 保持不变
多级页表查询 │
↓ ↓
┌──────────────────────┬─────────────┐
│ 物理页框编号 │ 页内偏移 │
└──────────────────────┴─────────────┘
页表项不只是保存地址,还会带有是否存在、是否可写、用户态能否访问等状态。处理器还会使用 TLB 缓存近期地址转换,避免每次访存都完整遍历多级页表。
需要注意:地址位于某个 VMA,并不表示对应物理页已经常驻内存。物理页可能尚未按需分配,也可能被换出;此时访问会引发页故障,由内核判断怎样处理。
6. 为什么需要虚拟地址空间
6.1 隔离与保护
每个进程在自己的地址空间中运行,不能仅凭一个普通指针访问另一个进程的私有内存。VMA 属性和页表权限还可以把代码设为可执行但不可写,把普通数据设为可读写但不可执行。
6.2 给程序稳定的地址视图
程序不必知道某个页面最终放在哪个物理页框。即使物理页位置变化,只要内核更新映射,进程仍可继续使用原虚拟地址。加载器、共享库和地址空间随机化也都能利用这种间接层。
6.3 支持稀疏分配和按需调页
一大段虚拟地址范围不一定立即对应同样大小的常驻物理内存。只有真正访问到某些页面时,内核才可能为它们准备物理页或把数据调入内存。
6.4 支持共享和写时拷贝
多个地址空间可以把各自的虚拟地址映射到同一个物理页,用于共享库、共享内存和写时拷贝。需要隔离时,再修改其中一方的映射即可。
malloc() 到底申请了什么
malloc() 是 C 库内存分配接口,不是简单等同于“直接向内核要一个物理内存块”。以 glibc 为例,一部分分配可能由传统堆区域提供,较大的分配也可能使用私有匿名映射,多线程情况下还可能存在多个分配 arena。
因此,malloc() 成功首先表示分配器交给程序一段可使用的虚拟地址范围。受按需调页和 Linux 内存过量承诺策略影响,相关物理页不一定在返回瞬间全部准备完成。真正触碰页面时,才可能发生实际的页分配与映射工作。
这也说明两点:堆不应理解成进程中唯一的动态内存来源;多次分配得到的地址也不保证严格连续或始终向同一方向增长。
7. fork() 为什么使用写时拷贝
fork() 成功后,父子进程运行在不同内存空间中,但创建时两边的虚拟地址空间内容相同。最直接的实现方式,是立即复制父进程的所有可写内存;问题是很多子进程很快就会调用 exec,即使不替换程序,也未必会修改大部分页面,提前复制会浪费时间和物理内存。
Linux 通常采用写时拷贝:
fork() 刚完成
父页表 ──┐
├──► 同一物理页:value = 100
子页表 ──┘
父子从各自地址空间访问,页面暂时共享
对原本可写、需要采用 COW 的私有页面,内核让相关页表映射暂时不能直接写入。当子进程执行写指令时:
第一步:CPU 发现当前页表项不允许直接写,触发页故障
第二步:内核找到对应 VMA,确认这段区域逻辑上允许写入
第三步:判断这是合法的写时拷贝情况,而不是非法越界
第四步:为写入方准备可写物理页,并保留原页面内容
第五步:只更新写入方的页表项
第六步:重新执行刚才的写指令
完成后,映射关系变成:
父页表 ─────► 原物理页:value = 100
子页表 ─────► 新物理页:value = 200
父子使用的虚拟地址数值仍可完全相同
写时拷贝通常以页为管理粒度,而不是以某个 C 变量为粒度。修改一个 int,可能需要处理包含它的整页。实际内核还会根据页面引用情况优化:如果写入方已经成为该页的唯一使用者,就可能不再需要真正复制,只需恢复合适的写权限。
8. 用父子进程观察写时拷贝后的结果
下面使用管道控制顺序:子进程完成修改并输出后通知父进程,父进程再读取自己的变量。这样观察结果不依赖谁先被调度,也不用通过固定休眠时间碰运气。
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
static int value = 100;
static int send_ready(int fd)
{
const char token = 'R';
while (write(fd, &token, 1) == -1) {
if (errno != EINTR) {
return -1;
}
}
return 0;
}
static int wait_ready(int fd)
{
char token = 0;
ssize_t result;
do {
result = read(fd, &token, 1);
} while (result == -1 && errno == EINTR);
return result == 1 && token == 'R' ? 0 : -1;
}
int main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
int ready_pipe[2];
if (pipe(ready_pipe) == -1) {
perror("pipe");
return 1;
}
printf("before fork : value=%d, virtual_address=%p\n",
value, (void *)&value);
pid_t pid = fork();
if (pid == -1) {
perror("fork");
return 1;
}
if (pid == 0) {
close(ready_pipe[0]);
printf("child before write : value=%d, virtual_address=%p\n",
value, (void *)&value);
value = 200;
printf("child after write : value=%d, virtual_address=%p\n",
value, (void *)&value);
if (send_ready(ready_pipe[1]) == -1) {
_exit(1);
}
close(ready_pipe[1]);
_exit(0);
}
close(ready_pipe[1]);
if (wait_ready(ready_pipe[0]) == -1) {
fprintf(stderr, "failed to synchronize with child\n");
return 1;
}
close(ready_pipe[0]);
printf("parent after child write: value=%d, virtual_address=%p\n",
value, (void *)&value);
int status = 0;
if (waitpid(pid, &status, 0) == -1) {
perror("waitpid");
return 1;
}
if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
fprintf(stderr, "child process failed\n");
return 1;
}
return 0;
}
运行结果:
before fork : value=100, virtual_address=0x6088a4b50010
child before write : value=100, virtual_address=0x6088a4b50010
child after write : value=200, virtual_address=0x6088a4b50010
parent after child write: value=100, virtual_address=0x6088a4b50010
结果中有三个关键点:
- 子进程写入前,父子看到相同的初始内容;
- 子进程写入后,打印出的虚拟地址没有变化;
- 父进程最后仍然读到
100,说明子进程的写入没有改掉父进程的私有数据视图。
这个实验直接证明了父子进程内存写入彼此隔离,也说明相同的指针数值不等于同一个可共同修改的变量。至于“第一次写入前共享物理页、写入时按页复制”的具体实现,需要结合 Linux 的 fork() 语义和内核内存管理机制来解释;只看四行用户态输出,不能直接读取或证明某个物理页框编号。
9. 页故障不等于程序崩溃
“页故障”描述的是 CPU 无法按当前页表状态完成访问,需要内核介入。它既可能是正常机制,也可能来自程序错误。
可以被内核正常处理
- 第一次访问尚未分配物理页的匿名内存;
- 访问尚未调入内存的文件映射页;
- 页面已被换出,需要重新调入;
- 对合法的私有 COW 页面进行第一次写入。
内核完成分配、调页或写时拷贝后,程序通常从原指令继续运行,用户不会看到崩溃。
无法按当前映射规则完成
- 地址根本不属于任何有效 VMA;
- 写入逻辑上只读的映射;
- 用户态访问仅允许内核使用的地址;
- 执行没有执行权限的数据页。
这类访问不能被解释为合法需求,Linux 通常向进程发送 SIGSEGV。
修改字符串字面量不是写时拷贝实验
字符串字面量不应被程序修改。在 C 语言层面,对它写入属于未定义行为;在常见 Linux 映射中,它通常位于只读区域,写入可能收到 SIGSEGV。
虽然它和 COW 都可能先表现为“向当前不可写的页执行写指令”,但内核还要查看 VMA 的逻辑权限:
- 原本可写的私有 COW 页面:此次写入合法,可以复制后继续;
- 原本就只读的字符串区域:此次写入不合法,不能通过复制变成可写。
所以,页表中的“暂时不可写”和程序语义上的“永远不允许写”不能混为一谈。
10. 堆向上、栈向下应该怎样理解
在常见 x86-64 Linux 进程中,传统 brk 堆区域常向较高地址扩展,主线程栈常向较低地址扩展。这是理解典型布局的有用观察,但不能升级成 C/C++ 语言层面的硬规则。
- 栈方向由体系结构和 ABI 决定,有的架构可以采用不同方向;
- 编译器可以重排、复用或直接优化掉局部变量,比较几个局部变量地址不一定能推出调用栈方向;
malloc()是分配器接口,可能从brk堆、匿名映射或不同 arena 中提供空间;- 相邻两次分配的地址先后受空闲块复用、对齐、分配大小和实现策略影响。
因此,更准确的说法是:“本次平台和本次运行观察到这样的增长方向”,而不是“堆栈天生相向生长”。
11. 几个容易写错的结论
11.1 页表就是一张简单的虚拟地址—物理地址表吗
这是入门模型。现代系统通常使用多级页表,页表项还包含权限、存在状态和其他架构相关标志;TLB 又会缓存转换结果。
11.2 父子进程继承同一张页表吗
不能简单说成共享同一张可随意修改的页表。父子拥有不同地址空间,fork() 需要建立子进程自己的页表结构或等价管理状态;初始页表项可以指向相同物理页,以便实现写时拷贝。之后一方更新自己的映射,不会直接把另一方的映射一起改掉。
11.3 地址相同、值不同,是否已经完整证明 COW
它证明了地址是按进程解释的,并证明父子私有内存修改相互隔离。COW 的物理页共享和复制时机属于内核实现,需要结合系统接口文档、内核文档或更底层的内存统计工具确认,不能只凭指针输出推导全部细节。
11.4 malloc() 返回成功,物理内存就已经全部分配了吗
不一定。分配器管理、匿名映射、按需调页和过量承诺共同影响实际物理页何时准备。返回非空指针也不是未来绝不会遇到内存压力的保证。
11.5 写时拷贝会复制整个进程吗
不会。核心目标正是避免立即复制整个地址空间。发生写入时通常按页面处理,而且只处理需要独立化的页面。
11.6 所有页故障都会收到 SIGSEGV 吗
不会。按需分配、调页和 COW 都可能利用页故障正常完成工作。只有内核确认访问不合法或无法修复时,用户进程才会收到相应信号。
12. 小结
进程地址空间可以看成进程对内存的虚拟视图。内核使用 VMA 描述哪些虚拟范围有效、具有什么属性,再通过页表把虚拟页映射到物理页。
虚拟地址空间解决“进程看见什么”
VMA 解决“这一段允许怎样访问”
页表解决“虚拟页当前映射到哪里”
页故障解决“当前映射不能直接完成访问时怎么办”
写时拷贝解决“父子怎样先共享、写入后再隔离”
理解了这几层,“相同地址为什么能有不同值”就不再矛盾:相同的是父子进程中的虚拟地址数值,不同的是各自页表最终提供的数据视图。写时拷贝利用这层映射关系,在保证进程独立性的同时,避免 fork() 一开始就复制所有可写内存。
更多推荐



所有评论(0)