
做 Linux 系统编程绕不过去的一个坎就是进程地址空间。我最早接触这个概念时完全被“父子进程打印出同一个指针地址却各自改不到对方的数据”这个现象搞懵了——同一个地址怎么可能对应两份不同的数据后来把页表、写时拷贝、虚拟内存管理这整条链路理顺才发现这个问题恰好是理解 Linux 内存模型最好的入口。这篇文章不会只堆概念我会从地址空间布局讲到多级页表的翻译过程再拆解 fork 背后的写时拷贝机制最后用一组可复现的 C 代码和 /proc 工具带你亲眼验证这些机制是怎么工作的。无论你是准备面试、排查线上问题还是想把内核知识补扎实这条主线都值得完整走一遍。1. 先建立全局观进程地址空间到底是什么1.1 一个帮助你理解的概念模型很多人把“进程地址空间”理解为“进程能用的内存”这个说法不算错但会让你错过真正要紧的细节。更准确的说法是地址空间是一张“虚拟地图”里面记录了这个进程所有合法地址的范围以及每个范围对应的属性——可读、可写、可执行以及它映射到哪儿。你可以把每个进程想象成手里握着一张城市地图地图上的每个街道门牌号就是虚拟地址而城市里真实的建筑才是物理内存。地图允许你画出一个“连续的街区”哪怕这些建筑在物理上东一个西一个完全不相邻。因为每个进程手里拿的是自己的地图所以进程 A 的门牌号 0x1000 和进程 B 的门牌号 0x1000完全可以指向两栋完全不同的建筑互不干扰。我后来排查很多诡异问题时都会先回到这个模型上。例如“为什么这个指针在父进程和子进程里地址一样内容却不一样”答案就藏在这张地图和背后物理页的关系里往下看就会明白。1.2 用户态与内核态的分野在 64 位 x86-64 Linux 上虚拟地址空间被一分为二低位部分归用户态高位部分归内核态。用户进程能直接访问的只有低位部分高位部分是内核的领地用户态代码一旦尝试访问就会触发保护异常也就是我们常说的段错误。典型的用户态地址空间布局从上到下大致是这样高地址 → 0x00007fffffffffff ------------------------------ | 环境变量、参数区栈顶 | | 栈区向下增长 | | ↓ | | 内存映射区mmap可向上增长 | | ↑ | | 堆区向上增长brk 管理 | | BSS 段未初始化全局变量 | | 数据段已初始化全局变量 | | 代码段text通常 0x400000 起| ------------------------------ 低地址 → 0x0000000000400000这里有几个容易忽视的细节。第一栈不是“向上长”的而是从高地址向低地址增长堆则相反从低地址向高地址增长所以堆和栈之间留了一块巨大的空洞区域平时不映射任何页只有 mmap 映射或栈/堆扩展到那里时才真正建立页表映射。第二现代 Linux 默认开了地址随机化ASLR所以 PIE 程序实际加载地址往往在 0x555555554000 附近而不是固定的 0x400000。1.3 地址空间里的两类映射文件映射与匿名映射看/proc/pid/maps时你会注意到每行末尾有的带路径有的不带。带路径的属于文件映射file-backed比如可执行程序的代码段、动态库 .so 的内容不带路径的属于匿名映射anonymous比如堆、栈、以及 malloc 申请的大块内存。这两类映射在系统里的行为完全不同。文件映射意味着这些虚拟页背后有一个磁盘文件作为“后备存储”如果把页面换出可以直接从文件读回来匿名映射没有后备文件换出时只能写到交换分区swap。写时拷贝对这两类页面也有不一样的路径这也是为什么读 smaps 时能看到 Shared_Clean、Shared_Dirty、Private_Clean、Private_Dirty 这么多字段。先记住“文件映射 vs 匿名映射”这个二分法后面分析内存占用时会非常有用。2. 页表与虚拟内存管理虚拟地址到物理页的翻译链路2.1 为什么非要加一层“虚拟”虚拟内存这套设计初看是“吃力不讨好”因为每次访问内存都要多做一次地址翻译。但你要是站在操作系统整体角度想就会发现它解决了几个用物理地址做不到的问题。第一个是进程隔离。没有虚拟内存时进程直接操作物理地址任何一个野指针都可能踩坏别的进程甚至内核的数据。有了页表之后每个进程都只能看到自己映射过的物理页默认情况下根本拿不到别人的页安全性完全由硬件翻译机制保证。第二个是内存紧凑性。每个进程的地址空间看起来是连续的代码不需要关心物理内存碎片。物理内存可能被分配得七零八落但虚拟地址空间永远是一片整齐的“逻辑连续区”编译器、链接器、加载器都只需要面对虚拟地址。第三个是“可撒谎”。虚拟内存系统可以假装给进程分配了很大的内存但实际物理页等到写入时才真正提供。这叫作按需分配demand paging。进程 malloc 了 1GB系统一开始可能只记账不干活等程序真的触碰到这些页时才一页一页地分配物理内存。没有虚拟内存这层缓冲这种“超额承诺”完全做不到。2.2 多级页表一次精妙的空间换时间地址翻译最直观的做法是“一级页表”把虚拟地址当作下标直接查一个巨大的数组数组里每一项对应一个物理页。听上去简单但在 32 位系统上4GB 地址空间分成 4KB 页后需要 100 万个页表项每项 4 字节就是 4MB 的页表。100 个进程就是 400MB还没算上物理内存本身。Linux 在 x86-64 上采用的是多级页表。标准 4 级结构从上到下依次是 PGD页全局目录、PUD页上级目录、PMD页中间目录、PTE页表项。每一级有 512 个表项因为 48 位虚拟地址去掉 12 位页内偏移后剩 36 位平均分给 4 级每级 9 位9 位正好编码 512 个表项。多级页表的最大优势是“用到哪一级才建哪一级”。一个进程如果只用了 2MB 地址空间顶多建立少量 PGD/PUD/PMD/PTE 表绝大多数 PGD 表项都是空的不用为整个地址空间预分配。这就像你在一个房间里只有一张桌子不需要为整个城市所有的房间都贴上目录。访问内存时的翻译过程大概是从 CR3 寄存器拿到顶级页表地址用虚拟地址的高 9 位索引 PGD得到 PUD 表的地址再用接下来的 9 位索引 PUD得到 PMD 表地址再索引 PMD 得到 PTE最后用 PTE 里的物理页基址加上虚拟地址低 12 位偏移得到真正的物理地址。所以一次普通内存访问在理论上要额外访问 4 次内存来查表远没有你想象的“一次搞定”。这也是为什么 CPU 必须引入 TLB 来缓存翻译结果。2.3 TLB页表的高频缓存TLB 的全称是 Translation Lookaside Buffer你可以把它理解成 CPU 内部专门缓存“虚拟地址 → 物理地址”的小型高速缓存。程序如果反复访问同一个页内的多个地址第一次翻译后结果就留在 TLB 里后续访问直接命中根本不用再走 4 级页表。但 TLB 有两点要特别注意。第一它的容量很小通常只有几十到几百个表项所以程序访问的内存页越少越集中TLB 命中率越高。大页2MB 或 1GB之所以能提升性能本质就是让一个 TLB 表项管住更大的地址范围减少表项数量。第二进程切换、mmap 修改、munmap 释放都可能使 TLB 失效内核必须做 TLB flush。频繁的上下文切换会连带造成 TLB 抖动这就是为什么线程共享地址空间在某些场景下比多进程有性能优势的原因之一。页表项本身还携带一大堆属性位排查问题时非常有用。我常用的几个属性位含义实际排查用途Present第 0 位页面是否在物理内存中为 0 时会触发缺页异常Writable第 1 位页面是否可写COW 就是靠清这个位实现的User第 2 位是否允许用户态访问内核页会置 0Accessed第 5 位 / Dirty第 6 位是否被访问 / 被写过内核换页算法依据PFN第 12 位起物理页帧号通过 /proc/pid/pagemap 可读需 root2.4 缺页异常按需分配与换页的发动机CPU 翻译地址时如果发现 PTE 的 Present 位为 0或者访问权限不符会触发缺页异常把控制权交给内核的缺页处理程序。这里要区分两种缺页小缺页minor fault和大缺页major fault。小缺页指页面其实已经在内存里只是当前进程的页表里没有映射。最典型的就是读共享库代码段、或者访问刚 malloc 的匿名内存——内核只需要建立映射把页表项填好即可不需要磁盘 I/O。大缺页则意味着页面在磁盘上文件或 swap必须从磁盘读回来代价高出一个数量级。排查性能问题时ps -o minflt,majflt和getrusage()里能直接看到这两个统计非常有参考价值。还有一个容易忽略的“零页优化”新分配的匿名页初始内容全是 0内核会让所有此类页暂时指向同一个共享的零页PTE 标记为只读。一旦进程真的写入某个字节才会触发 COW 式缺页分配出真正的物理页。所以连“分配一页内存”这件事都是懒的这也是 malloc 那么快的原因之一。3. 写时拷贝fork 能这么快的核心秘密3.1 如果没有写时拷贝fork 会怎样按教科书式思路fork 要创建子进程最简单粗暴的做法就是把父进程的所有物理页完整复制一份给子进程。这在理论上可行但实际上极其浪费一个占用了 2GB RSS 的进程fork 一次就要复制 2GB 数据耗时随内存规模线性增长。更尴尬的是绝大多数程序 fork 完之后立刻调用 exec 加载新程序exec 会直接把子进程的地址空间推倒重建前面辛辛苦苦复制的所有页全部作废。所以内核工程师发明了一个“先共享、后复制”的策略fork 时先不复制物理页父子进程共享同一批物理页只有当某一方真正写入时才把这一页复制一份。这就是写时拷贝Copy-on-Write简称 COW。3.2 COW 完整流程拆解COW 的实现分成三个阶段每一阶段都有各自的细节。第一个阶段是 fork 初始化。父进程调用 fork 进入内核后内核会复制 mm_struct 和 vm_area_struct 等地址空间元数据然后遍历所有“私有的、可写”的映射页把这些页的 PTE 写入权限位清掉也就是强行把页面变为只读。同时这些物理页的引用计数会加一表示现在有两个进程的页表指向它。注意本来是只读的页比如代码段不需要动它们本来就不能写。第二个阶段是写入触发。亲子任意一方对共享页执行写操作时CPU 发现 PTE 的写入权限位为 0抛出一个保护异常进入内核缺页处理程序具体路径是do_wp_page()。内核拿到这个页面的 struct page检查引用计数。第三个阶段是真正决定“要不要复制”。如果引用计数等于 1说明这个物理页已经只有当前进程在使用没必要复制直接把 PTE 的写入位恢复即可。如果引用计数大于 1说明还有别的进程在共享这个页内核会分配一个新物理页把旧页的内容复制过去更新当前进程的 PTE 指向新页并加写入权限然后把旧页的引用计数减一。整个过程在内核态完成用户进程毫无感知。这个过程里最值得品味的是“引用计数等于 1 就只改权限不复制”这个快路径。它让 Linux 在处理“fork 后很快只由一方使用”的场景时特别高效也解释了为什么很多 fork 子进程独立写各自数据时系统并没有被频繁的页复制拖垮。3.3 为什么父子进程的地址相同物理地址却不同理解了 COW 流程最初那个“同地址不同内存”的谜团就解开了。fork 之后父进程和子进程里的同一个堆指针虚拟地址当然一样因为页表里记录的就是同一个虚拟地址。在发生写入之前两个页表项都指向同一个物理页所以谁都没改到数据时看到的内容一模一样。一旦有一方写入内核复制物理页这一方页表指向新物理页另一方还指向旧物理页。从此同样的虚拟地址在不同的进程里映射到不同的物理页内容自然就分道扬镳了。我也见过有人误以为 COW 意味着“永远不会复制内存”这是不对的。只要两个进程都持续写各自的私有多页数据每一页都可能在第一次写入时触发一次复制累计起来依然是一笔不小的开销。检测 COW 开销的现实手段是看缺页次数和系统 CPU 时间如果 fork 后父子进程频繁交叉写大量内存你会发现 parent 和 child 的运行时间都会明显增长。3.4 fork execCOW 的最佳应用场景exec 系列函数的语义是把当前进程的地址空间整个替换掉。如果没有 COWfork 复制的大量内存会在 exec 时被扔掉白白浪费。有了 COW 之后fork 阶段做的只是清权限位、修改页表几乎不碰物理页内容所以 fork 一个 10GB 地址空间的进程也只需要少量时间。不过要注意COW 并不是对 exec 的完整替代。早期 Unix 还有一种更极端的vfork它直接让子进程共享父进程的地址空间并且阻塞父进程直到子进程 exec。vfork 更省内存但语义极其危险子进程只要敢写任意一个变量就会污染父进程的数据。现代 Linux 里 vfork 依然存在但实际场景中大部分用途已经被“fork COW”取代。我个人的实践体会是能用普通 fork 就尽量别用 vforkCOW 已经替你挡掉了绝大多数内存复制开销没必要为了那点性能去冒共享地址空间的险。4. 实操让虚拟内存机制现出原形4.1 用 /proc/ /maps 看清地址空间布局理论讲再多不如亲眼看一下。最容易上手的工具是/proc/pid/maps随便找个进程比如cat /proc/self/maps或者找一个在跑的服务的 PID看它的 maps 文件。每行包含六个字段起始地址-结束地址、权限、文件偏移、主设备号:次设备号、inode、文件路径。权限位那四个字符很有意思字母r、w、x、p分别代表可读、可写、可执行、私有如果是共享映射最后一位是s。你可以专门盯着[heap]、[stack]、动态库的代码段这几行看对比权限差异。例如堆区通常是rw-p动态库代码段是r-xp数据段是rw-p。看到r-xp的代码段时你的大脑应该自动联想到“这里映射了磁盘上的 .so 文件页面按需缺页加载同时通过写保护保护代码不被篡改”。4.2 用 smaps 观察共享页与私有页maps 只给了地址范围和权限要看内存的共享/私有分布需要用/proc/pid/smaps。它是 maps 的增强版为每个映射段列出更细的内存统计。我最常看的是这几个字段RSS驻留物理内存、PSS按比例分摊后的内存、Shared_Clean共享且干净的页、Shared_Dirty共享且脏的页、Private_Clean、Private_Dirty。这里有一个人人都会踩的认知坑用 RSS 统计进程内存时共享库的物理页会被每个进程重复计算。比如 libc.so 代码段占 2MB100 个进程都加载它RSS 加起来会多出 198MB 虚高。真正的系统实际占用应该看 PSS——它把共享页按共享进程数均摊。所以分析容器内存或进程内存占用时请优先关注 PSS而不是 RSS。4.3 COW 验证实验让父子进程“同址不同内存”下面这个实验我建议你亲手跑一遍它能一次性验证虚拟地址共享、COW 和物理页拆分三个结论。#include stdio.h #include stdlib.h #include unistd.h #include sys/wait.h int main(void) { char *p malloc(4096); sprintf(p, hello cow); pid_t pid fork(); if (pid 0) { perror(fork); exit(1); } if (pid 0) { /* 子进程写入触发 COW */ sleep(1); printf([child ] before write: %s, addr%p\n, p, p); sprintf(p, child modified); printf([child ] after write: %s, addr%p\n, p, p); sleep(1); } else { sleep(2); printf([parent] content: %s, addr%p\n, p, p); wait(NULL); } free(p); return 0; }编译运行后你会看到父子进程打印的addr完全相同但父进程最后看到的字符串仍然是hello cow而不是子进程写入的child modified。这说明虚拟地址被两者共享但物理页已经被 COW 拆开了。为了进一步验证物理页不同可以用/proc/pid/pagemap在 root 权限下把虚拟页号换成物理页帧号PFN。不过 pagemap 的格式略啰嗦需要读 8 字节、取第 0-54 位作为 PFN非 root 用户读到的是 0。我建议你在带 sudo 的环境中试一次实测看到两个进程的 PFN 不一样效果比文字描述直观得多。跑实验的过程中你还可以顺手关注一次缺页次数fork 前后分别在父进程里打印/proc/self/status里的minor和major字段或者在程序里用getrusage(RUSAGE_SELF)获取ru_minflt和ru_majflt。第一次访问 malloc 出来的 4KB 会产生一个小缺页子进程第一次写那个页时又会产生一个 COW 缺页观察数值变化能把“按需分配”和“写时拷贝”两个机制同时验证掉。4.4 用 pmap 和 status 快速排查内存形态日常排查内存问题我一般按这个顺序来先ps -o pid,vsz,rss,minflt,majflt -p pid看概览再pmap -x pid看各映射段明细最后对照/proc/pid/status里的 VmSize、VmRSS、VmData、VmStack、VmPTE。VPTE 这个字段在部分内核版本里叫 VmPTE它直接告诉你当前进程的所有页表本身占了多少物理内存——一个进程的页表也能占到几 MB在大规模容器部署时这个数字不可忽视。$ pmap -x 1234 Address Kbytes RSS Dirty Mode Mapping 0000555555554000 760 760 760 r-x-- app 0000555555624000 16 16 16 r---- app 0000555555628000 32 32 32 rw--- app ...看到这行输出试着把每个 Mapping 的权限和它的页属性对应起来r-x的代码段是文件映射私有的只读页写 COW 和它基本无关rw的匿名堆页则承载了绝大多数 COW 行为。这种“对着地址看属性、对着属性想机制”的训练做多了之后排查内存问题会快很多。5. 常见问题与排查技巧实录5.1 “malloc 了一大块内存为什么 RSS 没涨”这个问题几乎每个刚接触虚拟内存的人都会遇到。原因是 malloc 只是扩展了进程的堆区 VMA虚拟内存区域并没有真正分配物理页。只有当你实际读或写每个页时缺页异常才会让内核逐页把物理内存挂上来。所以 malloc 1GB 后 RSS 为 0 是完全正常的RSS 随着你逐个页访问而增长也属于正常现象。如果你确实想让内核立刻准备物理页可以用mmap加MAP_POPULATE标志但这种“提前分配”通常只有在性能敏感且需要避免运行中缺页抖动时才值得用。有一个更隐蔽的问题malloc 超过一定阈值默认 128KB可通过M_MMAP_THRESHOLD调整时会改用 mmap 而不是 brk得到的映射在释放时会立即归还系统。而小块堆内存走 brk释放后不一定立即还给内核堆的空间可能“只涨不缩”。这就是你看到进程 RSS 释放后依然居高不下的常见原因之一。5.2 “为什么访问一个跨进程的指针会段错误”代码里如果把某个进程的地址传给另一个进程去访问几乎必然段错误。原因很好理解虚拟地址在不同进程里映射的物理页完全不同目标进程根本没有给那个地址建立映射。IPC 的正确姿势应该用共享内存、管道、socket 等机制而不是直接传指针。每次遇到这种代码我都会提醒对方把虚拟内存模型重新复习一遍——你传的是地图上的“门牌号”不是建筑本身。5.3 “监控里 RSS 虚高到底该信谁”见 4.2 节提到的问题RSS 会把共享库重复计算。一个更极端的场景是多个进程同时 fork 自同一个父进程如果它们都只是只读访问共享内存页RSS 数值会非常好看但实际物理内存只有一份。判断真实内存压力时优先看系统的/proc/meminfo中的MemAvailable和CommitLimit而不是简单地把所有进程 RSS 相加。单个容器或进程的内存上限评估则看 cgroup 的memory.current和memory.peak那才是内核真正为你记账的数字。5.4 常见误区速查表误区真相同一虚拟地址一定对应同一物理地址不同进程的同一虚拟地址通常对应不同物理页父子进程 fork 后内存完全独立刚开始共享写入时才拆分COWCOW 等于不复制内存它只是延迟复制写多少就复制多少malloc 后内存立即分配只是映射 VMA物理页按需分配RSS 相加等于系统内存占用共享页被重复计算应看 PSS 或 cgroup 统计页表本身不要钱页表和 TLB 都有真实开销大页可以缓解排查这类问题时我的一个强烈建议是不要凭感觉猜直接用/proc/pid/pagemap、smaps和perf去测。内核给你留好了观察窗口只是很多人不知道而已。最后再分享一个排查技巧COW 之外还有一个配套机制容易被忽略madvise和fork之后的MADV_WIPEONFORK/MADV_DONTFORK。如果你在 fork 子进程后立刻 exec大量 COW 页很快被丢弃内核在部分场景下可以跳过这些页的处理省下清 PTE 的时间。我的实际体会是大规模并行计算场景里把“注定不会被父进程继续用的内存”显式标记掉能明显降低 fork 耗时但前提是你真的清楚这些页不会被复用否则容易引入隐蔽的数据一致性问题。从页表到 COW这条链路本身并不复杂真正难的是在实战中保持对“虚拟地址只是映射、物理页才是真相”的警觉。