4. x86_64 四级页表详解:PML4、PDPT、PD、PT 的结构与索引

x86_64 架构在长模式(Long Mode)下采用四级页表结构,将 48 位虚拟地址转换为 52 位物理地址。每一级页表负责解析虚拟地址中的一部分位段,逐级缩小查找范围,最终定位到物理页帧。这种分层设计在灵活性和性能之间取得了平衡,是 Linux 内核内存管理的基础。

核心概念:四级页表由 PML4(Page Map Level 4)、PDPT(Page Directory Pointer Table)、PD(Page Directory)和 PT(Page Table)组成。每个表项(Entry)占用 8 字节,每张表包含 512 个表项,覆盖 512 个 4KB 页(2MB 或 1GB 大页时结构略有不同)。

4.1 虚拟地址分解与索引机制

在四级页表模式下,48 位虚拟地址被划分为 5 个部分:4 个页表索引(各 9 位)和 1 个页内偏移(12 位)。MMU 硬件通过这 4 个索引,从 PML4 开始逐级查找,最终获得物理页基址。

虚拟地址位段分解(48位) PML4索引 位 47:39 PDPT索引 位 38:30 PD索引 位 29:21 PT索引 位 20:12 页内偏移 位 11:0 9位 9位 9位 9位 12位 MMU 硬件自动完成索引解析 每个索引值乘以 8(表项大小)得到在该页表中的偏移量,从而读取对应的 8 字节表项

4.2 四级页表层级结构

MMU 从 CR3 寄存器中获取 PML4 表的物理基地址,然后依次经过四级查找。每一级表项都包含下一级表的物理地址以及权限、存在位等属性。

四级页表硬件查找流程 CR3 寄存器 PML4 表 512 个表项 PDPT 表 512 个表项 PD 表 512 个表项 PT 表 512 个表项 每个表项(8 字节)结构 位 63:52 — 保留/标志 位 51:12 — 下一级页表物理基址(40 位) 位 11:0 — 属性标志(存在、读写、用户、XD 等) 物理地址计算示例 物理页基址 = 表项[51:12] 左移 12 位 + 页内偏移(虚拟地址 [11:0]) 大页支持 PD 表项可映射 2MB 大页(跳过 PT 级);PDPT 表项可映射 1GB 大页(跳过 PD 和 PT 级) 单进程页表内存占用 PML4 1 张(4KB)+ PDPT 最多 512 张 + PD 最多 512² 张 + PT 最多 512³ 张(实际按需分配)

4.3 各级页表详细结构

页表级别 名称 索引位段 覆盖虚拟地址范围 每表项映射大小
L4 PML4 位 47:39 512 GB 512 GB(指向 PDPT)
L3 PDPT 位 38:30 1 GB 1 GB(指向 PD)或 1 GB 大页
L2 PD 位 29:21 2 MB 2 MB(指向 PT)或 2 MB 大页
L1 PT 位 20:12 4 KB 4 KB(物理页帧)
关键理解:每个 PML4 表项覆盖 512 GB 虚拟地址空间(512 个 PDPT 表项 × 1 GB)。整个 PML4 表覆盖 512 × 512 GB = 256 TB,即 x86_64 当前支持的 48 位虚拟地址空间上限。

4.4 表项属性标志详解

每个页表项的低 12 位包含控制访问权限和缓存行为的标志位。这些标志由内核在建立映射时设置,MMU 在地址转换过程中自动检查。

