审校基线:本页引用的内核符号、行号与源码链接以 Linux 7.3-rc4(torvalds/linux master,2026-09-26 实测)为基准;审校当日 master 的确切 commit 未记录,后续版本可能已有漂移。
vma 是"地址空间的元数据",页表是"已建立的映射"——前者先行,后者滞后。缺页异常是连接两者的修复机制,COW 是它最漂亮的应用。
2026-09-25 · Virtual Memory, Page Faults, and the TLB

第 4 关 · 虚拟内存:页表、缺页异常、TLB

定位

M03 回答了"物理页从哪来"。这一关回答"进程看到的地址是怎么回事":虚拟地址 → 页表 → 物理页的翻译链条,以及这条链条断裂时(缺页异常)内核如何修复。

核心的一句关系:vma 是地址空间的元数据,页表是已建立的映射。mmap 只建 vma、不建页表;页表是缺页异常一次一次"修"出来的。前者先行,后者滞后——整个虚拟内存的懒惰哲学都在这句话里。

M01 的 fork(copy_mm 复制页表、COW)、M03 的页表页分配(order-0、__GFP_ZERO)和 vmalloc(靠页表拼虚拟连续),都是这一关的预告片。M05(页面回收)会讲"物理页不够时,映射如何被拆掉"。

核心机制深讲

4.1 多级页表:为稀疏地址空间省内存

x86-64 用 4 级页表(PGD → PUD → PMD → PTE;开 LA57 是 5 级)。48 位虚拟地址切成 9+9+9+9+12:每级 9 位索引(512 个表项),最后 12 位是页内偏移。CR3 寄存器存着当前进程顶级页表(PGD)的物理地址——进程切换的本质就是换 CR3(M07 会展开)。

为什么不用一级页表?算一笔账:48 位地址空间有 2^36 个页,一级页表需要 2^36 × 8 字节 = 512GB,每个进程一份——荒谬。多级页表的思想是稀疏性:地址空间大片空白,中间级页表按需分配,不用的地址范围整棵子树根本不存在。一个只用了几 MB 的进程,页表可能只有几十 KB。这是用"查找时多走几步"换"不用的不花钱"。

页表项(PTE)不只是地址,还塞了一堆标志位:Present(映射是否存在)、RW(可写)、User(用户态可访问)、Dirty/Accessed(硬件回写,供回收算法用,M05)、NX(不可执行)。COW 就藏在这里:fork 时把父子双方的 PTE 都标成只读(清 RW),缺页时靠 vma 的标志认出"这是 COW 页"再处理——权限位是机制,语义在 vma 里。

4.2 缺页异常:断裂的修复机制

用户访问一个地址,MMU 先查 TLB,miss 则硬件 walk 页表(4 级 = 最多 4 次内存访问)。如果 PTE 的 Present 位是 0,或权限不足(写只读页),CPU 触发 #PF,陷入 do_page_fault(arch/x86/mm/fault.c)→ handle_mm_fault(mm/memory.c)。

内核的处理逻辑是先查 vma(find_vma):

1. 匿名页首次访问(demand paging):mmap 匿名内存时只建了 vma,物理页在第一次访问时才分配(do_anonymous_page)。malloc 1GB 不访问,RSS ≈ 0——分配 ≠ 占用。
2. 写时复制(COW):PTE 只读但 vma 可写 → do_wp_page:分配新页、复制内容、把当前进程的 PTE 指向新页并恢复可写。fork 的全部代价都摊在这里,而不是 fork 当时(M01 Q6 的 1300 万 PTE 复制只是"改权限",真正的页面复制按需发生)。
3. 文件映射(file-backed):从 page cache 找页,没有就发起磁盘读(do_fault → filemap_fault)。mmap 文件和 read 走的是同一套 page cache——"文件"和"内存"在这里是同一个东西。

注意这个顺序:vma 先行,页表滞后。vma 回答"这个地址合不合法、是什么类型",页表只记录"已经建立的翻译"。缺页异常是连接两者的修复循环。

4.3 TLB:翻译的缓存,以及它的一致性代价

页表 walk 最多 4 次内存访问,TLB(快表)把"虚拟页号 → 物理页框"的翻译缓存起来。TLB miss 的代价是 walk,TLB 存在的理由就是"地址有局部性"。

