news 2026/10/23 3:52:37

Linux 6.6 IOMMU DMA重映射:IOVA、IOTLB与映射开销排查

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Linux 6.6 IOMMU DMA重映射:IOVA、IOTLB与映射开销排查

深夜里最让人后背发凉的故障,往往不是内核 panic,而是系统还活着、数据已经悄悄错了。一块设备通过 DMA 往内存里写数据,地址算偏了一点,越过驱动给它的缓冲区,落在隔壁某个文件系统的元数据页上,几分钟后整个存储层开始报校验错——肇事者和受害者之间隔着好几层软件栈,定位成本极高。IOMMU 的 DMA 重映射就是拦在 DMA 通路上的那道墙:设备发出的地址不再直接等于物理地址,而是一个由内核分配、由硬件翻译的虚拟地址,也就是IOVA。这篇内容围绕 Linux 6.6 的 IOMMU 子系统展开,把 DMA 重映射这条链路从硬件翻译模型、内核数据结构、dma_map_single的实际调用路径,一直讲到映射开销和现场排查手法。适合已经能看懂基本驱动代码、想搞清楚“我的 DMA 地址到底是怎么来的”的读者,也适合正在处理 IOMMU 相关启动告警、映射泄漏、性能抖动的人参考。

1. 设备越界的那次事故:DMA 重映射到底挡了什么

1.1 没有重映射时,设备拿到的是实打实的物理地址

在没有 IOMMU 参与的路径下,dma_map_single()这类 API 做的事情非常朴素:把 CPU 侧虚拟地址换算成物理地址,返回给驱动,驱动把这段物理地址写进设备的 DMA 描述符,设备发起总线事务,内存控制器按这个物理地址直接读写。中间没有任何翻译环节,也没有任何权限检查。设备想写哪里就写哪里,唯一的约束是驱动代码里那几个地址计算有没有写错。

这也是为什么早期很多驱动在dma_map_single()之后会把返回值当成一个“可以随便加减的整数”来用。它确实就是物理地址,加法语义和virt_to_phys()的结果完全一致。代码在实验环境里跑得好好的,换一块内存布局不同的机器就崩,根因往往就在这儿:驱动默认了某段区域的物理地址连续性,而这个假设在目标机器上根本不成立。

swiotlb是这条路径上唯一的例外。当设备受限于地址位宽、无法访问目标物理地址(比如 32 位设备要访问 4G 以上的内存),或者内存位于设备不可达的区域时,内核会把数据先拷到一块“反弹缓冲区”,再把那块缓冲区的物理地址交给设备。代价是一次完整的内存拷贝,而且这块缓冲区的大小是启动时静态预留的,用满了就只能报错。所以传统方案的本质是:能直连就直连,不能直连就拷贝,两者之间没有第三种选择。

1.2 打开重映射之后,设备眼里只剩 IOVA

一旦设备被纳入某个 IOMMU 域的管辖,dma_map_single()返回的就不再是物理地址,而是一段 IOVA。设备发出这个地址,硬件翻译单元查表把它换成真正的物理地址,再落到内存控制器上。设备自己完全不知道物理内存在哪,也不需要知道。

这个变化带来的第一个直接后果是:驱动的地址计算错误不再能无声无息地破坏内存。设备访问一个没有映射过的 IOVA,翻译表里查不到对应项,硬件会拒绝这次访问并产生一条 fault 记录。系统可能还是会报错,但错误被限制在“设备访问被拒绝”这个范围内,而不是变成一堆莫名其妙的文件系统损坏。从“静默数据污染”降级为“明确的访问拒绝”,这个差别在排查成本上是指数级的。

第二个后果是地址空间的收敛。一段 IOVA 空间是由软件分配的,它的位宽和设备能力可以解耦。一个只支持 32 位地址的设备,可以被分配在 4G 以下的 IOVA 区间里,而它实际映射到的物理页可以位于内存的任何位置。swiotlb那套反弹缓冲区在这种场景下就不需要了——不是因为它不够快,而是因为它解决的问题被翻译机制从根本上绕开了。这也是消费级平台上 IOMMU 最早被大规模启用的主要动机之一:不是为了安全,是为了省掉拷贝。

