资讯详情

进程地址空间、页表与写时拷贝:一篇讲透Linux虚拟内存

📅 2026/10/10 6:54:53 | 华诺云谱 👁 阅读
进程地址空间、页表与写时拷贝:一篇讲透Linux虚拟内存
写过 C 的程序员几乎都遇到过这么一幕定义一个指针printf 打印它的地址fork 一个子进程两边读这个指针——地址一模一样值却各改各的互不干扰。很多人第一次看到这个结果都会愣一下地址相同改的却是各自的内存这到底怎么做到的答案就在标题里那三个词进程地址空间、页表、写时拷贝。简单说你看到、用到的每一个指针从头到尾都是一串“虚拟地址号牌”真正的物理内存藏在这些号牌背后由内核里的页表和内存管理机制负责翻译和调度。理解了这套东西很多看似诡异的现象——为什么 malloc 了 1G 内存但 top 里 RES 很小为什么 fork 特别快为什么父进程一退出子进程动不动就 OOM——都能一眼看穿。这篇东西适合三类人正在啃 APUE 或《深入理解Linux内核》却总觉得隔着一层纸的读者准备操作系统或大厂面试想一口气把内存这块串起来的同学以及真在生产环境被“内存持续上涨”“莫名段错误”折磨过、想搞清楚排查思路的工程师。1. 进程地址空间每个进程都活在一张“地图”里1.1 地址空间不是一块连续的物理内存而是一堆区域描述符很多初学者最大的误区是把“进程地址空间”当成一块实实在在的大块内存。其实它只是一个“地图册”。内核给每个进程维护一个mm_struct里面挂了一串vm_area_struct也就是 VMA——每一段连续的虚拟地址范围都有对应的权限、映射目标比如文件映射、匿名映射。整张地图画的不是内存条上哪个位置而是“虚拟地址”怎么对应到“物理页”。拿 x86-64 Linux 来说用户态进程的地址空间从0x0000000000000000一直到0x00007fffffffffff约 128TB。 经典布局从低到高分别是只读代码段text从0x400000附近开始PIE 开启时加载到随机基址只读数据段.rodata和初始化数据段.dataBSS 段未初始化的全局变量不占物理页只是把虚拟地址先圈好堆heap从低地址往高地址生长内存映射区mmap base放共享库、mmap出来的匿名内存一般在高地址附近栈stack从高地址往低地址生长。用pmap看一眼就能建立直觉比如随便跑一个常驻进程pmap -x PID会列出很多行每行就是一段 VMA有起始地址、大小、权限、映射对象。我建议你打开自己的终端选个进程执行一次比起背结构体看真实地图更能把概念落下来。1.2 为什么非要绕一圈虚拟地址隔离和连续性缺一不可没有虚拟地址物理内存是共享的一个大池子每个进程直接访问物理地址编译器就得知道全局变量到底落在内存条的哪几个字节上进程一多谁改谁的数据根本没法管。引入虚拟地址之后每个进程手里只有自己的“地图”A 进程的地址 0x1000 和 B 进程的地址 0x1000 是两回事物理页被谁映射、能不能写全部由页表和权限控制。这就是进程隔离的地基。第二个好处是“连续性幻觉”。物理内存经过长期分配和释放碎片化是常态10G 的物理内存可能凑不出一段连续的 1G 空间但虚拟地址可以随便安排——内核只要在页表里把这段虚拟地址指向分散的物理页就行。对用户态程序来说malloc 返回的地址看起来连续没人关心背后的物理页散成什么样这让内存分配器简单了很多。顺带说一句这也解释了为什么指针在 64 位系统上可以指向很高的地址那不是物理地址只是地图上的坐标。1.3 栈、堆、mmap 区地址为什么不固定ASLR 会把栈、mmap 基址、共享库加载地址甚至可执行文件的加载地址随机化。所以同一次运行两次打印argv[0]或栈变量地址结果都不一样。这本来是为了防止攻击者猜固定布局去覆盖返回地址但很多人面试时会疑惑“为什么代码段不在 0x400000 了”。从 2.6.12 内核开始默认开启随机化setarch -R可以临时关掉方便调试那些对地址有强假设的程序。工作里常见的情况是某些二进制文件或调试器需要固定地址就会用setarch $(uname -m) -R启动否则每次附加调试、断点不对齐会很别扭。2. 页表那本把虚拟地址翻译成物理地址的“字典”2.1 页、页框、页表项以及为什么需要多级页表页表的核心单位是“页”。Linux 默认物理页大小是 4KB虚拟地址低 12 位是页内偏移剩下的高位部分用来索引页表。页表项PTE里最重要的几个字段PFN物理页框号翻译结果的“后半截”Present 位这一页是否存在访问不存在的页会触发缺页异常R/W 位可写标志正是写时拷贝的核心机关User/Supervisor 位用户态是否可访问Dirty 位页面被写过写回收时要回写Accessed 位页面最近被访问过内核做回收时参考它。如果只用一张大表来映射整个 64 位地址空间条目数天文数字每个进程都得背负巨大的常驻开销。于是内核用多级页表x86-64 下通常分四层PML4、PDPT、PD、PT64 位地址被切分成索引级每一级只建当前用到的分支。你可以把多级页表理解成一本有目录的词典只翻译几个词时不需要把整本词典每页都誊抄一遍翻到哪一页建哪一页空目录项直接指向“无”。这里值得记住一个直觉地址翻译不是“查一次表”而是从顶层一路走到底。MMU 里还有一层 TLB 快表缓存最近翻译过的虚拟页→物理页映射命中率超过 99% 是常态。反过来也得出一个实践规律如果程序跨页访问得极其频繁、或者进程反复切换导致 TLB 被冲掉性能会肉眼可见地下降这时候可以考虑透明大页THP用 2MB 大页减少页表项数量和 TLB miss。2.2 一次普通的内存访问背后发生了什么汇编里的movl (%rax), %ecx只是读到虚拟地址但 CPU 内部至少经历MMU 拿虚拟地址去查 TLB没命中就走页表遍历查到物理页号然后访问物理内存的 L1/L2 cache。如果页表项 Present0CPU 会陷入内核的缺页异常处理程序内核分配物理页、填页表项再返回用户态重新执行那一条指令。注意“重新执行”这个细节——它要求缺页异常处理不能改变寄存器现场否则那条指令就不知道拿什么地址去访问了。页表权限位也是“段错误”的老家。你访问了一个虚拟地址它落在某个 VMA 范围之外或者 VMA 没有读权限又或者越过了栈底CPU 直接抛段错误。很多新手以为“指针是野的”才会段错误实际上段错误的本质往往是页表那一步就否决了访问。改一下/proc/sys/vm/disable_rand_omization或者用 gdb 看 fault address你能精确知道崩溃地址是哪一页这比单纯看 Backtrace 更能定位根因。2.3 匿名页与文件映射mmap 是页表机制最直接的应用页表映射的对象分成两类匿名页和文件页。匿名页就是 malloc 或私有数据用的没有磁盘背景文件页来自文件系统mmap 一个文件进去虚拟地址映射的就是文件内容的物理页。mmap 的妙处在于零拷贝通常 read/write 需要文件页→用户缓冲复制一次mmap 直接让用户地址映射到文件页读文件像读内存内核把缺页补全做好。工作里最典型的用法是共享内存和数据库引擎的磁盘映射。比如我调试过一个服务启动时 mmap 一个 20G 的数据文件代码不做任何 I/O 红人数据访问全部走指针——其实每次缺页都在按需读盘这种“按需加载”能省下大量不必要的启动拷贝。另一个实用点是 mmap 的MAP_SHARED会让两个进程拿到同一组物理页写文件就像写内存但注意多做msync/fdatasync否则脏页延迟写盘进程一退数据就丢。3. 写时拷贝fork 为什么能这么快3.1 朴素 fork 的代价和 COW 的破题思路没有写时拷贝Copy-On-WriteCOW的时代fork 会老老实实把父进程的所有物理页复制一份给子进程。10G 内存的进程 fork 一次可能就要复制 10G 页——这谁受得了。更尴尬的是绝大多数场合 fork 完紧接着就 exec 加载新程序之前辛辛苦苦复制的地图页、数据页全被新映像替换掉纯属白干。COW 的思路非常直接fork 时先不复制物理页父子进程共享同一份物理页但把页表里的 R/W 位全部清掉标记成只读。谁先尝试写这个页谁就触发一次写保护缺页内核在异常处理里新分配一页物理内存把旧页内容拷过去再把新页标记为可写重新执行那条写指令。于是 fork 的开销从“复制全部物理内存”降为“复制页表 保护标记”速度自然快几个数量级。3.2 COW 完整执行流程以及两个进程“同地址不同值”的真相走一遍完整流程面试时照着说就很稳父进程调用 fork内核创建子进程的mm_struct和页表子进程页表复制父进程页表项同时把父子双方共享的那些页表项的 R/W 位改成只读父子某个进程执行写操作CPU 发现页表权限不足触发写保护缺页缺页处理程序判断这是 COW 场景分配一个物理页拷贝原页内容改掉当前进程页表项指向新物理页置为可写当前进程以后就写新页另一个进程的页表项仍指向旧物理页并恢复可写位它写的时候就不再有 COW 缺页返回用户态重放那条写指令这次一切都正常了。回到开头那个代码示例int a1; pidfork(); if(pid0){a2;}父子进程打印a都是同一个虚拟地址但写入后 a 的值互不影响。因为a是虚拟地址同一张地图在 fork 后给两个进程各留了一份坐标物理上 a 所在页在一方写入时已经一分为二。代码里看到的地址一样只是“虚拟坐标巧合”背后早就分道扬镳。3.3 forkexec 的工程节奏以及 vfork、posix_spawn生产里很少只用 fork 不用 exec。服务端 fork 一个子进程之后立刻 exec 新的可执行文件这段窗口期里 COW 要做的页表复制依然会发生但 exec 之后整个旧地址空间被清空重建物理页复制几乎为零——这就是 Linux 创建子进程最快的组合拳。以前还有个 vfork专门针对“fork 完马上 exec”的场景子进程先共享父进程地址空间父进程阻塞直到子进程 exec 或退出才恢复。问题在于子进程里多线程、信号处理稍有不慎就把父进程的内存写坏了所以现在不建议用。更好的是posix_spawn它把“fork 后立刻 exec”包装成一个系统调用很多运行时和语言库都用它替代裸 fork。还有一个坑多线程程序里 fork子进程只保留调用线程其他线程瞬间消失。如果这时候子进程里的代码正好持有某个锁那么这个锁状态就永远卡着后面的 malloc 用到的内部锁都可能死锁。所以多线程服务要 fork最稳妥的办法就是 fork 完立刻 exec或者在 fork 前把所有锁状态收拾干净——不在循环里干这些事真出事排查起来怀疑人生。3.4 看一眼缺页计数就知道 COW 在工作性能排查时缺页计数是最直观的证据。/proc/PID/stat里第 10、12 个字段分别统计 soft page fault 和 hard page fault不同内核版本字段位置会有差异最好用工具读。用一个循环频繁 fork 的压力程序你会发现minfltsoft page fault以极快的速度上涨——这些不少就是 COW 引起的写保护缺页。另一个常见观察点fork 之后父进程和子进程都持续写内存RSS 会慢慢翻倍如果有一方只读不写RSS 就只涨一份。这类现象可以解释为“COW 把内存复制延迟到了真正写的那一刻”代价是缺页异常变多CPU 上下文切换和异常处理有额外开销。所以不是任何时候 COW 都最优短时大量写覆盖的场景干脆在 fork 前就把热点数据用mmap(MAP_SHARED)放共享段反而省掉一页页的 COW 缺页。4. 虚拟内存管理从 malloc 到 OOM 的完整链路4.1 malloc 不分配物理内存只是在地图上“画圈”很多线上事故里程序申请了几百 G 虚拟内存却没崩原因就是 malloc 成功后内核并没有立刻划拨物理页。glibc 的 malloc 在分配较大内存通常超过 128KB时走mmap匿名映射小内存则通过brk扩展堆。brk 和 mmap 都是“改地址空间的长度”不触发物理页分配。真正分配物理内存发生在第一次访问某个页时——缺页异常里内核才把对应物理页挂上页表。这个“首次接触分配”的机制也方便了内核做 overcommit它允许你一定程度的“超卖”因为大量申请来的虚拟地址可能永远不会被碰。理解这一点就能解释为什么top显示 VIRT 几十 G、RES 才几百 M。VIRT 是虚拟地址空间总大小RES 才是真正驻留的物理页数。判断程序内存占用永远看 RES/RSSVSS 只是吹出来的泡泡。我这个建议听起来简单但真到应急时很多人看 top 默认的 VIRT 就误判“内存爆了”把大内存机器重启了一遍结果只是白白少了 swap。4.2 虚拟内存耗尽、物理内存耗尽和 OOM killer 的判决内存资源出问题分两种一种是虚拟地址空间耗尽常见于 32 位进程申请不到连续虚拟地址或者你设了RLIMIT_AS另一种是物理内存真正不够。Linux 在物理内存吃紧时先把干净文件页丢到空闲链表脏文件页写回磁盘再不行就换出匿名页到 swap。如果这一套都压不下来OOM killer 决定“杀谁保平安”。它给每个进程算一个 oom_score核心是进程占用的内存大小、swap 情况还要考虑 root 权限和直接恢复能力。你可以在/proc/PID/oom_score看具体数值。不要在生产环境把/proc/sys/vm/overcommit_memory改成 2那个值意味着“禁止超过物理内存swap 的分配申请”对某些数据库是灾难。默认的 0 含义是“合理超卖”malloc 大内存一般能成功。docker/容器环境里memcg 的memory.limit_in_bytes会把进程组内存限制住超限不是 OOM killer 主判决而是触发内核回收回收不掉就把这个 cgroup 里罪大的进程杀掉。所以我们在容器里看 OOM别只盯着宿主机的 dmesg还得看容器管理平台的日志特别是 cgroup 里memory.oom_control的设置。4.3 栈的自动增长、栈溢出和 ulimit 的边界栈和堆最大的区别是“方向反了”还自带自动增长能力。栈初始只有一个很小的映射每次函数调用压栈访问到新页时缺页异常发现地址在栈区 VMA 范围内且离栈底不远就自动扩展并分配物理页。这个“向下生长”的机制同时带来了风险如果程序递归过深或局部变量过大栈可以一直往下长直到越过ulimit -s规定的上限默认 8MB内核不再扩容访问就触发段错误。我调过不少半夜告警的 core dumpbacktrace 里清一色是递归函数一口气几十万层栈顶栈底撞击得干干净净。知道这个机制后有个实用排查技巧看 core 文件的栈地址和setrlimit的关系再用gdb的info proc mappings看栈的 VMA 范围确认崩溃地址是不是在栈区边缘。生产代码里大数组尽量放堆上、递归深了改成迭代这些老生常谈背后就是“栈页按需增长”这个机制决定的。4.4 内存回收、swap 与“内存越来越小”的假象物理页紧张时内核回收文件页比回收匿名页成本低所以系统里 file cache 通常被优先压缩。这也造成一个常见焦虑free -g 显示 used 特别高大片内存被 cache 吃掉。实际这是内核的正常操作一旦程序申请内存cache 会被让路。free左下角 available 字段已经把可回收的 cache 算进去了。我曾经为了“内存占用 96%”半夜起来“清 cache”后来才明白echo 3 /proc/sys/vm/drop_caches只是把文件页提前清出去程序真实占用一点没少反而是下一次 I/O 还要重读盘。Swap 则是另一个双刃剑。Anonymous 页在内存紧张时换到交换分区进程的 RSS 变小但它不会主动回收“已经分配但很久不碰”的虚拟区间所以 RSS 会飘忽。如果系统里 swap 一直在涨多半是物理内存配额被某些进程吃到极致加内存或减占用才是正解不要无脑调 high-zone 比例。4.5 从 /proc/pid/smaps 看进程的真实内存构成要掌握进程真实内存构成用它比 top 更可靠。smaps 里每个 VMA 输出一坨指标Rss这个区域实际驻留的物理页Pss按共享比例分摊后的“实际占用”这是最接近“这个进程为它支付了多少内存”的数字Private_Dirty进程私有且写脏的页这部分不能丢、不能共享Swap被换出的页数VmFlags匿名、可写、共享等标志。排查“某进程到底是不是内存大头”时我会把所有 VMA 的 Pss 累加比直接看 RES 靠谱得多。一个经验大量加载共享库的进程RES 看起来很大Pss 却很小因为几百个进程共享同一份只读代码页谁也不该背全部锅。反过来Private_Dirty高就意味着这进程自己有大量写脏页多半是数据堆或线程栈看它就能锁定真正的内存消耗者。5. 翻车现场内存排查与常见问题速查5.1 段错误定位路线图内存问题里最常碰的是段错误。我自己的定位顺序是先看 dmesg 里有没有segfault at 0x... error code里面包含触发异常的地址和错误类型码用 core dump 配合 gdbbt full看调用栈再回看/proc/PID/maps或 core 里的info proc mappings把异常地址丢进去归档——它落在栈区就是栈溢出落在堆里就是越界写读地址为零清一色是解引用 NULL 指针对越界写靠 ASan/Valgrind 效果最好但生产环境不便开这些优先用 release 版的保护机制和边界检验逻辑。这里有个隐蔽情况写保护的 COW 页面也会触发 segfault 吗不会。COW 缺页是内核内部处理的正常流程不产生用户态信号。而段错误是页表权限不足且内核认为“这是非法访问”——比如向只读 text 段写、访问未映射区域。区分“合法缺页”和“段错误”是理解虚拟内存的重要界限。5.2 OOM 排查时第一眼该看哪个指标被 OOM 杀掉后别急着加内存。先看/var/log/kern.log或 dmesg 里 OOM killer 的完整记录它会列出所有进程的 oom_score、RSS、swap 用量。核心逻辑是被杀的不一定是“内存占用最大的”而是 oom_score 最高的这个分数结合了内存、swap 和“杀掉后能回收多少内存”。如果一个进程占内存巨大但一直没被选很可能是因为它在 cgroup 里或者有更高的 oom_score_adj。我处理过一起典型案例一个 Java 服务长期开 64G 堆宿主机 96G其他进程各占十几 G结果 cgroup 限制把这个 Java 进程判定为第一嫌疑直接 OOM。后来把堆上限调低、给暂停恢复预留空间问题才消停。记住容器里 OOM 不是内核全局裁决而是 memcg 先裁篡改 oom_score_adj 的次数多了也可能误伤无辜。5.3 进程 RSS 涨了一定是内存泄漏吗不一定。很多情况下“内存缓慢上涨”只是冷热换页不积极内核把进程曾经碰过的页都留在页表里程序没有再访问但物理页也不释放。最典型的泄漏误判出现在长期运行的服务里RSS 缓慢涨到几个 G用 Valgrind 查不出 leak其实是因为程序在低峰期把大量新内存映射了但因为没跑足够多次的 GC 或没调用madvise(MADV_DONTNEED)内核就没法回收这些页。遇到这种情况看看 smaps 里匿名区域的Private_Dirty有没有持续增长尤其是mmap产生的 VMA 个数。有些漂亮的引用计数库也会把内存碎片留到堆的“空洞”里RSS 掉不下去。真有泄漏时最简单有效的是看pmap -x PID里哪个 VMA 的 RSS 区间在膨胀再用gdb或 perf 去抓这个区间的分配栈。别一上来就 unit test 级别排疑先定位增长域再缩范围比从代码翻 bug 快得多。5.4 避坑手册从页表到实践的几个忠告多年折腾下来有几个教训值得写在这里别用 VIRT 判断程序内存占用。无论 top 还是 psVIRT 只是地址空间的“长度”真实开销是 RSS/PSS。误判会在容量规划时多买几倍机器。页表本身也占内存不能被忽略。多级页表让每个进程的页表开销降到 KB 级但地址空间铺得极开时页表项数量仍然可观。机器上如果跑几万个进程光页表就吃掉不少内存。fork 的页表复制不是免费的。尽管 COW 免了物理页复制页表条目还是要复制几十 G 地址空间意味着海量页表项。频繁 fork 的程序minflt 计数会上涨得惊心动魄。多线程的栈各 8MB 虚拟按需物理分配。每个线程有自己的栈默认栈大小线程独立不是一份。虚拟地址总量很容易就到几百 GBRES 却不大别被 VSS 骗了。malloc 大块不等于马上有物理内存。想提前锁定物理页可以用mlockall(MCL_FUTURE)或反过来通过madvise让内核觉知访问模式否则你做个实时系统某次突发缺页可能就是延迟尖峰的原因。mmap 文件映射的脏页写回不会替你实时刷盘。要数据持久化老老实实 fsync/msync。线上丢数据十有八九踩这里。6. 最后一个让我直觉升级的调试习惯如果只能给你一条能立即用上的建议那就是写代码时多看一眼/proc/self/maps和自己的进程树。你可以在测试程序里每隔几秒打印一次这个文件观察一个普通小进程在 malloc、free、fork、exec 之后地址空间的 VMA 段是怎么长出来、又怎么消失的。等你能闭眼预判“这一步会改变哪个 VMA、涨哪块 PSS”的时候前面说的页表、写时拷贝、虚拟内存就全都串成了一条线而不是一堆名词。我自己被 COW 坑过最惨的一次是有个服务大量 fork 然后又立刻让子进程往共享缓存里写数据结果父进程的缓存每天都在悄悄涨内存——就是子进程每次写触发 COW 复制产生的私有页在膨胀。当时看了很久 smaps发现一个 64MB 的共享段突然多出一堆Private_Dirty才反应过来。这种问题网上搜不到答案只能靠把虚拟内存机制吃透之后现场推导。希望这篇指南能帮你省掉那几天的排查时间。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