真正的麻烦是一致性:内核改了某进程的页表(munmap、mprotect、COW 建新 PTE、页回收),其他 CPU 核上缓存的旧翻译必须作废——TLB shootdown:给相关 CPU 发 IPI(核间中断),让它们刷 TLB。这是 mmap/munmap 频繁场景的性能杀手,也是"改页表"这个操作最贵的隐藏成本。

两个缓解:

为什么这样设计

为什么 vma 和页表是两套结构,而不是"页表即地址空间"?

因为它们回答不同的问题,且生命周期不同。vma 是"意图"(这块地址干什么用、什么权限、 backing 是什么),页表是"现状"(哪些翻译已经建好)。意图可以大(mmap 1GB 一瞬间),现状必须小(物理页是稀缺品)。两套结构让内核能承诺大、兑现小——overcommit 和 demand paging 的全部基础。如果只有页表,mmap 就得立刻分配物理页,稀疏地址空间的优势就没了。

为什么缺页异常不直接 panic,而是修复?

因为"合法但还没建映射"是常态不是异常:第一次访问、COW、文件读入——进程的整个生命周期就是不断 fault、不断修复。把 #PF 设计成"修复循环"而不是"错误",是把懒惰做成了架构:fork 时不复制数据(COW)、mmap 时不分配页(demand paging)、exec 时不读文件(按需调页)。每一处"当时不做"省下的,都是 fork+exec 这种高频路径上的真金白银。

为什么 COW 在 fork 时只改 PTE 权限,而不连页表一起懒复制?

有人想过更懒的方案:fork 时连页表都不复制,子进程缺页时去父进程页表里查、按需建 PTE。内核没采用,因为生命周期解耦不了:父进程 exit/munmap 后页表就释放了,子进程找谁查?要维持查询就得把父 mm pin 住——省掉的复制变成了生命期管理的复杂性,外加跨进程的 TLB shootdown 协调。COW 的"fork 时一次性复制 PTE(O(页表项数))、运行时各自独立"是把复杂度前置、换运行时的简单。M01 Q6 的 1300 万 PTE 复制看着贵,但它是一次性的、可预测的贵。

与已学模块的关联

自测

出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。

Q1. x86-64 4 级页表的四级名称是什么?48 位虚拟地址如何切分?

PGD → PUD → PMD → PTE。9+9+9+9+12:每级 9 位索引(512 表项),低 12 位是页内偏移。CR3 存 PGD 的物理地址。开 LA57 是 5 级(多一级 P4D),地址 57 位。

Q2. CR3 寄存器里存的是什么?进程切换时它发生什么变化?

当前进程顶级页表(PGD)的物理地址。进程切换(switch_mm)的本质就是把 CR3 换成下一个进程的 PGD 地址——"换地址空间"在硬件层面就是这一条指令。换完之后 TLB 里旧进程的翻译必须作废(或靠 PCID/ASID 区分)。

Q3. 缺页异常的三类合法成因是什么?非法访问时内核做什么?

合法:(1) 匿名页首次访问(demand paging,按需分配物理页);(2) COW(写只读的共享页,do_wp_page 分配+复制);(3) 文件映射(从 page cache 找页/发起磁盘读)。非法:地址不在任何 vma 里 → SIGSEGV。判合法性的依据是 vma,不是页表。

Q4. 为什么 48 位地址空间不用一级页表?算一笔账。

2^48 / 4KB = 2^36 个页,一级页表需要 2^36 × 8 字节 = 512GB,每个进程一份。多级页表利用稀疏性:中间级按需分配,空白地址范围整棵子树不存在——"查找多走几步"换"不用的不花钱"。

Q5. fork 后,父子进程同一虚拟地址的 PTE 是什么状态?子进程写一个全局变量,硬件和内核依次发生什么?

fork 时 copy_page_range 把双方 PTE 都设成只读(清 RW,vma 记着可写+COW)。子进程写 → MMU 发现 PTE 只读 → #PF → handle_mm_fault 查 vma:合法且是 COW → do_wp_page:分配新物理页、复制旧页内容、把子进程的 PTE 指向新页并恢复可写 → 返回用户态重执行写指令。父进程的 PTE 不动——这就是"写时"复制。

