想象一下:小区里每个住户都收到一个统一的虚拟门牌号(比如”3 栋 2 单元 501”),但真正去敲门时,物业得靠一张住户登记表,把”3-2-501”翻译成”北区第 5 排第 3 块地”这个真实位置。
这篇文章讲的就是这张”登记表”——内存页表(Page Table)。它负责把进程眼里的虚拟地址翻译成真实的物理地址。我会讲清它是什么、怎么用、底层怎么实现,配架构图和时序图,最后带你动手看一眼自己的页表。
〇、一句话和一张全景图
页表是操作系统维护的一张”对照表”,把进程的虚拟地址翻译成真实的物理地址。每个进程各有一份,所以大家能共享同一块物理内存、却互不干扰。
flowchart LR
VA["进程眼里的虚拟地址<br/>连续、独立、是"假地址""]
PT["页表 Page Table<br/>虚拟页号 → 物理页号"]
PA["真实的物理内存<br/>分散的一块块物理页"]
VA -->|"1. 查表翻译"| PT
PT -->|"2. 找到对应物理页"| PA
一、为什么需要页表:从”内存碎片”和”隔离”说起
最早期,程序是直接写死物理地址的:代码里说”把数据放到物理地址 0x1000”,就真的写到 0x1000。这样有三个大问题:
- 容易撞车:两个程序都想用 0x1000,谁先谁后、会不会互相踩,完全没保障;
- 不隔离:一个程序写坏了自己的内存,可能顺带把别的程序也搞崩;
- 有碎片:内存用一段空一段,程序又要求”必须占一段连续的物理内存”,很快就被切得七零八落。
解决办法就是虚拟内存 + 页表:给每个进程一个假地址空间,这个空间看起来又大又连续、而且只属于它自己;真正访问时,再由硬件和操作系统一起,通过页表把假地址翻译成真地址。
这一下同时解决了上面三个问题:隔离(每个进程的假空间互不相通)、不浪费(物理内存可以按页分配、拼起来就行,不要求连续)、按需分配(用到哪页才给哪页,后面会细讲)。
flowchart TB
A["进程 A 访问虚拟地址 0x1000"] --> PTA["进程 A 的页表"]
PTA -->|"0x1000 → 物理页 200"| MEM["物理内存"]
B["进程 B 也访问虚拟地址 0x1000"] --> PTB["进程 B 的页表"]
PTB -->|"0x1000 → 物理页 500"| MEM
看这张图:两个进程都用虚拟地址 0x1000,但各自的页表把它翻译到了不同的物理页(A 到 200,B 到 500),所以互不干扰——这就是”隔离”的由来,也是”页表负责查找虚拟页号 → 物理页号”最直观的样子。
二、页表长什么样:虚拟页号 → 物理页号
先认识”页”
操作系统把物理内存切成一块块固定大小的小块,每块叫一个页(page),Linux 上通常是 4KB(4096 字节)。分配内存就按”页”为单位给。
地址被分成两段
一个虚拟地址可以拆成两段:
- 高位是”虚拟页号 VPN”:说明”我要访问第几页”;
- 低位是”页内偏移 offset”:说明”在这一页里的第几个字节”。
因为是 4KB = 2¹² 字节,所以 offset 占最低 12 位,剩下的高位全是页号。页表要做的事,就是把”虚拟页号”翻译成”物理页号”(也叫物理页框号 PFN);offset 保持不变,最后拼起来就是物理地址。
flowchart LR
V["虚拟地址"] --> V1["虚拟页号 VPN(高位)"]
V --> V2["页内偏移 offset(低 12 位)"]
V1 -->|"页表查询"| P1["物理页框号 PFN"]
P1 --> PA["物理地址 = PFN + offset"]
V2 --> PA
举一个具体例子:查一次”虚拟页号 → 物理页号”
(先说明:为了先让你看清本质,这里用一张”平表”举例;真实进程用的是下一节的多级页表,但查找思路完全一样。)
整个过程用四条公式就能算清楚:
- 页内偏移:
offset = 虚拟地址 mod 页大小(即取低 12 位) - 虚拟页号:
VPN = 虚拟地址 ÷ 页大小(整数除法,即高位) - 查页表:
PFN = 页表[VPN](用 VPN 当”行号”,去表里找物理页框号) - 拼出物理地址:
物理地址 = PFN × 页大小 + offset
假设某个进程的页表长这样(每一行就是一个”虚拟页号 → 物理页号”的对应关系):
| 虚拟页号 VPN | 物理页框号 PFN | 存在位 present |
|---|---|---|
| 0 | 100 | 1 |
| 1 | 7 | 1 |
| 2 | 88 | 1 |
| 3 | 41 | 0 |
表里三列分别是什么、有什么用:
- 虚拟页号 VPN:是”查表的入口”,也就是虚拟地址高位那一截。把它当成行号——程序要访问某个虚拟页,就拿着这个 VPN 去表里找对应的那一行。
- 物理页框号 PFN:是”查表的结果”,表示这个虚拟页被放在物理内存的第几页(第几框)。查到它之后,拼上低 12 位的 offset,就得到真正的物理地址。
- 存在位 present:是一个开关。1 表示这一页真的在物理内存里,可以直接访问;0 表示这一页还没分配(或被换出到磁盘了),一旦去访问它,就会触发后面”缺页异常”那一节讲的事情。
所以这张表的读法是:拿着 VPN 当行号 → 找到对应的 PFN → 再看 present 是不是 1。上面的例子里,VPN=3 那行 present=0,意味着第 3 个虚拟页目前还不能访问(访问就会缺页)。
现在程序要访问虚拟地址 0x1234(十进制 4660):
- 先拆:
VPN = 4660 ÷ 4096 = 1,offset = 4660 mod 4096 = 564(即0x234); - 拿着
VPN = 1去页表查第 1 行,得到PFN = 7; - 最后拼起来:
物理地址 = 7 × 4096 + 564 = 29236,即0x7234。
注意结果:0x1234 → 0x7234,低 12 位的 0x234 原封不动,只有高位的”页号”从 1 换成了 7——这就是”offset 不变、只换页号”。
flowchart LR
VA["虚拟地址 0x1234<br/>VPN=1,offset=0x234"]
TBL["进程页表<br/>0→100,1→7,2→88,3→41"]
PA["物理地址 0x7234<br/>PFN=7 + offset=0x234"]
VA -->|"用 VPN=1 查第 1 行"| TBL
TBL -->|"查到 PFN=7"| PA
一句话:页表本质就是一张”虚拟页号 → 物理页号”的映射表,像字典一样查。
三、为什么是多级页表:一张大表根本放不下
能不能只做”一张大表”,一个页号对应一行?理论上行,但太浪费了。
先把”太大”这个感觉算成具体数字。
3.1 四步算出这张表有多大
| 步骤 | 算式 | 结果 | 说明 |
|---|---|---|---|
| ① 地址空间 | 2⁴⁸ | 281,474,976,710,656 字节 ≈ 256 TiB | 48 位地址能区分的字节总数 |
| ② 页大小 | 4 KiB = 2¹² | 4,096 字节 | 页内偏移占 12 位 |
| ③ 页数 | 2⁴⁸ ÷ 2¹² = 2³⁶ | 68,719,476,736 页 ≈ 687 亿页 | 指数相减:48 − 12 = 36 |
| ④ 表大小 | 2³⁶ × 8 字节 = 2³⁹ | 549,755,813,888 字节 | 每页一行,每行 8 字节 |
第④步的单位换算链条:
549,755,813,888 字节
÷ 1024 → 536,870,912 KiB
÷ 1024 → 524,288 MiB
÷ 1024 → 512 GiB = 0.5 TiB
如果按硬盘厂商的十进制口径:549,755,813,888 ÷ 10⁹ ≈ 549.76 GB。
所以”约 500GB”是个口语说法,准确值是 512 GiB(0.5 TiB)。 页数也一样:2³⁶ = 687 亿,说”680 亿”偏小了一点。
3.2 三个前提数字是怎么定的
(1)为什么虚拟地址取 48 位? 这个数字不是”定出来的”,而是三个更基础的决定乘出来的:
48 = 12(页内偏移)+ 9 × 4(四级的索引)
从下往上看,三条依赖链各自独立:
- 12 位偏移 ← 页大小是 4 KiB。 页是内存管理的基本单位,这个数先定下来,偏移宽度就定了。
- 9 位索引 ← 一级页表必须装进一页。 4096 字节 ÷ 8 字节每项 = 512 项 = 2⁹。是”表要小到能塞进一个物理页”这个约束,逼出了 9。
- 4 级 ← 在”地址空间”和”遍历深度”之间做的选择。 前两条都定了之后,级数直接决定地址空间有多大:每加一级多 9 位地址,但 TLB 未命中时也多一次访存。
级数与地址空间的对照(注意早期 x86 每级是 1024 项、每项 4 字节,所以位宽不同):
| 级数 | 索引位宽 | 地址位数 | 地址空间 | 用在哪 |
|---|---|---|---|---|
| 2 | 10 + 10 | 32 | 4 GiB | x86 32 位(无 PAE,每级 1024 项) |
| 3 | 2 + 9 + 9 | 32 | 4 GiB | x86 32 位 PAE(顶级只有 4 项) |
| 4 | 9 × 4 | 48 | 256 TiB | x86-64 |
| 5 | 9 × 5 | 57 | 128 PiB | x86-64 + LA57 |
AMD 在 2000 年代初设计 x86-64 时,面对的就是这张表:三级若每级都按 512 项算,只能到 2³⁹ ≈ 512 GiB(PAE 因为顶级只有 4 项,实际仍停在 32 位),撑不了多久;五级在当年纯属浪费,还多一次访存。选 4 级,于是有了 48 位。
反过来说,如果硬要把 64 位用满:页号 = 64 − 12 = 52 位,按每级 9 位需要 ⌈52 ÷ 9⌉ = 6 级,比现在多两级——每次 TLB 未命中都多两次访存,代价不小。后来真正需要更大空间时,走的是”加一级”的 LA57(57 位),而不是一步跳到 64 位。
还有一条规则要记住:x86-64 要求 bit 63–48 必须全部等于 bit 47(这叫规范地址,canonical address),不满足就是非法地址,访问时触发 #GP。这条规则把 64 位空间切成”低半区给用户、高半区给内核”两块,内核判断一次地址属于哪一半,只要看最高位就够了。
顺带一个容易想反的点:x86-64 的物理地址是 52 位(40 位 PFN + 12 位偏移),比 48 位虚拟地址还宽。也就是说页表项可以指向 48 位虚拟范围之外的物理帧——物理内存可以比单个进程能直接寻址的空间更大。
(2)为什么页内偏移是 12 位? 因为页大小是 4 KiB = 4096 = 2¹²。偏移要能寻址页内的每一个字节(0–4095),正好需要 12 位。通用关系是:偏移位数 = log₂(页大小)。换成 2 MiB 页,log₂(2 MiB) = 21,偏移就变 21 位(第四节算过)。
(3)为什么一行是 8 字节? 一个页表项里要放 40 位 PFN + 12 位左右的标志位,合计 52 位,装进 64 位(8 字节)刚好还有余量给将来的扩展。而且 8 字节这个尺寸让”每级 512 项”变成 512 × 8 = 4096 字节 = 恰好一页——一级页表正好占一个物理页,这是设计上刻意凑出来的。
3.3 “每个进程一份、大部分没用”到底有多严重
每个进程一份:虚拟地址空间是进程私有的,所以每个进程都要有自己独立的一套页表。如果真用单级表:
| 进程数 | 页表总占用(每份 512 GiB) |
|---|---|
| 1 | 512 GiB |
| 10 | 5 TiB |
| 100 | 50 TiB |
一台服务器上开一百个进程是常态,50 TiB 的页表意味着这套方案根本不可能落地。
大部分行没用:一个进程实际用到的内存通常是几 MB 到几百 MB。以 200 MB 计算,也就是约 5 万页,而表里有 687 亿行——使用率在百万分之一量级。换成”只算用户态那半边”(2⁴⁷ 字节 = 128 TiB 空间)也得 2³⁵ 行、256 GiB,依然是灾难。
这两件事叠加,得出了后面所有设计的出发点:
- 必须分块,只为用到的区域建表 → 多级页表;
- 必须按需,用到哪页才建到哪页 → 缺页异常驱动的延迟分配;
- 表项必须紧凑,一级表刚好一页 → 9 位一级、512 项。
3.4 分而治之:四级页表
于是用多级页表:像一本大字典分”卷 → 章 → 节”,只把真正用到的部分建出来,没用的部分整块省略。以 x86-64 常见的四级页表为例,地址里的页号被切成四段,从上往下逐级查:
flowchart TB
ADDR["虚拟页号 VPN 切成四段(每段 9 位)"]
subgraph LEVEL["四级页表"]
direction TB
PGD["① PGD 页全局目录<br/>按第 1 段定位"]
PUD["② PUD 页上级目录<br/>按第 2 段定位"]
PMD["③ PMD 页中级目录<br/>按第 3 段定位"]
PTE["④ PTE 页表项<br/>按第 4 段定位到最终物理页"]
end
ADDR --> PGD --> PUD --> PMD --> PTE
PTE --> PA["得到物理页框号 PFN<br/>再拼上 offset 就是物理地址"]
再举一个真实的多级查找例子。48 位虚拟地址被切成五段,假设每段取值如下:
虚拟地址(48 位):
[ PGD 索引 9 位 | PUD 索引 9 位 | PMD 索引 9 位 | PTE 索引 9 位 | offset 12 位 ]
[ 0 | 1 | 2 | 3 | 0x567 ]
CPU 里的 CR3 寄存器存着 PGD 表的物理地址,一切从它开始,一级一级往下跳:
- 读 CR3 找到 PGD 表,用
PGD 索引 = 0取出第 0 个条目,里面存着 PUD 表的物理地址; - 到 PUD 表,用
PUD 索引 = 1取出第 1 个条目,里面存着 PMD 表的物理地址; - 到 PMD 表,用
PMD 索引 = 2取出第 2 个条目,里面存着 PTE 表的物理地址; - 到 PTE 表,用
PTE 索引 = 3取出第 3 个条目,里面存着最终物理页框号 PFN(假设 =0xABCDE); - 拼出物理地址:
物理地址 = PFN × 4096 + offset = 0xABCDE × 4096 + 0x567 = 0xABCDE567。
flowchart TB
CR3["CR3 寄存器"] --> PGD2["PGD 表(第 0 项)"]
PGD2 -->|"第 0 项存着 PUD 地址"| PUD2["PUD 表(第 1 项)"]
PUD2 -->|"第 1 项存着 PMD 地址"| PMD2["PMD 表(第 2 项)"]
PMD2 -->|"第 2 项存着 PTE 地址"| PTE2["PTE 表(第 3 项)"]
PTE2 -->|"第 3 项存着 PFN=0xABCDE"| PA2["物理地址<br/>PFN×4096 + offset<br/>= 0xABCDE567"]
每一级都只做”取一个下标、跳到下一张表”,直到最后一级才真正拿到物理页号——这就是真实进程页表查一次地址的完整过程。
好处很明显:没被用到的中间目录整棵不建,每个进程的页表小得多;缺的只是”多查几级”的时间成本,而这点成本被后面讲 TLB 那一节大幅抵消。
四、把地址拆成位:页表项结构与掩码演算
前面用「除以页大小」讲清了思路,但真实的地址翻译一次除法都没有——全是移位和掩码。这一节把地址和页表项都摊成二进制,一位一位地看。
4.1 页表项(PTE):64 位里只用了一半
四级页表每一级都是一张表,表里每个条目叫 PTE(Page Table Entry),固定 8 字节(64 位)。x86-64 的 4 KiB 页表项长这样:
| 位 | 字段 | 含义 |
|---|---|---|
| 0 | P | Present,这一项是否有效 |
| 1 | R/W | 0 = 只读,1 = 可写 |
| 2 | U/S | 0 = 只有内核能访问,1 = 用户态也能访问 |
| 3 | PWT | 写穿透(缓存策略) |
| 4 | PCD | 禁用缓存 |
| 5 | A | Accessed,CPU 访问过就置 1,由硬件写 |
| 6 | D | Dirty,被写过就置 1 |
| 7 | PS | Page Size,1 表示这一级直接指向大页(只对 PDE / PDPTE 有意义) |
| 8 | G | Global,全局页,切换进程时不清 TLB |
| 11–9 | AVL | 留给软件使用 |
| 51–12 | PFN | 物理页框号,40 位 |
| 62–52 | AVL | 留给软件(部分实现用于保护键) |
| 63 | XD | 禁止执行(NX 位,防代码注入) |
有两处值得停下来想一秒。
(1)PFN 只占 40 位。 2⁴⁰ 个页 × 4 KiB = 4 PiB 物理内存,远大于 48 位虚拟地址能表达的范围——这也是当初敢把虚拟地址做成 48 位的底气。
(2)低 12 位为什么能拿来放标志? 因为页是 4 KiB 对齐的:任何页的物理地址,低 12 位恒为 0。硬件干脆把这 12 位”白捡”过来放控制位。所以你看到的 PTE = 0x1a2b3067,其实是「页号 0x1a2b3 + 标志 0x67」:
0x1a2b3067
└──┬──┘└┬┘
PFN flags(0x67 = P|RW|US|A|D)
4.2 虚拟地址:4 段 9 位 + 12 位
48 位虚拟地址不是随便切的,是按「每级页表 512 项」倒推出来的:
| 位区间 | 宽度 | 字段 | 作用 |
|---|---|---|---|
| 47–39 | 9 | PML4 索引 | 找第四级(顶级)页表 |
| 38–30 | 9 | PDPT 索引 | 找第三级页表 |
| 29–21 | 9 | PD 索引 | 找第二级页表 |
| 20–12 | 9 | PT 索引 | 找最终那张页表 |
| 11–0 | 12 | 页内偏移 | 不参与翻译,原样拼到物理地址上 |
(Linux 内核里这四级另有名字——PGD、PUD、PMD、PTE,两套命名的对应关系见第五节。)
为什么每级正好 9 位? 2⁹ = 512 项,每项 8 字节,一张页表正好 512 × 8 = 4096 字节 = 刚好一页。这样每一级都能塞进一个物理页,不需要连续的大块内存。9 + 9 + 9 + 9 + 12 = 48,账正好对上。
4.3 三条公式
offset = va & 0xFFF // 低 12 位,原样保留
index(i) = (va >> shift_i) & 0x1FF // 取出第 i 级的 9 位,shift 取 39/30/21/12
pa = (pfn << 12) | offset // 拼回物理地址
为什么非要「先移位、再掩码」:& 0x1FF 只能保住最低的 9 位,所以得先把目标那一段挪到最低位来。顺序反了(先掩码再移位)拿到的是完全不同的东西。
4.4 掩码不是多余的
有人会问:48 位地址右移 30 位,剩下的本来就不多,掩码是不是白写?计算过程会告诉你不是:
va = 0x7f3c4a5b6c8d
va >> 30 = 0x1fcf1 ← 17 位,上面还挂着一截
(va >> 30) & 0x1FF = 0xf1 ← 掩掉才对
内核地址更夸张,因为 64 位寄存器里的高位做了符号扩展(bit 63–48 全是 1):
va = 0xffff888012345678
va >> 39 = 0x1ffff11 ← 25 位,全是"高位垃圾"
(va >> 39) & 0x1FF = 0x111 ← 掩掉才算对
不掩码就会拿着 0x1ffff11 当数组下标去访问页表,直接越界。掩码在这里不是保险,是必需。
4.5 完整演算:0x00007f3c4a5b6c8d
取一个典型的用户态地址(这类地址一般落在栈或 mmap 区)。先看它的 48 位二进制,按 9/9/9/9/12 分组:
011111110 011110001 001010010 110110110 110010001101
| 第几组 | 二进制 | 十六进制 | 十进制 | 含义 |
|---|---|---|---|---|
| 1 | 011111110 | 0x0fe | 254 | PML4 索引(最高 9 位) |
| 2 | 011110001 | 0x0f1 | 241 | PDPT 索引 |
| 3 | 001010010 | 0x052 | 82 | PD 索引 |
| 4 | 110110110 | 0x1b6 | 438 | PT 索引 |
| 5 | 110010001101 | 0xc8d | 3213 | 页内偏移(最低 12 位) |
再用公式逐级验算,每一步都列出”先移位得到什么、再掩码留下什么”:
| 级别 | 移位 | 移位后的值 | & 0x1FF 之后 |
|---|---|---|---|
| PML4 | >> 39 |
0xfe |
0x0fe |
| PDPT | >> 30 |
0x1fcf1 |
0x0f1 |
| PD | >> 21 |
0x3f9e252 |
0x052 |
| PT | >> 12 |
0x7f3c4a5b6 |
0x1b6 |
| 偏移 | 不移位 | —— | va & 0xFFF = 0xc8d |
两种算法结果必须一致:表格里的十六进制和二进制分组是同一件事的两种写法。先看二进制理解”哪几位”,再用移位掩码拿到”值”,这是看页表代码最实用的习惯。
4.6 拼回物理地址
查到 PFN 之后,回程只有两步:
PTE = 0x1a2b3067
& 0x000FFFFFFFFFF000 → 0x1a2b3000 (掩掉低 12 位标志)
>> 12 → 0x1a2b3 (得到 PFN)
pa = (0x1a2b3 << 12) | 0xc8d = 0x1a2b3000 | 0xc8d = 0x1a2b3c8d
注意结果 0x1a2b3c8d 的末尾:0xc8d 原封不动。这和前面 0x1234 → 0x7234 的结论完全一致——页内偏移从头到尾没被碰过,变的只有页号那一段。
(0x000FFFFFFFFFF000 这个掩码就是”40 位 PFN 左移 12 位”的形状:低 12 位是 0,高 12 位也是 0,中间 40 位全 1。)
4.7 大页只是”偏移位变多”
前面按 4 KiB(2¹²)分,所以偏移 12 位。换成大页,变的只是偏移占几位,公式一个字都不用改:
| 页大小 | 2 的幂 | 偏移位数 | PTE 里 PFN 的位置 | 例子(同一个 va) |
|---|---|---|---|---|
| 4 KiB | 2¹² | 12 | 51–12 | offset = 0xc8d |
| 2 MiB | 2²¹ | 21 | 51–21 | offset = 0x1b6c8d |
| 1 GiB | 2³⁰ | 30 | 51–30 | offset = 0xa5b6c8d |
用同一个虚拟地址算 2 MiB 大页,假设 PDE 里直接查到 PFN = 0x8d1:
offset = 0x7f3c4a5b6c8d & 0x1FFFFF = 0x1b6c8d
pa = (0x8d1 << 21) | 0x1b6c8d = 0x11a200000 | 0x1b6c8d = 0x11a3b6c8d
1 GiB 页同理,只是偏移变成 30 位:
offset = 0x7f3c4a5b6c8d & 0x3FFFFFFF = 0xa5b6c8d
pa = (0x29 << 30) | 0xa5b6c8d = 0xa40000000 | 0xa5b6c8d = 0xa4a5b6c8d
页号在 PTE 里的起始位跟着偏移走(12 → 21 → 30),这就是大页”省一级页表”在二进制层面的样子。
4.8 一段可以跑的代码
把上面的演算写成 C,改个地址就能自己验证:
/* pte_split.c —— 把一个 64 位地址拆成四级索引 + 页内偏移
* 编译:gcc -O2 -Wall pte_split.c -o pte_split
* 运行:./pte_split 0x7f3c4a5b6c8d
*/
#include <stdio.h>
#include <stdint.h>
#include <stdlib.h>
#define IDX_BITS 9 /* 每级索引 9 位 */
#define IDX_MASK ((1ULL << IDX_BITS) - 1) /* 0x1FF */
#define PAGE_BITS 12 /* 4 KiB 页,偏移 12 位 */
static const int SHIFT[4] = { 39, 30, 21, 12 }; /* 四级各自的右移量 */
static const char *NAME[4] = { "PML4", "PDPT", "PD", "PT" };
/* 打印一个值:十六进制、十进制、指定位宽的二进制 */
static void show(const char *tag, uint64_t v, int bits)
{
int i;
printf("%-6s = 0x%03llx = %4llu = 0b", tag,
(unsigned long long)v, (unsigned long long)v);
for (i = bits - 1; i >= 0; i--) /* 从最高位打到最低位 */
putchar(((v >> i) & 1ULL) ? '1' : '0');
putchar('\n');
}
int main(int argc, char **argv)
{
/* 命令行没给地址就用文章里的例子 */
uint64_t va = (argc > 1) ? strtoull(argv[1], NULL, 0) : 0x7f3c4a5b6c8dULL;
uint64_t offset = va & ((1ULL << PAGE_BITS) - 1); /* 低 12 位 */
uint64_t pfn, pa;
int i;
printf("va = 0x%016llx\n\n", (unsigned long long)va);
/* 1. 逐级拆:先移位,再掩码 */
for (i = 0; i < 4; i++) {
uint64_t idx = (va >> SHIFT[i]) & IDX_MASK;
show(NAME[i], idx, IDX_BITS);
}
show("OFFSET", offset, PAGE_BITS);
/* 2. 假设最后一级查到 PFN=0x1a2b3,拼回物理地址 */
pfn = 0x1a2b3;
pa = (pfn << PAGE_BITS) | offset;
printf("\nPFN = 0x%llx\n", (unsigned long long)pfn);
printf("pa = (pfn << 12) | offset = 0x%08llx | 0x%03llx = 0x%08llx\n",
(unsigned long long)(pfn << PAGE_BITS),
(unsigned long long)offset,
(unsigned long long)pa);
return 0;
}
跑出来的前五行应该和 4.5 节的表格完全对上:
va = 0x007f3c4a5b6c8d
PML4 = 0x0fe = 254 = 0b011111110
PDPT = 0x0f1 = 241 = 0b011110001
PD = 0x052 = 82 = 0b001010010
PT = 0x1b6 = 438 = 0b110110110
OFFSET = 0xc8d = 3213 = 0b110010001101
五、两套名字:Linux 的 PGD/PUD/PMD/PTE 与 x86 的 PML4/PDPT/PD/PT
同一套页表,硬件文档和 Linux 源码用的是两套名字。前面为了讲硬件位域用了后者,这一节把它们对齐——顺便把全称一次讲清。
5.1 四个缩写的全称、中文与层级
| 缩写 | 英文全称 | 中文含义 | 所属层级 | x86-64 硬件里的对应物 |
|---|---|---|---|---|
| PGD | Page Global Directory | 页全局目录 | 最高级(四级页表里的第 4 级) | PML4(Page Map Level 4) |
| PUD | Page Upper Directory | 页上级目录 | 第 3 级 | PDPT(Page-Directory Pointer Table) |
| PMD | Page Middle Directory | 页中级目录 | 第 2 级 | PD(Page Directory) |
| PTE | Page Table Entry | 页表项 | 最末级(第 1 级)的表项 | PT(Page Table)里的一行 |
两个容易混的细节:
- PGD/PUD/PMD 是”目录”(Directory),PTE 是”表项”(Entry)。 前三个的存在意义是”指向下一级表”,最后一级不再往下指,它的每一行直接给出物理页框号——所以叫 Entry 而不是 Directory。承载 PTE 的那张表,Linux 里就叫”PTE 表”(
pte_t数组)。 - “第几级”有两个数法。 内核源码按”从顶级往下”排(pgd → pud → pmd → pte);而说”四级页表”时,习惯从最底层往上数——PGD 是第 4 级,PTE 是第 1 级。本文统一采用后一种,看到”PGD 是第 4 级”不要觉得别扭。
5.2 为什么 Linux 不直接用硬件的名字
因为内核要一套代码跑遍所有架构:
| 架构 | 页表级数 |
|---|---|
| ARM32(经典配置) | 2 级 |
| x86 32 位(PAE) | 3 级 |
| x86-64 | 4 级(开 LA57 是 5 级) |
| ARM64 | 4 级或 5 级 |
如果按硬件名字写代码,mm/ 目录下每一处查表都要 #ifdef。Linux 的做法是起一套与硬件无关的名字,再用”折叠”(fold)机制把多余的层级消掉:
- 2 级配置下,
pud_offset()和pmd_offset()直接返回上一级的指针,不多一次访存、不占一块内存; - 这些折叠是编译期决定的,运行期零成本;
- 所以
pgd_offset → pud_offset → pmd_offset → pte_offset_map这条链在四种架构上是同一份源码。
命名里的 Global / Upper / Middle 就是”相对位置”,不含任何硬件含义——这是刻意的:一旦写死成 pml4,将来加一级就得改名。
顺带一句:Linux 4.11 之后为了支持 5 级页表,在 PGD 和 PUD 之间又插入了一级 P4D(Page 4th-level Directory)。四级配置下它同样被折叠掉,所以大多数时候你感知不到。
5.3 实际代码里长什么样
一次完整的逐级查找,就是四个函数首尾相接:
/* 假设 mm 指向进程的地址空间,addr 是要翻译的虚拟地址 */
pgd_t *pgd = pgd_offset(mm, addr); /* 用最高 9 位定位到 PGD 项 */
p4d_t *p4d = p4d_offset(pgd, addr); /* 四级配置下这一步被折叠 */
pud_t *pud = pud_offset(p4d, addr); /* 再用 9 位定位到 PUD 项 */
pmd_t *pmd = pmd_offset(pud, addr); /* 再用 9 位定位到 PMD 项 */
pte_t *pte = pte_offset_map(pmd, addr); /* 最后 9 位定位到 PTE 项 */
if (pte_present(*pte)) { /* P 位为 1 才表示这一页真在内存里 */
unsigned long pfn = pte_pfn(*pte); /* 取出 PFN */
unsigned long pa = (pfn << 12) | (addr & 0xFFF); /* 拼上页内偏移 */
}
对照第四节的公式看:pgd_offset 内部做的正是 (addr >> 39) & 0x1FF,pud_offset 是 (addr >> 30) & 0x1FF,一路往下。函数名只是把这几个移位掩码封装成了有语义的接口。
5.4 大页也挂在这套名字上
第四节说过大页靠 PTE 里的 PS 位,落到内核里就是两个判断:
| 判断 | 含义 | 对应硬件 |
|---|---|---|
pmd_huge(*pmd) |
这一级直接映射 2 MiB 大页 | PDE 的 PS = 1 |
pud_huge(*pud) |
这一级直接映射 1 GiB 大页 | PDPTE 的 PS = 1 |
也就是说:“2 MiB 大页”在软件里叫 PMD 级大页,”1 GiB 大页”叫 PUD 级大页。查表时如果发现 pmd_huge 为真,就不用再往下找 PTE 表了——少一级访存,这也是大页在 TLB 之外的另一层收益。
5.5 全文术语表
地址与页表
| 缩写 | 全称 | 中文 |
|---|---|---|
| VA | Virtual Address | 虚拟地址 |
| PA | Physical Address | 物理地址 |
| VPN | Virtual Page Number | 虚拟页号(地址高位那段) |
| PFN | Page Frame Number | 页框号(物理页编号) |
| PTE | Page Table Entry | 页表项 |
| PGD | Page Global Directory | 页全局目录 |
| PUD | Page Upper Directory | 页上级目录 |
| PMD | Page Middle Directory | 页中级目录 |
| P4D | Page 4th-level Directory | 页第四级目录(5 级页表引入) |
| PML4 | Page Map Level 4 | 第 4 级页映射表(x86 硬件名) |
| PDPT | Page-Directory Pointer Table | 页目录指针表(x86 硬件名) |
| PD | Page Directory | 页目录(x86 硬件名) |
| PT | Page Table | 页表(x86 硬件名) |
| PDE | Page Directory Entry | 页目录项 |
| PDPTE | Page-Directory Pointer Table Entry | 页目录指针表项 |
页表项里的位域
| 缩写 | 全称 | 中文 |
|---|---|---|
| P | Present | 存在位 |
| R/W | Read/Write | 读/写权限位 |
| U/S | User/Supervisor | 用户/内核权限位 |
| PWT | Page Write-Through | 页写穿透 |
| PCD | Page Cache Disable | 页缓存禁用 |
| A | Accessed | 访问位 |
| D | Dirty | 脏位 |
| PS | Page Size | 页大小位(置 1 表示大页) |
| G | Global | 全局页位 |
| AVL | Available | 软件可用位 |
| NX / XD | No-eXecute / eXecute Disable | 禁止执行位 |
硬件与系统
| 缩写 | 全称 | 中文 |
|---|---|---|
| MMU | Memory Management Unit | 内存管理单元 |
| TLB | Translation Lookaside Buffer | 转译后备缓冲器(地址翻译缓存) |
| CR3 | Control Register 3 | 控制寄存器 3,存顶级页表物理地址 |
| PCID | Process Context Identifier | 进程上下文标识(TLB 打标签用) |
| LA57 | Linear Address 57-bit | 57 位线性地址扩展(5 级页表) |
| CPU | Central Processing Unit | 中央处理器 |
| OS | Operating System | 操作系统 |
| ELF | Executable and Linkable Format | 可执行与可链接格式 |
容量单位
| 缩写 | 全称 | 换算 |
|---|---|---|
| KiB | Kibibyte | 1 KiB = 1024 B |
| MiB | Mebibyte | 1 MiB = 1024 KiB |
| GiB | Gibibyte | 1 GiB = 1024 MiB |
| TiB | Tebibyte | 1 TiB = 1024 GiB |
| PiB | Pebibyte | 1 PiB = 1024 TiB |
| KB / GB / TB | Kilobyte / Gigabyte / Terabyte | 十进制,1 KB = 1000 B |
六、一次地址翻译的完整过程(时序图)
CPU 每次访问内存,都要经历下面这个过程。注意主角是 MMU(内存管理单元)——页表查询由它这个硬件来做,操作系统只在”缺页”时介入:
sequenceDiagram
autonumber
participant CPU as CPU
participant TLB as TLB 快表
participant MMU as MMU
participant OS as 操作系统内核
CPU->>TLB: 访问内存:先拿虚拟地址查 TLB
alt TLB 命中
TLB-->>CPU: 直接返回物理地址(最快)
else TLB 未命中
TLB->>MMU: 让 MMU 逐级查页表
MMU->>MMU: PGD → PUD → PMD → PTE
alt 页存在(present = 1)
MMU-->>CPU: 拼出物理地址,并回填 TLB
else 页不存在(缺页)
MMU->>OS: 触发缺页异常 page fault
OS->>OS: 分配一页物理内存、填好页表项
OS-->>MMU: 处理完成
MMU-->>CPU: CPU 重新执行刚才那条指令
end
end
七、TLB:翻译过程的”加速缓存”
从上图能看出,一次完整的页表查询要连查四级、每级都是一次内存访问,太慢了。于是 CPU 里放了一个高速小缓存,叫 TLB(Translation Lookaside Buffer,快表),专门记住”最近用过的虚拟页 → 物理页”的翻译结果。
大部分内存访问都会先命中 TLB,不用真的去走四级页表。这就像查字典前先看手边的”生词小本本”:查过的词都记在小本本上,下次直接翻小本本,快得多。
flowchart LR
CPU["CPU 要访问内存"] --> TLB["查 TLB"]
TLB -->|"TLB 命中"| FAST["直接拿到物理地址(快)"]
TLB -->|"TLB 未命中"| SLOW["逐级查页表<br/>得到物理地址后,顺手存进 TLB"]
FAST --> MEM["物理内存"]
SLOW --> MEM
一句话:先查 TLB,命中就直接用;没命中才去查多级页表,查完再回填 TLB,下次访问同样的地址就快了。
八、缺页异常与按需分配:为什么”碰一下”才真正给内存
页表里的每一项(PTE)都带几个标志位,最关键的是 present(存在位):1 表示这一页已经在物理内存里,0 表示”还没分配”或”被换出到磁盘了”。
当 CPU 访问到一个 present=0 的页,就触发缺页异常(page fault)。内核被叫进来,现场分配一页物理内存、把页表项填好、再让 CPU 重新执行刚才的指令——这次就能访问了。整个过程对程序是透明的,程序根本不知道自己”等了一下”。
flowchart TB
CPU["CPU 访问某虚拟地址"] --> CHK["查页表,看 present 位"]
CHK -->|"present = 1"| OK["正常访问物理内存"]
CHK -->|"present = 0"| FAULT["触发缺页异常 page fault"]
FAULT --> OS["内核分配一页物理内存"]
OS --> FILL["把物理页框号填进页表项<br/>并把 present 置 1"]
FILL --> RETRY["CPU 重新执行刚才那条指令"]
RETRY --> OK
这张图就是一次缺页的完整闭环:查页表发现 present=0 → 缺页异常 → 内核分配物理页、填页表 → CPU 重新执行,这次就成功了。
这就是按需分配(demand paging):mmap 申请一大块内存时,操作系统常常只是”登记”了这段虚拟地址,一个物理页都没真给;等你第一次读/写某个页,才通过缺页异常真正分配。好处是不用的页不占内存。
九、页表什么时候创建?谁在维护它?
先纠正一个常见误解:页表不是”操作系统一直只有一张”,也不是”程序启动时一次性建好一整张”。准确说是两类:
- 内核自己的页表:开机(内核启动)时就建立,映射内核代码、数据和全部物理内存,系统运行期间一直存在;
- 每个进程的页表:在进程创建时由内核新建,之后随运行动态增补,进程退出时销毁。
具体到时间点,进程的页表是这样来的:
fork()创建进程时:内核给子进程建一张新页表,先复制父进程的页表(很多页用”写时复制 copy-on-write”先共享,真正被写时才各自复制一份);exec()换成新程序时:内核给这个进程换一张新页表,按 ELF 可执行文件重新映射代码段、数据段、栈、堆等;- 运行过程中:
mmap申请内存、栈/堆增长、发生缺页时,页表被一页一页地填上——这正是上一节”按需分配”,所以页表从创建起就一直在变,不是一次建好的; - 进程退出时:这张页表被销毁,属于它的物理页被回收。
一句话记忆:“内核的页表开机就有;每个进程的页表 fork/exec 时建、运行中动态填、退出时销毁。”
flowchart TB
BOOT["开机:内核页表建立(一直存在)"] --> FORK["fork:复制父进程页表,建出子进程页表"]
FORK --> EXEC["exec:按新程序重建页表"]
EXEC --> RUN["运行中:mmap / 栈堆增长 / 缺页<br/>页表被一页页填上"]
RUN --> EXIT["进程退出:页表销毁、物理页回收"]
十、怎么用 / 怎么观察:亲手看一眼页表在起作用
页表是操作系统内部自动维护的,程序员一般不直接”操作”它,但可以观察它。先看两个现成命令:
getconf PAGE_SIZE # 查看系统页大小(Linux 上通常是 4096 字节 = 4KB)
cat /proc/self/maps | head -n 5 # 查看当前进程自己的虚拟内存映射表
/proc/self/maps 每一行是一条映射,例如 00400000-00452000 r-xp ... /usr/bin/cat:最左边是虚拟地址范围,第二位是权限(r=读、w=写、x=执行、p=私有)。这些”看起来连续”的区间,背后正是靠页表映射到物理内存的。
再写一个 C 程序,直接看到”按需分配”的效果——mmap 只预留虚拟地址,真正碰某页才触发缺页、分配物理内存:
#include <stdio.h> // printf、perror
#include <sys/mman.h> // mmap、munmap、MAP_ANONYMOUS、MAP_FAILED
#include <unistd.h> // sysconf、_SC_PAGESIZE
#include <sys/resource.h> // getrusage、struct rusage
int main(void) {
long pagesize = sysconf(_SC_PAGESIZE); // 取系统页大小(通常 4096 字节)
size_t total = (size_t)pagesize * 4; // 一共映射 4 个页的大小
// 匿名映射 4 页:这一步只是"预留"虚拟地址,还没真正分配物理内存
// MAP_ANONYMOUS 表示不关联文件;MAP_PRIVATE 表示私有;fd 用 -1
char *p = mmap(NULL, total, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; } // 映射失败就报错退出
printf("页大小=%ld,映射了 %zu 字节\n", pagesize, total);
printf("虚拟地址起始=%p\n", (void *)p); // 打印这段内存的虚拟地址
// 记录动手前后的缺页次数(minor 缺页 + major 缺页)
struct rusage before, after;
getrusage(RUSAGE_SELF, &before);
p[0] = 'a'; // 第一次碰第 1 页 → 触发一次缺页,内核才分配物理页
p[pagesize] = 'b'; // 第一次碰第 2 页 → 再触发一次缺页
p[pagesize * 3] = 'd'; // 碰第 4 页 → 触发缺页;第 3 页没碰过,不分配
getrusage(RUSAGE_SELF, &after);
long faults = (after.ru_minflt - before.ru_minflt)
+ (after.ru_majflt - before.ru_majflt);
printf("刚才触发缺页次数约=%ld 次\n", faults); // 大约等于 3(碰了几个不同的页就缺几次)
munmap(p, total); // 解除映射,释放这段虚拟地址
return 0; // 程序正常结束
}
编译运行:gcc pagefault.c -o pagefault && ./pagefault。你会看到缺页次数大约等于 3——正是我们碰过的 3 个不同的页;第 3 页(下标 pagesize*2)从头到尾没碰过,所以一次缺页都没为它发生。这就是”用到哪页、才给哪页”。
十一、总结
把整篇文章收成几句话:
- 虚拟内存给每个进程一个独立、连续的假地址空间,页表负责把假地址翻译成真地址;
- 页表本质是”虚拟页号 → 物理页号“的映射,地址 = 页号(高位)+ 页内偏移(低 12 位);
- 因为全量页表太大,所以用多级页表(x86-64 是 PGD/PUD/PMD/PTE 四级),只建用到的部分;
- 翻译时先查 TLB 快表,未命中再逐级查页表;查不到就触发缺页异常,内核按需分配物理页;
- 这套机制一石三鸟:隔离、不浪费、按需分配。
理解了这套框架,再去看 mmap、/proc/pid/maps、或者 Linux 内核里 vm_area_struct 和四级页表的结构,就会觉得豁然开朗——它们不过是在把这套”翻译字典”做得更高效、更精细而已。