从fork地址相同到写时拷贝:吃透进程地址空间与页表机制
发布时间:2026/10/26 4:13:43 锦皓数字建站

在调试一个多进程程序的时候我碰到过一个让新手极其困惑的现象父进程里打印某个全局变量的地址是0x601040fork 出来的子进程里打印同一个变量地址还是0x601040。两个进程明明是两个独立的执行体为什么变量地址会一模一样更让人想不通的是子进程改了这个变量的值父进程那边纹丝不动既然地址一样改的不应该是同一块内存吗这两个问题指向的都是进程地址空间这套机制。它解决了操作系统里最核心的矛盾之一多个进程如何在一个物理内存有限的机器上互不干扰地运行。理解进程地址空间、页表、写时拷贝和虚拟地址这几件事你才能真正看懂 fork 的代价、内存映射文件的共享语义、以及为什么一个野指针有时候崩有时候不崩。这篇内容偏向操作系统底层与系统编程适合写过一点 C、用过 fork 和 malloc但对背后机制还停留在“知道有这么回事”阶段的读者。我会从“为什么要设计它”讲起一路拆到页表项里的每一个标志位和写时拷贝触发的那一刻中间配上可以直接跑起来的验证代码。1. 从一次段错误说起进程地址空间到底解决了什么问题1.1 虚拟地址不是障眼法是精心设计的隔离层很多人第一次接触虚拟地址会觉得这就是操作系统玩的障眼法应用程序看到的是假的地址硬件在背后偷偷换算成真的。这个理解没错但只说对了表象真正值得琢磨的是为什么非要做这层换算。设想一个没有虚拟地址的世界程序编译出来直接操作物理地址。那么你在写代码时就得操心我这次 malloc 拿到的地址会不会和隔壁进程要用的地址撞上操作系统加载第二个程序时得先扫描物理内存找到一块足够大且连续的空洞把程序搬进去还要把程序里所有写死的地址重新改一遍才能跑。物理内存一旦碎片化一个需要 100MB 连续空间的程序哪怕总空闲有 200MB 也起不来。更要命的是任何一个进程的野指针都可以直接踩到别的进程的数据一个 bug 能连带毁掉整台机器上所有在跑的东西。虚拟地址机制把“程序看到的地址”和“内存条上的实际位置”彻底解耦。每个进程拿到一张独立的映射表表里写着“我的 0x601040 对应物理内存的某一块”。编译器生成的地址是虚拟地址链接器算的也是虚拟地址CPU 执行时先查表再访存。物理内存怎么摆放是内核的事程序完全不用管。这就是解耦带来的最直接的价值程序不再需要关心自己被加载到哪也不需要关心别人在哪。1.2 为什么每个进程都以为自己独占整台机器的内存虚拟地址带来的第二个红利是“独占”的错觉。在 64 位系统上每个用户进程都拥有一套从 0 到某个上限的地址范围看起来整台机器的地址空间都是它的。所有进程的地址空间布局几乎是相同的代码段在上方数据段、堆、共享库、栈按固定的相对位置排布。这种一致性有个巨大的好处就是可预测性。栈地址的起始位置、堆的起始位置、共享库的加载区域内核会做一定的随机化处理但整体格局不变。调试器看到某个地址立刻能判断它大概率落在哪个区程序里errno或者线程局部存储的偏移也是基于这套布局设计的。如果地址空间不统一每次加载都要重新规划调试信息和 core dump 的分析会变得极其痛苦。注意所谓“进程看到的是连续地址空间”指的是虚拟地址连续背后的物理页几乎一定不连续。一个 10MB 的数组映射到物理内存上可能是散落在几十个页里。把虚拟地址连续等同于物理连续是初学阶段最容易带偏后续理解的误区。1.3 地址空间的布局不是随机排的每一块都有讲究拿一个典型的 64 位 Linux 进程来说用户空间布局大致是这样的最低地址往后是代码段text只读可执行接着是已经初始化的数据段和未初始化的 BSS 段可读写再往上是堆用 brk 或 mmap 向上生长中间一大片留空然后是动态链接库的映射区mmap 出来的匿名页和文件映射也落在这一带栈从高地址往低地址生长环境变量和命令行参数就在栈顶。内核空间和用户空间之间有一道分界线x86-64 上这条线大约在地址的高半部分用户空间占低 128TB内核空间占高半部分。每个进程的页表里都包含内核空间的映射但页表项上标记了只有特权级 0 才能访问所以用户代码跑过去立刻触发保护异常。这种设计的巧思在于发生系统调用或者进程从用户态切换到内核态时不需要切换页表基址寄存器内核代码可以直接沿用当前进程的页表访问内核空间省掉一次代价很高的地址空间切换。2. 地址空间存在的深层原因隔离、共享与按需分配2.1 进程隔离是底线但不能只靠边界检查有人会问操作系统不是有特权级吗为什么不能让用户进程直接用物理地址靠硬件在每个访存指令上做边界检查来隔离答案在于性能和灵活性边界检查只能保证不越界保证不了两个进程不重叠。每个进程的合法区间是动态变化的堆会涨栈会扩mmap 会新增映射。如果靠“给每个进程登记一个物理区间”来管理一旦堆和栈之间的空档被别的进程占了这个进程就再也长不大了。物理内存的连续性和进程需求的不连续性天然冲突。虚拟地址把“需求”和“供给”拆开。进程要向内核申请内存内核只需要在它的地址空间里找一段空闲的虚拟区间分配几个物理页挂上去就行物理页在哪完全无所谓。进程之间的虚拟区间可以重叠只要映射到不同的物理页即可。隔离从“物理上不碰撞”变成了“映射表上不指向同一块”这个转换是整套机制能成立的关键。2.2 共享内存和写时拷贝都建立在地址空间之上隔离不是目的可控的共享才是。两个进程想共享一块数据有了地址空间之后变得很自然把各自的虚拟区间映射到相同的物理页就行。共享库的代码段就是这么处理的同一个libc.so的代码页在物理内存里只有一份几百个进程都映射到它只读不写内存利用率直线上升。写时拷贝也建立在同一套机制上。fork 的时候子进程的地址空间和父进程的地址空间内容完全一样但并不是把物理页复制一遍。内核把父子进程的页表都指向同一批物理页然后把这些页在双方的页表项里都标记成只读。任何一方要写触发页保护异常内核才真正复制出一份新的页给写入方。这套机制把 fork 的代价从“复制整个地址空间”降低到了“复制页表”进程创建的语义没变开销却小了一个数量级。没有虚拟地址这套间接层写时拷贝根本无从谈起。2.3 按需分配与缺页异常内存可以“看着有实际没有”地址空间还有一个容易被低估的作用它让内存分配可以做到承诺与兑现分离。malloc 申请 1GB 内存内核可以立刻返回一个虚拟地址但一个物理页都不给只在这段虚拟区间上做标记。等你真正去读或者写某个地址时MMU 查表发现页表项是空的触发缺页异常内核这时候才分配物理页、建立映射。这个动作叫按需调页。这个设计在现实里的价值极高。程序习惯性地预留大块地址空间但只用其中很小一部分或者进程启动时先 mmap 一大块当内存池这些场景下按需分配能省下大量物理内存。也正因为如此你才会看到进程的虚拟内存大小VSZ可能有几个 G而实际驻留内存RSS只有几十兆。这两者的差距不是虚胖是地址空间机制允许的“延迟兑现”。2.4 地址空间与安全页表项上的权限位是最后一道闸门页表项里除了物理页号还有一组权限标志位它们才是运行时安全真正落地的地方。常见的标志包括存在位页面是否在物理内存里、读写位、用户/超级用户位、可执行位、脏位、访问位。代码段被标记为只读且可执行你往函数体里写数据会直接触发段错误栈和堆标记为不可执行这是缓解缓冲区溢出攻击的常规手段。这些标志不是建议性的是 MMU 在每次地址转换时强制性检查的。用户态代码试图访问标记为超级用户的页MMU 直接拒绝连内核代码路径都不会走到。理解这一点之后你就能明白为什么缓冲区溢出攻击过去那么容易得手而现在要先想方设法绕过不可执行栈也能明白为什么某些嵌入式和实时系统为了性能会放松这些保护代价是什么。3. 页表虚拟地址到物理地址的翻译官3.1 页表的最基本形态与地址拆分方式页表的核心作用只有一个给定虚拟地址查出对应的物理页号然后拼上页内偏移得到物理地址。这个过程由 MMU 硬件完成内核负责维护页表内容。地址被拆成“页号”和“页内偏移”两部分拆分位置由页大小决定。假设页大小是 4KB也就是 2 的 12 次方那么虚拟地址的低 12 位就是页内偏移高位是虚拟页号。32 位地址下虚拟页号有 20 位能索引 100 万个页面64 位地址下虚拟页号有 52 位理论上有 400 多万亿个页。如果直接用一张单级大表每个进程一张每个条目哪怕只占 8 字节64 位下一个进程的页表本身就要占用几十 TB 的空间显然不现实。所以现实中的页表都是多级的。32 位系统常用两级页表把 20 位虚拟页号再拆成 10 位 10 位第一级 1024 个条目每个条目指向一张第二级页表第二级 1024 个条目。64 位系统常用四级把 52 位拆成 9999剩下 16 位留给高半部分的内核空间划分每一级指向下一级的表。这套结构本质上是一棵多叉树只有实际用到的分支才会被分配内存。3.2 多级页表省内存的原理只分配用到的分支多级页表省内存的关键在于稀疏分配的天然适配。一个进程真正用到的地址空间往往只占理论范围的极小一部分代码几兆、数据几兆、栈几兆、堆看需求共享库几十兆。这些区域在 64 位地址空间里是极其稀疏地散落的。用一棵四级树去索引大部分分支根本不存在对应的中间层页表压根不用创建。算一笔账就清楚了。假设一个进程实际占用了 100MB 内存按 4KB 一页算有 25600 个页。这些页如果分布在一个完全不连续的区域里最坏情况下需要 25600 个叶子页表条目分散在若干个叶子页表里。每个叶子页表能装 512 个条目最坏需要 50 个叶子页表。每一层向上需要的表数量继续收缩。整个页表占用的内存大概是叶子层 50 张、中间层可能十几张、再上层几张每张 4KB总共不到 1MB。如果换成单级表64 位下要占几十 TB差了七个数量级。这就是“用层级换空间”的经典权衡。维度单级页表四级页表页表体积与地址空间总大小成正比64 位下不可接受与实际使用量成正比稀疏场景下极小查表次数1 次访存4 次访存未命中缓存时分配粒度全有或全无按需逐层分配缺页处理简单需要逐层检查路径3.3 页表项里的标志位与访存过程的完整链条一个页表项不是只有物理页号它是一堆字段的集合。存在位告诉你这个页当前是否在内存里如果不在访问时会触发缺页异常读写位决定这个页是可写还是只读用户位决定用户态能不能访问可执行位配合硬件提供的执行保护决定这个页能不能当指令执行访问位和脏位由 MMU 自动维护供页面置换算法参考此外还有缓存控制位、全局位等等。一次完整的访存长这样CPU 发出虚拟地址MMU 先查 TLB转换后备缓冲区本质是页表项的缓存命中就直接拿到物理地址没命中就按页表基址寄存器指向的根表逐级往下查。每一级查表都是一次内存访问四级页表意味着最多四次访存才能完成一次地址转换。查完之后把结果塞回 TLB后续再访问同一页就能直接命中。这也是为什么页表结构的变化对性能影响很大多一级就多一次可能的访存。提示TLB 的存在使得页表查询的开销在大多数情况下被隐藏。但进程切换时 TLB 需要刷新或者打上地址空间标识来区分这是上下文切换成本的重要来源之一。理解这一点才能理解为什么大量的线程同进程运行比大量独立进程切换要便宜。3.4 页表基址寄存器与进程切换时发生了什么每个进程都有自己的页表树根表的物理地址存在一个专门的寄存器里。x86-64 上是 CR3ARM64 上是 TTBR0/TTBR1。进程切换时内核要做的关键动作之一就是把这个寄存器换成新进程的根表地址。这个动作叫切换地址空间。换完之后TLB 里缓存的老进程的页表项就全部失效了如果不做处理新的地址会被错误翻译。所以切换时要清 TLB或者用地址空间标识ASID给 TLB 条目打标签让不同进程的条目可以共存而不互相污染。现代处理器一般支持 ASID这样切换进程时不必整体刷 TLB只需要切换当前的 ASID开销小很多。这也解释了为什么在支持 ASID 的平台上进程创建和切换的实测性能会更好。4. 写时拷贝fork 之后到底发生了几次内存复制4.1 fork 的直觉实现与它不可接受的开销教科书上讲 fork 会创建一个和父进程几乎一模一样的子进程包括地址空间内容的完整副本。如果严格按字面实现fork 的代价就是复制整个地址空间代码段不用复制只读可以共享但数据段、堆、栈全都要复制一份。一个占用了 500MB 内存的进程 fork 一次就要再消耗 500MB 物理内存还要花时间做复制。现实中的使用模式却往往是 fork 之后立刻 exec 别的程序。也就是说刚复制完的 500MB 马上就会被 exec 整个丢掉这次复制纯属浪费。即使不 exec子进程往往也只改动其中很小一部分数据其余部分父子完全一致。这两种场景都说明全量复制是极其低效的。写时拷贝就是为了消掉这笔开销而设计的。4.2 写时拷贝的完整实现思路fork 时内核做的是这样几件事子进程获得一份父进程页表的副本注意是页表副本不是物理页副本页表项里指向的物理页还是父进程原来那些同时内核把所有可写的页表项在父子双方的页表里都改成只读并在页表项里打上一个“写时拷贝”的内部标记页面本身的引用计数加一。接下来任何一方对这些页发起写操作MMU 会先检查读写位发现是只读页触发页保护异常。内核进入缺页处理路径发现地址所在的页带着写时拷贝标记就明白这不是真的非法访问而是需要兑现复制。此时内核分配一个新的物理页把老页的内容复制过去更新触发写入那一方的页表项指向新页并恢复可写减少老页的引用计数。如果老页的引用计数已经降到只剩另一方在用那也可以直接把老页的只读属性改回可写省掉一次复制。这套机制的妙处在于它让“复制整个地址空间”这个语义保持不变但实际搬运只发生在真正被写到的页上。父子进程共享不变的部分只在分歧处各自持有副本。从程序员的视角看fork 之后两个进程的内存是完全隔离的改谁都不影响谁这个保证一点没丢。4.3 一次写操作触发写时拷贝的微观过程把触发过程走一遍会理解得更扎实。假设父进程有一个全局数组arrfork 之前它是可写的对应某个物理页 P。fork 之后这个页在父和子双方的页表项里都变成只读标记写时拷贝P 的引用计数变成 2。子进程执行arr[0] 1。CPU 发出写请求MMU 查页表发现页是只读的硬件抛出一个页错误异常把出错的虚拟地址和错误码塞进特定寄存器跳到内核的缺页处理函数。内核根据错误码判断这是一次写保护错误而不是页不存在错误。接着它去找对应的虚拟内存区域描述符确认这个地址本来应该是可写的再检查页表项上的写时拷贝标记。确认无误后内核分配新页 Q把 P 的内容复制到 Q更新子进程的页表项让它指向 Q 并设为可写P 的引用计数减到 1。对于父进程既然现在只有它自己在用 P把 P 的页表项改回可写写时拷贝标记清掉。整个处理结束重新执行刚才那条写指令这次 MMU 看到的是可写页写入成功。这一整套下来只有真正被写的那一页被复制了其他页原封不动依然共享。4.4 写时拷贝的几个实战陷阱写时拷贝听起来很美用的时候有几个坑很容易踩。第一个是引用计数的维护成本。每一页的引用计数都要在 fork 时增加、写时拷贝时减少、进程退出时清理内核在页结构上维护这个计数锁竞争在高并发 fork 场景下会成为瓶颈。有些老版本的内核在这方面有过比较明显的性能问题。第二个是写时拷贝会放大内存峰值。fork 一个占内存很大的进程如果父子都开始大量写物理内存可能瞬间翻倍触发内存回收甚至 OOM。容器场景下内存限额卡得紧这种突发很容易被误判为内存泄漏。第三个是大页与写时拷贝的互动。透明大页让一次写时拷贝的复制粒度从 4KB 变成 2MB单次复制的开销明显变大延迟毛刺更容易出现。注意不要假设 fork 之后子进程立刻拥有独立内存。如果你的逻辑依赖“fork 后父子内存已经隔离”这个事实那是对的但如果你的逻辑依赖“fork 后内存已经复制完成”那就是错的。物理复制是延迟到写入那一刻的。这个区别在分析内存占用和性能曲线时非常关键。5. 动手验证亲手看清虚拟地址、页表与写时拷贝5.1 打印变量地址看清虚拟地址的一致性与独立性先写一段最直接的代码验证“父子进程同一变量地址相同但值互不影响”。#include stdio.h #include unistd.h #include sys/wait.h int shared_var 100; int main(void) { printf(before fork: shared_var %p, value %d\n, (void*)shared_var, shared_var); pid_t pid fork(); if (pid 0) { printf(child : shared_var %p, value %d\n, (void*)shared_var, shared_var); shared_var 200; printf(child after write: shared_var %p, value %d\n, (void*)shared_var, shared_var); _exit(0); } waitpid(pid, NULL, 0); printf(parent: shared_var %p, value %d\n, (void*)shared_var, shared_var); return 0; }编译运行之后你会看到子进程打印的地址和父进程完全一致都是同一个虚拟地址子进程把它改成 200 之后父进程读到的仍然是 100。这正是写时拷贝在起作用。地址一致是因为子进程复制了父进程的页表虚拟地址到物理页的映射关系被复制了值隔离是因为写入触发了复制子进程写到一张新页上父进程那张页仍保持原值。值得多说一句的是地址为什么能一致。因为 fork 复制的是地址空间的结构子进程的地址空间布局和父进程一模一样同一变量自然占据同一个虚拟地址。如果这里看到的地址不一样那才是出了问题。5.2 用引用计数和内存统计观察写时拷贝的实际开销光看值变化还不够我们想量化写时拷贝。可以在 fork 前后读取/proc/self/status里的 VmRSS驻留物理内存和/proc/self/smaps里的共享内存字段。写一段对照实验先 fork 一个吃掉几百兆内存的子进程让父子都只读观察 RSS再让父子都大量写观察 RSS 变化。# 简化示意查看当前进程的驻留内存与共享内存 grep -E VmRSS|VmSize|RssAnon|RssFile|RssShmem /proc/self/status实测下来会发现一个典型现象fork 之后、写入之前父子的 {RssShmem} 都会包含同一批页总和并不等于两者之和因为物理页只有一份一旦双方开始写同一批页RSS 总量会上升上升的幅度大致等于被写的页数乘以页大小。这个测量方法是判断“写时拷贝有没有真正生效”最直接的手段比看代码推测靠谱得多。提示观察时要注意页缓存和匿名页的区别。文件映射的页在写时拷贝里走的是另一条路径复制到匿名页只盯着 VmRSS 会漏掉部分信息把 RssAnon 和 RssShmem 一起看才完整。5.3 用页错误计数器确认写时拷贝的触发时机如果内核支持 perf可以直接统计缺页事件把写时拷贝的触发点抓出来。perf stat -e page-faults,minor-faults,major-faults ./cow_demominor fault 是页已经在内存里但页表项需要更新的情况写时拷贝触发的正是这一类。跑一个只读子进程minor fault 数量很低跑一个写完整个数据区的子进程minor fault 数量会明显上升大约和写入的页数同量级。这个对应关系非常直观是做性能分析时的常用佐证。另一个手段是/proc/pid/pagemap和/proc/pid/smaps能拿到每个虚拟页对应的物理页帧号需要相应权限把父子进程同一虚拟地址的物理帧号对比就能看到写入前相同、写入后不同。这个验证比任何文字说明都有说服力强烈建议自己动手做一遍做过之后对写时拷贝的理解会从“背下来的概念”变成“亲眼见过的事实”。5.4 观察栈、堆、共享库的地址分布理解地址空间布局最后再补一个小实验把各区域的地址打印出来验证地址空间的布局。#include stdio.h #include stdlib.h int g_init 1; int g_bss; int main(void) { int local 2; int *heap malloc(sizeof(int)); printf(text approx : %p\n, (void*)main); printf(data : %p\n, (void*)g_init); printf(bss : %p\n, (void*)g_bss); printf(heap : %p\n, (void*)heap); printf(stack : %p\n, (void*)local); printf(libc printf : %p\n, (void*)printf); free(heap); return 0; }跑出来通常是代码段地址最低数据段紧随其后堆在数据段上方并且向上增长栈地址明显偏高共享库落在中间偏上的映射区。每次运行的具体数值因为随机化会略有差异但相对次序稳定。把这个次序和前面讲的布局对上地址空间就不再是一个抽象名词而是一张你能画出来的图。6. 常见问题与踩坑记录6.1 写时拷贝与虚拟地址相关问题速查现象可能原因排查与处理思路fork 后子进程改数据父进程也变了误用了共享内存映射或全局可变状态被其他路径影响确认是否用了 MAP_SHARED 的 mmap检查是否有信号处理函数或其他线程在改数据父子进程变量地址相同但值不同怀疑程序有 bug其实是写时拷贝的正常表现这是预期行为虚拟地址相同不代表物理页相同内存占用统计出现双份怀疑泄漏写时拷贝被大量触发物理页真的复制了关注 RssAnon 与 minor fault 数量确认是否大量写入共享页程序访问某地址崩溃换一次运行却不崩访问落在未映射区间或落在依赖环境的内存布局上别依赖未定义行为用调试器看崩溃地址落在哪个区域页表分析工具读不到物理帧号权限不足或内核配置限制了 pagemap 访问检查权限容器环境里这类信息通常被屏蔽大页开启后延迟毛刺变多写时拷贝按大页粒度复制单次开销变大对延迟敏感的进程可考虑关闭透明大页或调整策略6.2 几个我在实际排查中踩过的坑第一个坑是把虚拟地址当成唯一标识。做进程间通信时曾经想用变量地址当 key 去共享数据结果两个进程地址一样但根本指向不同的物理页数据完全对不上。正确做法是用文件映射或者显式的共享内存区并约定偏移量而不是拿虚拟地址当索引。第二个坑是忽略写时拷贝导致的延迟抖动。某个服务 fork 出工作进程后工作进程第一笔请求的延迟总是偏高一开始以为是冷启动。后来用 perf 看 minor fault发现是写时拷贝在第一次写大量页时集中触发复制开销堆在了请求路径上。处理方式是在 fork 之后、开始服务之前先主动预热一遍要写的数据区把复制成本前移。第三个坑是误判内存泄漏。容器里看着内存一直在涨实际上是因为 fork 出来的子进程和父进程都在写同一批页写时拷贝复制了物理页父进程的 RSS 没降子进程的 RSS 又涨上来总和翻倍。这不是泄漏是写时拷贝的正常开销进程退出后会释放。区分这两种情况的关键是看增长曲线是否收敛以及是否与 fork 事件强相关。第四个坑是页表项的权限位理解不到位导致误判。有同学写了一段往只读段写数据的代码看到崩溃就以为是段错误机制没生效实际上是编译器把那个“变量”优化进了寄存器压根没发生内存写。这类问题最好在编译时关掉优化-O0再观察或者把变量声明成volatile并实际取地址避免被优化掉。6.3 关于页表与地址空间几个值得记住的经验判断遇到与内存相关的诡异问题时我一般按这个顺序排查先确认崩溃地址落在哪个区域是代码段、堆、栈还是未映射区再看/proc/pid/maps确认这个区域的权限是否符合预期接着看/proc/pid/smaps里的详细字段确认是共享页还是私有页最后用 perf 或 strace 看系统调用路径。这个顺序能把大部分问题定位在一两分钟内。关于页表有个判断标准值得分享如果一个进程的 VSZ 很大但 RSS 很小别急着喊内存泄漏先看是不是按需分配的正常表现如果 RSS 持续增长且不收敛再看是不是写时拷贝复制了物理页。这两个统计量配合 minor fault 计数一起看基本能覆盖 90% 的内存困惑。最后一个经验是关于写时拷贝的使用边界。它在“fork 后 exec”和“fork 后少量写”这两种模式下收益最大在“fork 后大量写”的模式下反而会把开销堆到后面甚至比直接复制更差。如果你的程序属于第三种模式不要指望写时拷贝来救场直接改成线程模型或者在 fork 前把共享数据整理成只读可能才是更合适的做法。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。