1.3 不只是隔离:scatter-gather 列表被真正“缝合”了

第三个收益经常被低估。设备做大批量传输时,驱动的缓冲区往往在物理上并不连续——一页一页散落在内存各处。没有重映射的时候,驱动要么把数据先拷进一块连续缓冲区,要么把散落的物理地址一段段塞进设备的 scatter-gather 描述符表,让设备自己去跳。后者的代价是描述符表可能很长,某些硬件对表项数量有硬性上限,超了就得拆包重发。

有了 IOMMU,这堆散页可以在 IOVA 空间里被映射成一段连续的地址。设备只需要看到一个起始地址加一个长度,描述符表项数量骤降,硬件侧的状态机也简单得多。内核里iommu_dma_map_sg()干的就是这件事:遍历 scatter-gather 列表,把每一段的物理地址映射到一段连续的 IOVA 上,最后把整段合并成一个或者少数几个 DMA 段。这个“缝合”动作对上层驱动是透明的,驱动拿到的sg_dma_address()看起来就是一段连续区间。

注意:iommu_dma_map_sg()的合并是有前提的,要求相邻段的 IOVA 连续,而这取决于 IOVA 分配器有没有拿到连续区间。真实场景下不是每次都能完美合并,后面第 5 章会展开讲。

2. IOVA 到物理地址的翻译链路解剖

2.1 硬件自己的页表:和 CPU 页表结构相似,但完全是另一套

DMA 重映射硬件维护的翻译表和 CPU 的页表在结构上高度相似——多级索引、页表项里带权限位、支持不同粒度的页——但两者是完全独立的两套数据结构,不共享任何页表页。CPU 的页表根由控制寄存器指向,DMA 重映射的页表根由硬件单元自己的寄存器指向,据此再走到设备对应的上下文,最后才进入多级页表。

以 x86 平台常见的实现为例,结构大致是三层:根表项指向一张上下文表,上下文表项里记录了该设备对应的页表根基址和地址宽度,再往下才是常规的多级页表。设备发起访问时,硬件用总线上的设备标识(来源 ID)定位上下文,用 IOVA 的高位段逐级索引页表,取到物理页基址后拼上页内偏移,得到最终物理地址。整个过程对设备完全透明,设备侧看到的就是一次普通的读写。

aarch64 平台的路径不同但思路一致:用流表(stream table)定位到设备对应的配置项,配置项里再指向翻译页表。区别在于分段方式、支持的页大小集合、以及权限位的编码细节,这些差异全部被内核的iommu_ops抽象掉了。写驱动的人不需要关心底层是哪一套,只需要通过统一接口做映射。

这套设计的直接推论是:页表页本身也是内核内存,也需要被分配和释放。一次iommu_map()如果覆盖的区间跨越了尚未建立页表项的范围,内核需要先从 slab 或伙伴系统拿到页表页,初始化清零,再挂到上一级表项里。小粒度、碎片化的映射会持续制造这种分配压力,这也是后面讲性能时绕不开的一个成本项。

2.2 IOTLB:翻译结果的缓存,也是性能与正确性的交界线

页表在内存里,每次 DMA 都走一遍多级查表会慢得没法用,所以硬件侧必然有一层翻译缓存,叫IOTLB。它缓存的是“IOVA 段 → 物理页”的映射结果。命中的时候,翻译几乎是零开销;未命中的时候,硬件要自己去内存里走表,这个延迟对高速设备来说是灾难性的。

问题在于,软件改了页表之后,IOTLB 里的旧条目不会自动失效。如果内核解除了某个页的映射,但 IOTLB 里还留着那条缓存,设备依然可以按旧翻译访问原来那块物理内存——这是实打实的安全漏洞,也是无数“为什么 unmap 之后设备还能写进去”类疑惑的根源。所以每一次iommu_unmap()之后都必须做失效操作,这个操作的粒度直接决定了 DMA 映射的整体性能。

