卷 III · 幻境CH 11深度 11/24

页表:一个地址是怎么被翻译的

上一章说你的地址都是编的,要经过一张翻译表。这一章把那张表打开。它是一棵四层的树,而你写的每一个地址,都要从树根走到树叶。这是全书最硬的一章,也是收获最大的一章——读完你会用一种全新的眼光看待「段错误」这三个字。

四级页表9|9|9|9|12TLB权限位

先算一笔账:为什么不能用一张大表

最朴素的翻译表是一维数组:虚拟页号当下标,查出物理页号。

算算这张表多大。48 位地址空间、4 KB 一页,那么虚拟页有 2³⁶ = 687 亿个。每个表项 8 字节:

687 亿 × 8 字节 = 512 GB

一张翻译表要 512 GB。而且每个进程一张。这显然行不通。

但注意一件事:你的进程实际用到的地址,只有那么零星几片——代码几 MB、堆几十 MB、栈几 MB、几个共享库。256 TB 的地址空间里,99.99% 是空的。

◆ 解法:把表做成一棵树

既然绝大部分是空的,那就只为用到的那部分建表

把 48 位地址切成 9 | 9 | 9 | 9 | 12:前面四个 9 位各是一级索引(2⁹ = 512 项,正好一页装得下),最后 12 位是页内偏移(4 KB)。

每一级都是一张 512 项的表,只有真的用到那条路径时才需要建下一级。于是一个典型进程的页表加起来只有几十 KB——比 512 GB 少了七个数量级。

四级表在 Linux 里的名字分别是 PGD、PUD、PMD、PTE。(较新的 x86 支持五级页表,把地址空间扩到 57 位,但原理完全一样,只是多套一层。)

亲手走一遍

▶ 动手 · 虚拟地址翻译器

下面这台机器是一张真的页表Map 套四层,索引是用 BigInt 从地址里真的移位取出来的((va >> 39) & 511 这样),权限检查、TLB 的 LRU 淘汰、缺页后的重试也都是真跑的。

请按顺序点前两个案例,看 TLB 怎么把 120 ns 变成 1 ns。然后一路点到最后——八个案例覆盖了你会遇到的全部情况。

页表项里的那几个位

走到第四级 PTE,拿到的不只是一个物理页号,还有一串标志位。它们才是重点:

含义不满足时用在哪
PPresent,这一页在内存里吗#PF 缺页按需分页、swap(第 12 章)
W可写吗#PF 写保护只读段、写时复制(第 13 章)
U用户态能碰吗#PF 越权★ 隔离内核空间(第 10 章)
NX禁止执行#PF 越权栈和堆都设它,挡缓冲区溢出攻击
AAccessed,被访问过硬件自动置位,页面回收算法据此判断冷热
DDirty,被写过硬件自动置位,决定换出时要不要写回磁盘
◆ 这里有个观念要更新

很多人以为「内存权限」是操作系统在软件层面检查的东西。不是。

权限是逐页记在页表项里的几个比特,由 MMU 硬件在每一次访存时检查——每一次,包括你写的每一个 a[i] = 1

这意味着:你没有任何办法绕过它。不是「不允许绕过」,是根本没有绕过的路径——除非你能改页表,而那需要 ring 0(第 2 章)。

反过来说,这也意味着权限的粒度就是页(4 KB)。你没法让一个变量只读而它旁边的变量可写——它们在同一页上。这个粒度限制会在很多地方冒出来。

TLB:这套机制唯一能跑得动的原因

四级页表意味着每次访存要先做四次访存去查表。也就是说,读一个变量要访问内存五次。

这显然不可接受。所以 CPU 里有一个专门的小缓存:TLB(Translation Lookaside Buffer),记着最近翻译过的「虚拟页 → 物理页」。

¤ 价目表 · 差两百多倍
  • TLB 命中:约 0.5 ns
  • TLB 未命中,走一趟四级页表:约 120 ns

你在上面那台引擎里能亲眼看到这个差距:点「读一条指令」再点「再读一次」,第二次四级全部跳过

好在程序有很强的局部性——顺序访问一个数组,几千个元素都在同一页里,一次翻译管很久。实际命中率通常在 99% 以上,所以平均成本接近 0.5 ns 而不是 120 ns。整套虚拟内存机制能存在,全靠这个局部性。

