2026.10.24 · 技术 / 操作系统

内存页表(Page Table)是什么、怎么用、实现原理是什么

从需要页表的原因讲到页表项位域:虚拟地址如何切成 4 段 9 位索引加 12 位偏移,掩码与移位怎么算、高低位怎么分,并演示 PFN 拼回物理地址、多级页表与 TLB、缺页异常,配架构图、时序图与可运行的拆地址程序。

本文浏览量—

想象一下:小区里每个住户都收到一个统一的虚拟门牌号(比如”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。这样有三个大问题:

  1. 容易撞车:两个程序都想用 0x1000,谁先谁后、会不会互相踩,完全没保障;
  2. 不隔离:一个程序写坏了自己的内存,可能顺带把别的程序也搞崩;
  3. 有碎片:内存用一段空一段,程序又要求”必须占一段连续的物理内存”,很快就被切得七零八落。

解决办法就是虚拟内存 + 页表:给每个进程一个假地址空间,这个空间看起来又大又连续、而且只属于它自己;真正访问时,再由硬件和操作系统一起,通过页表把假地址翻译成真地址。

这一下同时解决了上面三个问题:隔离(每个进程的假空间互不相通)、不浪费(物理内存可以按页分配、拼起来就行,不要求连续)、按需分配(用到哪页才给哪页,后面会细讲)。

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

举一个具体例子:查一次”虚拟页号 → 物理页号”

(先说明:为了先让你看清本质,这里用一张”平表”举例;真实进程用的是下一节的多级页表,但查找思路完全一样。)

整个过程用四条公式就能算清楚:

  1. 页内偏移:offset = 虚拟地址 mod 页大小(即取低 12 位)
  2. 虚拟页号:VPN = 虚拟地址 ÷ 页大小(整数除法,即高位)
  3. 查页表:PFN = 页表[VPN](用 VPN 当”行号”,去表里找物理页框号)
  4. 拼出物理地址:物理地址 = 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(四级的索引)

从下往上看,三条依赖链各自独立:

  1. 12 位偏移 ← 页大小是 4 KiB。 页是内存管理的基本单位,这个数先定下来,偏移宽度就定了。
  2. 9 位索引 ← 一级页表必须装进一页。 4096 字节 ÷ 8 字节每项 = 512 项 = 2⁹。是”表要小到能塞进一个物理页”这个约束,逼出了 9。
  3. 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 表的物理地址,一切从它开始,一级一级往下跳:

  1. 读 CR3 找到 PGD 表,用 PGD 索引 = 0 取出第 0 个条目,里面存着 PUD 表的物理地址;
  2. 到 PUD 表,用 PUD 索引 = 1 取出第 1 个条目,里面存着 PMD 表的物理地址;
  3. 到 PMD 表,用 PMD 索引 = 2 取出第 2 个条目,里面存着 PTE 表的物理地址;
  4. 到 PTE 表,用 PTE 索引 = 3 取出第 3 个条目,里面存着最终物理页框号 PFN(假设 = 0xABCDE);
  5. 拼出物理地址:物理地址 = 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 申请一大块内存时,操作系统常常只是”登记”了这段虚拟地址,一个物理页都没真给;等你第一次读/写某个页,才通过缺页异常真正分配。好处是不用的页不占内存。

九、页表什么时候创建?谁在维护它?

先纠正一个常见误解:页表不是”操作系统一直只有一张”,也不是”程序启动时一次性建好一整张”。准确说是两类:

  • 内核自己的页表:开机(内核启动)时就建立,映射内核代码、数据和全部物理内存,系统运行期间一直存在;
  • 每个进程的页表:在进程创建时由内核新建,之后随运行动态增补,进程退出时销毁。

具体到时间点,进程的页表是这样来的:

  1. fork() 创建进程时:内核给子进程建一张新页表,先复制父进程的页表(很多页用”写时复制 copy-on-write”先共享,真正被写时才各自复制一份);
  2. exec() 换成新程序时:内核给这个进程换一张新页表,按 ELF 可执行文件重新映射代码段、数据段、栈、堆等;
  3. 运行过程中:mmap 申请内存、栈/堆增长、发生缺页时,页表被一页一页地填上——这正是上一节”按需分配”,所以页表从创建起就一直在变,不是一次建好的;
  4. 进程退出时:这张页表被销毁,属于它的物理页被回收。

一句话记忆:“内核的页表开机就有;每个进程的页表 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)从头到尾没碰过,所以一次缺页都没为它发生。这就是”用到哪页、才给哪页”。

十一、总结

把整篇文章收成几句话:

  1. 虚拟内存给每个进程一个独立、连续的假地址空间,页表负责把假地址翻译成真地址;
  2. 页表本质是”虚拟页号 → 物理页号“的映射,地址 = 页号(高位)+ 页内偏移(低 12 位);
  3. 因为全量页表太大,所以用多级页表(x86-64 是 PGD/PUD/PMD/PTE 四级),只建用到的部分;
  4. 翻译时先查 TLB 快表,未命中再逐级查页表;查不到就触发缺页异常,内核按需分配物理页;
  5. 这套机制一石三鸟:隔离、不浪费、按需分配。

理解了这套框架,再去看 mmap、/proc/pid/maps、或者 Linux 内核里 vm_area_struct 和四级页表的结构,就会觉得豁然开朗——它们不过是在把这套”翻译字典”做得更高效、更精细而已。