页表结构与转换机制:一级页表、二级页表、段映射与页映射

内存管理单元(MMU)的核心工作,说白了就是做一件事:把虚拟地址翻译成物理地址。而这件事的「翻译词典」,就是页表。

ARMv7 的页表结构,我习惯把它理解成一棵「两级查找树」。你想想看,如果只用一张大表来映射整个 4GB 地址空间,那这张表得有多大?4GB / 1MB(段粒度)= 4096 个条目,每个条目 4 字节,光一级页表就要 16KB。这还不算细粒度映射的情况。所以 ARM 设计了两级结构——一级页表做粗粒度索引,二级页表做细粒度定位。

核心概念速览:

  • 一级页表(L1):基地址存放在 TTBR0/TTBR1 寄存器中,覆盖整个 4GB 空间
  • 二级页表(L2):由一级页表条目指向,提供更细的映射粒度
  • 段映射:一级页表直接完成,粒度 1MB
  • 页映射:需要两级页表配合,粒度 4KB 或 64KB

一级页表的结构

一级页表有 4096 个条目,每个条目对应 1MB 的虚拟地址空间。这 4096 个条目,每个都是 32 位的描述符。描述符的类型决定了这个 1MB 区域怎么映射。

我记得第一次看 ARM 手册时,被描述符的位域定义搞得头大。其实没那么复杂——你只需要关注描述符的低 2 位,它们决定了映射方式:

描述符类型 低 2 位 映射粒度 说明
无效(Fault) 00 — 该区域未映射,访问会触发缺页异常
段(Section) 10 1MB 一级页表直接完成映射,效率最高
粗页表(Coarse) 01 4KB / 64KB 指向二级页表,支持细粒度映射
细页表(Fine) 11 1KB / 4KB / 64KB ARMv7 中已废弃,不建议使用

我的经验:实际项目中,我几乎只用「段映射」和「粗页表」两种。细页表在 ARMv7 中虽然手册里还有,但 Linux 内核和主流 RTOS 都不再支持。别在这上面浪费时间。

段映射:一级页表直接搞定

段映射是最简单的模式。一级页表的描述符直接给出物理基地址——1MB 对齐的物理地址。虚拟地址的高 12 位(4096 个条目)作为索引,找到描述符后,描述符中的物理基地址加上虚拟地址的低 20 位(1MB 内的偏移),就得到了最终的物理地址。

说白了,就是一次查表,一步到位。我在做裸机程序时特别喜欢用段映射——代码量少,性能好,而且不用担心 TLB 抖动。但缺点也很明显:最小粒度是 1MB,如果你只想映射 4KB 的外设寄存器,用段映射就太浪费了。

注意:段映射的描述符中,物理基地址字段只有 12 位(对应 1MB 对齐),所以物理地址的高 12 位由描述符提供,低 20 位直接来自虚拟地址。这意味着你无法用段映射访问非 1MB 对齐的物理区域。

二级页表与页映射

当一级页表的描述符类型为「粗页表」时,它指向一个二级页表。二级页表有 256 个条目,每个条目对应 4KB 的虚拟地址空间。虚拟地址的中间 8 位(一级页表索引之后的 8 位)用来索引二级页表。

整个地址翻译过程分两步:

  1. 一级查表:虚拟地址[31:20] 索引一级页表,得到二级页表的物理基地址
  2. 二级查表:虚拟地址[19:12] 索引二级页表,得到 4KB 页的物理基地址
  3. 组合:物理基地址 + 虚拟地址[11:0] = 最终物理地址

二级页表的描述符也有几种类型:

描述符类型 低 2 位 粒度 使用场景
无效 00 — 未映射
大页(Large Page) 01 64KB 适合大块连续内存,减少页表占用
小页(Small Page) 10 4KB 最常用,Linux 默认页大小
极小页(Tiny Page) 11 1KB ARMv7 中已废弃

实际项目中的选择:我在一个视频编解码项目中,把代码段用段映射(1MB),数据缓冲区用页映射(4KB)。代码段大且连续,段映射省页表空间;数据缓冲区需要按页分配和回收,页映射更灵活。两种方式混用,效果很好。