但也正因如此,破坏局部性的代码会付出双倍代价:既 cache miss,又 TLB miss。这就是为什么随机访问一个大数组会慢得离谱——远不止「缓存不命中」那么简单。

大页(huge page)就是针对这个问题的:把页从 4 KB 变成 2 MB,同样多的 TLB 条目能覆盖 512 倍的内存。数据库和 JVM 常开这个(-XX:+UseLargePages),代价是内存粒度变粗、更容易浪费。

三种失败,一个出口

上面那台引擎里有三个失败案例,值得放在一起对比——它们在硬件上都是 #PF,但原因完全不同:

案例哪一步失败它揭示了什么
写代码段PTE 找到了,但 W 位是 0同一个地址,读没事写就崩——权限不是地址的属性,是页表项里的位
解引用空指针第三级 PMD 就断了崩溃不是因为「0 是魔法值」,是因为那条路径压根没建表
用户态摸内核PTE 在,物理页也在,但 U 位是 0★ 内核就在你的地址空间里,你却碰不到——整个安全边界物理上就是这一个比特

第二个案例有个细节特别值得注意,而且很多人会猜错:空指针断在第三级,不是第一级。

为什么?因为 0x0 和代码段的 0x400000 前两级索引是一样的(都是 0)——它们共用同一张 PGD 和 PUD。只有到了 PMD 这一级才分开。

这正是多级页表省内存的原理在起作用:相近的地址共享上层的表。上面那台引擎会如实告诉你断在哪一级,因为它是真的一级级走下去的。

换页表 = 换一个世界

页表的树根地址存在 CR3 寄存器里(ARM 上是 TTBR0)。

这意味着切换进程只需要改一个寄存器——把 CR3 指向另一棵树,整个地址空间的含义瞬间全变了。同样的 0x400000,现在翻译到完全不同的物理页。

这个设计极其优雅:一条指令切换整个世界。

但它的代价你在第 9 章已经见过了:TLB 里那几百条记录全部作废,因为它们是按旧地图翻译的。这就是进程切换比线程切换贵的根本原因,也是 PCID/ASID 这些特性存在的理由。

你可以在上面那台引擎里点「清空 TLB」,然后重新翻译一次——那正是每次进程切换都会发生的事。

⚠ 页表本身也占内存

页表存在物理内存里,所以它也要占地方。一个占用 1 GB 内存的进程,光页表大约需要 2 MB(1 GB ÷ 4 KB × 8 字节,加上上层的表)。

通常无所谓。但有两种情况会让它变成问题:

  • 大量进程共享大内存:100 个进程各自映射同一个 1 GB 的共享内存段,那就是 100 份页表 = 200 MB,纯粹浪费。Linux 的 huge page 共享页表(HugeTLB)就是为了解决这个。
  • 稀疏地映射巨大空间:你 mmap 了 1 TB 但只零星用了几页,中间层的表仍然要建,可能反而比数据占得多。
↑ 回到应用层

「为什么随机访问大数组比顺序访问慢几十倍?」

这个现象大家都知道,但通常只归因于「缓存不命中」。实际上你付了两笔钱

val a = IntArray(64 * 1024 * 1024)   // 256 MB

// 顺序:cache 命中率高,TLB 一条管 1024 个 int
for (i in a.indices) sum += a[i]

// 随机:几乎每次都 cache miss,而且每次都可能 TLB miss
for (i in a.indices) sum += a[idx[i]]
  • cache miss约 80 ns(主存访问)
  • TLB miss:再加 约 120 ns(走四级页表)

256 MB 的数组需要 65536 个页表项,而 TLB 只有几百上千条——随机访问时 TLB 几乎必然不命中

所以「让数据紧凑、按顺序访问」这条老建议,收益比大多数人以为的还要大。它同时在优化两级缓存。这也解释了为什么面向数据的设计(把 struct 数组换成数组的 struct)在性能敏感的场合效果那么显著。

这一章的一句话

你写的每一个地址,都要被切成 9|9|9|9|12,从树根走到树叶,才换来一个真的物理页号——顺便被硬件检查一遍 W、U、NX 三个比特。这套机制能跑得动,全靠 TLB 把 120 纳秒变成 0.5 纳秒;而进程切换之所以昂贵,就是因为换 CR3 那一下把 TLB 全部作废了。

下一章:如果走到 PTE 发现 P 位是 0——这一页不在内存里——会发生什么?答案分成两种,而它们之间差了三个数量级