4、页表格式详解:描述符格式、页表层级(L0-L3)、属性位解析

页表这东西,说白了就是硬件用来做地址翻译的“字典”。ARMv8/v9 的页表格式,我刚开始看的时候也觉得头大,层级多、位域复杂。但摸过几个项目之后,你会发现它其实很有规律。

今天我们就把它拆开揉碎了讲。我会结合我自己在调试虚拟化平台时踩过的坑,带你彻底搞懂 L0 到 L3 的每一级描述符,以及那些关键的属性位——XN、PXN、AF、SH 到底怎么用。

4.1 页表层级:从 L0 到 L3

ARMv8/v9 的页表最多支持 4 级,分别是 L0、L1、L2、L3。每一级都指向下一级,直到 L3 指向最终的物理页面。

为什么需要这么多级?你想想看,64 位的地址空间太大了,如果只用一级页表,那页表本身就会大得离谱。多级结构的好处是:按需分配。你只需要为实际使用的虚拟地址范围建立页表,没用的区域可以留空。

我习惯把页表层级想象成一棵“倒着长的树”:

  • L0:根节点,覆盖整个 64 位地址空间。但通常只用了低 48 位或 52 位。
  • L1:中间节点,每个 L0 表项指向一个 L1 表。
  • L2:同样是中间节点,粒度更细。
  • L3:叶子节点,直接指向 4KB 的物理页面。

这里有个关键点:不是所有层级都必须存在。如果你使用 64KB 页面,L2 就可以直接作为叶子节点,不需要 L3。如果你使用 1GB 的大页,L1 就能直接映射,跳过 L2 和 L3。这就是所谓的“块映射”(Block Mapping),在性能敏感的场景下非常有用。

层级与页面大小对照表

层级 页面大小(4KB 粒度) 页面大小(64KB 粒度) 说明
L0 512 GB 4 TB 仅用于 52 位地址空间
L1 1 GB 64 GB 可做块映射
L2 2 MB 64 MB 可做块映射
L3 4 KB 64 KB 必须为页映射

嗯,这里要注意:L0 只在启用 52 位物理地址(LPA2)时才存在。大多数场景下,我们只用 L1、L2、L3 三级就够了。

4.2 描述符格式:表描述符 vs 块描述符 vs 页描述符

页表中的每一项,我们叫它“描述符”(Descriptor)。描述符有三种类型:

  • 表描述符(Table Descriptor):指向下一级页表的基地址。出现在 L0、L1、L2。
  • 块描述符(Block Descriptor):直接映射一大块内存。出现在 L1、L2。
  • 页描述符(Page Descriptor):映射一个 4KB 或 64KB 的页面。只出现在 L3。

描述符的最低两位(bit[1:0])用来区分类型:

bit[1:0] 类型 说明
00 无效(Invalid) 该地址范围未映射
01 表描述符 指向下一级页表
10 块描述符 直接映射(L1/L2)
11 页描述符 映射页面(L3)

我曾经在调试一个虚拟化平台时,发现 Guest OS 访问某些地址会触发 Stage-2 缺页异常。查了半天,发现是 Stage-2 页表的 L2 描述符被错误地配置成了“表描述符”,导致硬件继续往下走 L3,而 L3 页表根本不存在。嗯,从那以后我每次配置页表都会先检查描述符类型位。

4.3 属性位解析:XN、PXN、AF、SH 等

描述符里除了地址信息,还包含一堆属性位。这些位控制着内存的访问权限、缓存策略、共享属性等。我挑几个最关键的来讲。

4.3.1 XN 与 PXN:执行权限控制

  • XN(Execute Never):禁止在该内存区域执行指令。这是最常用的安全机制,比如数据段(.data、.bss)都应该设置 XN=1,防止代码注入攻击。
  • PXN(Privileged Execute Never):禁止特权级(EL1/EL2)在该区域执行指令。但非特权级(EL0)可以执行。这个位在用户态和内核态分离的场景下非常有用。

我个人的习惯是:所有非代码段都设置 XN=1。PXN 则用在需要让用户态执行、但内核态不应该执行的场景,比如某些 JIT 编译器生成的代码区域。