页表描述符详解

描述符里除了地址信息,还包含了很多控制位。我挑几个最常用的说说:

  • XN(Execute Never):禁止执行。我在做安全启动时,用这个位把数据区标记为不可执行,防止代码注入攻击。
  • Domain(域):ARMv7 的域机制,可以批量控制一组页面的访问权限。Linux 内核用这个实现用户态和内核态的隔离。
  • AP(Access Permission):读写权限控制。组合 AP[2:1] 和 SBO(System/Bus)位,可以实现只读、读写、只执行等权限。
  • C/B(Cacheable / Bufferable):缓存策略控制。外设寄存器一般配成非缓存非缓冲,内存区域配成写回缓存。
  • NS(Non-Secure):安全扩展中的非安全位。TrustZone 场景下用这个区分安全世界和非安全世界。

避坑指南:我曾经在一个项目中,把外设寄存器配成了 Cacheable,结果读回来的数据总是旧的——因为 CPU 从缓存里读,而外设已经更新了物理内存。排查了两天才发现是描述符的 C 位设错了。记住:外设寄存器一定要配成 非缓存非缓冲(Strongly Ordered)。

地址转换流程全景图

下面这张图展示了我刚才讲的两级页表转换流程。你可以看到虚拟地址怎么被拆分成三段,分别用于一级索引、二级索引和页内偏移。

ARMv7 两级页表地址转换流程 ARMv7 两级页表地址转换流程 虚拟地址 (32-bit) 一级索引 [31:20] (12位) 二级索引 [19:12] (8位) 页内偏移 [11:0] (12位) 一级页表 (L1) 4096 个条目,每个 4 字节 TTBR0/TTBR1 指向基地址 二级页表 (L2) 256 个条目,每个 4 字节 由 L1 描述符中的地址指向 物理地址 (32-bit) L2 描述符中的基地址 + 页内偏移 说明 段映射:跳过二级页表,L1 描述符直接给出 1MB 物理基地址 + 低 20 位偏移

段映射 vs 页映射:怎么选?

我经常被问到这个问题。其实没有绝对的好坏,关键看场景:

对比维度 段映射 页映射
映射粒度 1MB 4KB(小页)或 64KB(大页)
页表占用 仅一级页表,16KB 一级页表 + 二级页表,约 20KB 起
TLB 利用率 一个 TLB 条目覆盖 1MB 一个 TLB 条目覆盖 4KB
缺页处理 粒度粗,不适合按需分页 粒度细,适合虚拟内存
典型场景 裸机、RTOS、大块连续内存 Linux、多进程、内存保护

性能调优提醒:如果你的程序频繁访问大量分散的 4KB 页面,TLB 可能会频繁失效。这时候可以考虑用大页(64KB 或 1MB 段映射)来减少 TLB 压力。我在数据库项目中就用过这个技巧——把热数据区用段映射,查询性能提升了 15%。

页表配置实战要点

最后,我总结几个实际配置页表时容易踩的坑:

  • 页表对齐:一级页表必须 16KB 对齐,二级页表必须 1KB 对齐。不对齐的话,MMU 会直接报错。
  • TTBR 切换:切换 TTBR0/TTBR1 后,必须执行 ISB(指令同步屏障)和 DSB(数据同步屏障),否则 CPU 可能还在用旧页表。
  • TLB 失效:修改页表后,记得执行 TLBIALL 或类似指令清空 TLB。我曾经忘了做这一步,结果 CPU 一直用缓存的旧映射,调试了一整天。
  • 域访问控制:DACR(域访问控制寄存器)的默认值是 0,会禁止所有域访问。记得在使能 MMU 前配好 DACR。

我的习惯:每次修改页表后,我都会在调试器里读一下页表描述符的物理地址字段,确认写进去的值是对的。然后再执行 TLB 失效和 ISB。这个习惯帮我避免了好几次「页表改了但没生效」的尴尬。