M03 回答了"物理页从哪来"。这一关回答"进程看到的地址是怎么回事":虚拟地址 → 页表 → 物理页的翻译链条,以及这条链条断裂时(缺页异常)内核如何修复。
核心的一句关系:vma 是地址空间的元数据,页表是已建立的映射。mmap 只建 vma、不建页表;页表是缺页异常一次一次"修"出来的。前者先行,后者滞后——整个虚拟内存的懒惰哲学都在这句话里。
M01 的 fork(copy_mm 复制页表、COW)、M03 的页表页分配(order-0、__GFP_ZERO)和 vmalloc(靠页表拼虚拟连续),都是这一关的预告片。M05(页面回收)会讲"物理页不够时,映射如何被拆掉"。
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 里。
用户访问一个地址,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 回答"这个地址合不合法、是什么类型",页表只记录"已经建立的翻译"。缺页异常是连接两者的修复循环。
页表 walk 最多 4 次内存访问,TLB(快表)把"虚拟页号 → 物理页框"的翻译缓存起来。TLB miss 的代价是 walk,TLB 存在的理由就是"地址有局部性"。
真正的麻烦是一致性:内核改了某进程的页表(munmap、mprotect、COW 建新 PTE、页回收),其他 CPU 核上缓存的旧翻译必须作废——TLB shootdown:给相关 CPU 发 IPI(核间中断),让它们刷 TLB。这是 mmap/munmap 频繁场景的性能杀手,也是"改页表"这个操作最贵的隐藏成本。
两个缓解:
switch_mm 换 CR3 都要清空,M07 的上下文切换代价一半花在这)。x86 的 PCID 就是干这个的。为什么 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 复制看着贵,但它是一次性的、可预测的贵。
copy_page_range 逐项复制 PTE 并清 RW 设 COW——现在明白为什么不能整页 memcpy(权限位必须逐项改),以及为什么这是 O(页表项数) 的代价。do_wp_page 分配+复制+改 PTE。fork 的代价被拆成"当时"(复制 PTE)和"按需"(复制数据)两部分。begin_new_exec 扔掉旧 mm(整棵页表树释放)、建新 mm——"换地址空间"就是"换一棵页表树 + 换 CR3"。出题原则:10 题中记忆性题目不超过 3 道,其余为应用/推理/权衡类——场景预测、设计 trade-off 比较、从代码路径找问题、解释"为什么这样设计而不是那样"。难度递进,最后两道需要想一会儿。
PGD → PUD → PMD → PTE。9+9+9+9+12:每级 9 位索引(512 表项),低 12 位是页内偏移。CR3 存 PGD 的物理地址。开 LA57 是 5 级(多一级 P4D),地址 57 位。
当前进程顶级页表(PGD)的物理地址。进程切换(switch_mm)的本质就是把 CR3 换成下一个进程的 PGD 地址——"换地址空间"在硬件层面就是这一条指令。换完之后 TLB 里旧进程的翻译必须作废(或靠 PCID/ASID 区分)。
合法:(1) 匿名页首次访问(demand paging,按需分配物理页);(2) COW(写只读的共享页,do_wp_page 分配+复制);(3) 文件映射(从 page cache 找页/发起磁盘读)。非法:地址不在任何 vma 里 → SIGSEGV。判合法性的依据是 vma,不是页表。
2^48 / 4KB = 2^36 个页,一级页表需要 2^36 × 8 字节 = 512GB,每个进程一份。多级页表利用稀疏性:中间级按需分配,空白地址范围整棵子树不存在——"查找多走几步"换"不用的不花钱"。
fork 时 copy_page_range 把双方 PTE 都设成只读(清 RW,vma 记着可写+COW)。子进程写 → MMU 发现 PTE 只读 → #PF → handle_mm_fault 查 vma:合法且是 COW → do_wp_page:分配新物理页、复制旧页内容、把子进程的 PTE 指向新页并恢复可写 → 返回用户态重执行写指令。父进程的 PTE 不动——这就是"写时"复制。
约 0。mmap 只建了 vma(地址空间元数据),没建 PTE、没分配物理页——"承诺"了 1GB 地址,"兑现" 0 页。第一次访问时才触发缺页异常按需分配(demand paging)。RSS 统计的是已建映射的物理页,所以是 0。这是"分配 ≠ 占用"的标准演示。
TLB shootdown。每次改页表(munmap 拆掉映射、mmap 建新映射),其他 CPU 核上缓存的旧翻译必须作废——内核发 IPI(核间中断)让相关核刷 TLB。核越多、改得越频繁,shootdown 开销越大。mmap_lock 锁竞争是另一个方向,但 TLB shootdown 是"改页表"这个操作自带的、躲不掉的一致性税。
(1) PCID/ASID:TLB 条目带"属于哪个进程"的标签,换 CR3 时带上新进程的 PCID,旧条目自然不命中——切换不再清空 TLB。(2) 全局位(G bit):内核高半部分映射在所有进程一致,标 G 位后进程切换时保留。注:KPTI(Meltdown 缓解)之后内核有了独立页表,第 (2) 条的效果被削弱——安全和性能直接冲突的实例。
copy_page_range 必须逐项复制 PTE,而不能对页表页整页 memcpy?复制后父子 PTE 的权限位有什么变化?因为复制的同时要改权限:每个 PTE 的 RW 位要清掉(标只读),并靠 vma 语义记为 COW——整页 memcpy 复制的是字节,改不了"每个表项的权限位"。这就是 M01 Q6 代价的来源:O(页表项数) 的逐项处理。复制后父子双方同一地址的 PTE 都指向同一物理页、都是只读;真正的页面复制推迟到某一方第一次写(do_wp_page)。
COW 的"fork 时一次性复制 PTE、运行时各自独立"是把复杂度前置、换运行时的简单:复制是 O(页表项数) 一次性的、可预测的,而 lazy 方案把复杂度摊进了每一次缺页和每一次生命期交互。
handle_mm_fault、do_wp_page、copy_page_range,缺页与 COW 的本体do_page_fault,从 #PF 到 vma 查找mmap 只建元数据#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。
在页面里每题写下你的答案(关键词即可),卡住的题点一下"卡住了"标记,
点"生成作答汇总"后复制、贴到聊天里——对错由我来判定,我逐题批改并据此
调整下一关的深浅;准备好了说"下一关"。
点下面按钮生成汇总文本,复制后贴到「内核二十关」侧聊里,对错由我判定。