内核为此提供了两套语义。严格模式下,每次iommu_unmap()之后立刻刷 IOTLB,保证映射变更短时间内生效,代价是每次解映射都触发一次硬件失效,失效指令本身有固定延迟,而且会清掉一票本来有用的缓存条目,后续访问全部退化成查表。非严格模式下,失效操作被攒起来,等累积到一定规模或者显式调用iommu_iotlb_sync()时才一次性下发,中间那段时间里旧映射可能仍然有效。

/* 严格模式的近似语义:unmap 后立即同步 */ iommu_unmap(domain, iova, size); iommu_iotlb_sync(domain, &iotlb_gather); /* 非严格模式下,iotlb_gather 会把区间攒起来, * 由定时器或阈值触发批量下发 */

映射方向上也有类似机制。iommu_map()建立新页表项之后,理论上也要让 IOTLB 感知,但新增映射不存在“旧条目可用”的安全问题,所以内核提供了iommu_iotlb_sync_map(),允许把一批 map 操作的失效合并成一次。批量注册大块 DMA 缓冲区的场景下,这个优化能省掉相当可观的开销。

2.3 翻译失败之后:fault 记录与上报路径

设备访问了一个没有映射的 IOVA,或者访问权限不匹配(比如映射时只给了读权限,设备却发起了写),硬件会拒绝这次事务,并把故障信息写进一组记录寄存器:出错的设备标识、出错的地址、错误类型。随后触发中断,把这件事告诉软件。

内核的 fault 处理路径经过几轮演进,在 6.6 里已经比较完整:底层的硬件驱动收到中断后解析记录,转成统一的故障描述,交给iommu_report_device_fault()。如果设备挂在一个支持按需分页的域上,这个故障会被投递给注册了处理器的驱动;如果没有处理器,就打印一条告警,把设备标识和地址打出来。

现场看到这类告警时,判断逻辑其实很简单:绝大多数情况下这不是 IOMMU 的 bug,而是驱动 bug。要么是映射的长度不够,设备访问越界了;要么是映射建立的时机不对,设备在 map 之前就开始传输了;要么是映射被提前解除,设备还在用旧的 IOVA。真正的硬件问题极少见,通常表现为某块地址区间无论怎么映射都报错,那种情况才需要怀疑页表项格式或者硬件配置。

3. dma_map_single 在 6.6 内核里的真实调用链

3.1 谁把 IOMMU 版的 dma_map_ops 装了上去

6.6 内核里,struct device上直接挂着dma_ops指针,dma_map_single()展开之后就是dev->dma_ops->map_page(...)。关键在于这个指针是什么时候、被谁改写的。

时间点在设备探测阶段。总线代码在设备添加到系统之后,会调用iommu_probe_device(),这一步完成设备与 IOMMU 硬件的关联、找到或创建对应的iommu_group、并决定这个设备应该挂到哪种默认域上。紧接着总线会调用iommu_setup_dma_ops(),这个函数做的事很关键:如果设备确实被纳入了某个支持 DMA 的域,就把dev->dma_ops换成iommu_dma_map_ops,同时初始化该域的 IOVA 分配器。

这一步的失败是静默的。如果设备没有被正确关联到 IOMMU——比如固件描述里缺少设备与 IOMMU 的关联信息——iommu_setup_dma_ops()会直接返回,dev->dma_ops保持原样,设备继续走直连路径,一切看起来都正常,只是重映射根本没生效。这就是为什么有些机器上明明 IOMMU 是开着的,某些设备却完全没有被隔离保护。

iommu_dma_init_domain()在初始化 IOVA 分配器时需要知道这个域的地址空间范围。这个信息来自域的 geometry 字段,由硬件驱动在分配域的时候填好。范围的上界还要受设备自身 DMA 掩码的约束——一个只能寻址 32 位的设备,它的 IOVA 分配上限就是 4G。这两个约束取交集,才是最终可用的分配区间。

3.2__iommu_dma_map里那几行看起来不起眼的对齐处理

真正干活的是__iommu_dma_map()。它的入参是设备、物理地址、长度、保护标志和 DMA 掩码,返回一个可以直接交给设备的 IOVA。代码不长,但有几处细节值得逐行看。