位 标志名称 含义 说明
0 P Present(存在位) 1 表示该表项有效;0 表示未映射,访问触发缺页异常
1 R/W Read/Write(读写位) 1 允许写入;0 只读(即使 CPL=0 也受限制)
2 U/S User/Supervisor(用户/特权位) 1 允许用户态访问;0 仅内核态可访问
3 PWT Page Write-Through(页直写) 控制 Cache 写策略:1 直写,0 写回
4 PCD Page Cache Disable(页缓存禁用) 1 禁用该页的硬件缓存,用于设备内存映射
5 A Accessed(访问位) MMU 在访问该页时自动置 1,内核可定期清理用于页面置换
6 D Dirty(脏位) MMU 在写入该页时自动置 1,用于判断是否需要回写磁盘
7 PS Page Size(页大小位) 仅在 PDPT 和 PD 级有效:1 表示大页映射
8 G Global(全局位) 1 表示该页在 TLB 刷新时不被清除(需 CR4.PGE 启用)
63 NX No-Execute(不可执行位) 1 禁止在该页执行代码(需 IA32_EFER.NXE 启用)
安全注意:NX 位(位 63)是现代操作系统实现 NX(No-Execute)保护的关键。Linux 内核在建立用户态代码段映射时清除 NX 位,在数据段和堆栈段映射时设置 NX 位,防止缓冲区溢出攻击执行恶意代码。

4.5 页表分配与内核接口

Linux 内核通过 struct page 和 pgtable_t 类型管理页表页。分配页表页时,内核使用 alloc_pages 或 __get_free_page 从 memblock 或 buddy 分配器获取物理页帧。

/* 内核页表分配核心函数(简化) */
static pte_t *pte_alloc_one_kernel(struct mm_struct *mm)
{
    struct page *page;
    pte_t *pte;

    /* 从 buddy 分配器分配一个物理页帧 */
    page = alloc_pages(GFP_KERNEL | __GFP_ZERO, 0);
    if (!page)
        return NULL;

    /* 获取页的线性地址 */
    pte = (pte_t *)page_address(page);

    /* 初始化页表页的引用计数 */
    pgtable_page_ctor(page);

    return pte;
}

/* 建立 PML4 表项示例 */
static void set_pml4e(pml4e_t *pml4, pml4e_t new)
{
    /* 写入 PML4 表项 */
    WRITE_ONCE(*pml4, new);

    /* 刷新 TLB,确保新映射生效 */
    __flush_tlb_one(pml4e_pfn(new) << PAGE_SHIFT);
}
性能优化:内核使用 __GFP_ZERO 标志确保新分配的页表页全部清零,避免遗留数据导致安全漏洞。页表页的分配是热路径操作,频繁的分配/释放会影响性能,因此内核引入了页表页缓存(pgtable_cache)机制。

4.6 地址转换完整流程示例

假设虚拟地址 0x7f8a4b3c2d10,MMU 执行以下步骤完成转换:

  1. 提取 PML4 索引:位 47:39 = 0xFE(254),从 CR3 指向的 PML4 表偏移 254 × 8 = 2032 处读取表项。
  2. 检查存在位:若 P=0,触发 #PF(缺页异常);否则获取 PDPT 表物理基址。
  3. 提取 PDPT 索引:位 38:30 = 0x145(325),从 PDPT 表偏移 325 × 8 = 2600 处读取表项。
  4. 检查 PS 位:若 PS=1,则为 1GB 大页映射,直接计算物理地址;否则继续。
  5. 提取 PD 索引:位 29:21 = 0x2E(46),从 PD 表偏移 46 × 8 = 368 处读取表项。
  6. 检查 PS 位:若 PS=1,则为 2MB 大页映射;否则继续。
  7. 提取 PT 索引:位 20:12 = 0x1A3(419),从 PT 表偏移 419 × 8 = 3352 处读取表项。
  8. 获取物理页基址:PT 表项 [51:12] 左移 12 位得到物理页基址。
  9. 加上页内偏移:位 11:0 = 0xD10(3344),最终物理地址 = 物理页基址 + 0xD10。
四级页表转换流程(4KB 页) CR3 → PML4 基址 PML4[索引] → PDPT PDPT[索引] → PD PD[索引] → PT PT 检查 P 位 检查 PS 位(1GB大页?) 检查 PS 位(2MB大页?) 物理地址 = PT[索引][51:12] << 12 + 虚拟地址[11:0] 页内偏移直接拼接,无需查表 缺页异常 #PF

