页表:一个地址是怎么被翻译的
上一章说你的地址都是编的,要经过一张翻译表。这一章把那张表打开。它是一棵四层的树,而你写的每一个地址,都要从树根走到树叶。这是全书最硬的一章,也是收获最大的一章——读完你会用一种全新的眼光看待「段错误」这三个字。
先算一笔账:为什么不能用一张大表
最朴素的翻译表是一维数组:虚拟页号当下标,查出物理页号。
算算这张表多大。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,拿到的不只是一个物理页号,还有一串标志位。它们才是重点:
| 位 | 含义 | 不满足时 | 用在哪 |
|---|---|---|---|
| P | Present,这一页在内存里吗 | #PF 缺页 | 按需分页、swap(第 12 章) |
| W | 可写吗 | #PF 写保护 | 只读段、写时复制(第 13 章) |
| U | 用户态能碰吗 | #PF 越权 | ★ 隔离内核空间(第 10 章) |
| NX | 禁止执行 | #PF 越权 | 栈和堆都设它,挡缓冲区溢出攻击 |
| A | Accessed,被访问过 | — | 硬件自动置位,页面回收算法据此判断冷热 |
| D | Dirty,被写过 | — | 硬件自动置位,决定换出时要不要写回磁盘 |
很多人以为「内存权限」是操作系统在软件层面检查的东西。不是。
权限是逐页记在页表项里的几个比特,由 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——这一页不在内存里——会发生什么?答案分成两种,而它们之间差了三个数量级。