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 页表遍历流程:硬件是怎么找到物理地址的?
说了这么多,我们来看看硬件实际是怎么走查页表的。下面这张图展示了从虚拟地址到物理地址的完整流程:
硬件遍历页表的过程是自动的,不需要软件干预。但软件需要确保页表在内存中的布局是正确的,否则硬件会触发各种异常(Translation Fault、Access Flag Fault、Permission Fault 等)。
4.5 实战建议:如何配置页表
最后,我分享几个实战中的小建议:
- 优先使用块映射:如果内存区域足够大(比如 1GB 或 2MB),尽量用块描述符,减少页表层级,降低 TLB 压力。
- 合理设置 AF 位:在系统初始化阶段,提前把关键内存区域的 AF 置 1,避免运行时触发不必要的异常。
- 多核场景注意 SH 位:共享数据一定要设置 Inner Shareable,否则你会遇到诡异的数据不一致问题。
- 安全第一:所有非代码段设置 XN=1,特权代码段考虑 PXN。
小技巧:调试页表问题时,可以用 JTAG 或模拟器直接读取内存中的页表描述符,检查每个位域的值是否正确。我习惯在页表初始化后,打印出关键描述符的十六进制值,对照 ARM 手册逐位核对。