Linux 6.6 IOMMU DMA重映射:原理、验证与排障指南 做驱动调试的人大概都经历过这种场面设备驱动跑得好好的某一刻数据突然错乱日志里干干净净用崩溃转储一看内核某块结构体被一段完全不相干的十六进制数据糊住了。查到最后往往是设备发出的 DMA 地址写错了或者描述符里的地址字段被固件改花了——没有 IOMMU 的时候总线上的 DMA 请求就是全裸奔的设备爱往哪写就往哪写内存管理单元完全管不了它。Linux 6.6 内核里 IOMMU 子系统的 DMA 重映射DMA Remapping就是专门收拾这个问题的一层它把设备看到的地址空间和 CPU 看到的物理地址空间彻底切开设备发出的每一个地址都要先过这张翻译表。这篇内容围绕 IOMMU 的 DMA 重映射原理展开同时把 6.6 上怎么打开、怎么观察、怎么验证、怎么排坑一次讲透适合正在写 PCIe 驱动、做设备直通、或者被 DMA 越界搞得睡不着的同行参考。1. IOMMU 到底在解决什么问题从一次指针踩内存说起1.1 没有 IOMMU 时设备的 DMA 是完全不设防的先把最基础的一层说清楚。CPU 访问内存要经过 MMU页表决定哪块虚拟地址对应哪块物理页越界就缺页异常。但设备不一样设备发起 DMA 时把地址丢到总线上由内存控制器直接取数或者写数这条路默认绕开了 MMU。也就是说一块 PCIe 采集卡只要往描述符里写一个0xffff_8880_0000_0000这样的地址硬件就会老老实实往那个物理位置写数据中间没有任何审批环节。我在某实验室见过一块自定义采集卡FPGA 侧的地址位宽算错了一位描述符里的目标地址被左移了一位结果驱动程序填给它的缓冲区地址整体偏移。没开 IOMMU 的机器上这个偏移会随机打坏相邻的 slab 对象现象是跑几个小时之后文件系统报错或者某个完全无关的进程收到 SIGBUS。这种问题最难查的地方在于故障点和根因之间隔着几百毫秒甚至几秒崩溃现场和元凶毫无关联。打开 IOMMU 之后同样的错误地址会立刻触发一次 IOMMU 缺页内核打印出一行包含设备标识和出错地址的 fault 日志驱动直接被判定为行为异常。故障从偶发玄学变成稳定复现这个价值远比性能优化重要。所以我一直跟人说IOMMU 首先是一个调试工具和隔离边界其次才是一个地址翻译器。1.2 三层价值隔离、翻译、访问控制把 IOMMU 的能力拆开看其实是三件相互支撑的事。第一是地址翻译设备用一套地址内核里叫 IOVAI/O Virtual Address硬件按页表翻成物理地址PA。第二是隔离每个设备严格说是每个 IOMMU group有自己独立的地址空间A 设备映射的区域 B 设备看不见也摸不着哪怕两块卡是同型号、同一个驱动。第三是访问控制每一个页表项都带权限位读、写、执行是分开的设备只能做被显式允许的操作。这三层叠起来才支撑了后来一堆上层玩法设备直通给虚拟机时把整个 group 的地址空间交给客户机管内核里的vfio靠它做用户态驱动容器场景下做设备隔离内核自己的DMA API靠它在 32 位设备上做地址窗口收敛bounce buffer 那一套。很多人以为 IOMMU 就是为了虚拟化其实虚拟化只是最出名的一个消费者日常驱动开发里它天天都在干活。1.3 为什么叫重映射而不是映射这个用词值得掰一下因为它直接对应硬件结构。设备发出的地址在 IOMMU 硬件里叫输入地址经过查表后得到输出地址再送到内存控制器。翻译过程是进来一个地址出去另一个地址所以叫 remapping。而 CPU 侧的 MMU 我们习惯叫 mapping因为它是从没有物理归属的虚拟地址建立起映射关系。落到代码上这个差别体现得很明显CPU 侧你操作的是vm_area_struct和页表是申请地址→缺页→填页表的懒惰路径IOMMU 侧是先显式建映射→再告诉设备可以用这个地址没有缺页这回事ATS/PRI那套另说属于设备主动问路后面章节会提。理解了这个不对称你就能明白为什么 DMA 映射的 API 必须成对出现dma_map_single/dma_unmap_single中间那段窗口硬件已经准备好了用完必须还回去否则 IOVA 会漏。2. DMA 重映射的核心数据结构与地址翻译链路2.1 设备、group、domain三个概念别搞混刚接触这块的人最容易在这三个词上打转。我用一个类比说清楚设备是具体的网卡、采集卡、NVMe 盘group是一批无法相互隔离的设备集合硬件上它们共用同一个翻译单元或者同一条通往内存的路径内核只能把它们当成一个整体来管domain是真正承载页表的那块地址空间一个 domain 里可以挂多个 group。关键点在于内核默认策略是一个 group 一个 domain同一 group 里的设备共享同一套页表。这就意味着如果同一个 group 里有一块你不信任的设备它会和同组的可信设备平起平坐。做过直通的人应该都踩过这个坑——想把某块网卡单独丢给虚拟机结果发现它和板载的一个 USB 控制器同组只能整组一起交出去或者用pci-stub提前把同组设备都占住。在 6.6 里查看分组很简单for g in /sys/kernel/iommu_groups/*; do echo $(basename $g) for d in $g/devices/*; do echo $(basename $d) $(lspci -nns $(basename $d) 2/dev/null | cut -c1-80) done done分组结果完全由固件描述决定ACPI 表或者设备树里的描述决定了哪些设备归到一组内核只是照着执行。所以有些分组看起来莫名其妙那是固件的锅不是内核的锅。2.2 页表结构从根表一路走到最后一页x86 平台上的 DMA 重映射硬件ACPI 里描述为 DMAR它的翻译链路大致是三层结构先按设备标识找到根表项再按总线号找到上下文表项上下文表项里存着真正的多级页表基址。真正做地址翻译的部分和 CPU 的分页结构思路一致可以是三级、四级甚至五级具体级别在上下文表项里写明。另一类主流平台用的表述叫 SMMU结构换了个名字但思想一样设备按流 ID 查到一张表里的表项表项指向另一层配置配置里指定用哪套页表、哪个地址空间。你在设备树里给设备节点写一条描述 IOMMU 归属的属性内核初始化时就能把这条链路串起来。这个多层结构的存在直接导致了两个工程后果。第一初始化阶段必须先把整条链路建好包括分配页表内存、填根表和上下文表这段代码在内核启动早期跑调试起来很麻烦。第二页表级别可以按设备能力协商一块只支持 32 位地址的设备给它配的页表级别低IOVA 窗口自然也小这直接决定了你能给这台设备分配多大的 DMA 缓冲区。后面第 4 节讲参数计算时会用到这一点。2.3 IOVA 分配器地址不是随便给的页表建好了还得决定给设备什么 IOVA。这块在 6.6 里是dma-iommu这层在管核心是一个按地址范围分配的管理器内部用红黑树记录已占用的区间再配一层 per-CPU 的小缓存来加速短小分配的复用。为什么要有 per-CPU 缓存因为 DMA 映射调用极其频繁网卡每发一个包、磁盘每做一次 I/O 都要映射一次如果每次都去红黑树上做插入删除锁竞争会直接把性能打下来。缓存的做法是unmap 时把释放的 IOVA 区间按大小档位存进当前 CPU 的缓存数组下次分配同档位时直接从数组里取取不到再去全局树里找。实测下来这个缓存能干掉相当可观的一笔分配开销代价是 IOVA 的复用延后了一点对于调试来说你可能会看到某些 IOVA 长时间没被回收。默认情况下64 位设备拿到的 IOVA 窗口通常是 48 位宽度也就是一个非常大的地址空间。这个宽度不是拍脑袋定的它来自设备的coherent_dma_mask和dma_mask设备支持多宽的地址内核就据此外推 IOMMU 能给多宽的窗口同时受 IOMMU 硬件本身支持的最大宽度约束。如果你的驱动里把dma_mask设成 32 位恭喜你给自己套了一个 4GB 的笼子映射压力大时很快会撞墙。2.4 权限位、缓存一致性与 IOTLB页表项里的权限位不用多说读、写分开内核在dma_map_*时按数据传输方向设置。这里有个细节值得提DMA_FROM_DEVICE方向下建的是可写权限设备要往内存写而不是只读反过来DMA_TO_DEVICE建的是可读。刚接触的人经常反着理解以为方向是CPU 要干什么其实是设备要干什么。一致性方面同一块内存 CPU 和设备都能访问缓存就成了麻烦。一致性架构x86 常见的那种硬件自己保证非一致性架构上就得软件动手dma_map_single在映射前要把 CPU 缓存里对应的数据刷到内存dma_unmap_single之后要让 CPU 缓存失效否则 CPU 读到的是旧值。这一套在 IOMMU 之上照样要做IOMMU 管的是地址翻译和权限不管缓存。最后是 IOTLB。页表改了不等于硬件立刻知道IOMMU 内部有自己的地址翻译缓存翻译结果会被缓存起来。改了映射必须让缓存失效否则设备还在用旧的翻译结果访问已经回收的内存。这就是严格模式和延迟模式的分界点严格模式下每次 unmap 立刻发失效指令安全但慢延迟模式下把要失效的区间攒起来攒到一定量或者遇到必须同步的点再一起发快但有窗口风险。# 查看当前是否严格模式不同小版本参数名可能微调以内核文档为准 cat /proc/cmdline | tr \n | grep -E iommu3. Linux 6.6 里的那几条关键路径3.1 启动阶段固件描述怎么变成 domain内核启动早期IOMMU 的初始化分两步走。第一步是探测硬件、解析固件描述把每一条哪个设备归哪套翻译硬件管的关系记录下来形成设备到 group 的对应关系。这一步在内存管理初始化之前就要做完一部分因为后面的 DMA 一致性分配可能马上就需要翻译能力。第二步是惰性建域。内核不会在启动时给所有设备都建 domain而是等设备真正被驱动 probe、第一次调用 DMA 映射时才建。这个设计很务实一台机器上可能有几十个设备大部分根本没驱动管全建一遍纯属浪费页表内存。你在日志里看到的那些初始化行只说明翻译硬件就绪了不代表每个设备都已经有自己的地址空间。这里有个排查技巧开机日志里搜 IOMMU 相关的行能看到翻译单元被识别、支持的页表级别、以及是否有设备因为描述冲突被跳过。如果某块设备明明在却查不到它的 group八成是固件里的描述没覆盖到它或者是热插拔槽位的描述写得有问题。3.2 驱动 probe 之后dma_map_single 里的四层调用这是整条链路里最值得逐层看的一段。你的驱动调用dma_map_single(dev, ptr, size, dir)往下依次是通用 DMA 层先看这个设备有没有 IOMMU domain有就走 IOMMU 路径没有就退化成直接的物理地址映射这个退化路径叫 direct mapping需要swiotlb兜底处理地址不可达的情况。走 IOMMU 路径时进入dma-iommu这层先做页对齐处理你传进来的地址和长度几乎不可能是页对齐的这层会算出覆盖范围并想好头尾两页怎么处理。分配 IOVA从分配器里拿一段足够大的连续地址注意这里要的是连续 IOVA物理上可以不连续。落页表调用iommu_map把这批 IOVA 和对应的物理页逐个建立映射页大小会尽量往大页上凑能用一个 2MB 页就不用 512 个 4KB 页这对减少 TLB 压力和页表内存都很有意义。最终返回给你的是 IOVA你把它写进设备寄存器或者描述符。注意返回的这个地址绝对不能当指针用CPU 直接解引用它是未定义行为因为它不在 CPU 的地址空间里。顺手贴一个最小验证模块的核心片段方便你在自己的板子上跑一遍#include linux/module.h #include linux/pci.h #include linux/dma-mapping.h static int __init dmatest_init(void) { struct pci_dev *pdev; void *buf; dma_addr_t dma; u64 mask DMA_BIT_MASK(48); pdev pci_get_device(PCI_ANY_ID, PCI_ANY_ID, NULL); if (!pdev) return -ENODEV; if (dma_set_mask_and_coherent(pdev-dev, mask)) dev_warn(pdev-dev, 48-bit mask not accepted\n); buf dma_alloc_coherent(pdev-dev, 4096, dma, GFP_KERNEL); if (!buf) return -ENOMEM; pr_info(iommu probe: va%px dma%pad\n, buf, dma); dma_free_coherent(pdev-dev, 4096, buf, dma); pci_dev_put(pdev); return 0; }跑起来看dma打印出来的值。如果机器上 IOMMU 生效这个值几乎不会等于__pa(buf)如果两者相等说明走的是 direct mapping 或者身份映射IOMMU 实际没在翻译。3.3 iommu_map 内部页大小拆分与批量映射进到iommu_map之后核心动作是先算这一段能用的最大页是多少。硬件会给一个支持的页大小位图映射的 IOVA 和物理地址又各有对齐要求两头一取得出这一段里能用大页的范围剩下的边角用 4KB 页补齐。这个拆分逻辑是性能的关键也是坑的来源。举个具体例子你要映射 4MB 加 4KB 的缓冲区起始地址只对齐到 4KB。那么开头那一小段只能是 4KB 页中间能凑出连续的 2MB 对齐区域就用 2MB 页末尾再补。这样拼出来的页表条目数远少于全用 4KB 页TLB 命中也更好。iommu_map_sg走的是同一套逻辑只是把散列表一次性批量处理省掉了多次调用的开销网卡这类做多段 DMA 的设备全靠它。6.6 里这块还在演进中属性结构逐步统一老的按设备单独实现映射回调的方式正被按域分配的分页接口替代。这意味着如果你在维护一个厂内私有的 IOMMU 驱动升级到 6.6 附近时要留意接口变化别照着老版本的样板抄。3.4 unmap 的时机比 map 更容易出事的地方映射写起来简单回收才是见功力的地方。dma_unmap_single做三件事清页表项、把 IOVA 还给分配器、按模式决定是否立刻发失效指令。任何一步出问题都有明确症状。漏了 unmapIOVA 就一直被占着跑久了分配器找不到连续区间驱动申请缓冲区开始失败日志里会出现 DMA API 调试相关的告警或者分配失败。这个在长时间压测里才暴露所以很多驱动的问题是在跑了一两天之后才冒出来的。更阴险的是时序错位设备还在往缓冲区写数据你的代码已经把 unmap 做了IOVA 随后被分配给另一个设备。这时候总线上还飞着旧设备的写请求落到新设备的缓冲区里数据就被污染了。这类问题表现为随机数据错乱且只在负载高的时候出现。规避办法很朴素必须确保设备已经停止对该缓冲区的访问读完成寄存器、等中断、做一次同步屏障才能 unmap。延迟模式会让这个窗口更长调试环境建议先开严格模式稳定之后再考虑性能优化。4. 实操打开 IOMMU 之后怎么观察和验证4.1 内核命令行参数与生效判断先确认硬件和固件是否就绪。开机日志里搜翻译单元的初始化信息能看到它被识别、支持的最大地址宽度、页大小位图。如果完全没有相关行先查固件里对应的开关有没有打开x86 平台上这个开关有时在芯片组配置里名字不一定显眼。内核参数方面常用的几个iommuon强制启用iommupt让设备默认走身份映射只建一张等价映射性能几乎无损隔离性大部分丢失iommu.strict1强制严格模式iommu.passthrough0禁用透传。这里要提醒一句参数名在不同小版本上偶有调整动手前翻一下随内核源码附带的命令行参数文档比在网上抄别人的配置靠谱。判断是否真的生效最直接的办法是看设备的 DMA 地址和物理地址是否相同也就是上面那段模块代码的输出。第二直接的办法是看/sys/kernel/debug/iommu/devices/下面有没有这个设备的条目——6.x 里加的这个调试接口相当好用里面能看到它当前挂的域类型、是否启用 ATS、以及身份映射状态。我第一次用它排查一个以为开了其实没开的直通问题时五分钟就定位到了。4.2 用 tracepoint 看每一次映射的真实参数调性能或者查泄漏光看日志不够得上 tracepoint。IOMMU 子系统埋了几个关键点映射、解映射、批量映射都能记录下域指针、IOVA、物理地址、长度、权限。# 打开映射相关的事件 cd /sys/kernel/debug/tracing echo 1 events/iommu/enable echo 1 tracing_on # 跑一段你的业务 sleep 5 echo 0 tracing_on cat trace | head -50从输出里能直接读出三件事映射长度分布大部分是 256 字节还是 4KB反映你的缓冲区设计是否合理、IOVA 是否集中在低地址说明窗口开小了或者分配器压力大、以及 map 和 unmap 的数量是否匹配不匹配就是泄漏。我用这套方法抓到过一个很典型的问题某驱动的接收路径每包都映射一次小缓冲区日志里 unmap 次数比 map 少了不到千分之一。看着不起眼的偏差跑一晚上就累积出几万个没回收的 IOVA最后分配大块连续区间失败。查出来是错误分支上少写了一行 unmap。4.3 观察点整理从文件系统到日志观察目标位置关键信息设备分组情况/sys/kernel/iommu_groups/哪些设备共用一套翻译能否独立直通设备当前域状态/sys/kernel/debug/iommu/devices/域类型、是否身份映射、ATS 状态每次映射的详细参数events/iommu/下的 tracepointIOVA、物理地址、长度、权限缺页与访问越权内核日志设备标识、出错地址、访问类型映射泄漏与误用内核日志需开 DMA API 调试长度不一致、方向不一致、提前释放这张表建议存下来。我排查 IOMMU 相关问题基本就是从上往下过一遍先看分组对不对再看域建没建再看映射有没有异常最后看有没有硬件报错顺序乱了容易钻牛角尖。4.4 严格模式与延迟模式的实际代价性能这块给个量级参考具体数字跟平台强相关别照抄。在一台普通双路服务器上跑万兆网卡的小包收发严格模式下每个包一次映射一次失效吞吐比延迟模式低一截某些配置下差距比较明显换成大包或者批量提交的场景差距迅速缩小因为失效指令的摊薄效应出来了。我的建议是分环境对待开发和功能验证阶段一律开严格模式让错误尽早暴露生产环境如果压测通过、驱动行为规范可以切延迟模式换性能。但前提是驱动真的守规矩——所有 DMA 缓冲区的生命周期都有明确的同步点。我见过为了追性能直接切延迟模式结果把一个本来就存在的时序 bug 放大了十倍的情况那种排查成本完全不值得。提示切模式之前先做一轮长时间压力测试重点看接收方向的数据完整性这个方向的问题最容易被延迟失效掩盖。5. 常见问题与排查速查表5.1 三类典型故障的定位路径第一类是开了 IOMMU 之后设备直接不工作。现象是驱动 probe 成功但第一次 DMA 就报错或者数据全零。先查这个设备的 DMA 地址宽度如果驱动把dma_mask设成了 32 位而设备的实际缓冲区又申请在 4GB 以上映射就会失败或者触发 bounce buffer。其次查设备是否被分到了一个奇怪的 group导致它的域里没有预期的映射。最后确认固件描述里这个设备确实归 IOMMU 管——有些平台对某些根端口默认不做翻译。第二类是跑一段时间后随机出错。这是最典型的延迟失效或者生命周期问题。把严格模式打开复现一次如果严格模式下问题消失基本可以确定是失效时机或者缓冲区回收时机的问题。再用 tracepoint 对齐 map/unmap 的时间戳和业务日志找出哪个缓冲区的释放早于设备停止访问。第三类是性能比不开 IOMMU 差很多。先分清是映射开销还是失效开销。映射开销大的特征是 CPU 占用高、映射次数多优化方向是合并小映射、用散列表批量映射、复用缓冲区失效开销大的特征是延迟模式的收益明显优化方向是调整同步粒度。还有一种容易被忽略的情况映射时页大小没凑上大页全是 4KB 页导致 TLB 压力大这个从 tracepoint 的长度分布能看出来。5.2 排查速查表现象可能原因快速验证处理方式第一次 DMA 就失败地址宽度设置过窄打印dma_mask与缓冲区地址按设备能力放开到 48 位或更高数据随机错乱提前 unmap 或延迟失效切严格模式复现补同步点确认设备停止后再释放运行数小时后分配失败IOVA 泄漏对比 map/unmap 计数检查所有错误分支的回收映射成功但设备读到旧数据CPU 缓存未刷单步调试确认 map 前的操作检查方向参数是否传错设备访问直接触发 fault映射范围没覆盖全看 fault 日志里的地址检查长度与页对齐处理想直通却整组被牵连group 内设备不可分查分组目录提前占住同组其他设备性能断崖式下跌全小页映射或严格模式看映射长度分布合并映射、调整模式5.3 几个只有踩过才知道的细节边界页的处理。你传进去的缓冲区地址没对齐、长度也不是页整数倍时dma-iommu这层需要保证覆盖范围。它可能会对首尾页做特殊处理也可能直接要求调用方保证对齐。不同版本的策略有差异稳妥的做法是自己在驱动里就按页对齐申请缓冲区别把麻烦丢给底层。我吃过一次亏一个 64 字节的映射跨越了页边界尾部那一小段落到了相邻的映射区域里两个设备互相踩查了两天才定位。方向参数不是装饰品。传错方向不会立刻报错但页表权限建错了某些平台上设备会收到访问拒绝某些平台上则完全不报错只是缓存维护做反了。后一种最要命表现为数据延迟一个周期才更新看起来像硬件时序问题。身份映射不等于没开。iommupt或者某些驱动主动请求身份映射时设备看到的就是物理地址隔离性基本没有但翻译链路还在。排查时别看到地址相等就断定 IOMMU 没工作得去看域类型。热插拔设备要重新看分组。上电顺序不同固件给的分组可能不一样一个槽位冷启动和热插拔之后可能落到不同的组里。做直通方案时不能假设分组固定。调试阶段把 DMA API 调试选项打开。这个选项会额外检查每次映射和释放的参数一致性代价是性能下降但能在开发期抓出大量低级错误包括长度不一致、方向不一致、重复释放。上线前关掉即可。我在实际使用中的体会是IOMMU 这层东西的价值不在于它有多复杂而在于它把设备的错误行为从不可观测变成了可观测。以前遇到 DMA 越界只能靠缩小范围、加内存屏障、打点猜现在一行 fault 日志直接告诉你是哪个设备、访问了哪个地址、是读还是写。把第 4 节那几个观察点用熟绝大部分和 DMA 相关的疑难杂症都能在半小时内锁定方向剩下的只是修驱动的问题。