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 的块)。

我画了一张结构图,帮你理清这四级的关系:

ARMv8 四级页表结构(4KB 页面) Level 0 页表 1 个条目 Level 1 页表 最多 4 个条目 Level 2 页表 最多 512 个条目 Level 3 页表 最多 512 个条目 L0: 1TB 范围 | L1: 512GB 范围 | L2: 1GB 范围(块)或 2MB 范围(表)| L3: 4KB 页 虚拟地址拆分:L0[38:39] | L1[30:38] | L2[21:30] | L3[12:21] | 页内偏移[0:12] 表描述符(Table Descriptor) 指向下一级页表的基地址 用于 L0、L1、L2 块描述符(Block Descriptor) 直接映射一块连续物理内存 用于 L2(2MB)或 L3(4KB) 属性字段:XN | PXN | AF | AP[2:1] | AttrIndx[2:0] | NS | SH[1:0] 每个描述符的低 12 位存放属性,控制访问权限、缓存策略、执行权限等

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 避坑指南

最后,分享几个我实际项目中遇到的坑:

  1. AF 位没置 1:MMU 一启动就触发异常。我调试了整整一天才发现是 AF 位没写。后来我直接在宏里把 AF 写死。
  2. 块描述符的缓存属性:用 L2 块描述符映射外设时,记得把 AttrIndx 指向 Device 类型。我见过有人用 Normal 映射 UART,结果数据被缓存了,串口输出乱码。
  3. PXN 和 XN 的优先级:如果 XN=0 但 PXN=1,EL0 可以执行,EL1 不行。反过来,XN=1 时不管 PXN 是什么,都不能执行。别搞反了。
  4. 页表对齐:表描述符指向的下一级页表必须 4KB 对齐。我刚开始写时忘了这个,MMU 直接报错。

嗯,页表描述符的内容就这些。理解了表描述符和块描述符的区别,再掌握那几个关键属性字段,你就能自己写出正确的页表了。