【Linux 系统】进程虚拟地址空间与写时拷贝
2026/7/19 2:43:09 网站建设 项目流程

父进程创建子进程后,两边打印同一个全局变量,可能得到完全相同的地址;等子进程修改变量后,两边的地址仍然相同,值却不一样。

如果把程序打印的地址理解成物理内存地址,这个现象就解释不通。真正需要补上的一层,是进程虚拟地址空间:程序使用虚拟地址,内核通过内存区域和页表把它转换为实际的物理内存访问。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>intglobal_initialized=10;intglobal_uninitialized;staticvoidshow_stack_frames(intdepth){intmarker=depth;printf("stack frame %d : %p\n",depth,(void*)&marker);if(depth<3){show_stack_frames(depth+1);}}intmain(intargc,char*argv[]){staticintstatic_initialized=20;staticintstatic_uninitialized;constchar*literal="hello virtual memory";intstack_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");return1;}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",(constvoid*)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);return0;}

运行结果:

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;其中每一行表示一段虚拟地址范围,权限字段中的rwx分别表示读、写、执行,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_SOURCE200809L#include<errno.h>#include<stdio.h>#include<stdlib.h>#include<sys/types.h>#include<sys/wait.h>#include<unistd.h>staticintvalue=100;staticintsend_ready(intfd){constchartoken='R';while(write(fd,&token,1)==-1){if(errno!=EINTR){return-1;}}return0;}staticintwait_ready(intfd){chartoken=0;ssize_tresult;do{result=read(fd,&token,1);}while(result==-1&&errno==EINTR);returnresult==1&&token=='R'?0:-1;}intmain(void){setvbuf(stdout,NULL,_IONBF,0);intready_pipe[2];if(pipe(ready_pipe)==-1){perror("pipe");return1;}printf("before fork : value=%d, virtual_address=%p\n",value,(void*)&value);pid_tpid=fork();if(pid==-1){perror("fork");return1;}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");return1;}close(ready_pipe[0]);printf("parent after child write: value=%d, virtual_address=%p\n",value,(void*)&value);intstatus=0;if(waitpid(pid,&status,0)==-1){perror("waitpid");return1;}if(!WIFEXITED(status)||WEXITSTATUS(status)!=0){fprintf(stderr,"child process failed\n");return1;}return0;}

运行结果:

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

结果中有三个关键点:

  1. 子进程写入前,父子看到相同的初始内容;
  2. 子进程写入后,打印出的虚拟地址没有变化;
  3. 父进程最后仍然读到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.4malloc()返回成功,物理内存就已经全部分配了吗

不一定。分配器管理、匿名映射、按需调页和过量承诺共同影响实际物理页何时准备。返回非空指针也不是未来绝不会遇到内存压力的保证。

11.5 写时拷贝会复制整个进程吗

不会。核心目标正是避免立即复制整个地址空间。发生写入时通常按页面处理,而且只处理需要独立化的页面。

11.6 所有页故障都会收到SIGSEGV

不会。按需分配、调页和 COW 都可能利用页故障正常完成工作。只有内核确认访问不合法或无法修复时,用户进程才会收到相应信号。


12. 小结

进程地址空间可以看成进程对内存的虚拟视图。内核使用 VMA 描述哪些虚拟范围有效、具有什么属性,再通过页表把虚拟页映射到物理页。

虚拟地址空间解决“进程看见什么” VMA 解决“这一段允许怎样访问” 页表解决“虚拟页当前映射到哪里” 页故障解决“当前映射不能直接完成访问时怎么办” 写时拷贝解决“父子怎样先共享、写入后再隔离”

理解了这几层,“相同地址为什么能有不同值”就不再矛盾:相同的是父子进程中的虚拟地址数值,不同的是各自页表最终提供的数据视图。写时拷贝利用这层映射关系,在保证进程独立性的同时,避免fork()一开始就复制所有可写内存。


需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询