Linux虚拟内存底层原理:页表、写时拷贝与进程地址空间全解析
有段时间我一直在排查一个诡异的内存问题某个服务在监控面板上显示内存占用 20GB可物理内存RSS却只有 3GB而且无论怎么压测那 20GB 只增不减。后来才意识到我盯的根本不是物理内存而是进程虚拟地址空间的占用。从那之后我认真把页表、写时拷贝和虚拟内存管理这条链路完整啃了一遍才发现系统编程里很多看似玄学的坑——fork 为什么快、malloc 返回的地址为什么能跳过一块未映射区域、为什么某个变量一写就段错误——全部可以归结到这套机制上。这篇文章就是把这些内容串起来的一份完整笔记适合已经写过一段时间 C/C、想彻底搞懂进程地址空间底层原理的朋友。1. 一切从每个进程都独占内存这个错觉开始1.1 没有虚拟内存的时代有多难在没有虚拟内存的早期系统里程序直接使用物理地址。进程 A 和进程 B 都在物理地址 0x1000 处写数据结果就是互相踩踏一个进程可以把另一个进程的内存改得面目全非。更要命的是程序还要求连续的大块物理内存配置内存碎片一多即使系统总空闲内存还有不少也凑不出一块足够大的连续区域来加载新程序。虚拟内存的引入把地址和物理存储彻底解耦。进程不再关心物理内存长什么样它看到的是从 0 到最大地址的一条连续街道内核在背后负责把这条虚拟街道映射到真正分散的物理页上。以 64 位 x86 为例用户空间实际可用的是低 47 位128TB并不是完整的 2^64因为高 16 位必须做符号扩展这部分细节后面讲页表时还会提到。1.2 地址空间更像一张地图而不是一整块连续内存很多初学者把虚拟地址空间理解成一整块真的存在的内存这是一切困惑的源头。更准确的理解是虚拟地址空间是一张地图上面标注着哪些地址有内容映射到了物理页、哪些地址是空的未映射。你去读写地图上没标注的区域CPU 会触发缺页异常内核检查后确认这块地址非法就用 SIGSEGV 干掉进程——这就是段错误最常见的原因。所以进程的虚拟地址空间默认就是一张大片留白的地图而不是铺满内容的大数组。内核只把程序实际需要的那几块区域映射好代码段、数据段、堆、栈、动态库、mmap 区域其余上百万 GB 的空间全是留白。提示写一个循环让指针不断递增并往里面写数据用不了多久就会撞上未映射区域程序以段错误收场。这个实验比任何文档都直观。1.3 最重要的推论虚拟地址相同物理地址可以完全不同同一个数值 0x7ffee1234000在进程 A 里可能是栈上某个变量在进程 B 里可能是堆上的一块数据在内核空间里甚至对应一段内核栈。进程之间的地址是同名不同人就像两个人身份证号不同但门牌号可以相同。这个推论是理解进程隔离、fork以及后面一切内容的地基。搞明白这个推论之后进程 A 改了一个地址进程 B 的数据会跟着变吗这类问题就有了解答方向不会因为它们的物理页互不相干。除非你显式使用共享内存或者用 mmap 映射同一个文件否则虚拟地址相同只是巧合底层的物理页完全各归各。2. 页表虚拟地址翻译到物理地址的那本电话簿2.1 为什么按页翻译而不是按字节地址翻译如果按字节做需要记录每个字节的映射关系存储量直接爆炸。于是硬件和操作系统约定按固定大小的页来管理x86-64 上标准页是 4KB此外还有 2MB 大页和 1GB 大页。页的好处是物理内存分配、权限检查、换入换出全部以页为单位一套规则统一处理。页表就是这本电话簿虚拟页号 → 物理页帧号。电话簿里每条记录页表项除了物理页帧号还有一堆标志位常用的几个列在这里标志位含义典型用途Present (P)该页是否真正映射了物理页为 0 时访问会触发缺页异常Read/Write (RW)是否可写写时拷贝机制的核心开关User/Supervisor (US)允许用户态还是仅内核态访问区分用户空间与内核空间Accessed (A) / Dirty (D)本页是否被访问过 / 被写过内核回收物理页时的依据NX该页不可执行防止在栈或数据段上执行代码2.2 多级页表把一本超大电话簿压成几页如果只用一张表映射 128TB 用户空间就算绝大多数地址都是留白也得预留天文数字的条目物理内存直接不够。所以 x86-64 采用四级页表PML4 → PDPT → PD → PT每一级有 512 个条目逐级往下索引。虚拟地址的 48 位这样切分47 39 38 30 29 21 20 12 11 0 | PML4 索引 | PDPT 索引 | PD 索引 | PT 索引 | 页内偏移 |翻译流程CPU 从 CR3 寄存器拿到 PML4 页的物理地址用虚拟地址的 PML4 索引9 位找到下一级表的物理地址再用 PDPT 索引找到再下一级依此类推最后一级 PT 条目里保存的就是物理页帧号加上页内偏移12 位得到最终物理地址。这条路叫页表遍历page table walk。多级结构最妙的地方在于只有实际映射了内容的路径才需要分配页表页。如果一个进程只映射了 0x400000 和 0x7ffd... 两块区域中间所有层的对应条目都留空整个进程的页表加起来可能只有几十 KB。这就像一本城市电话簿虽然城市地址空间巨大但没住人的街道根本不用印出来等真有人入住再临时补上那一页。2.3 TLB给地址翻译加一个缓存页表遍历每级一次内存访问四级就是四次一次普通内存读要额外读好几次内存性能不可接受。所以 CPU 内部有一个专门缓存最近翻译结果的 TLBTranslation Lookaside Buffer。命中 TLB 时一次虚拟地址翻译只需几纳秒未命中才需要真正走页表遍历然后再把结果填进 TLB。进程切换时需要切换 CR3旧 TLB 条目也就失效了。早期的 CPU 选择直接刷掉整个 TLB所以进程切换开销不小新一点的 CPU 支持 PCID进程上下文标识符把不同进程的 TLB 条目打上标签切换时不用全刷。这个细节平时写代码感知不到但做性能分析时能解释很多为什么进程切换这么贵的现象。注意TLB 容量很有限几十到几百个条目程序访问的数据局部性越好TLB 命中率越高。这就是为什么遍历大数组要尽量顺序访问而不是随意跳地址。3. fork 的速度之谜写时拷贝COW的完整链路3.1 fork 为什么可以做到瞬间返回系统编程教科书会告诉你 fork 创建子进程子进程获得父进程内存的副本。如果这个副本真是逐字节拷贝那么一个 4GB 的进程 fork 一次就是 4GB 的内存搬运怎么也要几毫秒到几十毫秒。但实际你测 fork 会发现它几乎瞬间完成因为 Linux 用了一个核心技巧不拷贝数据只拷贝页表然后把共享页都标记为只读。具体流程是fork 时内核把父进程页表复制一份给子进程父子进程的页表条目指向同一批物理页但 RW 标志位全部从可写改成只读同时清空 CPU 的脏页标志。这一刻子进程和父进程看到的内容一模一样但谁也不能写。这就像两兄弟合住一间房房东内核把房门钥匙给了两份但门上贴了禁止改动的封条。3.2 第一次写操作缺页异常的灵魂处理既然是只读页表父进程或子进程一写不就段错误了吗不会。CPU 触发的是缺页异常page fault而不是普通违例。Linux 内核里的异常处理程序会判断这个页表条目 Present 为 1、RW 为 0、当前是写操作、且该页原本可写——这些条件凑齐就说明这是写时拷贝页。此时内核走一条专门的 COW 处理路径分配一页新的物理页把旧物理页内容拷贝到新页把当前进程的页表条目指向新物理页并置为可写旧物理页仍然映射在另一个进程的页表里保持只读返回用户态重新执行那条触发异常的写入指令。整个过程对用户态完全透明你只是感觉到写了一行数据背后内核替你完成了一次搬新家。哪个进程先动手写哪个进程就拿到新页没动手的那个进程继续用着旧页。所以 COW 是按需复制——谁要改谁负责出这份拷贝的钱。3.3 用数字掂量一下 COW 到底省了多少假设一个进程占 1GB 内存fork 一次如果不做 COW需要拷贝 1GB 数据外加拷贝页表做了 COW只拷贝页表。1GB / 4KB 262144 个页面最底层的页表项页是 262144 / 512 512 个也就是 2MB再加上上面三级的几个页总共才 2MB 多一点。fork 从 GB 级数据拷贝降到了 MB 级页表复制还没有算物理内存的节省。常见的 fork 后立即 exec 用法更是把 COW 的价值发挥到极致父进程的物理页根本没被写子进程一 exec 就把页表整个换掉了COW 在中间几乎零额外开销。提示看 RSS 就能直观验证 COW。fork 之后、子进程写内存之前父子进程的 RSS 几乎不增长子进程写入大块内存后RSS 立刻上升。用 /proc/ /status 里的 VmRSS 配合一个小实验就能观察到。COW 也不是没有代价。如果子进程 fork 后根本不 exec而是疯狂覆盖一块几 GB 的内存每写一个新页就会触发一次缺页异常和内存拷贝这时候整体开销反而比一次性 memcpy 还高缺页异常路径、页表更新、TLB 抖动都算进去了。所以 COW 适合少量写入和写完即 exec的场景不适合fork 后全量覆盖的场景。4. 进程地址空间的地图绘制内存布局、VMA 与 malloc 的真实行为4.1 从低地址到高地址一次看完进程的内存布局一个典型的 Linux 用户进程虚拟地址空间从低到高大致是区域说明典型位置代码段text可执行指令只读可执行0x400000 附近只读数据rodata字符串常量、静态常量紧随 text数据段data/bss已初始化 / 未初始化全局变量代码段之后堆区小块 malloc向高地址增长数据段之后mmap 区域动态库、大块 malloc、共享内存从高地址向低地址增长栈区局部变量、函数调用帧向低地址增长0x7fff... 附近内核空间内核代码与数据用户态不可访问0xffff800000000000 以上注意堆向上、栈向下中间隔着大片的 mmap 区域所以堆和栈一般不会相遇。这也是为什么一个无限 malloc 的程序往往先触碰 mmap 区域边界而不是栈区。理解这张城市地图对后续阅读任何内存报错、崩溃日志都有直接帮助。4.2 VMA内核眼里进程内存的管理单元用户态看到的是地址 内容内核看到的则是一棵按地址排序的 VMA 树。VMAVirtual Memory Area是内核记录某一段连续地址拥有什么属性的数据结构每个 VMA 保存起始地址、结束地址、权限、是否映射文件、映射偏移等信息。/proc/ /maps 的每一行就是一个 VMA。比如这一行7f8a4c000000-7f8a4c200000 r-xp 00000000 08:01 1234567 /usr/lib/libc.so.6含义地址范围 7f8a4c000000 到 7f8a4c200000拥有读和可执行权限r-x是私有映射p对应设备 08:01 上的文件偏移 0inode 是 1234567。看懂这一行你也就理解了动态库为什么能多进程共享物理页同一个 .so 文件被映射进多个进程时文件页只有一份物理副本代码段通过只读共享各进程自己的数据段则各自私有。VMA 对系统编程的意义在于很多内存相关的系统调用本质上就是对 VMA 树做插入、删除、合并。mmap 是插入一个新 VMAmunmap 是删除mprotect 是修改某个 VMA 的权限。你对内存做的每个操作最终都落在这棵树上。4.3 malloc 的两种策略brk 与 mmap 的分工很多人以为 malloc 是向操作系统要内存其实大部分 malloc 根本不碰系统调用。glibc 的 malloc 维护自己的空闲链表小块分配从空闲区直接划拨只有当前空闲区不够时才向操作系统申请。申请路径有两条brk把堆的末尾向上推适合小块、频繁分配的场景但释放后堆顶不下降时容易形成内存空洞mmap在 mmap 区域分配一块独立映射。glibc 默认超过 128KBMMAP_THRESHOLD的分配直接走 mmap好处是释放时直接 munmap 归还内核坏处是每次分配都涉及系统调用性能稍差。所以你在程序里大块 malloc查看 maps 文件会看到某个 0x7f... 开头的地址段增长小块 malloc 则体现在堆区增长上。排查内存问题时这个区分很有用定位到底是哪块地址在涨能快速缩小怀疑范围。栈的情况单独说一下。栈在 VMA 里也是特殊的初始值很小主线程典型 8MB每次压栈导致越界访问时内核通过缺页异常按需扩展栈 VMA。每增长一页就多一个物理页一旦超出栈区域上限就 SIGSEGV。这就是经典栈溢出的底层原理——不是没有虚拟地址而是栈区域的 VMA 边界到了。5. 亲手观察用 /proc 和一段 C 代码验证地址空间的每个结论5.1 先写一段地址探针程序下面这段 C 程序打印各种对象的地址你可以直接从输出里验证上面的布局#include stdio.h #include stdlib.h int global_var 42; /* data 段 */ char buf[4096]; /* bss 段 */ int main(void) { int local_var 1; /* 栈 */ char *heap_small malloc(64); /* 堆brk */ char *heap_big malloc(1024 * 1024); /* mmap 区域 */ printf(main %p\n, (void *)main); printf(global %p\n, (void *)global_var); printf(bss %p\n, (void *)buf); printf(stack %p\n, (void *)local_var); printf(brk %p\n, (void *)heap_small); printf(mmap %p\n, (void *)heap_big); getchar(); return 0; }编译运行后在另一个终端执行cat /proc/pid/maps你就能看到程序的 text、data、堆、mmap、栈分别落在哪个地址区间。对比 printf 的输出和 maps 文件之前所有的布局说法都一一对应。第一次做完这个实验时栈地址是 0x7ffd...mmap 是 0x7f...差距非常直观。5.2 用 VmRSS 验证 COW 的事后数据想看到写时拷贝的真实效果可以做个验证型程序分配并初始化 512MB 内存fork 后让子进程先只读一遍、再写一遍每个阶段都打印父进程和子进程的 VmRSS。关键读取函数可以这样封装static void print_rss(const char *tag) { FILE *f fopen(/proc/self/status, r); char line[256]; while (fgets(line, sizeof(line), f)) { if (strncmp(line, VmRSS:, 6) 0) { printf(%s: %s, tag, line); break; } } fclose(f); }运行结果通常符合这个规律分配并全部写完后父进程 VmRSS 约 520MBfork 后子进程只读父子 VmRSS 基本不变——共享物理页不算重复占用子进程逐页写入整块内存后子进程 VmRSS 涨到接近 520MB父进程保持不变。这个实验比任何文档都更能说明问题COW 省的是未触碰的内存一旦真正写入该来的物理内存还是会来。5.3 从 /proc/ /smaps 看更细的内存属性maps 只给出 VMA 的骨架smaps 则补充了每个 VMA 的细节字段。最有价值的几个字段是Rss该 VMA 当前占据的物理页数量Pss按共享比例折算后的物理页数多进程共享一页时每个进程只分摊 1/NSwap换出的页数Private_Dirty / Shared_Dirty私有脏页和共享脏页数量这是判断 COW 是否已经发生的最有用字段。排查哪个进程吃物理内存多时把每个 VMA 的 Pss 排序一眼就能看出大头在哪里。日常监控面板显示的 RSS 是总和它会把多个进程共享的库页重复计算Pss 才是更接近真实的单进程内存账本。很多内存异常排查到最后其实是共享页被重复计算造成的虚惊一场。6. 我在实际项目中踩过的地址空间相关的坑6.1 坑一把 VIRT 当物理内存占用监控数据全看错了监控工具默认显示 VIRT虚拟地址空间大小而虚拟地址是地图而不是实际内容。一个长时间运行的服务光靠动态链接库、mmap 预留、线程栈VIRT 就能轻松上几十 GB实际物理占用RSS可能只有一两 GB。我们一度以为服务内存泄漏扩容加机器后问题依旧最后发现监控面板看的是 VIRT。排查内存问题时务必同时看 RSS/PSS而不是只看 VIRT。6.2 坑二fork 后在子进程里狂写内存COW 从帮手变成拖累有人写多进程服务fork 后每个子进程都要加载 1GB 的配置数据并修改结果系统出现明显的掉速和内存抖动。原因就在 COW每写一页就触发一次缺页异常和拷贝量大以后系统调用和异常处理的开销超过直接拷贝。正确做法是加载完配置再 fork或者 fork 后用 exec 重新加载而不是依赖 COW 兜底。6.3 坑三多线程程序动 fork 的连锁反应多线程程序里 fork子进程只保留调用 fork 的那个线程其他线程全部消失。如果那些线程当时正持有锁比如 malloc 内部的锁子进程里再用 malloc 就可能死锁。现代 glibc 在 fork 后有清理逻辑但仍建议多线程程序能不用 fork 就不用改用 posix_spawn 或手工拉起子进程省得在细节上踩雷。6.4 坑四ASLR 把动态库基址随机化带来的调试噩梦开启 ASLR 后每次启动程序栈、堆、动态库基址都不同gdb 断点有时会因为地址变化而失效崩溃日志里的地址难以复现。调试时可以用setarch -R关闭地址随机化或者在 gdb 里使用set disable-randomization on保持运行稳定。理解了页表和 mmap 机制后你才明白这件事的本质同一个 VMA 每次启动被分配到不同基址这叫合理的安全机制但确实给调试添了麻烦。6.5 排查内存问题时的三板斧顺序遇到内存相关异常我的排查顺序基本固定先看 /proc/pid/smaps 的 Pss确认物理内存大头在哪再看 maps 里的 VMA 边界确认是不是某块区域异常增长最后用 strace 跟踪 mmap/brk/munmap 调用定位是哪条代码路径在持续申请。这套流程配合上面几个工具绝大部分地址空间的坑都能在几分钟内定位。如果还解不出来我通常会直接看进程是否触发了大量缺页异常用perf stat -e page-faults -p pid观察全局异常频率这往往能把 COW、换页、堆扩展三类问题区分开。地址空间的机制逻辑其实很简洁页表负责翻译VMA 负责描述COW 负责偷懒缺页异常负责兜底。工程上遇到内存相关怪问题先把问题翻译成这几个词再往下排查往往比到处瞎猜快得多。