1. 没有SMMU的世界:外设DMA是一颗“流弹”
1.1 DMA乱写带来的内存踩踏与虚拟化危机
先讲一段我自己的经历。早年调试一块PCIe网卡驱动,设备一启动,主机直接死机,dmesg完全来不及打出来,最后用软硬件联合调试,才发现网卡发起的DMA写操作覆盖了一段关键内核数据结构。当时我下意识以为是设备固件问题,排查了整整一天,最后才意识到真正的源头是SMMU——这块网卡的DMA在SMMU里没有任何翻译规则,等于外设可以在物理内存里随意走动,想写哪里写哪里。
这其实就是没有SMMU时系统的默认状态。外设访问内存不经过CPU,而是通过总线控制器直接读写物理地址。只要设备具备bus master能力,驱动或者固件里一个错误的地址值,就足以让DMA写穿整个系统。在虚拟化场景下问题更严重:如果把PCIe设备直接分配给虚拟机(device passthrough),宿主机没有办法限制设备只能访问Guest的物理内存,设备DMA可以越过虚拟化边界,直接读走宿主机或其他虚拟机的敏感数据。这条路径一旦被利用,比软件漏洞来得更直接,根本不需要构造任何payload——设备本身就是一把能开任何门的钥匙。
SMMU(System Memory Management Unit)就是为解决这个问题诞生的。它挂在设备和内存之间,拦截所有外设发起的DMA请求,完成两件事:一是检查这个请求有没有访问目标内存的权限,二是把设备视角的地址翻译成真正的物理地址。站在系统架构的角度,SMMU的位置大致在NoC/总线互联层与内存控制器之间,和CPU侧的MMU并列,只是服务对象从CPU换成了外设。
1.2 SMMU与CPU侧MMU的分工,为什么不能互相替代
很多刚开始接触底层的人会问:CPU MMU已经存在这么多年,外设DMA为什么不能也走MMU?原因很简单,DMA请求根本不经过CPU,它的源地址、目标地址都直接来自设备或总线事务,CPU MMU管不着这一段路径。所以SMMU实际上是一个独立于CPU MMU的地址翻译和访问控制单元,两者共享同一套物理内存视图,但各自管各自的事务源。
打个比方,CPU MMU像是大楼前台,所有从正门进来找人的访客都要经过前台登记;SMMU则是每层楼走廊尽头的独立门禁,任何从侧门、货梯、地下车库进来的设备人员,要进对应房间之前,先得在门禁上刷卡核对目的地。前台管不了货梯进来的人,只能靠门禁来管。
SMMU在系统架构里还有一个很关键的角色:它让“设备隔离”这件事从硬件层面具备了可能性。CPU侧MMU保护的是软件视角的地址空间,SMMU保护的是物理内存不被未授权的外设访问。两者配合,才构成了完整的SoC内存安全边界。对于做SoC验证或者底层固件的朋友,必须在看系统框图时把SMMU当作和GIC、内存控制器同等重要的模块,而不是一个可选的“附加组件”。现代ARMv8/v9服务器和移动SoC里,几乎所有主流外设——PCIe、USB控制器、SD/eMMC控制器、GPU、视频编解码单元——都会挂在SMMU后面。
2. STE、CD与两级地址翻译:SMMU内部的数据链路
2.1 StreamID与STE:外设如何找到自己的翻译规则
SMMU面对的是一堆不同类型的外设,每个外设发起DMA时,硬件总线事务上会带一个标识,这个标识就是StreamID。在PCIe场景下,StreamID通常由设备的Bus/Device/Function(BDF)映射而来;在SoC内部,StreamID由NoC或总线路径分配,比如某个USB控制器固定在StreamID 0x10,某个DPU固定在StreamID 0x30。
SMMU拿到StreamID之后,用它去查询Stream Table(流表),流表里存放的条目就是STE(Stream Table Entry)。STE是SMMU地址翻译链路的第一级数据结构,每个STE通常固定64字节,里面包含了这个设备使用的翻译模式、页表指针以及一些控制字段。STE.Config字段决定设备的工作模式,可以是translate(正常翻译)、bypass(直接透传不翻译)或abort(所有DMA全部拒绝)。登场之后所有逻辑都在这个基础上展开。
流表本身有两种组织方式,线性表和两级表。线性表好理解,就是把所有STE按StreamID顺序排列,连续存放;两级表结构则类似CPU页表,先用StreamID高位索引L1表,再拿低位索引L2表,适合StreamID空间很大但实际使用稀疏的SoC。实操中,流表基地址寄存器通常要求16KB对齐,具体以芯片手册为准。
2.2 Stage1与Stage2:IOVA到IPA再到PA的两跳翻译
有了STE,接下来就是SMMU最核心的两级地址翻译机制。Stage1负责把设备发起的IOVA(I/O Virtual Address,设备视角的虚拟地址)翻译成IPA(Intermediate Physical Address,中间物理地址);Stage2再把IPA翻译成最终的PA(Physical Address)。在虚拟化场景下,IPA就是虚拟机看到的“物理地址”,PA才是真实物理内存地址。
为什么需要两跳?这要结合KVM这类虚拟化软件来看。Hypervisor负责把物理内存分配给各个虚拟机,它会记录一份IPA到PA的Stage2映射;而虚拟机内部的操作系统和驱动则负责给设备建立Stage1映射。Guest里的驱动可以自由地管理IOVA分配,不需要知道真实物理内存布局;Hypervisor只要配置好Stage2,就能牢牢掌控设备DMA真正能触摸的物理内存范围。换句话说,Guest内驱动无论如何折腾IOVA,最终都逃不出Hypervisor在Stage2里划定的笼子。
在非虚拟化场景里,可以只开Stage1,让IOVA直接翻译到PA;某些实现也支持只开Stage2,把ATS/PRI这种PCIe特性绕到另一边来管理。SMMU页表的遍历格式和CPU的VMSA(Virtual Memory System Architecture)格式基本一致,同样支持4KB、16KB、64KB三种granule,同样有大页块描述符。这个设计非常有价值:做过Linux内核页表开发的人,学习SMMU页表结构几乎没有额外成本,很多概念可以直接迁移。
2.3 SubstreamID与SVM:让设备直接访问进程地址空间
SMMUv3引入了一个PCIe生态里很重要的概念——SubstreamID。前面说StreamID先定位STE,STE只决定设备属于哪个“域”;当设备支持PASID(Process Address Space ID)时,DMA请求还会带一个SubstreamID,用它去索引STE指向的CD(Context Descriptor)表。CD类似于配置上下文的入口,里面装着Stage1页表基地址、ASID、TCR配置等字段。
为什么要在设备上下文之上再增加一层进程上下文?因为有了SubstreamID和CD,SMMU可以让设备直接访问某个进程的虚拟地址空间,这就是SVM(Shared Virtual Memory)。普通的IOMMU使用方式里,驱动要先分配IOVA、做映射,CPU指针和设备指针是两套地址;SVM场景下设备看到的地址就是CPU的虚拟地址,两者共享同一套页表,GPU或者智能网卡可以直接按指针访问用户态数据,省掉了pin pages和地址映射的开销。
代价是SVM对软件和硬件的要求都很高:PCIe设备要实现PASID机制,SMMU要支持SubstreamID翻译,操作系统要有完整的IOMMU SVM框架,驱动还得处理设备缺页时的page fault再映射逻辑。实际项目里,把SVM跑通往往是在RCAR、DPU这类复杂加速器上,普通简单设备直接用传统IOVA映射反而更稳。但理解这条链路,对看OpenCL、CUDA、RDMA的共享内存机制帮助很大。
3. 从SMMUv2到SMMUv3.x:架构重构与ARMv8/v9时代的新功能
3.1 队列机制取代寄存器机制:SMMUv3最大的一次架构变化
ARM的SMMU发展到现在,v2和v3是两大分水岭,而v3是一次不折不扣的架构重构,不只是寄存器换个地址那么简单。SMMUv2时代,地址翻译、TLB刷新、配置等操作大量依赖寄存器直接访问,软件要维护各种状态和互斥机制。到了SMMUv3,ARM把控制面整个改成了基于内存队列的模型——软件把命令写入Command Queue,SMMU从队列头部取命令执行;SMMU产生的fault和错误信息写进Event Queue,软件去Event Queue读取。
这个变化的直接好处是并发性大幅提升。多核CPU可以同时往命令队列提交TLB invalidate命令、CFGI命令,不需要像v2那样拿一把大锁保护寄存器;SMMU硬件也可以更灵活地调度命令执行。另一个好处是扩展性强:新的命令类型和事件类型可以随版本不断追加,而不需要不断修改寄存器组。
拿我自己调过的场景来说,v2的时代,TLB刷新稍微频繁一点,寄存器访问的瓶颈就很容易出现;v3的队列模型下,同步命令配合SYNC机制,软件可以批量提交命令后统一等待完成,整体吞吐量提升明显。对于需要高频DMA映射/解映射的NVMe、RDMA网卡这类场景,这个差异非常关键。
表格对比一下v2和v3的主要差异:
| 对比维度 | SMMUv2 | SMMUv3 |
|---|---|---|
| 控制面方式 | 寄存器直接配置 | 内存队列命令/事件 |
| TLB维护 | 主要靠寄存器操作,软件互斥复杂 | 命令队列批量提交TLBI/SYNC |
| PASID/SVM支持 | 基本不支持 | 通过STE/CD/SubstreamID完整支持 |
| PCIe ATS/PRI | 支持度有限 | 原生支持且与队列机制融合更顺 |
| 可扩展性 | 新功能需改动寄存器地址空间 | 命令/事件类型可随版本扩展 |
| 上下文隔离 | 简单域模型 | 支持多级上下文、细粒度隔离 |
3.2 ARMv9、52位物理地址与安全隔离的新边界
ARMv9引入的很多新特性都辐射到了SMMU。最大的显性变化是物理地址位宽:ARMv8时代的SoC多数支持48位PA,到了ARMv9和新版SMMUv3.x,物理地址支持扩展到52位,SMMU的STE、CD、页表格式都跟着扩展,地址寄存器也需要能容纳更宽的物理地址。对大内存服务器场景来说,这直接影响设备能否访问到高端内存区域。
安全方面,ARMv9定义的RME(Realm Management Extension)需要SMMU配合实现Realm内存的DMA隔离。如果SoC实现了RME,普通非安全设备不能直接DMA访问Realm内存,SMMU必须识别请求所属的安全状态(Secure、Non-Secure、Realm),并据此执行不同的访问策略。这也导致SMMU本身可能要分成多个安全上下文来管理,固件在初始化SMMU时就得按secure和non-secure两套配置分别处理。
SMMUv3.x面向PCIe生态还有一个比较大的功能扩展是MPAM(Memory System Resource Partitioning and Monitoring),用于对内存带宽和缓存资源做分区监控和分配。这个特性更多偏向服务质量的保障,在多租户、多业务混合部署的场景里,可以用来限制某个设备或虚拟机的内存带宽占用,避免干扰其他业务。对普通驱动开发来说暂时接触不多,但系统架构师做性能隔离规划时,SMMU已经不只是内存保护工具,而是整个SoC内存资源的精细化调度器。
3.3 ATS与PRI:设备主动查询与反向缺页
PCIe生态里,SMMUv3有两个很反直觉的功能值得单独说:ATS(Address Translation Services)和PRI(Page Request Interface)。
ATS允许PCIe设备主动向SMMU发翻译请求,把IOVA对应的PA提前查出来,缓存在设备的ATC里。这样后续DMA请求就可以直接带PA,不用每次经过SMMU做页表遍历,降低了访存延迟,也减轻了SMMU的负载。代价是必须有缓存一致性机制——当驱动改动了页表映射,软件必须向SMMU提交ATC Invalidate命令,让SMMU通知设备把对应的ATC缓存刷掉。这个流程一旦漏了,设备拿着旧的PA访问,轻则数据错乱,重则触发总线错误。
PRI则更“反直觉”:设备发起DMA时如果发现某个IOVA没有映射,它不再傻等或报fault,而是通过PRI队列向软件发一个“缺页请求”。软件收到后分配物理页、建立映射,再告诉设备可以重试了。这套机制本质上把CPU虚拟内存的按需调页能力扩展到了设备侧,是SVM真正落地的前提。调试的时候,我见过不少人在SMMU驱动里看到PRI相关事件一头雾水,其实只要明白“PRI就是设备的page fault”这句话,整个逻辑就顺了。
4. 让一个设备能跑通DMA:SMMU配置流程与软件通信机制
4.1 全局初始化与设备映射:顺序真的很重要
在Linux内核里,驱动开发时通常不会直接操作SMMU寄存器,而是通过IOMMU API(iommu_domain、iommu_map、dma_map_single等)间接完成映射。但如果你是做固件、BSP、SoC验证,或者需要调试SMMU自身的行为,就绕不开手动配置的过程。
一个典型的SMMU全局初始化流程大致是这样的:
- 初始化Command Queue和Event Queue的内存区域,配置SMMU_CMDQ_BASE、SMMU_EVENTQ_BASE等寄存器,并分配好生产者/消费者索引的存储。
- 配置Stream Table基地址(SMMU_STRTAB_BASE),选择线性表或二级表,按实际设备数量分配STE空间。
- 如果有多个安全状态域,还需要分别配置Secure和Non-Secure侧的寄存器视图。
- 设置SMMU全局配置寄存器(如SMMU_CR0),完成全局enable。
- 为具体设备填充STE和CD,把翻译模式、页表基地址、地址宽度、内存属性写完整。
- 向命令队列提交CFGI_STE命令,随后发SYNC命令,确保SMMU内部的缓存和实际的STE一致。
- 最后才让设备侧开启bus master,发起DMA。
这里我想强调一个很容易被忽略的点:第6步和第7步的顺序千万不能颠倒。如果设备已经处于bus master enable状态,而STE还没配好或者没有执行CFGI指令,设备一旦发DMA,SMMU就会用旧的甚至空的翻译上下文去处理,结果就是fault风暴,甚至直接影响总线上的其他设备。我踩过这个坑之后,习惯在固件里先把所有设备的STE预先建好并同步完,再统一放开设备DMA。
4.2 命令队列与事件队列:两个收发的“信箱”
SMMUv3的软件交互核心是两条队列:Command Queue(命令队列)和Event Queue(事件队列)。理解这两个队列的工作方式,是调试SMMU的基础。
Command Queue是软件写给SMMU的“指令信箱”。软件把命令描述符按格式写入队列内存,然后更新Producer Index;SMMU从Consumer Index位置读取命令并执行,完成后更新自己的Consumer Index。常见的命令类型有TLBI(TLB invalidate)、CFGI(配置缓存无效化)、SYNC(同步屏障)、PRI相关响应等。
Event Queue则相反,是SMMU写给软件的“报告信箱”。当SMMU遇到翻译错误、页表遍历异常、命令执行失败等情况时,会把一个Event记录写入Event Queue,并更新Producer Index;软件通过轮询或者中断发现新事件后,读取并更新Consumer Index。Event记录里包含错误类型、StreamID、SubstreamID、访问地址等信息,是定位问题最重要的线索。
写命令队列时有一个细节:队列缓冲区有界,满了之后如果继续写会丢命令。正确做法是写之前先判断剩余空间,不够时就等SMMU消费,或者触发一次SYNC命令迫使队列推进。很多初写SMMU驱动的朋友在压力测试时遇到诡异丢TLB的情况,一查往往是命令队列溢出后软件没做保护。SYNC命令在队列机制里举足轻重,它保证之前所有命令都已被SMMU执行完毕,相当于一个内存屏障,所有需要等待生效的操作后面都要跟一条SYNC。
4.3 DMA映射、Cache一致性与TLB维护的取舍
SMMU的页表属性直接决定了DMA访问内存时的Cache策略和一致性语义。同样是内存,Normal Cacheable和Device nGnRnE是完全不同的访问模型:Cacheable具备缓存和乱序合并的能力,性能高,但需要确保CPU和设备之间看到的数据一致;Device内存类型则严格保证访问次序和副作用,适合配置寄存器、doorbell这类场景。
Linux内核里dma_map_single、dma_alloc_coherent这些API之所以能屏蔽细节,是因为它们内部会统一处理一致性映射和Buffer属性。但自己在裸机环境配置SMMU时,就必须明确每一段DMA buffer到底应该是Cacheable还是Non-Cacheable。配置错的结果很有迷惑性:有时候系统跑起来看起来没问题,一旦数据量大、缓存未命中率上去,就会出现偶发性数据错乱,极难复现。
TLB维护策略则要讲究“合并”。每次修改IOVA映射后,SMMU里对应的TLB缓存都不会自动失效,软件必须显式发TLBI命令。如果驱动频繁做细粒度的iommu_map/unmap,每次都发TLBI+SYNC,性能会有明显损耗。建议做法是尽量批量映射成一个大区域,映射变更集中到少数几次TLBI;或者利用Linux内核的IOMMU flush queue机制,把TLB无效化延迟收集、批量执行。
5. 调试SMMU:故障排查链路与容易踩的坑
5.1 先从Event Queue读错误码,再动手查寄存器
遇到SMMU相关的问题,我的第一反应永远是先看Event Queue里有没有记录。SMMU的Event队列会把fault类型、出错的StreamID、SubstreamID、以及访问地址都写进去,这条信息比任何逻辑分析仪都直接。
整理一个SMMUv3常见事件类型对照表,方便大家抄作业:
| 事件类型 | 含义 | 典型处理思路 |
|---|---|---|
| F_TRANSLATION | IOVA在页表里没有对应映射 | 检查驱动是否漏了dma_map/iommu_map,检查IOVA是否算错 |
| F_PERMISSION | 访问权限不足,如对只读页做写操作 | 检查PTE的AP权限位和设备访问方向是否匹配 |
| F_ADDR_SIZE | IOVA或输入地址超过SMMU支持范围 | 检查TCR配置的地址宽度,和IOMMU domain的geometry是否一致 |
| F_ACCESS | 页表遍历时访问标志位需要软件介入 | 检查是否有AF管理机制,必要时让内核自动处理 |
| F_CD_FETCH | 获取CD描述符时发生异常 | 检查CD表地址、Entry有效位、内存属性是否正常 |
| F_WALK_EABT | 页表遍历过程中发生外部总线错误 | 检查页表内存本身是否被意外释放或改写 |
看到F_TRANSLATION,第一反应不要把锅甩给SMMU硬件。绝大多数情况是软件没建映射——要么驱动没调用dma_map接口,要么IOVA地址算错导致落到了空洞区域。看到F_PERMISSION才需要去查页表属性。
5.2 三个容易踩的坑:Bypass配置、StreamID错位、属性不一致
第一个坑是把SMMU配成全局Bypass。调试阶段有人嫌SMMU麻烦,直接让所有设备bypass,这样确实能快速运行,但代价是整个内存保护体系形同虚设,设备DMA又变成了一颗“流弹”。更隐蔽的问题是,一旦后续需要正式开启翻译,那些靠bypass才能跑通的驱动逻辑会在瞬间全部暴露问题。我的建议是:即使调试阶段,也至少保持abort模式,让未映射的DMA直接失败,然后根据Event Queue逐项补映射,这样整个系统的安全边界从来都是存在的。
第二个坑是StreamID和SubstreamID对不上号。StreamID并不是按设备挂载顺序从0开始连续编号的,而是由SoC的NoC/PCIe RC映射关系决定。很多SoC里,USB控制器的StreamID可能是一个分布很稀疏的值,而PCIe的BDF映射也有专门的公式。如果你按“第N个设备”的直觉去填充STE,等于给设备A配置了设备B的翻译规则,表现出的症状极其抽象:一个看起来完全正常的设备,DMA访问量一大就崩,或者在另一个设备上出现了完全不属于它的页表错误。务必从手册或者系统集成工具里确认每个master的StreamID分配表。
第三个坑是页表属性与设备行为不一致。前面提过Cacheable和Device memory的区别,实际中很多数据错乱来自这里:驱动把DMA buffer设成了Cacheable,但设备要求的是强序nGnRnE,结果设备读取到未刷新的缓存数据;或者反过来,设备本来可以享受Cache的吞吐,驱动却全配成了Device memory,性能断崖式下跌。调试这类问题,需要同时核对CPU侧的页表属性、SMMU侧的页表属性、以及设备侧对传输的ordering要求,三个维度对齐才能保证正确性和性能兼得。
5.3 我的排查顺序和方法论
最后整理一套我平时排查SMMU问题的顺序,非常适合作为团队调试规范,尤其对没有太多SMMU经验的人:
- 先确认SMMU全局是否使能,再确认设备对应的STE配置是translate、bypass还是abort。很多时候问题只是设备挂在了错误的SMMU instance上。
- 读Event Queue。有Event就按错误码分类处理;没有Event但系统卡死,说明问题很可能不在SMMU翻译阶段,而是出在总线地址映射、中断路由或内存一致性。
- 检查StreamID/SubstreamID是否与硬件集成表一致。可以直接从寄存器里读回设备实际上报的StreamID,结合SMIDR之类的寄存器验证。
- 如果是启动早期就死机,优先怀疑命令队列和事件队列本身有没有初始化对,队列的内存地址是否合法、是否可被SMMU访问。
- 如果以上都正常但DMA数据还是不对,把SMMU页表属性、CPU页表属性、Cache维护指令、设备侧缓存行为放到一张表里逐项对齐。
- 需要看到硬件级信息时,才动总线trace或者逻辑分析仪,对照AXI通道上的地址和属性。
这个顺序的好处是,每一步都能快速收敛范围。大多数问题在1到3步就能定位,真正需要上总线trace的场景其实很少。还有一个很实用的经验:调试期间把Event Queue对应的中断打开,并打印每次fault的详细信息。即使你觉得系统一切正常,也先开着,因为很多SMMU fault出现时系统并不会马上崩溃,而是在几秒甚至几分钟后才因内存被破坏而挂掉,那时候再回溯就非常困难了。
我个人的一个习惯是,把SMMU看作一个独立的“设备”来调试,而不是CPU内存子系统的附属品。它有自己的命令队列、事件队列、页表遍历逻辑和缓存状态,所有交互都要按设备模型来理解。你越早习惯同时从CPU视角和设备视角看同一块内存,就越容易在复杂SoC的系统架构里快速定位问题。这个思路,比硬记一堆寄存器地址要管用得多。