4. TLB的刷新机制:全局刷新、ASID-based刷新、VMID-based刷新

TLB 刷新的本质是什么?说白了就是告诉硬件:「你缓存的那份地址映射关系,已经过期了,别用了。」

我在做第一颗虚拟化芯片的时候,就被 TLB 刷新坑过一次。当时一个虚拟机迁移后,TLB 里还残留着旧 VM 的页表缓存,结果 Guest OS 访问自己的内存,竟然映射到了另一个 VM 的物理地址上。嗯,从那以后,我对 TLB 刷新机制就格外上心。

SMMU 的 TLB 刷新,按粒度可以分为三个层次:全局刷新、ASID 级刷新、VMID 级刷新。你想想看,每次刷新都意味着 TLB 命中率下降、性能损失,所以选对刷新粒度,是系统软件工程师的基本功。

4.1 全局刷新(TLBI ALL)

全局刷新,就是直接把整个 TLB 清空。最粗暴,也最彻底。

适用场景:

  • 系统刚启动,页表还没建立
  • 整个地址空间被重新映射(比如内核页表切换)
  • 安全世界与普通世界切换(TrustZone)
  • 调试阶段,不确定哪些 TLB 条目脏了

对应的指令很简单:

// 全局无效化所有 TLB 条目(非安全状态)
TLBI ALL

// 全局无效化所有 TLB 条目(安全状态)
TLBI ALLS

// 全局无效化所有 TLB 条目(包括安全和非安全)
TLBI ALLNS

我个人的习惯是:不到万不得已,不用全局刷新。为什么?因为全局刷新会清空所有进程的 TLB 条目,包括那些完全没被修改过的。你想想看,一个 64 核的系统,每个核的 TLB 里可能缓存了几百个条目,一次全局刷新,所有核都得重新填充 TLB,这个开销是巨大的。

注意:全局刷新后,所有后续的内存访问都会触发 TLB Miss,直到页表重新被缓存。在实时性要求高的系统中,这可能导致不可预测的延迟抖动。

4.2 ASID-based 刷新

ASID(Address Space ID)是 SMMU 用来区分不同进程地址空间的标识符。每个进程拥有独立的 ASID,TLB 条目会带上 ASID 标签。

ASID-based 刷新,只清除特定 ASID 对应的 TLB 条目。其他进程的 TLB 缓存不受影响。

我在项目中遇到过这样一个场景:一个数据库进程频繁做 mmap/munmap,每次 munmap 后都需要刷新 TLB。如果用全局刷新,其他几十个进程的 TLB 全部被清空,性能直接掉 30%。改用 ASID-based 刷新后,只有数据库进程受影响,其他进程的 TLB 命中率纹丝不动。

// 无效化特定 ASID 的所有 TLB 条目
TLBI ASID, #0x12

// 无效化特定 ASID + 特定 VA 范围的 TLB 条目
TLBI VAA, #0x12, X1

ASID 刷新的核心优势:

  • 精确打击:只影响目标进程
  • 减少抖动:其他进程的 TLB 命中率不受影响
  • 适合多进程场景:Linux 内核中,进程切换时通常只需要刷新旧进程的 ASID

避坑指南:我曾经在调试一个 Android 系统时发现,某个 GPU 驱动在释放内存后忘记刷新 ASID,导致 GPU 访问了已经被回收的物理页。排查了两天才定位到——TLB 里还残留着旧映射。记住:释放内存后,一定要刷新对应的 ASID。

4.3 VMID-based 刷新

VMID(Virtual Machine ID)是虚拟化场景下的概念。每个虚拟机(VM)拥有独立的 VMID,TLB 条目会同时带上 VMID 和 ASID 标签。

VMID-based 刷新,只清除特定 VM 的所有 TLB 条目。同一个物理主机上的其他 VM 不受影响。

// 无效化特定 VMID 的所有 TLB 条目
TLBI VMID, #0x5

// 无效化特定 VMID + 特定 ASID 的 TLB 条目
TLBI VMALLS12E1, #0x5