Q6. mmap 了 1GB 匿名内存但一个字节都没访问,此时进程 RSS 约多少?为什么?

约 0。mmap 只建了 vma(地址空间元数据),没建 PTE、没分配物理页——"承诺"了 1GB 地址,"兑现" 0 页。第一次访问时才触发缺页异常按需分配(demand paging)。RSS 统计的是已建映射的物理页,所以是 0。这是"分配 ≠ 占用"的标准演示。

Q7. 场景:多线程程序频繁 mmap/munmap,大量 CPU 时间花在内核态。除了锁竞争,还有一个和 TLB 相关的性能杀手是什么?

TLB shootdown。每次改页表(munmap 拆掉映射、mmap 建新映射),其他 CPU 核上缓存的旧翻译必须作废——内核发 IPI(核间中断)让相关核刷 TLB。核越多、改得越频繁,shootdown 开销越大。mmap_lock 锁竞争是另一个方向,但 TLB shootdown 是"改页表"这个操作自带的、躲不掉的一致性税。

Q8. 进程切换换 CR3 后,为什么 TLB 不一定需要全刷?两个机制各是什么?

(1) PCID/ASID:TLB 条目带"属于哪个进程"的标签,换 CR3 时带上新进程的 PCID,旧条目自然不命中——切换不再清空 TLB。(2) 全局位(G bit):内核高半部分映射在所有进程一致,标 G 位后进程切换时保留。注:KPTI(Meltdown 缓解)之后内核有了独立页表,第 (2) 条的效果被削弱——安全和性能直接冲突的实例。

Q9. M01 Q6 的场景:50GB 进程 fork,约 1300 万个 PTE 要处理。为什么 copy_page_range 必须逐项复制 PTE,而不能对页表页整页 memcpy?复制后父子 PTE 的权限位有什么变化?

因为复制的同时要改权限:每个 PTE 的 RW 位要清掉(标只读),并靠 vma 语义记为 COW——整页 memcpy 复制的是字节,改不了"每个表项的权限位"。这就是 M01 Q6 代价的来源:O(页表项数) 的逐项处理。复制后父子双方同一地址的 PTE 都指向同一物理页、都是只读;真正的页面复制推迟到某一方第一次写(do_wp_page)。

Q10. 设计题:有人提议 fork 时连页表都不复制——子进程缺页时去父进程的页表里查、按需建自己的 PTE(lazy page table copy)。从正确性、并发、TLB 一致性三个角度论证为什么内核没采用。

COW 的"fork 时一次性复制 PTE、运行时各自独立"是把复杂度前置、换运行时的简单:复制是 O(页表项数) 一次性的、可预测的,而 lazy 方案把复杂度摊进了每一次缺页和每一次生命期交互。

延伸阅读

#PF(缺页异常)
  └─ 硬件陷入 → do_page_fault()
       └─ 先查 vma:这个地址合法吗?
            ├─ 不合法 → SIGSEGV
            └─ 合法 → 按成因分三支修复
                 ├─ 匿名页首次访问 → 分配新页(或零页)
                 ├─ 文件映射 → 从 page cache/磁盘读入建 PTE
                 └─ COW 写 → do_wp_page:分配+复制,恢复可写
       └─ 建好 PTE → 返回 → CPU 重执行 fault 指令
分叉点:合法性看 vma,不是页表——页表只记录"已建好的"

跟着箭头走一遍 #PF 的处理:硬件陷入 → 查 vma 定合法性 →
三种合法成因各走一条修复分支 → 建 PTE → 返回重执行。
记住分叉点:合法性看 vma,不是页表 —— 页表只记录"已建好的"。

虚拟地址切分(x86-64 四级):9 + 9 + 9 + 9 + 12
PGD → P4D → PUD → PMD → PTE → 物理页帧
              每级 512 项 × 8B = 4KB,正好一页

中间级按需分配:空白地址范围 → 整棵子树不存在
一级页表要 512GB(Q4 那笔账)→ 四级只建"用到的"分支
多级页表的全部意义:为稀疏地址空间省内存

看懂 9+9+9+9+12 的切分,以及"中间级按需分配":
空白地址范围整棵子树不存在 —— 这就是多级页表相对一级页表的全部意义。
右下角那笔 512GB 的账,对应 Q4。

在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。

作答汇总

点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。