3. 分页机制详解:页表结构、多级页表与页表项标志位

分页(Paging)是现代操作系统内存管理的核心机制,它将虚拟地址空间划分为固定大小的页(Page),并将物理内存划分为同样大小的页框(Page Frame)。Linux 内核通过页表(Page Table)完成虚拟地址到物理地址的转换。本章深入剖析 x86-64 架构下的多级页表结构、各级页表的职责以及页表项中的关键标志位。

核心概念:页表是操作系统为每个进程维护的地址转换映射表。多级页表的设计是为了在 64 位地址空间下节省内存——它只为实际使用的虚拟地址范围分配页表层级,而非一次性建立完整的线性页表。

3.1 多级页表结构(PGD / PUD / PMD / PTE)

在 x86-64 架构中,Linux 使用 4 级页表(部分新硬件支持 5 级页表,但本文以 4 级为例)。虚拟地址被划分为 5 个部分:页全局目录(PGD)、页上级目录(PUD)、页中间目录(PMD)、页表(PTE) 以及 页内偏移(Offset)。每一级目录都包含指向下一级目录或最终物理页框的基地址。

地址划分示意(48 位虚拟地址,4 级页表):
| 63:48 (符号扩展) | 47:39 (PGD索引) | 38:30 (PUD索引) | 29:21 (PMD索引) | 20:12 (PTE索引) | 11:0 (页内偏移) |
4级页表地址转换流程 4级页表地址转换流程(x86-64) 虚拟地址(48位有效) PGD索引(9位) PUD索引(9位) PMD索引(9位) PTE索引(9位) 页内偏移(12位) PGD PUD PMD PTE 物理页框 物理地址 = 页框基址 + 偏移 每级页表包含 512 个表项(2^9) 页大小 4KB(2^12),支持 48 位虚拟地址

3.2 各级页表的职责与数据结构

Linux 内核使用 pgd_t、pud_t、pmd_t、pte_t 四种数据类型分别表示各级页表项。每个进程的 mm_struct 中保存了指向其 PGD 的指针 pgd。

页表层级 数据类型 索引位数 表项数量 职责描述
PGD (Page Global Directory) pgd_t 9 位 (47:39) 512 顶级目录,指向 PUD 的基地址;每个进程独有
PUD (Page Upper Directory) pud_t 9 位 (38:30) 512 第二级目录,指向 PMD 的基地址
PMD (Page Middle Directory) pmd_t 9 位 (29:21) 512 第三级目录,指向 PTE 的基地址
PTE (Page Table Entry) pte_t 9 位 (20:12) 512 最后一级,包含物理页框号(PFN)和访问权限标志
关键点:如果某级页表的所有表项都无效(未映射),内核会直接将该级页表置为 NULL,从而避免为未使用的地址范围分配页表内存。这是多级页表节省内存的核心原理。

3.3 页表项(PTE)标志位详解

页表项不仅包含物理页框号(PFN),还包含一组标志位(Flags),用于控制页面的访问权限、缓存策略和状态信息。以下是最关键的标志位:

标志位 位位置 名称 含义
P bit 0 Present 页面是否在物理内存中。为 0 时访问该页会触发缺页异常(Page Fault)
R/W bit 1 Read/Write 0 表示只读,1 表示可读写。用于写时复制(COW)和代码段保护
U/S bit 2 User/Supervisor 0 表示仅内核态可访问,1 表示用户态也可访问
A bit 5 Accessed 页面被访问过(读或写)。由硬件置位,操作系统用于页面置换算法(如 LRU)
D bit 6 Dirty 页面被写入过。用于判断是否需要回写至交换设备或文件
PS bit 7 Page Size 大页标志(2MB/1GB)。在 PUD 或 PMD 级设置,跳过后续页表层级
NX bit 63 No-Execute 禁止执行。用于防止代码注入攻击(DEP/NX 保护)
注意:标志位 P(Present)是页表项是否有效的总开关。如果 P=0,CPU 会忽略其他所有标志位并触发缺页异常。操作系统在缺页处理中会检查页表项的其他位(如用于存储交换位置的位)来恢复页面。
页表项(PTE)结构 x86-64 页表项(PTE)结构(64位) bit 63 (NX) 禁止执行 bit 62:12 (PFN + 保留位) 物理页框号(Physical Frame Number) bit 11:9 (AVL) 软件可用位 bit 8:0 (标志位) P | R/W | U/S | PWT | PCD | A | D | PS | G 关键标志位组合示例 P=1, R/W=0, U/S=0 → 内核只读页(典型代码段) P=1, R/W=1, U/S=1, NX=1 → 用户可读写但不可执行页(典型栈/堆)

