3. 分页机制详解:页表结构、多级页表与页表项标志位
分页(Paging)是现代操作系统内存管理的核心机制,它将虚拟地址空间划分为固定大小的页(Page),并将物理内存划分为同样大小的页框(Page Frame)。Linux 内核通过页表(Page Table)完成虚拟地址到物理地址的转换。本章深入剖析 x86-64 架构下的多级页表结构、各级页表的职责以及页表项中的关键标志位。
3.1 多级页表结构(PGD / PUD / PMD / PTE)
在 x86-64 架构中,Linux 使用 4 级页表(部分新硬件支持 5 级页表,但本文以 4 级为例)。虚拟地址被划分为 5 个部分:页全局目录(PGD)、页上级目录(PUD)、页中间目录(PMD)、页表(PTE) 以及 页内偏移(Offset)。每一级目录都包含指向下一级目录或最终物理页框的基地址。
| 63:48 (符号扩展) | 47:39 (PGD索引) | 38:30 (PUD索引) | 29:21 (PMD索引) | 20:12 (PTE索引) | 11:0 (页内偏移) |
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 保护) |
3.4 缺页异常与页表协同
当 CPU 访问一个虚拟地址,而对应页表项的 Present 位为 0 时,会触发 缺页异常(Page Fault)。Linux 内核的缺页异常处理函数 do_page_fault() 会根据异常地址和错误码执行以下流程:
- 检查地址合法性:判断虚拟地址是否属于进程的地址空间(VMA)。
- 判断异常类型:是保护错误(如写只读页)还是缺失错误(页面不在内存)。
- 分配物理页框:从伙伴系统分配一个物理页框,并更新页表项(设置 PFN 和标志位)。
- 页面内容填充:如果是匿名页则清零;如果是文件映射页则从磁盘读取。
- 恢复执行:重新执行触发异常的指令。
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 大页。
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() 等安全接口。