深入解析Linux内核dma_map_ops三种实现方式:直接映射、IOMMU与SWIOTLB
2026/9/17 7:23:30 网站建设 项目流程

写驱动这些年,我遇到过不止一次这样的问题:明明在单片机上把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 buffer(SWIOTLB);
  • 处理 cache 一致性,在 map 和 unmap 时按需 flush。

这些活被封装在struct dma_map_ops里。这个结构体定义了map_pageunmap_pagemap_sgunmap_sgallocfreesync_single_for_devicesync_single_for_cpu等回调。你写的驱动只跟通用 API 打交道,真正的“分发”发生在struct device里的dma_ops指针上。

1.2 三种实现的一句话定位

先给结论,后面再逐个拆:

  • 直接映射(dma-direct):设备能访问全部物理内存,物理地址就是 DMA 地址,不做额外地址翻译,只需要合理判断范围和做 cache 操作。
  • IOMMU 映射(dma-iommu):硬件有 IOMMU(SMMU、Intel VT-d、AMD IOMMU),内核为设备分配 I/O 虚拟地址,并维护页表。设备看到的是“连续虚拟地址”,背后物理页可以分散。
  • SWIOTLB:设备没有 IOMMU,但 DMA 能力受限(只能访问低端内存),或者内存被拉伸到了设备够不着的地方,就用一块预留的“反弹缓冲”中转数据。

代码层面的对应关系是:

  • dma_direct_ops,文件在kernel/dma/direct.c
  • iommu_dma_ops(不同架构可能命名不同),核心在drivers/iommu/dma-iommu.c
  • swiotlb_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()会做三件事:分配一个 IOVA(I/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 的题眼是“swiotlb=force”和“反弹缓冲”。它解决的场景是:设备物理寻址能力有限,而内存可能超过了这个限制。比如 32 位设备只能访问 4GB 以下内存,但机器有 16GB 物理内存,系统把大部分内存放到 4GB 以上去了,设备的 DMA 请求就过不去。

SWIOTLB 的工作原理特别朴素:内核启动时预留一块物理连续内存(默认大小约 64MB,可调),这块内存位于设备能寻址的范围内。当dma_map_single()传入的 buffer 地址设备没法访问时,就在 SWIOTLB 里分配一块区域,返回这块区域的 DMA 地址给设备。

数据流是:

  1. 方向为DMA_TO_DEVICE:把原始 buffer 内容拷贝到 SWIOTLB 区域,设备从 SWIOTLB 读数据。
  2. 方向为DMA_FROM_DEVICE:设备往 SWIOTLB 区域写数据,DMA 完成后再拷回原始 buffer。

这就是“反弹”的含义:先弹到中间区域,再弹回来。多一次内存拷贝,性能折扣是实打实的,但保证了功能正确。

swiotlb_tbl_map_single()负责在预留池中做 slot 分配。它要考虑对齐要求(比如设备要求 2MB 对齐),所以swiotlb内部用bitsalloc_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,取决于地址是否越界、是否swiotlb=force

3.2 dma_mask 和 attrs 是两条关键线

搞懂分发机制后,第二个关键参数是dev->dma_maskdev->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_SYNC:map/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.passthrough=1:IOMMU 透传模式,设备不走 IOVA 翻译,IOMMU 只做隔离,arch_setup_dma_ops()可能把 ops 设回 direct。
  • 内核配置CONFIG_IOMMU_DMA:决定dma-iommu.c有没有被编进去。
  • 设备树中dma-ranges:描述总线到父总线的地址映射,影响struct devicebus_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 映射。
  • 内核命令行有swiotlb=force,或者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(内核命令行swiotlb=128),或者从根源上减少 bouncing(检查 dma_mask 设置)
DMA 随机会话卡死,地址错乱DMA 方向写反(用了DMA_TO_DEVICE接收数据)检查dma_map_single()的 dir 参数,接收方向用DMA_FROM_DEVICE
CPU 读回的全是脏数据没有在 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_rx,CAN 的 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 路径不对,又不想重新编译内核,可以试试把swiotlb=force加到内核命令行,强制走 bounce buffer。如果这样跑起来功能和原来一样,那说明问题不在 bounce buffer 本身;如果性能明显下降,那就说明默认路径已经在走 bounce buffer 了。这个对比实验五分钟就能做完,比看半天源码定位还快。

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

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

立即咨询