Appearance
分页与多级页表
写作时间:
2026-03-30 首次提交,2026-04-03 最近修改当前字符:
13710写作时间:
2026-03-30当前字符:`13504`
上一课从地址绑定讲到分段,最后留下了一个未解决的问题:每个段内部仍然要求连续的物理内存,外部碎片依然存在。分页沿着"缩小分配单位"这条路继续推进:不再以可变大小的段为单位,而是把虚拟地址空间和物理内存都切成固定大小的小块。
分页
分页(paging)是将虚拟地址空间和物理内存都划分成固定大小的块,通过页表建立虚拟页到物理页帧之间的映射关系。
这里涉及三个基本概念。虚拟地址空间被划分成的固定大小块叫做页(page),物理内存被划分成的同样大小块叫做页帧(frame),也叫物理页。页和页帧的大小相同,x86-64 上默认为 4KB。页表(page table) 记录虚拟页到物理页帧的对应关系:它是一个数组,以虚拟页号为下标,每个条目叫做页表项(Page Table Entry, PTE),记录物理页帧号和一组属性标志。虚拟页和物理页帧之间不需要按顺序对应,程序看到的连续虚拟地址在物理内存里完全可以打散存放。
具体来看地址翻译的过程。虚拟地址在硬件层面被拆分为两部分:高位是虚拟页号(page number),低位是页内偏移(page offset)。以 4KB 页为例,一页有 4096 个字节,页内偏移必须能寻址页内的每一个字节,所以它至少需要 log2(4096) = 12 位。因此虚拟地址的低 12 位是页内偏移,其余高位是虚拟页号。在 32 位地址空间中,高 20 位用作页号,最多可以表示 2^20 = 1048576 个页,总共覆盖 2^20 × 4KB = 4GB 的虚拟地址空间——也就是说,理论上一个 32 位进程的虚拟地址空间可以达到 4GB。以一个 32 位地址为例:
虚拟地址 0x12345678(32 位示例)
├── 高 20 位: 0x12345 → 虚拟页号,查页表
└── 低 12 位: 0x678 → 页内偏移,保持不变
查阅页表发现: 虚拟页 0x12345 → 物理页帧 0xABCDE
物理地址 = 物理页帧号 << 12 | 页内偏移 = 0xABCDE678由于任何空闲页帧都可以分配给任何虚拟页,外部碎片至此消除。
分页引入了另一种代价——内部碎片。如果一段数据只需要 4097 字节(4KB + 1 字节),它至少占用两页(8KB),最后一页浪费了 4095 字节。但内部碎片与外部碎片有本质区别:内部碎片被限制在每段连续区域的最后一页,最多浪费一页减一字节,而且这种浪费不会阻止后续分配继续进行。外部碎片则可能导致空闲内存总量充足却完全无法分配的死局。
在 x86-64 上,每个 PTE 是一个 64 位(8 字节)的值,布局如下:
63 62 52 51 12 11 9 8 7 6 5 4 3 2 1 0
┌───┬──────────┬─────────────────────────┬─────┬──┬──┬─┬─┬─┬─┬─┬─┐
│NX │ reserved │ 物理页帧号 (40 bits) │ AVL │G │PS│D│A│C│T│W│P│
└───┴──────────┴─────────────────────────┴─────┴──┴──┴─┴─┴─┴─┴─┴─┘中间 40 位存放物理页帧号,低 12 位是标志位。以上 PTE 布局和下面的标志位定义都是 x86-64 四级页表模式下的规格,32 位 x86 的 PTE 是 32 位(4 字节),字段布局有所不同。
页表本身就是一个由 PTE 组成的数组,存放在物理内存中。在 x86-64 上,一张页表页占 4KB,恰好容纳 512 个 PTE(512 × 8 字节 = 4096 字节)。这个 512 对应 2^9,后面多级页表的"每级 9 位索引"就来自于此。
为什么页表必须存放在物理内存中?
页表是 MMU 翻译虚拟地址时查阅的数据结构。如果页表本身使用虚拟地址,MMU 就需要先翻译页表的虚拟地址才能读取页表,而翻译这个虚拟地址又需要查页表——形成无限递归。所以页表的基址必须是物理地址。CPU 调度一课提到的 CR3 寄存器就存放着当前进程顶级页表的物理基址。
页表存储在物理内存中也带来了性能问题:每次内存访问都需要先查页表(至少一次额外的物理内存访问),再访问目标数据。对于多级页表,一次翻译需要多次内存访问,开销更高。这个问题的解决方案就是前面提到的 TLB,本课后半部分会展开。
这些标志位控制该页的属性和状态1:
c
// arch/x86/include/asm/pgtable_types.h (selected flags)
#define _PAGE_BIT_PRESENT 0 // P: 页在物理内存中
#define _PAGE_BIT_RW 1 // R/W: 0=只读, 1=可读写
#define _PAGE_BIT_USER 2 // U/S: 0=仅内核态, 1=用户态可访问
#define _PAGE_BIT_ACCESSED 5 // A: 页被访问过(读或写)
#define _PAGE_BIT_DIRTY 6 // D: 页被写入过
#define _PAGE_BIT_PSE 7 // PS: 大页(2MB/1GB)
#define _PAGE_BIT_NX 63 // NX: 不可执行
#define _PAGE_PRESENT (1UL << _PAGE_BIT_PRESENT)
#define _PAGE_RW (1UL << _PAGE_BIT_RW)
#define _PAGE_USER (1UL << _PAGE_BIT_USER)
#define _PAGE_ACCESSED (1UL << _PAGE_BIT_ACCESSED)
#define _PAGE_DIRTY (1UL << _PAGE_BIT_DIRTY)其中 Present 位最为关键:当 CPU 访问一个 Present = 0 的页时,硬件触发缺页异常(Page Fault, #PF),把控制权交给内核。特权边界一课已经介绍过这个异常。内核利用它实现按需分页和写时复制(COW)等机制,下一课会详细展开。R/W 位控制页的读写权限,进程生命周期一课介绍的 COW 就靠它触发写入异常。Accessed 位和 Dirty 位由 CPU 硬件自动设置:前者在页被访问时置 1,后者在页被写入时置 1。它们让内核能够追踪每个页的使用状况,后续的内存管理课会用到这两个位。
多级页表
多级页表(multi-level page table)是把页表从单层数组改造成多层的树形结构,只为进程实际使用的虚拟地址范围分配页表节点。
我们先从两级页表来理解基本原理。回顾上一节的 32 位地址示例:低 12 位是页内偏移,高 20 位是虚拟页号,因此整个地址空间最多有 2^20 个虚拟页,对应 2^20 个 PTE。但上一节也提到,一张页表页只有 4KB。在 32 位 x86 上每个 PTE 是 4 字节,一张页表页容纳 4096 / 4 = 1024 个 PTE——远小于 2^20。一张页表页装不下所有 PTE,而如果为 2^20 个条目分配一整块连续内存,不管进程实际用了多少虚拟页,这块内存都要始终占着。两级页表的做法是:把这 2^20 个条目分成 1024 组,每组 1024 个 PTE(恰好一张页表页)。第一级叫页目录(page directory),有 1024 个条目,每个条目指向一个第二级页表。每个页目录条目管辖 1024 个虚拟页,也就是 1024 × 4KB = 4MB 的虚拟地址范围。
上一课的分段一节画过进程的内存布局:代码段和数据段占据低地址的一小片区域,栈在高地址向下生长,中间隔着巨大的空洞。一个 32 位进程的虚拟地址空间最大可达 4GB,但实际映射了代码、数据和栈的部分可能只有几十 MB,剩下的绝大部分地址没有对应任何东西——既没有代码,也没有数据,进程从来不会访问那里。
这些空洞覆盖的页目录条目就可以标记为"不存在",对应的第二级页表根本不需要分配。1024 个页目录条目中可能只有不到 10 个指向了真实的第二级页表,其余全部为空。这就是省内存的关键——空洞不占任何页表空间。
前面的示例用的是 32 位地址空间。现代 x86-64 机器的寄存器是 64 位宽,但虚拟地址并没有用满全部 64 位——四级页表模式下实际使用 48 位虚拟地址(4 级 × 每级 9 位索引 + 12 位页内偏移 = 48 位)。x86-64 使用四级页表,Linux 在源码中进一步统一为五层命名,以便同一套代码兼容四级和五级页表硬件:
text
PGD -> P4D -> PUD -> PMD -> PTE- PGD(Page Global Directory):顶级页目录
- P4D(Page 4th-level Directory):第四级目录,四级页表机器上被折叠
- PUD(Page Upper Directory):上级目录
- PMD(Page Middle Directory):中级目录
- PTE(Page Table Entry):最终的页表项,上一节已经介绍过
在最常见的四级页表机器上,P4D 这一层会被折叠(fold)掉:源码中仍然有 p4d_t 类型和 p4d_offset() 函数,但硬件实际做页表遍历时只走四级。因此在 Linux 源码中会看到五个层级名字,在硬件文档中只有四级——两边描述的是同一棵树,只是抽象层不同。
下面是 x86-64 四级页表模式下虚拟地址的位域拆分:
c
/*
* x86-64 虚拟地址位域拆分(常见四级页表,48 位寻址,4KB 页)
*
* 63 48 47 39 38 30 29 21 20 12 11 0
* ┌───────┬────────┬────────┬────────┬────────┬─────────┐
* │ sign │ PGD │ PUD │ PMD │ PTE │ offset │
* │extend │ 9 bits │ 9 bits │ 9 bits │ 9 bits │ 12 bits │
* └───────┴────────┴────────┴────────┴────────┴─────────┘
* 16b index index index index 页内偏移
* into into into into
* PGD PUD PMD PT
*
* 每级 9 位 → 512 个条目 × 8 字节/条目 = 4KB = 恰好一页
*/真正参与查表的是 4 个 9 位索引加上最后 12 位页内偏移。每级 9 位索引对应 512 个条目(2^9 = 512),每个条目 8 字节,所以每一级页表页恰好占 4KB(512 × 8 = 4096),正好是一个物理页帧的大小。高 16 位(第 48-63 位)不参与索引,硬件要求它们全部和第 47 位保持一致:如果第 47 位是 0,高 16 位必须全为 0;如果第 47 位是 1,高 16 位必须全为 1。满足这个规则的地址叫做 canonical address(规范地址),不满足的地址会被 CPU 直接拒绝。
从 Linux 的数据结构角度看,内核用 mm_struct2 来描述一个进程的整个地址空间。这里只需要关注其中一个字段:
c
// include/linux/mm_types.h (simplified)
struct mm_struct {
pgd_t *pgd; // top-level page table pointer
struct maple_tree mm_mt; // all VMAs of this address space (maple tree)
unsigned long total_vm; // total pages mapped
unsigned long stack_vm; // pages used by stack
unsigned long start_code, end_code; // code segment range
unsigned long start_brk, brk; // heap range
unsigned long start_stack; // stack start
};本课只需要关注 pgd:它指向顶级页表页。其余字段管理进程的虚拟内存区域(VMA)、各段地址范围和内存统计,后续内存映射一课会深入展开。CPU 切换到某个进程时,会把 pgd 所指页表页的物理地址装入 CR3 寄存器。一次地址翻译就是从 CR3 出发,沿着这棵树逐级向下:
text
mm_struct (进程地址空间描述符)
└── pgd ----> 顶级页表页(物理地址存在 CR3 中)
└── pgd entry -> 下一级页表页
└── p4d entry -> 下一级页表页(四级机型上通常折叠)
└── pud entry -> 下一级页表页
└── pmd entry -> 最后一级页表页
└── pte entry -> 物理页帧号 + 标志位用一个具体的虚拟地址来走一遍完整的翻译过程。假设进程要访问虚拟地址 0x0000018141407100,CPU 首先按照 9-9-9-9-12 的规则把它拆成五个部分。注意 9 位索引不按 4 位十六进制边界对齐,所以需要从二进制层面拆分:
虚拟地址: 0x0000_0181_4140_7100
二进制:
0000 0000 0000 0000 | 0 0000 0011 | 0 0000 0101 | 0 0000 1010 | 0 0000 0111 | 0001 0000 0000
符号扩展 (16b) PGD (9b) PUD (9b) PMD (9b) PTE (9b) 页内偏移 (12b)
= 3 = 5 = 10 = 7 = 0x100然后从 CR3 出发,逐级查表:
- 查 PGD:CR3 指向顶级页表页的物理地址。CPU 用 PGD 索引 3 找到该页中第 3 个条目,读出其中存放的下一级页表页的物理地址。
- 查 PUD:跳到上一步拿到的物理地址,用 PUD 索引 5 找到第 5 个条目,读出下一级页表页的物理地址。
- 查 PMD:跳到上一步拿到的物理地址,用 PMD 索引 10 找到第 10 个条目,读出最后一级页表页的物理地址。
- 查 PTE:跳到上一步拿到的物理地址,用 PTE 索引 7 找到第 7 个条目。这个 PTE 中存放着目标物理页帧号,假设是 0x1A2B3。
- 拼接:物理页帧号 0x1A2B3 左移 12 位得到 0x1A2B3000,加上页内偏移 0x100,最终物理地址就是 0x1A2B3100。
如果任何一级的条目 Present 位为 0,CPU 就不再继续往下走,而是触发缺页异常,把控制权交给内核处理。
Linux 内核里对这件事提供了一组统一的遍历宏3。下面是简化后的伪代码:
c
// Linux 通用页表遍历(基于内核宏,简化)
// arch/x86/include/asm/pgtable.h
// 输入:mm_struct(包含 CR3)和虚拟地址 vaddr
// 输出:物理地址
pgd_t *pgd = pgd_offset(mm, vaddr); // CR3 + PGD 索引 → PGD 条目
if (pgd_none(*pgd)) // PGD 条目为空 → 地址无映射
return PAGE_FAULT;
p4d_t *p4d = p4d_offset(pgd, vaddr); // PGD 条目 + P4D 索引 → P4D 条目
if (p4d_none(*p4d))
return PAGE_FAULT;
pud_t *pud = pud_offset(p4d, vaddr); // P4D 条目 + PUD 索引 → PUD 条目
if (pud_none(*pud))
return PAGE_FAULT;
pmd_t *pmd = pmd_offset(pud, vaddr); // PUD 条目 + PMD 索引 → PMD 条目
if (pmd_none(*pmd))
return PAGE_FAULT;
pte_t *pte = pte_offset_kernel(pmd, vaddr); // PMD 条目 + PTE 索引 → PTE 条目
if (!pte_present(*pte)) // Present 位为 0 → 缺页
return PAGE_FAULT;
unsigned long phys = (pte_val(*pte) & PTE_PFN_MASK) | (vaddr & ~PAGE_MASK);
// PTE 中的物理页帧号 + 虚拟地址的低 12 位页内偏移 = 最终物理地址代码中每一级都检查了条目是否为空(pgd_none / pud_none 等)。访问到空条目时会触发上一节介绍过的缺页异常。但从内存分配的角度看,空条目恰恰是多级页表省空间的关键:内核只会为进程实际使用的地址范围逐级分配页表页,未使用的范围对应的条目始终为空,不占任何内存。四级 x86-64 机器上 P4D 会被折叠,实际遍历时这一级总是直接通过。
一个具体的计算可以说明节省的幅度。假设一个进程使用了两片地址范围:低地址的代码+数据+堆共 7MB,高地址的栈 8MB。我们来画出这个进程实际的页表树:
PGD (1 页, 512 个条目, 绝大多数为空)
│
├── entry 0 ──→ PUD 页 (低地址区域)
│ └── entry 0 ──→ PMD 页
│ ├── entry 0 ──→ PTE 页 (代码段)
│ ├── entry 1 ──→ PTE 页 (代码+数据)
│ ├── entry 2 ──→ PTE 页 (堆)
│ └── entry 3 ──→ PTE 页 (堆)
│
├── entry 255 ──→ PUD 页 (高地址区域)
│ └── entry 511 ──→ PMD 页
│ ├── entry 508 ──→ PTE 页 (栈)
│ ├── entry 509 ──→ PTE 页 (栈)
│ ├── entry 510 ──→ PTE 页 (栈)
│ └── entry 511 ──→ PTE 页 (栈)
│
└── entry 1~254, 256~511 ──→ 全部为空,不分配任何下级页表数一下实际分配的页表页:1(PGD)+ 2(PUD)+ 2(PMD)+ 8(PTE)= 13 页 = 52KB。
作为对比,如果用单层数组会怎样?单层数组以虚拟页号为下标做直接索引:要查虚拟页 #N,就去数组第 N 个位置取 PTE。这个进程的代码在低地址(虚拟页号接近 0),栈在高地址(虚拟页号接近 2^36)。为了能通过下标直接访问两头的 PTE,数组必须从 0 一路延伸到 2^36,中间那些没有映射的位置也必须存在——否则下标就对不上了。所以同一个只用了 15MB 的进程,单层数组仍然需要 2^36 × 8 字节 = 512GB。多级页表把这个开销从 512GB 降到了 52KB。
多级页表的代价是翻译延迟:TLB 未命中时,CPU 要逐级访问内存查四次页表才能拿到物理地址。层级越多延迟越大,所以四级页表的 48 位地址空间对绝大多数应用已经足够。Linux 5.0 开始支持五级页表(LA57),把地址空间扩展到 57 位,但只有内存数据库、大规模科学计算等需要超大地址空间的场景才会开启。
按需分页
按需分页(demand paging)是把页面装入内存的时机推迟到第一次真正访问时,而不是在进程启动或映射建立时一次性全部装入。
前面介绍 Present 位时提到,一个合法的虚拟页可能暂时没有物理页帧与之对应。按需分页就是这背后的机制:内核先在地址空间中标记一段虚拟地址范围为合法可用(记录其起止地址和访问权限),但不立刻为其分配物理页帧。等进程第一次真正访问到某一页时,Present 位为 0 触发缺页异常,内核在异常处理中再补上物理页。地址先变成合法可用,物理页后面才补——这是按需分页最核心的时间顺序。这带来两个直接收益:进程启动更快(不必等所有页装入 RAM),内存占用更小(虚拟地址空间可以远大于实际驻留在 RAM 中的页面数)。
缺页异常
缺页异常(page fault)是 CPU 在访问某个虚拟页时发现当前页表无法直接满足这次访问,于是转入内核处理的异常。
用一个具体的场景来看完整的处理流程。假设进程刚通过 malloc() 拿到了一段地址,现在第一次执行 *p = 42:
CPU 尝试向 p 所在的虚拟页写入,查页表发现 PTE 的 Present 位为 0——这个页还没有物理页帧。CPU 触发缺页异常,把故障地址保存在 CR2 寄存器中,连同错误码(标明是写操作、用户态、页不存在)一起交给内核。
内核拿到故障地址后,在当前进程的地址空间中查找:这个地址落在哪个合法区域里?权限是否允许写入?如果地址不属于任何合法区域,或者权限不匹配(比如试图写入一个只读区域),内核会给进程发送 SIGSEGV 信号,进程被终止。程序员在终端看到的 "Segmentation fault"(段错误)就是这个信号导致的。
如果地址合法,内核知道这一页还没有对应的物理页帧,需要现在补上。内核从空闲的物理页帧中取出一张,将其清零,然后填入 PTE(设置物理页帧号、Present = 1、R/W = 1),最后返回用户态。CPU 重新执行刚才那条 *p = 42 指令,这一次页表项已经就绪,写入正常完成。
对程序来说,*p = 42 只是一条普通的赋值语句。缺页异常、页帧分配、页表更新全部发生在背后,程序完全感知不到。缺页异常不等于程序出错——对按需分页来说,第一次访问尚未装入的页本来就是正常路径。
Linux 按处理代价把缺页分为两类。次要缺页(minor fault) 不需要等待磁盘 I/O,上面 malloc() 后首次写入的情形就是典型例子:内核在内存中就能完成全部处理(取空闲页帧、填 PTE、返回)。主要缺页(major fault) 则需要从磁盘读取数据才能继续。比如程序刚启动时,可执行文件的代码段还没有全部加载到内存,CPU 执行到某一页的指令时触发缺页异常,内核必须从磁盘把对应的代码页读进来,线程在等待磁盘 I/O 期间会进入睡眠。
进程生命周期一课讲过的 Copy-on-Write 也是缺页异常路径上的一个分支:fork 后父子共享的只读页被写入时,触发的缺页异常使内核复制物理页并恢复可写权限。
TLB
上一课已经介绍了 TLB 的基本概念:它是 CPU 内部的高速缓存,存放虚拟页到物理页帧的翻译结果,使得绝大多数地址翻译不需要走页表遍历。这一节展开 TLB 在实际系统中面临的几个问题。
首先要区分清楚:TLB 未命中不等于缺页。 一次内存访问会经过三层判定。第一层,TLB 中是否有这条翻译——命中则直接拿到物理地址。第二层,如果 TLB 未命中,硬件沿页表遍历查找——如果页表项合法(Present = 1),结果回填进 TLB,访问继续。第三层,只有页表项无法满足(Present = 0 或权限不符)时,才触发缺页异常进入内核。也就是说,TLB 未命中只是多走了一遍页表遍历,并不一定进入内核。
第二个问题是进程切换时怎样区分不同地址空间的 TLB 项。两个进程都可能有虚拟页号 0x12345,TLB 必须区分它们。最简单的做法是在上下文切换时把旧进程的 TLB 项全部清空,但代价很大——切回来时所有翻译都要重新查页表。更高效的方案是给每个 TLB 项附加一个地址空间标识符 ASID(Address Space Identifier),只有虚拟页号和 ASID 同时匹配才算命中。x86-64 上对应的机制叫 PCID(Process-Context Identifier)。有了 ASID/PCID,进程切换时不必清空 TLB,切回来时还能复用之前的缓存。
第三个问题出现在多核系统上。假设内核修改了某个进程的页表项(比如把一个页设成只读),正在其他 CPU 核上运行的线程可能仍然在自己的 TLB 中保留着旧映射。内核必须通知这些 CPU 核失效对应的 TLB 项,这个过程叫 TLB shootdown。它通过跨核中断(IPI, Inter-Processor Interrupt)完成:一个核心发通知,其他核心停下来执行失效操作,再继续原来的工作。
为什么 TLB shootdown 往往很贵?
失效一条 TLB 项本身只是一条指令(x86 上是 invlpg),但 TLB shootdown 贵在协调:内核要先确定哪些 CPU 正在使用这个地址空间,然后向它们发 IPI。收到 IPI 的 CPU 必须中断当前执行流,进入内核执行失效操作,再返回原来的任务。一次页表修改被放大成多次跨核打断。如果页表更新很频繁,这个开销会显著影响系统性能。
大页与透明大页
TLB 的覆盖范围取决于每个条目管辖的页大小。默认 4KB 页时,512 个 TLB 条目只能覆盖 2MB 地址空间;如果改用 2MB 的大页(Huge Page),同样 512 个条目可以覆盖 1GB。
透明大页(Transparent Huge Pages, THP)是内核在不改变应用接口的前提下,用更大的页粒度(x86-64 上是 2MB)建立和维护页表的机制。应用程序仍然按普通内存使用,是否把 512 张 4KB 页折叠成 1 张 2MB 页由内核后台判断。好处是页表更小、TLB 命中更容易;代价是一次缺页或回收涉及的数据量更大(2MB vs 4KB),内部碎片也可能增加,并且要求更高的物理连续性。大页适合热点内存范围大且访问局部性稳定的场景。
小结
| 概念 | 说明 |
|---|---|
| 分页(paging) | 虚拟地址空间和物理内存都划分成固定大小的页/页帧,通过页表映射 |
| PTE | 页表项,64 位值,包含物理页帧号和标志位(Present、R/W、Dirty 等) |
| 多级页表 | 树形结构的页表,只为实际使用的地址范围分配节点 |
| 页表遍历 | 逐级查表把虚拟地址翻译成物理地址的过程 |
| 按需分页 | 地址先合法,物理页在第一次访问时才分配 |
| 缺页异常 | 页不存在或权限不符时转入内核处理的异常 |
| minor / major fault | minor 在内存中完成,major 需要等待磁盘 I/O |
| TLB 未命中 vs 缺页 | TLB 未命中只是重走页表遍历,缺页是页表本身无法满足访问 |
| ASID / PCID | 给 TLB 项附加地址空间标识,进程切换时不必清空 TLB |
| TLB shootdown | 页表更新后通知其他 CPU 核失效旧 TLB 项的跨核同步过程 |
| 大页 / THP | 用更大的页扩大 TLB 覆盖范围,代价是更粗的分配与回收粒度 |
arch/x86/include/asm/pgtable_types.h#L8— PTE 标志位定义(_PAGE_BIT_PRESENT起始处) ↩︎include/linux/mm_types.h#L1123—struct mm_struct定义 ↩︎arch/x86/include/asm/pgtable_64_types.h#L48— 四/五级页表层级宏(PGDIR_SHIFT);页表遍历的核心函数__handle_mm_fault()在mm/memory.c#L6355↩︎