☰
RISC-V IOMMU 在 Linux 下的设备直通与地址重映射实践
2026/10/1 14:02:33 网站建设 项目流程

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 换页表不用通知 Host,Host 做内存迁移也不用改 Guest 的页表,各管各的,职责清晰。

在非虚拟化场景下,通常只启用 Second-stage,或者干脆把 First-stage 配成恒等映射。而在设备直通场景下,两级都要开:Guest 里的驱动用 GVA 发 DMA,IOMMU 先按 Guest 页表翻成 GPA,再按 Host 页表翻成 HPA,设备最终访问到正确的物理内存。

这里有个容易搞混的点:设备上下文和进程上下文是两回事。设备上下文是"每个设备一份",描述这个设备用哪套 Second-stage 页表、支持什么地址宽度、有哪些能力位;进程上下文是"每个地址空间一份",描述 First-stage 页表在哪。一个设备可以关联多个进程上下文(比如支持 PASID 的场景),但同一时刻生效的是一套。

2.2 设备目录与上下文定位:硬件怎么找到你的页表

IOMMU 收到一个 DMA 请求,怎么知道该用哪套页表?靠的是请求里携带的Device ID(通常是 PCIe 的 BDF:Bus/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 对应的表项根本不用建。

设备上下文里几个关键字段值得单独拎出来说。tc(Translation Control)字段控制这级翻译开不开、页表是几级;iosatp(IO Second-stage Address Translation and Protection)存 Second-stage 页表基址和模式;fsc(First-stage Context)指向进程目录表;msi相关字段控制 MSI 中断地址翻译。这些字段在 Linux 驱动里都有对应的宏定义,调试的时候直接 dump 设备上下文内存,对着手册一位一位看,比瞎猜快得多。

提示:调试 IOMMU 问题时,先把 Device Directory Table 的基址从寄存器里读出来,然后手动解析到叶子表项,确认设备上下文的 iosatp 指向的页表就是你期望的那张。很多"翻译不生效"的问题,最后都发现是设备上下文压根没配对。

2.3 IOTLB 与缓存一致性:性能与正确性的平衡

每次 DMA 都走一遍多级页表查询,开销太大,所以 IOMMU 内部有缓存,叫IOTLB(I/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_SUPPORT=y CONFIG_IOMMU_API=y # RISC-V IOMMU 驱动 CONFIG_RISCV_IOMMU=y # VFIO 相关,做设备直通必备 CONFIG_VFIO=y CONFIG_VFIO_PCI=y CONFIG_VFIO_IOMMU_TYPE1=y # 如果做虚拟化直通 CONFIG_VFIO_PCI_VGA=y

设备树(Device Tree)里要正确描述 IOMMU 节点,包括寄存器基址、中断号、以及各设备到 IOMMU 的关联(通过iommus属性)。这一步经常被忽略,结果就是 IOMMU 驱动加载了,但设备根本没被纳入 IOMMU 管理。检查方法:ls /sys/kernel/iommu_groups/,如果设备直通相关的 group 是空的,多半是设备树没配对。

启动参数也建议加上iommu=pt(passthrough 模式,对没显式绑定的设备走恒等映射,减少性能损失)和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,host=01:00.0,虚拟机启动后lspci就能看到设备了。

这里的关键是IOMMU group 的隔离性。RISC-V IOMMU 的 group 划分依赖硬件拓扑,PCIe 交换机下游的设备如果没开 ACS(Access Control Services),可能被划到同一个 group。我遇到过一块双口网卡两个口在同一 group 的情况,想只直通一个口做不到,最后只能两个口一起给虚拟机。

3.3 地址重映射的实操与参数计算

地址重映射是 IOMMU 最实用的能力之一。举个真实场景:某设备要求 DMA 缓冲区物理地址连续且 4KB 对齐,但 Host 内存碎片化严重,kmalloc拿不到连续大页。这时候可以用 IOMMU 把多个分散的物理页,映射成设备看到的一段连续地址。

在 Linux 里,这套机制通过 DMA API 和 IOMMU 驱动配合实现。设备驱动调用dma_alloc_coherent时,如果设备挂在 IOMMU 下,内核会分配物理页,然后通过 IOMMU 建立映射,返回给驱动的是"设备可见地址"(IOVA),而不是物理地址。设备拿 IOVA 去 DMA,IOMMU 负责翻译回真实物理页。

参数计算上,重点是 IOVA 空间的规划。假设我们要给一个设备预留 256MB 的 IOVA 窗口,页大小 4KB,那么需要 65536 个页表项。如果页表是三级(Sv39 风格),每级 512 项,那么:

  • 叶子级覆盖 512 × 4KB = 2MB
  • 中间级覆盖 512 × 2MB = 1GB
  • 根级覆盖 512 × 1GB = 512GB

256MB 的窗口只需要根级 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分配器有iova=on之类的参数可以调,具体看内核版本。

减少不必要的失效操作。全局失效代价最大,能按设备失效就别全局。Linux 驱动里iommu_unmap会尽量做范围失效,但如果映射关系复杂,可能退化成全局失效。设计映射策略时尽量让同一设备的映射集中,减少失效范围。

passthrough 模式。对不需要隔离的设备,用iommu=pt让它走恒等映射,省掉翻译开销。但要注意,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,host=01:00.0,x-no-mmap=on之类的参数调整映射方式。

三是中断路由问题。前面说的 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 的生态还在快速变化,遇到规范里没写清楚的行为,多看看内核邮件列表和驱动源码,比翻手册管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询