3.4 缺页异常与页表协同

当 CPU 访问一个虚拟地址,而对应页表项的 Present 位为 0 时,会触发 缺页异常(Page Fault)。Linux 内核的缺页异常处理函数 do_page_fault() 会根据异常地址和错误码执行以下流程:

  1. 检查地址合法性:判断虚拟地址是否属于进程的地址空间(VMA)。
  2. 判断异常类型:是保护错误(如写只读页)还是缺失错误(页面不在内存)。
  3. 分配物理页框:从伙伴系统分配一个物理页框,并更新页表项(设置 PFN 和标志位)。
  4. 页面内容填充:如果是匿名页则清零;如果是文件映射页则从磁盘读取。
  5. 恢复执行:重新执行触发异常的指令。
性能优化:硬件 TLB(Translation Lookaside Buffer)会缓存最近使用的页表项。当页表项被修改(如设置 Present 位或 Dirty 位)后,内核必须通过 invlpg 指令或 TLB 刷新(如 flush_tlb_mm)使对应 TLB 条目失效,否则 CPU 可能使用过期的缓存转换。

3.5 大页(Huge Pages)与页表层级跳过

当 PS 标志位(Page Size) 在 PUD 或 PMD 级被置位时,CPU 会跳过后续的页表层级,直接使用当前目录项中的物理地址作为页框基址。这允许使用 2MB(PMD 级大页) 或 1GB(PUD 级大页) 的页面大小。

大页类型 设置层级 页面大小 优势 劣势
标准页 PTE 4KB 内存利用率高,无内部碎片 TLB 覆盖范围小,页表占用大
透明大页(THP) PMD 2MB 减少 TLB 缺失,提升大内存访问性能 可能增加内存浪费(内部碎片)
显式大页(HugeTLB) PUD 1GB 极低 TLB 缺失率,适合数据库/虚拟化 需预先预留,灵活性差
内核接口:用户态可通过 mmap 配合 MAP_HUGETLB 标志或通过 /sys/kernel/mm/hugepages/ 接口配置大页。内核自动使用透明大页(THP)时,会在后台尝试合并相邻的 4KB 页为 2MB 大页。
标准页与大页对比 标准页(4KB) vs 大页(2MB)页表层级对比 标准页(4KB) PGD → PUD → PMD → PTE 4KB 物理页框 透明大页(2MB) PGD → PUD → PMD (PS=1) 2MB 连续物理页框 页表层级跳过原理 当 PMD 表项的 PS 位为 1 时,CPU 不再查找 PTE 层级, 直接使用 PMD 中的地址作为 2MB 页框基址,节省一次内存访问。 1GB 大页则在 PUD 级设置 PS 位,跳过 PMD 和 PTE 两级。

3.6 页表遍历与内核宏

Linux 内核提供了一组宏来遍历多级页表,这些宏在 <asm/pgtable.h> 和 <linux/mm.h> 中定义。以下是一个典型的页表遍历代码片段:

// 内核中遍历页表的典型模式(x86-64)
pgd_t *pgd;
pud_t *pud;
pmd_t *pmd;
pte_t *pte;
unsigned long addr = 0x7f1234560000;  // 示例虚拟地址

pgd = pgd_offset(mm, addr);           // 获取 PGD 表项
if (pgd_none(*pgd) || pgd_bad(*pgd))
    goto out;

pud = pud_offset(pgd, addr);          // 获取 PUD 表项
if (pud_none(*pud) || pud_bad(*pud))
    goto out;

pmd = pmd_offset(pud, addr);          // 获取 PMD 表项
if (pmd_none(*pmd) || pmd_bad(*pmd))
    goto out;

// 检查是否为大页(PMD 级)
if (pmd_large(*pmd)) {
    // 处理 2MB 大页
    unsigned long phys = pmd_pfn(*pmd) << PAGE_SHIFT;
    phys += addr & ~PMD_MASK;
    // ...
    goto out;
}

pte = pte_offset_kernel(pmd, addr);   // 获取 PTE 表项
if (pte_none(*pte))
    goto out;

// 获取物理页框号
unsigned long pfn = pte_pfn(*pte);
unsigned long phys_addr = (pfn << PAGE_SHIFT) + (addr & ~PAGE_MASK);

out:
    // 清理或返回
注意事项:直接遍历用户进程的页表需要持有 mm->page_table_lock 或使用 rcu_read_lock(),防止页表在遍历过程中被并发修改。内核模块中应优先使用 follow_pte() 或 access_process_vm() 等安全接口。

3.7 总结