第一处是iova_offset()。某些硬件对 DMA 地址有对齐要求,物理地址的低几位不能随便用。这个函数算出物理地址相对于 IOVA 粒度需要偏移多少,后面的处理都基于这个偏移量。第二处是长度的对齐:iova_align(iovad, size + iova_off),把“偏移量 + 原始长度”向上取整到 IOVA 粒度。这两步合起来的效果是:内核可能映射了比请求稍大一点的范围,但保证映射区间在 IOVA 空间里是对齐的。

第三处是iommu_dma_alloc_iova(),从该域的 IOVA 分配器里要一段空闲区间。注意这里传的掩码是dma_mask,意思是分配结果必须落在这个设备能寻址的范围内。分配失败直接返回错误,这种情况下dma_map_single()会返回DMA_MAPPING_ERROR,驱动必须用dma_mapping_error()检查。

第四处是iommu_map(),把 IOVA 区间和物理页真正绑定起来。注意传进去的物理地址是phys - iova_off,因为前面长度加了偏移,这里要减掉,保证映射覆盖的实际物理范围从页边界开始。如果这一步失败,代码会立刻把刚分配的 IOVA 还给分配器,不留垃圾。这个“失败即回滚”的模式在内核里是强制要求,任何资源获取之后失败的分支都必须把已获取的资源释放干净。

最后返回值是iova + iova_off,把偏移加回去,这样设备拿到的地址加上页内偏移之后,能对应上驱动传入的原始物理地址起始位置。看起来绕,实际上是为了同时满足硬件对齐要求和 API 语义。

3.3 IOVA 分配器:rbtree 和 genpool 两条腿走路

6.6 里的 IOVA 分配器在drivers/iommu/iova.c,核心数据结构是struct iova_domain,里面挂着一棵红黑树记录已分配的区间,外加一个位图式的快速分配器。

红黑树那条路径是老牌实现,优点是通用、能处理任意大小的请求,缺点是每次分配都要走树查找,碎片化之后查找路径变长。位图那条路径针对“固定大小、高频分配”的场景做了优化,把 IOVA 空间切成固定粒度的小块,用位图记录占用情况,分配和释放都是常数时间。实际运行时,小的、大小固定的请求走位图,大的或者大小不固定的请求退回红黑树。

分配策略上有一个容易被忽略的倾向:优先分配低地址。原因是历史上大量设备只有 32 位寻址能力,把 IOVA 尽量压在低 4G 范围内,可以避免分配失败。这个策略在纯 64 位设备的环境里其实是不必要的,但为了兼容性一直保留着。如果在一个 64 位设备为主的系统上观察到 IOVA 分配意外失败,而 IOVA 空间明明还很空,八成是低地址区域被占满了,可以检查一下是不是有大量小粒度映射堆积在低端。

iova_rcache是另一层优化。释放掉的 IOVA 区间如果立刻又被申请,走完整的树查找和插入是浪费。缓存机制把最近释放的区间按大小分类存起来,下次申请时先看缓存里有没有合适的,有就直接复用。这个优化在高频 map/unmap 的网络设备场景下效果明显。

3.4iommu_map内部:页大小选择和批量失效

iommu_map()本身是个薄壳,核心逻辑在__iommu_map()。它做的事情是按可用的最大页粒度,把请求区间切成若干段逐段映射。

页大小的选择靠iommu_pgsize()。这个函数同时考虑三个因素:当前 IOVA 和物理地址的对齐情况、剩余长度、以及域支持的页大小集合(pgsize_bitmap)。逻辑是从大到小尝试,找到一个既能对齐、又不超出剩余长度的粒度。比如一段 2MB 对齐、长度足够的区间,如果域支持 2MB 页,就会一次性用一个页表项覆盖,比用 512 个 4KB 页表项高效得多。

硬件驱动实现的是map_pages回调,接收 IOVA、物理地址、大小、页大小、保护标志和分配标志。分配标志gfp传下去是为了让驱动在需要新建页表页时知道能不能睡眠——原子上下文里只能用GFP_ATOMIC,映射失败的概率会高一些。这也是为什么在中断上下文里做 DMA 映射要格外小心,一旦页表页分配不出来,整个映射就失败了。

