4. x86_64 四级页表详解:PML4、PDPT、PD、PT 的结构与索引
x86_64 架构在长模式(Long Mode)下采用四级页表结构,将 48 位虚拟地址转换为 52 位物理地址。每一级页表负责解析虚拟地址中的一部分位段,逐级缩小查找范围,最终定位到物理页帧。这种分层设计在灵活性和性能之间取得了平衡,是 Linux 内核内存管理的基础。
4.1 虚拟地址分解与索引机制
在四级页表模式下,48 位虚拟地址被划分为 5 个部分:4 个页表索引(各 9 位)和 1 个页内偏移(12 位)。MMU 硬件通过这 4 个索引,从 PML4 开始逐级查找,最终获得物理页基址。
4.2 四级页表层级结构
MMU 从 CR3 寄存器中获取 PML4 表的物理基地址,然后依次经过四级查找。每一级表项都包含下一级表的物理地址以及权限、存在位等属性。
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(物理页帧) |
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 启用) |
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 执行以下步骤完成转换:
- 提取 PML4 索引:位 47:39 =
0xFE(254),从 CR3 指向的 PML4 表偏移254 × 8 = 2032处读取表项。 - 检查存在位:若 P=0,触发 #PF(缺页异常);否则获取 PDPT 表物理基址。
- 提取 PDPT 索引:位 38:30 =
0x145(325),从 PDPT 表偏移325 × 8 = 2600处读取表项。 - 检查 PS 位:若 PS=1,则为 1GB 大页映射,直接计算物理地址;否则继续。
- 提取 PD 索引:位 29:21 =
0x2E(46),从 PD 表偏移46 × 8 = 368处读取表项。 - 检查 PS 位:若 PS=1,则为 2MB 大页映射;否则继续。
- 提取 PT 索引:位 20:12 =
0x1A3(419),从 PT 表偏移419 × 8 = 3352处读取表项。 - 获取物理页基址:PT 表项 [51:12] 左移 12 位得到物理页基址。
- 加上页内偏移:位 11:0 =
0xD10(3344),最终物理地址 = 物理页基址 + 0xD10。
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 切换时不变 |
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);
}
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];
fix_to_virt 和 __va/__pa 宏进行页表操作,递归映射主要用于早期启动阶段和异常处理路径。内核维护 struct page 数组和 mem_map 来管理物理页帧,页表页的虚拟地址通过 page_address 获取。