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 刷新决策树:
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 是铁三角
- 多核用广播:别自己折腾一致性