4.7 内核与用户态地址空间划分

x86_64 架构下,Linux 内核将 256 TB 虚拟地址空间划分为用户态(低地址)和内核态(高地址)两部分。这种划分通过 PML4 表项的第 256 项(索引 256)作为分界线实现。

地址范围 PML4 索引 归属 说明
0x0000000000000000 - 0x00007fffffffffff 0 - 255 用户态 128 TB,用户进程独占,切换进程时 CR3 切换
0xffff800000000000 - 0xffffffffffffffff 256 - 511 内核态 128 TB,所有进程共享,CR3 切换时不变
设计意图:内核态地址空间的高位全部为 1(0xffff 开头),用户态地址空间的高位全部为 0(0x0000 开头)。MMU 通过检查虚拟地址的最高 16 位即可快速判断访问来源,无需遍历页表。这种设计也使得内核可以直接引用用户态地址(通过 copy_from_user/copy_to_user 系列函数)。

4.8 TLB 与页表协同

TLB(Translation Lookaside Buffer)是 MMU 内部的页表缓存,存储最近使用的虚拟地址到物理地址的映射。当页表内容被修改时,必须刷新对应的 TLB 条目,否则 MMU 可能使用过期的缓存映射。

/* TLB 刷新操作 */
/* 刷新单个虚拟地址的 TLB 条目 */
static inline void __flush_tlb_one(unsigned long addr)
{
    asm volatile("invlpg (%0)" : : "r" (addr) : "memory");
}

/* 刷新全部 TLB(进程切换时使用) */
static inline void __flush_tlb_all(void)
{
    asm volatile("mov %0, %%cr3" : : "r" (read_cr3()) : "memory");
}

/* 刷新非全局 TLB 条目 */
static inline void __flush_tlb_global(void)
{
    unsigned long cr4 = read_cr4();
    write_cr4(cr4 & ~X86_CR4_PGE);
    write_cr4(cr4 | X86_CR4_PGE);
}
性能陷阱:频繁的 TLB 刷新会严重降低性能。Linux 内核通过以下策略优化:
1. 使用 invlpg 指令只刷新单个页面,避免全局刷新。
2. 利用全局页(G 位)标记内核永久映射,避免在进程切换时刷新。
3. 大页映射(2MB/1GB)减少页表级数,降低 TLB 压力。
4. 使用 PCID(Process-Context Identifiers)技术,在 TLB 中区分不同进程的映射。

4.9 页表自映射与递归映射

Linux 内核在早期 x86_32 架构中使用了递归页表(也称为自映射)技术,通过将 PML4 表的一个表项指向 PML4 表自身,使得内核可以通过虚拟地址直接访问和修改页表。在 x86_64 架构中,这种技术被用于固定映射(Fixmap)和 vmalloc 区域的管理。

/* 递归页表原理(概念性代码) */
/* 假设 PML4[511] 指向 PML4 表自身 */
/* 那么虚拟地址 0xffffffff80000000 可以通过以下方式访问 PML4 表项 */
unsigned long *pml4_self = (unsigned long *)0xffffffff80000000;

/* 访问 PML4[0] 表项 */
unsigned long pml4_entry_0 = pml4_self[0];

/* 访问 PDPT[0] 表项(通过递归) */
unsigned long *pdpt_self = (unsigned long *)0xffffffff7ffff000;
unsigned long pdpt_entry_0 = pdpt_self[0];
现代替代方案:当前 x86_64 Linux 内核更倾向于使用 fix_to_virt 和 __va/__pa 宏进行页表操作,递归映射主要用于早期启动阶段和异常处理路径。内核维护 struct page 数组和 mem_map 来管理物理页帧,页表页的虚拟地址通过 page_address 获取。