避坑指南:我曾经在移植一个 RTOS 时,忘了给栈区域设置 XN=1。结果攻击者通过栈溢出注入了一段 shellcode,直接拿到了系统控制权。从那以后,我写页表配置代码时,第一件事就是检查所有数据段的 XN 位。

4.3.2 AF:访问标志位

AF(Access Flag)表示该页是否被访问过。硬件在第一次访问一个页面时,如果 AF=0,会触发一个“访问标志异常”(Access Flag Fault)。软件(通常是 OS 内核)需要将 AF 置 1,然后重新执行指令。

这个机制有什么用?说白了,就是给 OS 一个“懒加载”的机会。OS 可以在第一次访问时才真正分配物理页面,而不是提前把所有页都准备好。这在内存紧张的系统里非常实用。

但要注意:AF 异常的开销不小。每次缺页都要走异常处理流程,如果频繁触发,性能会急剧下降。我建议在性能关键的路径上,提前把 AF 置 1,避免运行时触发异常。

4.3.3 SH:可共享属性

SH(Shareability)控制着内存区域在多核之间的可见性。它有两个位:

SH[1:0] 含义 说明
00 不可共享(Non-shareable) 仅当前核心访问,无需与其他核同步
10 外部共享(Outer Shareable) 多核之间需要一致性
11 内部共享(Inner Shareable) 同一个集群内的多核需要一致性

你想想看,如果两个 CPU 核同时读写同一个变量,但页表里设置的是 Non-shareable,那每个核看到的可能是自己 Cache 里的旧值,结果就是数据不一致。所以,多核共享的数据结构,一定要设置 SH=11(Inner Shareable)。

4.3.4 其他重要属性位

  • AP[2:1]:访问权限位。控制读/写权限,以及特权级/用户级的访问控制。
  • AttrIndx[2:0]:内存属性索引。指向 MAIR(Memory Attribute Indirection Register)中的一项,用来定义 Cache 策略(如 Write-Back、Write-Through、Non-Cacheable 等)。
  • NS(Non-Secure):仅在安全世界(Secure World)的页表中有效。NS=1 表示该页属于非安全世界。
  • Contiguous 位:允许将多个相邻的页合并成一个更大的 TLB 条目,减少 TLB 压力。这个位在 64KB 页面下特别有用。

4.4 页表遍历流程:硬件是怎么找到物理地址的?

说了这么多,我们来看看硬件实际是怎么走查页表的。下面这张图展示了从虚拟地址到物理地址的完整流程:

ARMv8/v9 页表遍历流程 页表遍历流程(4KB 页面,48 位地址) 虚拟地址(64 位,实际使用低 48 位) L0[47:39] L1[38:30] L2[29:21] L3[20:12] Offset[11:0] L0 页表 L1 页表 L2 页表 L3 页表 物理地址(PA) 说明: 1. 硬件从 TTBRx_ELx 寄存器获取 L0 页表基地址。 2. 用虚拟地址的 L0 索引找到 L0 描述符,获取 L1 页表基地址。 3. 依次遍历 L1、L2、L3,最终从 L3 描述符中提取物理地址 + 页内偏移。

硬件遍历页表的过程是自动的,不需要软件干预。但软件需要确保页表在内存中的布局是正确的,否则硬件会触发各种异常(Translation Fault、Access Flag Fault、Permission Fault 等)。

4.5 实战建议:如何配置页表

最后,我分享几个实战中的小建议:

  1. 优先使用块映射:如果内存区域足够大(比如 1GB 或 2MB),尽量用块描述符,减少页表层级,降低 TLB 压力。
  2. 合理设置 AF 位:在系统初始化阶段,提前把关键内存区域的 AF 置 1,避免运行时触发不必要的异常。
  3. 多核场景注意 SH 位:共享数据一定要设置 Inner Shareable,否则你会遇到诡异的数据不一致问题。
  4. 安全第一:所有非代码段设置 XN=1,特权代码段考虑 PXN。

小技巧:调试页表问题时,可以用 JTAG 或模拟器直接读取内存中的页表描述符,检查每个位域的值是否正确。我习惯在页表初始化后,打印出关键描述符的十六进制值,对照 ARM 手册逐位核对。