
1. 为什么要在 RISC-V 上折腾 IOMMU第一次在 RISC-V 平台上跑设备直通的时候我踩了一个很典型的坑把一块 PCIe 网卡通过 VFIO 交给虚拟机虚拟机里lspci能看到设备驱动也加载了但一发包就整机卡死Host 侧 dmesg 刷屏报 DMA 相关的错误。当时排查了大半天最后定位到根因——设备用的是物理地址做 DMA而虚拟机看到的是 Guest 物理地址两者对不上设备直接把数据写到了 Host 的任意内存区域。这个问题在 x86 上有成熟的 VT-d/AMD-Vi 兜底在 ARM 上有 SMMU而在 RISC-V 上负责这件事的硬件单元就叫IOMMU。这篇内容想聊的就是 RISC-V IOMMU 在 Linux 下的完整实践路径从最基础的设备直通需求讲起一路走到地址重映射的底层机制。核心关键词会反复出现RISC-V、IOMMU、Linux、设备直通、地址重映射。适合谁看如果你正在做 RISC-V 平台的虚拟化、驱动开发或者单纯想搞明白 IOMMU 到底在 DMA 路径上做了什么手脚那这篇应该能帮你少走点弯路。我会尽量把硬件手册里那些干巴巴的寄存器描述翻译成为什么这么设计和实际怎么调的大白话。先说清楚 IOMMU 到底解决什么问题。CPU 访问内存要经过 MMU把虚拟地址翻译成物理地址而外设做 DMA 的时候传统上是直接拿物理地址去访问内存的中间没有任何翻译和隔离。IOMMU 就是给外设也配一个MMU让设备发出的 DMA 地址先经过一层翻译再落到真实物理内存上。这一层翻译带来三个直接好处一是隔离设备只能访问被授权的内存区域越界就报错而不是静默写坏数据二是直通虚拟机里的设备可以用 Guest 物理地址由 IOMMU 翻译到 Host 物理地址实现接近原生的性能三是地址重映射可以把分散的物理页拼成连续的设备可见地址空间解决设备对地址对齐和连续性的苛刻要求。RISC-V IOMMU 的规范这几年才逐步稳定下来Linux 侧的支持也在持续演进。和 x86 那种寄存器一大堆、文档厚得能砸人的风格不同RISC-V IOMMU 的设计相对克制核心概念就那么几个设备上下文Device Context、进程上下文Process Context、两级地址翻译First-stage / Second-stage、以及 IOTLB 缓存。把这几个概念理顺剩下的就是配置寄存器和调试的问题了。2. RISC-V IOMMU 的核心机制拆解2.1 两级翻译GPA 到 HPA 的关键一跃理解 RISC-V IOMMU最关键的是搞懂它的两级地址翻译模型。这两级分别叫First-stage translation和Second-stage translation名字听着抽象其实对应的是两种不同的使用场景。First-stage 翻译输入是设备的虚拟地址VA在虚拟化场景下就是 Guest 虚拟地址 GVA输出是 Guest 物理地址GPA。这一级的页表格式和 RISC-V 的 Sv39/Sv48/Sv57 基本一致由进程上下文Process Context来指定根页表。Second-stage 翻译输入是 GPA输出是 Host 物理地址HPA由设备上下文Device Context指定根页表。两级串起来就实现了 GVA → GPA → HPA 的完整链路。为什么非要分两级因为这两级的归属不一样。First-stage 属于 Guest 自己管Guest 操作系统维护自己的页表决定虚拟地址怎么映射到它以为的物理地址Second-stage 属于 Host 管Hypervisor 决定这个 Guest 的物理地址实际落在 Host 的哪块内存上。两级分离之后Guest 换页表不用通知 HostHost 做内存迁移也不用改 Guest 的页表各管各的职责清晰。在非虚拟化场景下通常只启用 Second-stage或者干脆把 First-stage 配成恒等映射。而在设备直通场景下两级都要开Guest 里的驱动用 GVA 发 DMAIOMMU 先按 Guest 页表翻成 GPA再按 Host 页表翻成 HPA设备最终访问到正确的物理内存。这里有个容易搞混的点设备上下文和进程上下文是两回事。设备上下文是每个设备一份描述这个设备用哪套 Second-stage 页表、支持什么地址宽度、有哪些能力位进程上下文是每个地址空间一份描述 First-stage 页表在哪。一个设备可以关联多个进程上下文比如支持 PASID 的场景但同一时刻生效的是一套。2.2 设备目录与上下文定位硬件怎么找到你的页表IOMMU 收到一个 DMA 请求怎么知道该用哪套页表靠的是请求里携带的Device ID通常是 PCIe 的 BDFBus/Device/Function和可选的PASID。硬件拿 Device ID 去查一张叫Device Directory Table的表找到对应的设备上下文再从设备上下文里拿到 Second-stage 页表基址如果开了 PASID再顺着设备上下文里的指针去查 Process Directory Table找到进程上下文拿到 First-stage 页表基址。Device Directory Table 的层级结构是可配置的常见的是三级DDT 根表 → 中间表 → 叶子表叶子表项就是设备上下文。为什么要分级因为 Device ID 空间可能很大比如 16 位 BDF 就是 64K 个设备用一张平表太浪费内存分级之后按需分配没接设备的 ID 对应的表项根本不用建。设备上下文里几个关键字段值得单独拎出来说。tcTranslation Control字段控制这级翻译开不开、页表是几级iosatpIO Second-stage Address Translation and Protection存 Second-stage 页表基址和模式fscFirst-stage Context指向进程目录表msi相关字段控制 MSI 中断地址翻译。这些字段在 Linux 驱动里都有对应的宏定义调试的时候直接 dump 设备上下文内存对着手册一位一位看比瞎猜快得多。提示调试 IOMMU 问题时先把 Device Directory Table 的基址从寄存器里读出来然后手动解析到叶子表项确认设备上下文的 iosatp 指向的页表就是你期望的那张。很多翻译不生效的问题最后都发现是设备上下文压根没配对。2.3 IOTLB 与缓存一致性性能与正确性的平衡每次 DMA 都走一遍多级页表查询开销太大所以 IOMMU 内部有缓存叫IOTLBI/O Translation Lookaside Buffer作用类似 CPU 的 TLB。IOTLB 缓存的是设备地址 → 物理地址的翻译结果命中就直接用不命中才去查页表。缓存带来一个经典问题页表改了缓存里的旧翻译怎么办这就是 invalidation失效机制的用武之地。RISC-V IOMMU 规范定义了几类失效操作按设备失效只清某个设备的缓存、按地址范围失效、全局失效。Linux 驱动在修改页表映射之后必须调用对应的失效接口否则设备可能还在用旧的翻译结果轻则数据错乱重则内存踩踏。失效操作是异步的硬件处理完会通过一个叫iotinval的完成机制通知软件。这里有个实操细节失效命令是写到一个命令队列Command Queue里的软件写完要更新队列尾指针寄存器硬件从队列头消费。如果队列满了软件得等硬件消费完再写。我在早期调试时遇到过命令队列溢出导致失效丢失的情况表现就是改了页表但设备行为没变查了半天才发现是队列没处理好。缓存一致性还有一层IOMMU 的页表遍历PTW读的是内存里的页表如果 CPU 刚改了页表但还在 CPU cache 里没写回内存IOMMU 可能读到旧值。所以改页表之后、发失效命令之前通常需要做一次内存屏障或者 cache flush确保页表更新对 IOMMU 可见。这个顺序不能乱先写页表 → 屏障 → 发失效 → 等失效完成。3. Linux 下的实操从设备直通到地址重映射3.1 环境准备与内核配置动手之前先把环境理清楚。我用的是一块支持 RISC-V IOMMU 的开发板内核版本 6.6 以上IOMMU 驱动在 6.2 之后才逐步完善太老的版本坑多。内核配置里几个必须打开的选项# IOMMU 核心支持 CONFIG_IOMMU_SUPPORTy CONFIG_IOMMU_APIy # RISC-V IOMMU 驱动 CONFIG_RISCV_IOMMUy # VFIO 相关做设备直通必备 CONFIG_VFIOy CONFIG_VFIO_PCIy CONFIG_VFIO_IOMMU_TYPE1y # 如果做虚拟化直通 CONFIG_VFIO_PCI_VGAy设备树Device Tree里要正确描述 IOMMU 节点包括寄存器基址、中断号、以及各设备到 IOMMU 的关联通过iommus属性。这一步经常被忽略结果就是 IOMMU 驱动加载了但设备根本没被纳入 IOMMU 管理。检查方法ls /sys/kernel/iommu_groups/如果设备直通相关的 group 是空的多半是设备树没配对。启动参数也建议加上iommuptpassthrough 模式对没显式绑定的设备走恒等映射减少性能损失和iommu_debug打开调试日志。生产环境可以去掉 debug但调试阶段这两个参数能省你很多事。注意不同厂商的 RISC-V IOMMU 实现可能在寄存器偏移和扩展能力上有差异务必对照具体芯片的手册别直接照搬通用规范里的偏移量。3.2 设备直通的完整配置流程设备直通的核心思路是把目标设备从 Host 的常规驱动上解绑交给 VFIO 接管再把设备对应的 IOMMU group 透传给虚拟机。步骤如下。第一步找到目标设备和它的 IOMMU group# 查看设备的 BDF 和当前驱动 lspci -nn | grep -i ethernet # 假设是 0000:01:00.0 # 查看它属于哪个 IOMMU group ls -l /sys/bus/pci/devices/0000:01:00.0/iommu_group第二步解绑 Host 驱动并绑定到 vfio-pci# 解绑原驱动 echo 0000:01:00.0 /sys/bus/pci/devices/0000:01:00.0/driver/unbind # 绑定 vfio-pci echo vfio-pci /sys/bus/pci/devices/0000:01:00.0/driver_override echo 0000:01:00.0 /sys/bus/pci/drivers/vfio-pci/bind第三步确认 IOMMU group 的隔离性。一个 group 里的所有设备必须一起透传因为 IOMMU 的隔离粒度是 group 而不是单个设备。如果 group 里混进了别的关键设备比如 Host 的存储控制器那这个设备就没法安全直通得考虑换插槽或者用 ACS 补丁拆分 group。第四步在 QEMU 启动参数里加上-device vfio-pci,host01:00.0虚拟机启动后lspci就能看到设备了。这里的关键是IOMMU group 的隔离性。RISC-V IOMMU 的 group 划分依赖硬件拓扑PCIe 交换机下游的设备如果没开 ACSAccess Control Services可能被划到同一个 group。我遇到过一块双口网卡两个口在同一 group 的情况想只直通一个口做不到最后只能两个口一起给虚拟机。3.3 地址重映射的实操与参数计算地址重映射是 IOMMU 最实用的能力之一。举个真实场景某设备要求 DMA 缓冲区物理地址连续且 4KB 对齐但 Host 内存碎片化严重kmalloc拿不到连续大页。这时候可以用 IOMMU 把多个分散的物理页映射成设备看到的一段连续地址。在 Linux 里这套机制通过 DMA API 和 IOMMU 驱动配合实现。设备驱动调用dma_alloc_coherent时如果设备挂在 IOMMU 下内核会分配物理页然后通过 IOMMU 建立映射返回给驱动的是设备可见地址IOVA而不是物理地址。设备拿 IOVA 去 DMAIOMMU 负责翻译回真实物理页。参数计算上重点是 IOVA 空间的规划。假设我们要给一个设备预留 256MB 的 IOVA 窗口页大小 4KB那么需要 65536 个页表项。如果页表是三级Sv39 风格每级 512 项那么叶子级覆盖 512 × 4KB 2MB中间级覆盖 512 × 2MB 1GB根级覆盖 512 × 1GB 512GB256MB 的窗口只需要根级 1 项、中间级 1 项、叶子级 128 项页表内存开销很小。但如果 IOVA 空间碎片化页表项会散落在多级内存开销和查询开销都会上升。所以实践中尽量让 IOVA 分配器Linux 里是iova子系统分配连续的 IOVA 段。// 简化的映射建立流程内核态 struct iommu_domain *domain iommu_domain_alloc(pci_bus_type); iommu_attach_device(domain, dev); // 建立映射iova - paddr长度 size权限读写 iommu_map(domain, iova, paddr, size, IOMMU_READ | IOMMU_WRITE); // 用完解除 iommu_unmap(domain, iova, size);iommu_map内部会走页表建立 IOTLB 失效的完整流程。注意size必须是页大小对齐的非对齐会返回错误。我见过有人传了个非 4KB 对齐的 size结果映射只建立了一部分设备访问后半段直接报错。3.4 中断重映射MSI 地址翻译的坑设备直通里还有个容易被忽略的环节MSI 中断的地址翻译。传统 MSI 是设备往一个特定物理地址写数据触发中断这个地址在虚拟化场景下也需要翻译否则 Guest 里的设备会把中断写到 Host 的物理地址导致中断投递错乱。RISC-V IOMMU 的设备上下文里有 MSI 相关配置字段控制 MSI 地址翻译的开关和页表。开启之后设备发出的 MSI 写请求也会经过 IOMMU 翻译把 Guest 的 MSI 地址翻到 Host 实际的中断控制器地址。实操中这个环节的坑在于MSI 翻译和 DMA 翻译用的是不同的配置路径。DMA 走 iosatp 指向的页表MSI 走设备上下文里的 msi 字段。如果只配了 DMA 没配 MSI设备直通后 DMA 正常但中断收不到表现就是网卡能收包但协议栈没反应。排查时先看/proc/interrupts里有没有对应设备的中断计数没有的话基本就是 MSI 翻译没配好。4. 常见问题与排查技巧实录4.1 DMA 报错与翻译失败排查IOMMU 相关的问题dmesg 里通常会有明显线索。常见的几类报错和处理思路整理成表报错关键词可能原因排查方向DMAR: Fault/IOMMU fault设备访问了未映射地址检查设备上下文是否配对页表是否覆盖该地址invalid device contextDevice ID 查不到上下文确认设备树 iommus 属性、DDT 表项是否建立IOTLB invalidation timeout失效命令未完成检查命令队列是否溢出、中断是否正常MSI translation faultMSI 地址翻译未配置检查设备上下文 msi 字段和中断控制器地址DMA read/write outside mapped region映射范围不足核对 iommu_map 的 size 和实际访问范围排查的第一步永远是确认设备是否真的在 IOMMU 管理下。方法cat /sys/kernel/debug/iommu/devices/看设备列表或者dmesg | grep -i iommu看驱动初始化日志。如果设备压根没被 IOMMU 接管那所有翻译相关的配置都是白搭。第二步是抓 fault 的详细信息。RISC-V IOMMU 有 fault 记录队列Fault Queue硬件把出错的请求信息设备 ID、地址、错误类型写进去软件读出来解析。Linux 驱动会把这些信息打到 dmesg但默认可能不够详细可以调高日志级别或者直接读 fault 队列寄存器。实操心得遇到间歇性的 DMA 错误先怀疑 IOTLB 失效没做干净。尤其是频繁改映射的场景比如动态内存分配失效命令的时序问题会导致偶发错误很难复现但危害大。建议在改映射的代码路径上加日志确认每次 map/unmap 都配了对应的失效。4.2 性能调优的几个关键点IOMMU 开了之后DMA 性能会有一定下降因为多了一层翻译。下降幅度取决于 IOTLB 命中率。几个调优方向增大 IOTLB 或者提高命中率。这个主要靠硬件软件能做的是让 IOVA 分配尽量连续减少页表项数量提高缓存效率。Linux 的iova分配器有iovaon之类的参数可以调具体看内核版本。减少不必要的失效操作。全局失效代价最大能按设备失效就别全局。Linux 驱动里iommu_unmap会尽量做范围失效但如果映射关系复杂可能退化成全局失效。设计映射策略时尽量让同一设备的映射集中减少失效范围。passthrough 模式。对不需要隔离的设备用iommupt让它走恒等映射省掉翻译开销。但要注意passthrough 意味着没有隔离保护只适合可信设备。实测数据上在开启 IOMMU 的情况下大块连续 DMA比如 1MB 以上的性能损失通常在 5% 以内因为 IOTLB 能覆盖而小块随机 DMA 损失可能到 15%~20%因为 IOTLB 频繁 miss。所以如果应用对小块 DMA 性能敏感得权衡隔离性和性能。4.3 设备直通后虚拟机启动失败的几种情况设备直通配置完虚拟机起不来或者起来后设备不可用常见原因有这么几类。一是IOMMU group 隔离问题。前面提过group 里混了别的设备透传时要么全给要么全不给。检查ls /sys/kernel/iommu_groups/*/devices/看目标设备所在 group 是否干净。二是BAR 空间映射冲突。设备的 MMIO BAR 需要映射到 Guest 的地址空间如果 Guest 的地址空间不够或者和已有设备冲突设备初始化会失败。QEMU 里可以用-device vfio-pci,host01:00.0,x-no-mmapon之类的参数调整映射方式。三是中断路由问题。前面说的 MSI 翻译没配好或者中断控制器如 PLIC/APLIC的配置和 IOMMU 不匹配都会导致中断收不到。检查 Guest 里/proc/interrupts和 Host 里对应设备的中断计数。四是固件/驱动版本不匹配。RISC-V IOMMU 规范还在演进老固件配新内核驱动可能行为不一致。尽量用配套的固件和内核版本别混搭。4.4 调试工具与手段汇总最后整理一下我常用的调试手段按从粗到细的顺序dmesg | grep -i iommu看驱动初始化和 fault 日志第一手信息。/sys/kernel/debug/iommu/内核 IOMMU 调试接口能看设备列表、domain 信息、映射关系。lspci -vvv看设备的 IOMMU 相关能力位确认设备是否声明了 ATS/PASID 等特性。手动解析 Device Directory Table从寄存器读基址按手册格式解析确认设备上下文内容符合预期。抓 fault 队列硬件记录的出错请求最权威的现场证据。perf或ftrace跟踪 IOMMU 驱动的 map/unmap 调用看时序和频率。我个人在实际操作中的体会是IOMMU 的问题八成出在配置没对齐上——设备树、内核配置、QEMU 参数、固件版本任何一处不一致都会导致行为异常。所以排查时别急着怀疑硬件先把这几处的配置逐项核对一遍往往问题就浮出来了。另外RISC-V IOMMU 的生态还在快速变化遇到规范里没写清楚的行为多看看内核邮件列表和驱动源码比翻手册管用。