十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

深入解析Linux内核dma_map_ops三种实现方式:直接映射、IOMMU与SWIOTLB

深入解析Linux内核dma_map_ops三种实现方式:直接映射、IOMMU与SWIOTLB 写驱动这些年我遇到过不止一次这样的问题明明在单片机上把DMA用得飞起比如串口空闲中断加DMA收发、CAN的FIFO DMA搬数据一转到Linux内核驱动突然发现DMA没那么“直接”了。请求发出去之前要先经过一个叫dma_map_ops的东西往深处看它实现还不止一套。这篇文章就围绕“dma map ops 实现的三种方式”展开把直接映射、IOMMU映射、SWIOTLB这三种实现掰开揉碎讲清楚。先说清楚背景DMA不只是“搬数据”那一下驱动和设备之间需要一个统一的地址翻译与同步机制dma_map_ops就是内核抽象出来的一组操作函数包括 map、unmap、alloc、free、sync 等。你调用dma_map_single()时底层实际执行的就是dma_map_ops-map_page()你调用dma_alloc_coherent()时底层走的是dma_map_ops-alloc()。同一个 API在不同硬件平台、不同内存拓扑下解析方式完全不同。这套机制最适合三类人看一是写 Linux 驱动时需要做 DMA 传输的开发者二是刚接触内核内存子系统、想搞明白“地址到底怎么翻译”的学习者三是从 STM32、GD32 这类 MCU 转到嵌入式 Linux 的工程师。搞清楚这三种实现你会突然发现以前踩过的很多随机性 bug 都有了解释。1. DMA 子系统的“总闸”dma_map_ops 到底是什么1.1 从驱动视角看 DMA 映射的固定套路无论底层是哪种实现驱动代码里那套 DMA API 基本不变。最常见的组合是dma_map_single()映射一块 buffer拿到 DMA 地址交给设备传输完成后dma_unmap_single()解映射或者dma_alloc_coherent()直接分配一块“设备能访问、CPU 也能访问”的内存。很多人用的时候没多想以为dma_map_single()就是把物理地址强转一下。实际上它背后干了很多活检查缓冲区地址是否在设备 DMA 能力范围内决定是否需要经过 IOMMU 翻译分配 I/O 虚拟地址决定是否要走 bounce bufferSWIOTLB处理 cache 一致性在 map 和 unmap 时按需 flush。这些活被封装在struct dma_map_ops里。这个结构体定义了map_page、unmap_page、map_sg、unmap_sg、alloc、free、sync_single_for_device、sync_single_for_cpu等回调。你写的驱动只跟通用 API 打交道真正的“分发”发生在struct device里的dma_ops指针上。1.2 三种实现的一句话定位先给结论后面再逐个拆直接映射dma-direct设备能访问全部物理内存物理地址就是 DMA 地址不做额外地址翻译只需要合理判断范围和做 cache 操作。IOMMU 映射dma-iommu硬件有 IOMMUSMMU、Intel VT-d、AMD IOMMU内核为设备分配 I/O 虚拟地址并维护页表。设备看到的是“连续虚拟地址”背后物理页可以分散。SWIOTLB设备没有 IOMMU但 DMA 能力受限只能访问低端内存或者内存被拉伸到了设备够不着的地方就用一块预留的“反弹缓冲”中转数据。代码层面的对应关系是dma_direct_ops文件在kernel/dma/direct.ciommu_dma_ops不同架构可能命名不同核心在drivers/iommu/dma-iommu.cswiotlb_dma_ops老版本有独立实现现在大多合并进 dma-direct 路径核心在kernel/dma/swiotlb.c。1.3 为什么不能只留一种实现这个问题值得先想明白。如果只保留直接映射那所有设备都必须能访问整段物理内存某些老旧外设和 32 位总线设备就干不了活如果只保留 IOMMU 映射那没 IOMMU 的廉价平台直接跑不起来如果只保留 SWIOTLB那每次 DMA 都得搬一次数据性能直接腰斩。所以内核的做法是根据硬件能力、设备树/ACPI 描述、命令行参数在dma_map_ops中选择最合适的一种。这背后体现了一个设计原则API 稳定策略多变。驱动不用知道自己跑在哪个平台上所有差异都被dma_map_ops吸收。2. 三种方式的设计与实现拆解2.1 直接映射dma-direct 的“直通”哲学dma-direct 是性能最好的路径因为它几乎不额外复制数据。什么情况下会走它设备没有绑定 IOMMU且dma_mask覆盖了内存在物理地址空间的范围。看dma_direct_map_page()的实现核心逻辑并不复杂先通过dma_direct_to_addr()把传入的物理地址转成 DMA 地址再检查这个地址是否满足设备的dma_mask限制和总线限制。如果地址越界内核会尝试走 SWIOTLB如果没启用 SWIOTLB直接返回错误。这里有个容易忽略的细节dma-direct 不等于“什么都不做”。它还负责 cache 同步。map 的时候如果方向是DMA_TO_DEVICE需要先dma_direct_sync_single_for_device()把 CPU cache 里的数据刷到内存unmap 的时候如果方向是DMA_FROM_DEVICE要 invalidate cache否则 CPU 读到的是过期的 cache 行。分配方向也很有意思。dma_alloc_coherent()在 direct 路径下优先走dma_direct_alloc()如果 size 足够大会尝试 CMA 区域保证物理连续小内存则可能用dma_pool之类的机制。很多驱动在 probe 时申请一块 DMA buffer用的就是这条路。为什么强调“物理连续”因为 DMA 设备看到的是物理地址没有 IOMMU 时不连续就完蛋。直接映射的优势就是零额外开销地址翻译几乎等价于“物理地址-DMA 地址”这个恒等变换。代价是它要求物理内存必须连续而且设备寻址范围要够大。2.2 IOMMU 映射用“虚拟地址”把碎片拼成连续有 IOMMU 的平台上事情就变得优雅了。iommu_dma_map_page()会做三件事分配一个 IOVAI/O 虚拟地址构造页表映射返回 IOVA 给设备。设备看到的是一个虚拟的、看起来连续的地址空间物理内存可以分散在任意位置IOMMU 负责翻译。这套机制的收益很明显打破物理连续性约束。原来一个 4MB 的连续 DMA 分配可能因为内存碎片而失败有 IOMMU 后只要能把 4MB 拆成多个 page 页IOVA 上连续就行。隔离能力。设备只能访问被映射的区域访问越界会被 IOMMU 拦下来系统安全性提升。scatter-gather 的“假连续”。dma_map_sg()把多个分散的 segment 映射成连续 IOVA设备如果支持 SG就能用 DMA 链表搬运数据。但 IOMMU 路径不是没有成本。分配 IOVA 是个相对重的操作涉及iova_alloc()、页表分配和 TLB 刷新。为了缓解这个开销内核有 IOVA 缓存、快速路径分配、以及DMA_ATTR_SKIP_CPU_SYNC这类跳过 CPU 同步的属性。数据量大、频率高的场景下IOMMU 路径的性能优化非常考验功底。另外要注意IOMMU 这个“虚拟地址”和 CPU 的虚拟地址完全两码事。驱动拿到 IOMMU 映射返回的 DMA 地址把它写进设备寄存器就行千万不要拿去当 CPU 指针解引用。这个错误我见过新手犯过一执行就 page fault。2.3 SWIOTLB没有 IOMMU 时的“中间人”SWIOTLB 的题眼是“swiotlbforce”和“反弹缓冲”。它解决的场景是设备物理寻址能力有限而内存可能超过了这个限制。比如 32 位设备只能访问 4GB 以下内存但机器有 16GB 物理内存系统把大部分内存放到 4GB 以上去了设备的 DMA 请求就过不去。SWIOTLB 的工作原理特别朴素内核启动时预留一块物理连续内存默认大小约 64MB可调这块内存位于设备能寻址的范围内。当dma_map_single()传入的 buffer 地址设备没法访问时就在 SWIOTLB 里分配一块区域返回这块区域的 DMA 地址给设备。数据流是方向为DMA_TO_DEVICE把原始 buffer 内容拷贝到 SWIOTLB 区域设备从 SWIOTLB 读数据。方向为DMA_FROM_DEVICE设备往 SWIOTLB 区域写数据DMA 完成后再拷回原始 buffer。这就是“反弹”的含义先弹到中间区域再弹回来。多一次内存拷贝性能折扣是实打实的但保证了功能正确。swiotlb_tbl_map_single()负责在预留池中做 slot 分配。它要考虑对齐要求比如设备要求 2MB 对齐所以swiotlb内部用bits和alloc_size来管理对齐和索引。池子满了会报“DMA: Out of SW-IOMMU space”这是嵌入式开发板上特别常见的错误后面排查部分再细聊。老版本内核里有独立的swiotlb_dma_ops但现代主线内核已经把它合并进了 dma-direct 路径dma_direct_map_page()发现地址越界就主动调用 swiotlb 相关函数。所以看新内核代码时dma_direct_map_page()函数里都能看到swiotlb_map()的影子。3. 从 dma_map_single 出发一次调用如何走到三种实现3.1 调用链和 ops 分发过程我建议你拿到任意一份内核源码顺着dma_map_single()往下走这是理解整套机制最快的方法。大致的调用路径是dma_map_single() - dma_map_single_attrs() - dma_map_page_attrs() - ops get_dma_ops(dev) - ops-map_page(dev, page, offset, size, dir, attrs)关键就在get_dma_ops(dev)这一步。它返回的是dev-dma_ops这个指针在设备驱动模型初始化时被设置。三种实现最终分派到不同函数场景ops实际调用函数无 IOMMU地址合法dma_direct_opsdma_direct_map_page有 IOMMUiommu_dma_opsiommu_dma_map_page地址越界回退dma_direct_ops内部swiotlb_map / swiotlb_tbl_map_single注意第三行在较新的内核里SWIOTLB 不是独立的 ops而是 dma-direct 内部的 fallback。只要dev-dma_ops dma_direct_ops实际可能是 direct 也可能是 swiotlb取决于地址是否越界、是否swiotlbforce。3.2 dma_mask 和 attrs 是两条关键线搞懂分发机制后第二个关键参数是dev-dma_mask和dev-coherent_dma_mask。这东西几乎每个 DMA 驱动都要设置但很多人只是照抄。dma_mask是“设备能寻址的 DMA 地址最大值”。如果设备是 32 位地址总线dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))会把可寻址范围限制在 4GB 以内。coherent_dma_mask限制的是 coherent 映射比如dma_alloc_coherent()拿到的内存。内核里有一个统一的判断if (dma_capable(dev, dma_addr, size, true)) return dma_addr; // 直接映射可用 else return swiotlb_map(...); // 否则走反弹缓冲很多 DMA 问题就是因为 mask 设错了设大了设备实际寻址不了那么高数据写到不该写的地方设小了地址动不动越界性能暴跌所有 DMA 都走 swiotlb。attrs则是一组位标志常见的有DMA_ATTR_SKIP_CPU_SYNCmap/unmap 时不做 cache 同步适合驱动自己管理同步时机。DMA_ATTR_NO_KERNEL_MAPPING只给设备访问CPU 不需要内核映射省 vmalloc 空间。DMA_ATTR_NO_WARN分配失败时不打印警告。attrs 直接影响底层实现的执行路径。比如DMA_ATTR_SKIP_CPU_SYNC在 direct 路径下会跳过dma_direct_sync_single_for_device()在 iommu 路径下也会跳过对应的 cache 操作。3.3 运行时如何选择dma_configure 与 dev-dma_ops 的赋值dev-dma_ops不是凭空冒出来的。设备加入驱动模型时dma_configure()会解析设备树或 ACPI 表里的信息。比如设备树的iommus属性如果存在说明设备挂在 IOMMU 后面内核就会为它设置iommu_dma_ops。如果没有iommus也没有别的限制arch_setup_dma_ops()就会把 ops 指向dma_direct_ops。这里有几个容易被忽略的触发点内核命令行iommu.passthrough1IOMMU 透传模式设备不走 IOVA 翻译IOMMU 只做隔离arch_setup_dma_ops()可能把 ops 设回 direct。内核配置CONFIG_IOMMU_DMA决定dma-iommu.c有没有被编进去。设备树中dma-ranges描述总线到父总线的地址映射影响struct device的bus_dma_limit。bus_dma_limit是个很容易被忽略的变量。它表示上游总线上所有总线约束的交集dma_direct_map_page()会把它和dma_mask一起参与判断。如果总线上挂了多个设备某个桥的寻址能力受限那所有下游设备的 DMA 地址范围都会被压缩。3.4 新内核的变化swiotlb 并入 direct、IOMMU 化整为零看内核代码时要注意版本差异。内核 5.x 之后swiotlb不再维护独立的dma_map_ops而是在dma_direct_map_page()内部判断并跳转。所以你搜索swiotlb_dma_ops时老版本能找到新主线可能只剩下swiotlb_map()这类函数。同时IOMMU 路径也在持续变化。比如在 ARM64 平台上drivers/iommu/dma-iommu.c被广泛用于 SMMU在 x86 平台上Intel VT-d 虽然有自己的驱动但iommu_dma_ops仍作为通用 DMA API 的接入点。“IOMMU 化整为零”的意思是IOMMU 不只负责 DMA 地址翻译还参与页错误处理、SVA共享虚拟地址、设备私有内存管理等但驱动感知到的还是同一套dma_map_ops接口。所以如果你维护的是老内核驱动切到新内核时务必重新审视一遍dma_map_ops的调用链。dma_alloc_coherent()在新内核上的行为、失败返回值、错误日志格式都可能有变化。4. 三种方式的坑问题现象与排查思路4.1 怎么确认当前走的到底是哪条路径不少人来问“为什么我的 DMA 这么慢”我第一反应就是让他确认是不是走了 swiotlb。判断方法其实不难把dmesg | grep -i dma拉出来看能看到IOMMU enabled之类的日志同时设备树里有iommus说明走了 IOMMU 映射。内核命令行有swiotlbforce或者dmesg里有software IO TLB的初始化信息说明所有 DMA 都可能走 swiotlb。还可以打开内核的 DMA debug 功能CONFIG_DMA_API_DEBUG会在 map/unmap 时打印映射方向和地址能直接看到返回的 DMA 地址到底长什么样。我经常用的一招是在驱动里打印dev-dma_ops指针的名字或者直接dev_info(dev, dma_ops%px\n, dev-dma_ops)然后对照 System.map 解析。虽然 debug 内核对生产环境有影响但开发阶段这招最直观。4.2 常见问题速查表现象大概率原因处理思路dmesg 报 “Out of SW-IOMMU space”SWIOTLB 池被耗尽大量 DMA 都在 bounce buffer加大 swiotlb内核命令行swiotlb128或者从根源上减少 bouncing检查 dma_mask 设置DMA 随机会话卡死地址错乱DMA 方向写反用了DMA_TO_DEVICE接收数据检查dma_map_single()的 dir 参数接收方向用DMA_FROM_DEVICECPU 读回的全是脏数据没有在 unmap 时做DMA_FROM_DEVICE的 cache invalidate检查驱动是否调用了dma_unmap_single()如果刻意用DMA_ATTR_SKIP_CPU_SYNC记得手动dma_sync_single_for_cpu()dma_alloc_coherent 大块内存反复失败物理内存碎片严重CMA 配置不足调整 CMA 大小device tree 的linux,cma或者换用dma_map_sg() IOMMU 避免大块连续物理内存IOVA 分配失败iova_alloc报错IOMMU 地址空间耗尽或碎片化调整 IOMMU 页表区域大小检查是否有大量DMA_ATTR_*长生命周期映射没有释放设备只能访问 32 位地址但 DMA 返回的地址 4Gdma_mask 没设或 bus_dma_limit 被放宽用dma_set_mask_and_coherent()显式设置设备寻址范围驱动 probe 时 DMA 设备准备好了但 map 失败设备电源域没起来或者 IOMMU 相关的 clocks 没开先确认设备时钟、复位、电源域再排查 IOMMU 的iommu_probe_device是否执行这里重点夸一下CONFIG_DMA_API_DEBUG。它能在 map 次数和 unmap 次数不匹配、方向错误、重复映射同一地址时直接报警比你自己对着寄存器猜快太多。我查过很多诡异问题最后都是靠它定位到“少了一次 unmap”。4.3 从 MCU 转向内核的认知转变如果你之前是在 STM32、GD32、ESP32-S3 上写 DMA 的转到 Linux 内核驱动时有三个认知必须升级。第一MCU 上的 DMA 配置是“通道 外设 内存地址”的固定组合比如串口 DMA 要配hdma_usart1_rxCAN 的 Rx FIFO DMA 要跟滤波器关联。Linux 的 DMA API 不关心你是串口还是 CAN它只负责“把这块内存变成设备可访问的地址”和“处理一致性”。第二MCU 上不需要考虑 cache 一致性因为 Cortex-M 通常没有复杂 cache 或默认配置就是透写的。但 Cortex-A 上CPU cache 和 DMA 是两套体系dma_map_single()就是在帮你做 cache 同步。忘掉 sync轻则数据旧重则数据全错。第三MCU 上一切地址都是“真实”的不管是外设寄存器还是内存缓冲区。Linux 里 dma 地址可能来自 IOMMU 的虚拟空间也可能来自 SWIOTLB 的预留池它和物理地址之间不是恒等关系。驱动里写设备寄存器时用 DMA 地址读 buffer 时用 CPU 地址两者要分得清清楚楚。从dma_map_ops入手理解这三种实现我个人的体会是别一开始就扎进每个函数的细节先抓住“什么时候走哪条路”这个主脉络再回头啃源码效率高很多。拿到一个新平台第一件事就是确认 IOMMU 开没开、设备树里有没有iommus、dma_mask 设对了没有这三样齐了绝大部分 DMA 问题已经解决一半。最后再分享一个小技巧如果你怀疑 DMA 路径不对又不想重新编译内核可以试试把swiotlbforce加到内核命令行强制走 bounce buffer。如果这样跑起来功能和原来一样那说明问题不在 bounce buffer 本身如果性能明显下降那就说明默认路径已经在走 bounce buffer 了。这个对比实验五分钟就能做完比看半天源码定位还快。
返回列表