虚拟化场景下,TLB 刷新的层次关系:

刷新粒度 影响范围 典型场景 性能影响
全局刷新 所有 VM + 所有进程 Hypervisor 启动/关闭 极大
VMID 刷新 单个 VM 内所有进程 VM 迁移、VM 内存热插拔 中等
ASID 刷新 单个 VM 内单个进程 进程切换、munmap 小

我建议你在设计虚拟化方案时,优先使用 VMID-based 刷新。举个例子:当 VMM 需要回收某个 VM 的物理内存时,只需要刷新该 VMID 对应的 TLB 条目,其他 VM 的 TLB 缓存完全不受影响。这在多租户场景下尤其重要——你总不希望因为一个租户的内存操作,导致所有租户都卡顿吧?

4.4 三种刷新机制的对比与选择

下面这张图,是我自己总结的 TLB 刷新决策树:

TLB 刷新决策树 需要刷新 TLB? 是否在虚拟化环境? 是 → 使用 VMID-based 刷新 只影响单个进程?→ 加 ASID 否 → 非虚拟化 只影响单个进程? 是 → ASID 刷新 否 → 全局刷新 💡 选择原则: 1. 能选 ASID 不选 VMID,能选 VMID 不选全局 2. 不确定影响范围时,选更精确的粒度 3. 调试阶段可以先用全局刷新,稳定后优化为精确刷新

4.5 刷新指令的执行与同步

TLB 刷新指令不是立即生效的。你执行了 TLBI 指令后,还需要等待它完成。为什么?因为 SMMU 是流水线架构,刷新指令可能被后面的内存访问指令「超车」。

正确的做法是:

// 1. 执行 TLB 刷新
TLBI ASID, #0x12

// 2. 等待刷新完成(DSB 保证所有之前的 TLB 操作完成)
DSB SY

// 3. 确保后续指令看到新的页表(ISB 清空流水线)
ISB

我见过不少工程师只写了 TLBI,忘了加 DSB 和 ISB。结果呢?TLB 刷新还没完成,CPU 就继续执行后面的代码了,访问的还是旧映射。这种 bug 极其难复现,因为时序问题只在特定负载下才会触发。

血的教训:我曾经在一个网络加速项目中,因为漏掉了 DSB,导致数据包偶尔被路由到错误的物理地址。排查了整整一周,最后用 trace 工具抓到了 TLB 刷新未完成的证据。从那以后,我写 TLB 刷新代码都是「三步走」:TLBI → DSB → ISB,一步都不能少。

4.6 多核系统中的 TLB 刷新广播

在多核系统中,一个核执行 TLB 刷新,其他核的 TLB 也需要同步。SMMU 支持两种模式:

模式 描述 适用场景
广播刷新 一个核执行 TLBI,硬件自动广播到所有核 共享页表(如内核页表)
本地刷新 每个核独立执行自己的 TLBI 进程私有页表(每个核运行不同进程)

我个人建议:能用广播就用广播。为什么?因为手动管理多核 TLB 一致性太容易出错了。你想想看,如果核 0 修改了页表,核 1 不知道,核 1 的 TLB 里还缓存着旧映射,那核 1 访问内存时就会读到错误的数据。

广播刷新的代价是:所有核都要停下来等待 TLB 刷新完成。这在某些场景下会导致性能下降。但相比数据一致性错误,这点性能损失是值得的。

实战建议:在 Linux 内核中,flush_tlb_all() 就是广播刷新。如果你在写设备驱动,需要修改页表,记得调用这个函数,而不是自己手动去刷每个核的 TLB。

4.7 小结

TLB 刷新,说白了就是「让硬件忘记过期的映射」。选对粒度,性能翻倍;选错了,要么数据出错,要么性能崩盘。

记住三个原则:

  • 能精确就精确:ASID > VMID > 全局
  • 同步不能省:TLBI + DSB + ISB 是铁三角
  • 多核用广播:别自己折腾一致性