映射完成之后,如果驱动实现了批量失效,iommu_iotlb_sync_map()会被调用,把这一批 map 产生的失效需求合并处理。如果域配置成了严格模式,或者驱动没实现这个回调,就退化成每次 map 后单独处理。

4. 域模型:为什么有些机器上 IOMMU 形同虚设

4.1 硬件实例、设备组、域:三个概念别混

iommu_device描述的是一个物理 IOMMU 硬件实例,系统里可以有好几个。iommu_group描述的是一组“互相之间无法被 IOMMU 隔离开”的设备——因为 IOMMU 的隔离最小粒度是组,不是单个设备。一个组里的设备共享同一套翻译表,组内设备之间可以互相访问对方的 DMA 区域,这是硬件拓扑决定的,软件无法改变。

iommu_domain才是一套独立的地址空间,对应一组翻译页表。把组挂到域上,组内所有设备的 DMA 访问就都走这套页表。内核里iommu_attach_group()完成这个绑定。同一个域可以挂多个组,那些组的设备就共享同一套 IOVA 空间,互相可见。

这个模型解释了一个常见困惑:为什么给某块设备做了隔离,同一张卡上的另一个功能却依然能访问它的内存。答案很简单,那两个功能在同一个 IOMMU 组里,硬件层面就没法分开。

4.2 默认域类型:identity、DMA、blocked 的取舍

设备探测时如果没有驱动显式接管,内核会给它分配一个“默认域”。这个默认域的类型由iommu_def_domain_type全局变量决定,可以通过启动参数调整。

identity 域的行为是:翻译表被配置成恒等映射,IOVA 等于物理地址。等于说重映射机制还在,但翻译结果是直通的。好处是零性能损失、零兼容性问题;坏处是隔离保护基本没有,设备越界照样能写坏内存。很多发行版为了兼容性,默认就是这个模式。

DMA 域才是真正启用重映射的模式,走完整的 IOVA 分配和页表翻译。隔离生效,反弹缓冲区省掉,代价是映射开销和 IOTLB 失效成本。

blocked 域是第三种状态,所有访问一律拒绝。通常用在设备刚被探测到、驱动还没接管、但又不希望它乱发 DMA 的时间窗口里。

启动参数里,iommu.passthrough=1会把默认域强制成 identity,iommu.strict=0则把失效模式切成非严格。这两个参数在排查性能问题和兼容性问题时用得最多,但要注意它们影响的是全局默认行为,个别驱动仍然可以自己指定域类型。

提示:看到“开了 IOMMU 但设备 DMA 还能越界”的现象,第一件事就是确认这个设备最终挂的是哪种域,而不是急着怀疑硬件。

4.3 dma-iommu 与 SPAPR:两套完全不同的软件栈

内核里存在两套把 IOMMU 接到 DMA API 上的实现,选择靠配置项。

CONFIG_IOMMU_DMA打开时走的是drivers/iommu/dma-iommu.c,也就是前面分析的 IOVA 分配器 +iommu_map那套。这是绝大多数平台的路径。

另一套是 SPAPR 风格(CONFIG_IOMMU_SUPPORT下的一类实现),主要在特定的服务器平台上使用,它的特点是 IOVA 空间由固件预先划分好,DMA 窗口是一段固定的地址区间,映射操作更像是在固定窗口里做页表填充,而不是自由分配。dma_map_single()在这套实现下的返回值落在那个预划窗口内,行为语义和 dma-iommu 有细微差别,尤其是窗口耗尽时的失败模式。

写可移植驱动的时候,不要假设 IOVA 是连续分配的,也不要假设释放之后立刻能重新分配到同一段地址。这两条假设在 dma-iommu 下大多成立,在别的实现下未必。

5. 映射粒度与开销:性能到底花在哪

5.1 scatter-gather 列表的合并逻辑

iommu_dma_map_sg()的流程值得拆开看。它先算出整个列表需要的 IOVA 总长度,一次性向分配器申请一段连续区间,然后逐段映射。因为区间是一次性拿到的,各段的 IOVA 天然连续,所以最终可以合并成少数几个 DMA 段,甚至一段。

