Skip to content

分页与多级页表

  • 写作时间: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 出发,逐级查表:

  1. 查 PGD:CR3 指向顶级页表页的物理地址。CPU 用 PGD 索引 3 找到该页中第 3 个条目,读出其中存放的下一级页表页的物理地址。
  2. 查 PUD:跳到上一步拿到的物理地址,用 PUD 索引 5 找到第 5 个条目,读出下一级页表页的物理地址。
  3. 查 PMD:跳到上一步拿到的物理地址,用 PMD 索引 10 找到第 10 个条目,读出最后一级页表页的物理地址。
  4. 查 PTE:跳到上一步拿到的物理地址,用 PTE 索引 7 找到第 7 个条目。这个 PTE 中存放着目标物理页帧号,假设是 0x1A2B3。
  5. 拼接:物理页帧号 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。

反转页表(Inverted Page Table)

前面讨论的页表是按虚拟地址空间组织的:每个进程有一张独立的页表,以虚拟页号为下标。反转页表换了一个组织维度:整个系统只有一张页表,以物理页帧号为下标。每个条目记录"这个物理页帧当前被哪个进程的哪个虚拟页使用"。

反转页表的大小取决于物理内存而非虚拟地址空间。一台 16GB 内存的机器只需要约 400 万个条目(16GB / 4KB),远小于 2^36 个条目。IBM PowerPC 和 HP PA-RISC 架构使用过反转页表。

但反转页表的查找效率是个问题。给定一个虚拟地址,需要搜索整张表才能找到对应的物理页帧。实际实现用哈希表加速查找:对虚拟页号做哈希,映射到反转页表的某个位置,哈希冲突通过链表解决。x86 架构没有采用反转页表,而是选择了多级页表方案。

多级页表的代价是翻译延迟: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 faultminor 在内存中完成,major 需要等待磁盘 I/O
TLB 未命中 vs 缺页TLB 未命中只是重走页表遍历,缺页是页表本身无法满足访问
ASID / PCID给 TLB 项附加地址空间标识,进程切换时不必清空 TLB
TLB shootdown页表更新后通知其他 CPU 核失效旧 TLB 项的跨核同步过程
大页 / THP用更大的页扩大 TLB 覆盖范围,代价是更粗的分配与回收粒度


  1. arch/x86/include/asm/pgtable_types.h#L8 — PTE 标志位定义(_PAGE_BIT_PRESENT 起始处) ↩︎

  2. include/linux/mm_types.h#L1123struct mm_struct 定义 ↩︎

  3. arch/x86/include/asm/pgtable_64_types.h#L48 — 四/五级页表层级宏(PGDIR_SHIFT);页表遍历的核心函数 __handle_mm_fault()mm/memory.c#L6355 ↩︎