4、ARMv8-A 转换过程:TTBR0与TTBR1寄存器、TLB查找流程、页表遍历过程、缺页异常处理

好,咱们进入正题。ARMv8-A 的地址转换,说白了就是一套硬件自动完成的查表游戏。你写程序时用的是虚拟地址,CPU 要把它变成物理地址才能去内存拿数据。这个过程,我习惯叫它「页表漫步」——听起来挺浪漫,其实背后全是寄存器、缓存和异常处理在忙活。

4.1 TTBR0 与 TTBR1:两个翻译基址寄存器

ARMv8-A 里有两个关键的寄存器:TTBR0_EL1TTBR1_EL1。它们存的是页表的基地址。为什么需要两个?因为操作系统通常把虚拟地址空间分成两半:用户空间和内核空间。

  • TTBR0:管用户空间(低地址区域,比如 0x0000_0000_0000_0000 往上)。每个进程都有自己的页表,所以进程切换时 TTBR0 要重新加载。
  • TTBR1:管内核空间(高地址区域,比如 0xFFFF_0000_0000_0000 往上)。内核页表是所有进程共享的,所以 TTBR1 基本不变。

嗯,这里要注意:具体怎么划分高低地址,取决于 TCR(Translation Control Register) 里的配置。TCR 里有个字段叫 T1SZT0SZ,它们决定了地址空间的尺寸。我个人习惯把 T0SZ 设成 24,这样用户空间就是 2^(64-24) = 2^40 字节,也就是 1TB。够用了吧?

关键点:TTBR0 和 TTBR1 的切换是硬件自动完成的。CPU 根据虚拟地址的最高几位来判断该用哪个寄存器。你不需要写代码去判断——硬件比你快得多。

4.2 TLB 查找流程:先查缓存,再查内存

TLB(Translation Lookaside Buffer)是页表的硬件缓存。为什么需要它?因为每次地址转换都去内存里遍历页表,那性能就崩了。TLB 的存在,就是为了让大部分转换在纳秒级完成。

TLB 查找流程大致如下:

  1. CPU 拿到虚拟地址,先拆成 VA[47:12](页号部分)和 VA[11:0](页内偏移)。
  2. 用页号去 TLB 里做 全相联查找(或者组相联,具体看实现)。
  3. 如果 TLB 命中,直接拿到物理页号,拼上偏移就完事了。整个过程 1-2 个时钟周期。
  4. 如果 TLB 未命中,那就得走页表遍历了——这个慢得多,可能要几十甚至上百个周期。

我记得有一次调试一个性能问题,发现某个模块的 TLB 缺失率特别高。查了半天,原来是页表映射的粒度太大,导致 TLB 覆盖范围不够。后来改成 4KB 页粒度,配合大页(2MB)混合使用,问题就解决了。说白了,TLB 的命中率直接决定了你的程序跑得快不快。

避坑指南:我曾经在项目里遇到过 TLB 一致性问题。你修改了页表,但 TLB 里还存着旧映射。这时候必须手动做 TLB 失效(invalidate)。ARMv8 提供了 TLBI 指令,比如 TLBI VAE1IS 可以按地址失效。千万别忘了这步,否则你会看到各种诡异的内存访问错误。

4.3 页表遍历过程:四级页表,步步为营

ARMv8-A 支持 4KB 页、16KB 页和 64KB 页。最常用的是 4KB 页,配合四级页表结构。咱们以 4KB 页、48 位虚拟地址为例,看看遍历过程:

级别 索引位 页表项大小 描述
L0 VA[47:39] 8 字节 顶级页表,共 512 项
L1 VA[38:30] 8 字节 第二级,共 512 项
L2 VA[29:21] 8 字节 第三级,共 512 项
L3 VA[20:12] 8 字节 最后一级,共 512 项,指向物理页

遍历过程是这样的:

  1. 从 TTBR0 或 TTBR1 拿到 L0 页表的基地址。
  2. 用 VA[47:39] 作为索引,在 L0 页表中找到对应的页表项(PTE)。
  3. 检查 PTE 的 有效位(bit 0)。如果为 0,说明这个映射不存在,直接触发缺页异常。
  4. 如果有效,看 PTE 的 类型位(bit 1)。如果是块映射(block),说明这级就终结了,直接拿到物理地址。如果是表映射(table),说明还要继续往下走。
  5. 重复步骤 2-4,直到 L3 页表。L3 的 PTE 一定是页映射(page),指向一个 4KB 的物理页。

你想想看,每次遍历都要访问 4 次内存(L0 到 L3)。如果 TLB 没命中,这开销可不小。所以很多高性能处理器会做 硬件页表预取,提前把相邻的页表项加载到缓存里。

实战经验:我在做嵌入式系统时,发现有些 SoC 的页表遍历器(page table walker)是独立于 CPU 核心的硬件模块。它可以在后台预取页表项,减少 TLB 缺失的惩罚。但要注意,如果页表本身不在缓存里,遍历器会触发缓存缺失,那延迟就更大了。所以,把页表放在快速内存(比如 TCM 或 L2 缓存)里,是个好习惯。

4.4 缺页异常处理:从硬件到软件的交棒

当页表遍历过程中发现某个 PTE 无效(有效位为 0),或者访问权限不匹配(比如写只读页),CPU 就会触发一个 缺页异常(Page Fault)。这个异常会交给操作系统内核处理。

缺页异常的处理流程大致如下:

  1. CPU 保存当前上下文(PC、PSTATE 等),跳转到异常向量表中的 同步异常处理程序
  2. 读取 FAR_EL1(Fault Address Register),拿到出错的虚拟地址。
  3. 读取 ESR_EL1(Exception Syndrome Register),分析异常原因。比如是「翻译错误」还是「权限错误」。
  4. 内核根据出错地址,查找对应的 VMA(虚拟内存区域)。如果 VMA 存在,说明是合法访问,只是物理页还没分配。内核就分配一个物理页,更新页表,然后返回用户空间重新执行那条指令。
  5. 如果 VMA 不存在,或者访问权限不对,那就触发 SIGSEGV,杀掉进程。

嗯,这里有个细节:缺页异常处理完成后,返回用户空间时,CPU 会重新执行那条导致异常的指令。因为页表已经更新了,这次 TLB 查找就能成功了。

警告:我曾经在项目里遇到一个坑:缺页异常处理程序里修改了页表,但没有做 TLB 失效。结果返回用户空间后,TLB 里还是旧的无效映射,再次触发缺页异常,形成死循环。所以,内核在更新页表后,一定要调用 flush_tlb_page() 或类似的函数,确保 TLB 和页表一致。

另外,ARMv8 还支持 硬件页表遍历(Hardware Page Table Walk)。也就是说,TLB 未命中时,硬件会自动去内存里遍历页表,不需要软件介入。只有在遍历失败(比如 PTE 无效)时,才会触发异常交给软件。这种设计大大减少了缺页异常的次数,提升了性能。

最后总结一下:TTBR0/TTBR1 决定了页表的根,TLB 是加速器,页表遍历是必经之路,缺页异常是兜底机制。这四个环节环环相扣,任何一个出问题,你的程序都可能崩溃。我建议你在调试内存问题时,多看看 FAR_EL1ESR_EL1 这两个寄存器,它们能告诉你很多信息。