但这里有个前提:分配器得真的给出一段连续区间。如果 IOVA 空间碎片化严重,alloc_iova()可能只能满足总长度要求,实际拿到的区间虽然是连续的(分配器保证这一点),但如果中间有已经映射的物理页不满足合并条件,合并还是会打折。更常见的情况是映射过程中某一段的物理地址对齐不满足大页要求,导致这一段只能用 4KB 页映射,虽然 IOVA 连续,但页表项数量上去了。

合并的结果通过修改 scatter-gather 表项体现:被合并的段,其后的表项被标记为空,sg_dma_len()返回合并后的长度。驱动如果自己遍历列表而不检查这个标记,就可能重复处理同一段数据。这是驱动开发里一个挺隐蔽的坑。

5.2 大页映射的触发条件

想拿到大页映射,需要同时满足几个条件:IOVA 在该粒度上对齐、物理地址在该粒度上对齐、剩余长度足够、域的pgsize_bitmap支持该粒度。四个条件缺一不可。

实际影响最大的是物理地址对齐。用alloc_pages()申请高阶页能保证物理对齐,用kmalloc()拿到的缓冲区往往只有小粒度对齐。所以高性能驱动通常会自己做页对齐的缓冲区池,而不是随手kmalloc一块内存就丢给 DMA。这个投入在映射频繁的场景下回报很明显:一个 2MB 的传输,用 4KB 页需要 512 个页表项,用 2MB 页只需要 1 个,页表遍历深度和 IOTLB 条目占用都差一个数量级。

pgsize_bitmap是硬件能力的直接反映。有的实现只支持 4KB 和 2MB,有的支持到 1GB。域创建时这个位图被填好,映射时按位从高到低尝试。如果发现预期的大页映射没生效,先打印一下域的pgsize_bitmap,再看地址对齐,基本能定位。

5.3 一次 map/unmap 的固定开销构成

拆开看,一次映射的成本大概是这几块:IOVA 分配器的查找和插入、页表页的分配与初始化(如果跨越了未建立的范围)、页表项写入、以及 IOTLB 失效。解映射反过来,加上区间归还和失效。

在频繁做小粒度映射的场景(比如网络收包,每个包一次映射),固定开销占比很高。这时候几个优化方向比较有效:一是用dma_map_sg()代替循环调用dma_map_single(),把多次分配合并成一次;二是开启非严格失效模式,把 IOTLB 刷新的代价摊薄;三是驱动层面做缓冲区复用,映射一次长期使用,而不是用完就解映射。

反过来,在映射生命周期很长的场景(比如显卡的大块显存映射),固定开销可以忽略,真正要关注的是页表项数量和 IOTLB 的命中率。这种情况下大页映射的价值远高于减少映射次数。

场景特征主要开销来源优先优化方向
小粒度、高频次(网络收发包)IOVA 分配 + IOTLB 失效批量映射、缓冲区复用、非严格模式
大粒度、低频次(显存、大缓冲)页表项数量、IOTLB 容量大页映射、物理地址对齐
生命周期长、映射后不变初次建立映射的成本预映射、启动阶段一次性完成
原子上下文映射页表页分配失败率预分配页表页、避免在中断里做首次映射

6. 动手验证:让 IOVA 和物理地址同时可见

6.1 环境与配置确认

先确认三件事:内核配置里CONFIG_IOMMU_SUPPORT和CONFIG_IOMMU_DMA是打开的,启动命令行里没有iommu=off,并且目标设备确实被纳入了某个域。

确认设备归属最直接的办法是看 sysfs 里的iommu_group链接。设备目录下如果有iommu_group这个符号链接,说明它被关联到了某个组;如果压根没有这个链接,那就别指望重映射生效了,先去查固件描述和总线探测日志。

内核配置可以用/proc/config.gz或者构建目录下的.config检查。CONFIG_IOMMU_DEFAULT_DMA_STRICT和CONFIG_IOMMU_DEFAULT_PASSTHROUGH决定了默认域类型,这两个选项在不同发行版上差异很大,实验前最好确认一下,否则可能拿到的结果和预期完全相反。

6.2 一个能编译的最小观测模块

