3、页表描述符详解:Level 0/1/2/3页表、表描述符与块描述符、属性字段(XN/PXN/AF/AP等)
页表描述符,说白了就是 MMU 用来查地址的「字典条目」。ARMv8 的四级页表,每一级都有自己的一套规则。我刚开始接触时也觉得头大,但拆开来看,其实就两类东西:表描述符和块描述符。
3.1 四级页表结构总览
ARMv8 支持 4KB、16KB、64KB 三种页面粒度。以最常用的 4KB 页为例,页表分为 Level 0 到 Level 3 共四级。每一级描述符要么指向下一级页表,要么直接映射一块内存。
核心概念:Level 0 和 Level 1 通常只存放表描述符;Level 2 可以放表描述符或块描述符;Level 3 只能放页描述符(也就是 4KB 的块)。
我画了一张结构图,帮你理清这四级的关系:
3.2 表描述符 vs 块描述符
描述符的格式,ARM 手册里写得很详细。我这里只挑重点说。
表描述符的 bit[1:0] 是 0b11。它包含一个指向下一级页表的物理地址。嗯,这里要注意:下一级页表必须对齐到 4KB 边界。
块描述符的 bit[1:0] 是 0b01(L3 页描述符也是 0b01)。它直接给出物理地址的高位,低位由虚拟地址的页内偏移决定。
| 描述符类型 | bit[1:0] | 映射粒度 | 出现层级 | 典型用途 |
|---|---|---|---|---|
| 表描述符 | 0b11 | — | L0, L1, L2 | 指向下一级页表 |
| 块描述符 | 0b01 | 2MB(L2) | L2 | 映射大块连续内存(如外设) |
| 页描述符 | 0b01 | 4KB(L3) | L3 | 标准页面映射 |
| 无效描述符 | 0b00 | — | 任意 | 未映射的地址区域 |
我的经验:在嵌入式系统中,我习惯把外设寄存器区域用 L2 块描述符映射成 2MB 的块。这样页表小,TLB 命中率也高。但要注意,块描述符的权限和缓存属性是统一的,不能对块内不同页面单独设置。
3.3 属性字段详解
描述符的低 12 位是属性字段。这些位决定了 MMU 怎么处理这块内存。我挑几个最关键的说说。
3.3.1 XN 与 PXN:执行权限控制
- XN(Execute Never):置 1 时,EL0 和 EL1 都不能从该区域取指令。
- PXN(Privileged Execute Never):置 1 时,EL1 不能取指令,但 EL0 可以(如果 XN 没设的话)。
为什么要分两个?说白了,就是给内核态和用户态不同的执行权限。我见过一个坑:有人只设了 XN,忘了设 PXN,结果内核代码被攻击者跳转到用户空间执行了恶意代码。
我曾经踩过的坑:在调试一个安全启动方案时,我把内核堆栈区设了 XN,但没设 PXN。结果内核在堆栈上执行了数据,导致异常。后来我养成了习惯:数据区一律 XN=1、PXN=1,代码区一律 XN=0、PXN=0。
3.3.2 AF:访问标志位
AF(Access Flag)表示该页是否被访问过。ARMv8 要求 AF 必须置 1,否则 MMU 会触发 Access Flag 异常。
你可能会问:「为什么要有这个位?」嗯,这是给 OS 做页面置换用的。但嵌入式系统通常不用虚拟内存交换,所以我建议你直接在所有描述符里把 AF 写死为 1。省得麻烦。
3.3.3 AP[2:1]:数据访问权限
AP 字段控制读写权限。两位组合起来有四种情况:
| AP[2:1] | EL1(内核) | EL0(用户) | 说明 |
|---|---|---|---|
| 0b00 | 读写 | 无 | 仅内核可访问 |
| 0b01 | 读写 | 读写 | 完全开放 |
| 0b10 | 只读 | 无 | 仅内核只读 |
| 0b11 | 只读 | 只读 | 完全只读 |
注意:AP[0] 在 ARMv8 中用于指示是否使用非安全空间(NS 位),与权限无关。别搞混了。
3.3.4 其他重要属性
- AttrIndx[2:0]:指向 MAIR 寄存器中的属性索引,决定缓存策略(如 Normal、Device、Strongly Ordered)。
- SH[1:0]:共享属性,控制多核间的缓存一致性。00=非共享,01=保留,10=外部共享,11=内部共享。
- NS:非安全位,用于 TrustZone 场景。置 1 表示该页属于非安全世界。
3.4 描述符格式示例
拿一个典型的 L3 页描述符来说,它的 64 位格式长这样:
位域分布(L3 页描述符,4KB 页):
63:48 — 物理地址[47:32](高位)
47:16 — 物理地址[31:0](低位,低 12 位为零)
15:12 — 保留 / 软件使用
11:10 — AP[2:1](访问权限)
9 — PXN(特权执行永不)
8 — XN(执行永不)
7:6 — 保留
5 — AF(访问标志)
4:2 — AttrIndx[2:0](MAIR 索引)
1:0 — 描述符类型(0b01 表示页描述符)
实际写代码时,我一般用宏来拼这些位:
// 一个简单的 L3 页描述符构造示例
#define PAGE_DESCRIPTOR_TYPE (0x1UL << 0)
#define PAGE_AF (0x1UL << 5)
#define PAGE_ATTR_INDEX(n) ((n & 0x7) << 2)
#define PAGE_AP_RW_EL1 (0x0UL << 10) // AP[2:1] = 00
#define PAGE_AP_RW_ALL (0x1UL << 10) // AP[2:1] = 01
#define PAGE_XN (0x1UL << 8)
#define PAGE_PXN (0x1UL << 9)
uint64_t make_l3_page(uint64_t phys_addr, uint64_t attr_idx, uint64_t ap) {
uint64_t desc = phys_addr & 0x0000FFFFFFFFF000ULL; // 物理地址,低 12 位清零
desc |= PAGE_DESCRIPTOR_TYPE;
desc |= PAGE_AF;
desc |= PAGE_ATTR_INDEX(attr_idx);
desc |= ap;
return desc;
}
个人习惯:我写页表初始化代码时,会先定义一个默认描述符模板,然后按需修改个别字段。这样代码清晰,也容易调试。你想想看,如果每个页表条目都手写位域,出错了查起来多痛苦。
3.5 避坑指南
最后,分享几个我实际项目中遇到的坑:
- AF 位没置 1:MMU 一启动就触发异常。我调试了整整一天才发现是 AF 位没写。后来我直接在宏里把 AF 写死。
- 块描述符的缓存属性:用 L2 块描述符映射外设时,记得把 AttrIndx 指向 Device 类型。我见过有人用 Normal 映射 UART,结果数据被缓存了,串口输出乱码。
- PXN 和 XN 的优先级:如果 XN=0 但 PXN=1,EL0 可以执行,EL1 不行。反过来,XN=1 时不管 PXN 是什么,都不能执行。别搞反了。
- 页表对齐:表描述符指向的下一级页表必须 4KB 对齐。我刚开始写时忘了这个,MMU 直接报错。
嗯,页表描述符的内容就这些。理解了表描述符和块描述符的区别,再掌握那几个关键属性字段,你就能自己写出正确的页表了。