下面这段代码做一件事:找一个 PCI 设备,分配一页内存,用 DMA API 映射,然后把物理地址和返回的 DMA 地址一起打印出来。如果重映射生效,两个值应该完全不同;如果是直连,两个值会相等。

#include <linux/module.h> #include <linux/kernel.h> #include <linux/dma-mapping.h> #include <linux/pci.h> #include <linux/gfp.h> #include <linux/mm.h> static int __init dma_iova_show_init(void) { struct pci_dev *pdev; struct device *dev; struct page *page; dma_addr_t dma; phys_addr_t pa; void *va; pdev = pci_get_device(PCI_ANY_ID, PCI_ANY_ID, NULL); if (!pdev) { pr_err("no pci device found\n"); return -ENODEV; } dev = &pdev->dev; page = alloc_page(GFP_KERNEL | __GFP_ZERO); if (!page) { pci_dev_put(pdev); return -ENOMEM; } va = page_address(page); pa = page_to_phys(page); dma = dma_map_page(dev, page, 0, PAGE_SIZE, DMA_BIDIRECTIONAL); if (dma_mapping_error(dev, dma)) { pr_err("dma_map_page failed on %s\n", dev_name(dev)); __free_page(page); pci_dev_put(pdev); return -EIO; } pr_info("dev=%s phys=%pa dma=%pad va=%px\n", dev_name(dev), &pa, &dma, va); memset(va, 0x5a, PAGE_SIZE); dma_sync_single_for_device(dev, dma, PAGE_SIZE, DMA_BIDIRECTIONAL); dma_unmap_page(dev, dma, PAGE_SIZE, DMA_BIDIRECTIONAL); __free_page(page); pci_dev_put(pdev); return 0; } module_init(dma_iova_show_init); MODULE_LICENSE("GPL");

编译需要一个内核构建树,make -C /lib/modules/$(uname -r)/build M=$(pwd) modules。加载用insmod,输出在dmesg里。

几个细节要注意:%pa和%pad这两个格式符接受的是地址的指针,不是值本身,写错了会打印出随机内容。dma_sync_single_for_device()在一致性 DMA 场景下是空操作,但对流式 DMA 是必需的,这里写上是养成习惯,别省。

6.3 三种典型输出的解读

第一种,两个地址相等:说明设备走的是直连路径,重映射没生效。检查iommu_group链接、启动参数、默认域类型。

第二种,两个地址不同,DMA 地址落在低 4G 范围内:这是最典型的情况,重映射生效了,分配器按低地址优先策略给了个低位 IOVA。如果设备本身支持 64 位寻址,可以通过调整dma_mask观察到 IOVA 范围变化。

第三种,dma_map_page直接失败:可能是 IOVA 空间耗尽(大量映射未释放)、也可能是设备 DMA 掩码设置不合理导致分配范围过窄。先看 IOVA 分配器的状态,再检查驱动的dma_set_mask调用有没有写错。

想进一步验证映射关系,可以在模块里加一步:拿到 IOVA 之后,用iommu_iova_to_phys()反查。这个接口需要拿到设备所属的域,可以通过iommu_get_domain_for_dev()获取。反查结果应该等于pa,如果不等,说明映射过程出了问题。

6.4 观察 IOVA 空间的碎片化程度

debugfs 里有 IOMMU 相关的视图,不同平台路径不一样,用find /sys/kernel/debug -name '*iommu*'这类方式先定位。能看到的内容包括各个域的地址范围、已分配的 IOVA 区间、页表项数量。

如果发现 IOVA 空间里有一堆大小不一的空洞,且总空闲量明明够但分配大块区间老是失败,那就是碎片化问题。常见成因是驱动每次映射的长度不一致——时而 4KB 时而 64KB,释放顺序又乱。解决办法要么在驱动侧统一映射粒度,要么在系统层面减少不必要的 map/unmap 频率。

7. 排查手记:几个高频错误与判断方法

7.1 启动日志里那些需要认真读的行

启动阶段如果打印出设备与 IOMMU 关联失败的信息,通常意味着固件描述里缺少必要项。这类问题在虚拟化环境、老平台、以及设备直通场景下比较常见。表现形式是设备被降级到直连或恒等映射,功能正常但没有隔离保护。

另一类日志是“设备组不是单设备组”的提示。这说明硬件拓扑上多个设备共享翻译单元,无法单独隔离。给虚拟机做设备直通时遇到这个提示,就得把整个组一起分配过去,只直通其中一个功能会失败。

还有一种情况是域分配失败,通常伴随内存不足或者页表页耗尽。在高并发映射的场景下,如果GFP_ATOMIC分配反复失败,映射就会持续报错。这种问题的解法不是在 IOMMU 层面调参,而是减少原子上下文里的映射次数。

7.2 映射泄漏:失败路径上最容易漏的那一步

iommu_map()失败之后必须调用iommu_dma_free_iova()把 IOVA 还回去,这一步漏了就是泄漏。短时间看不出来,长时间运行之后 IOVA 空间被吃光,所有新映射全部失败,设备彻底不能用。

排查方法比较直接:如果系统运行一段时间后 DMA 映射开始大面积失败,而物理内存还很充裕,就去看 IOVA 分配器的使用量。如果发现已分配量持续增长且从不下降,基本可以确认是某条错误路径漏了释放。

自己写驱动时,遵循“获取后立即设置失败分支”的习惯,把分配和释放成对写好,比事后排查省太多时间。内核里iommu_map的调用点,几乎每一个都跟着完整的错误处理,可以拿来当范本。

7.3 几个反复踩到的坑

第一个坑是把 IOVA 当物理地址用。有些驱动会在映射之后自己拿 IOVA 去做virt_to_page()之类的操作,这在直连模式下碰巧能跑通,开启重映射之后必然崩。IOVA 只是个给设备看的地址,CPU 侧要用还得用原始虚拟地址或者物理地址。

第二个坑是在映射之后修改了缓冲区的位置。dma_map_single()绑定了具体的物理页,如果映射之后又把数据拷到别的缓冲区,设备访问的还是原来那页,数据自然是旧的。这种问题表现出来的现象是“偶尔读到旧数据”,很难和数据竞争区分开。

第三个坑是同步操作缺失。流式 DMA 映射之后,CPU 侧的写入不保证对设备可见,必须调用dma_sync_single_for_device()。一致性 DMA 没有这个问题,但一致性缓冲区在有的平台上性能差,很多驱动会在两者之间做权衡,权衡错了就是数据不一致。

第四个坑是映射长度和实际传输长度不一致。映射了 4KB,设备传了 8KB,后 4KB 的访问会触发 fault。这种错误在日志里看得很清楚——地址、长度、设备标识都有——照着改就行,别去怀疑硬件。

第五个坑是在设备还在传输的时候解映射。解映射之后 IOTLB 里可能还有旧条目,设备继续访问会写到已经被回收的物理页上,典型表现是“内存莫名其妙被改写,但没人报错”。正确的顺序是先停止设备传输、确认完成、再解映射。

提示:调试 IOMMU 相关问题时,把iommu.strict=1临时打开,可以让映射变更立刻生效,虽然性能差但能排除掉一大批“旧缓存导致的假象”。定位完再关掉。

我个人在实际调试里最深的体会是,IOMMU 相关的问题九成以上出在映射的生命周期管理上,而不是翻译机制本身。硬件翻译是确定性的,页表建好就是建好了;真正容易出错的是“什么时候建、建多大、什么时候拆、拆之前设备停了没有”这四个问题。把这四个问题在驱动代码里回答清楚,DMA 重映射这条链路基本上就不会再给你制造意外。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/23 3:52:12

Windows 10性能治理:四层可验证系统级调优方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:51:57

Grad-CAM热力图实战:PyTorch实现、目标层选择与排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:51:23

国产碳化硅MOSFET横向评测:12维度量化选型方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:50:14

Ubuntu 24.04 安装搜狗输入法:fcitx4 框架与 X11 会话完整指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:50:13

Jupyter Notebook 网页版:从启动配置到内核排错与导出

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/23 3:49:58

STM32外部中断寄存器编程实战:从GPIO到NVIC的完整链路

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华