☰
Linux DMA coherent映射深度解析:从dma_alloc_coherent到CMA与IOMMU
2026/10/10 2:09:42 网站建设 项目流程

前阵子调试一块外设驱动,在中断处理里用dma_alloc_coherent分配描述符表,结果系统跑起来没多久就分配失败,然后整个 DMA 链路全都卡死。顺着这条线把 coherent 映射的整套机制从头到尾捋了一遍,才意识到这块内容远比 API 表面的三行代码复杂:它牵扯到 CPU 缓存一致性、CMA 连续内存、atomic pool,甚至还有 IOMMU 页表属性。这篇笔记就是把当时排查和读内核源码的过程整理出来,写给同样在 Linux 下写 DMA 驱动的朋友。

这篇文章会讲清楚四层东西:第一,coherent 映射到底在解决什么问题;第二,从驱动调用到内核底层,dma_alloc_coherent完整走了一遍哪些路径;第三,设备树里的dma-coherent、DMA mask、dma-ranges这些隐藏规则是怎么影响分配结果的;第四,我在实际项目中踩过的坑和排查思路。内容偏底层,但对写网卡驱动、音视频采集驱动、PCIe 设备驱动的开发者来说,这些知识点迟早会用到。

1. coherent DMA 到底解决什么问题

1.1 一次 DMA 数据不一致事故

先从一个最基础的问题说起:为什么内核要单独搞一套 coherent 映射?

假设设备要向内存写数据,DMA 控制器直接把数据写到物理内存里。CPU 这边为了性能,平时读写内存都要经过 cache。这两者一旦协同不好,问题就来了:CPU 往地址 A 写了一个值,这个值可能只停留在 CPU cache 里,还没有写回到物理内存;此时设备 DMA 直接从物理内存地址 A 读数据,读到的就是旧值,而不是 CPU 刚写的值。反过来也一样,设备往物理内存写了新数据,但 CPU cache 里还残留着旧数据,CPU 去读的时候命中 cache,拿到的还是旧值。

打个比方:你手头有一份共享文档,你本人一直在你自己的草稿纸(cache)上修改,实际网上那份文件(内存)根本没人更新过;另一个人却直接下载网上那份文件来读,拿到的自然是旧版本。这种数据错乱在 DMA 驱动里是致命的,尤其是描述符、状态字这类每个数据包都要读写的东西。

要解决这个矛盾,通常有两条思路。第一,每次 DMA 操作前后,软件主动把 cache 里的数据刷回内存,或者把对应 cache line 失效掉,让 CPU 和设备看到的内存视图保持一致。第二,干脆让这一段内存区域不走 cache,或者用硬件机制保证 cache 和设备之间自动同步,这样 CPU 读写和设备读写都直接落在同一份真实内存上,天然没有一致性问题。

1.2 coherent 映射与 streaming 映射的分工

Linux DMA 子系统把上述两条思路封装成了两种映射方式:

coherent 映射,对应dma_alloc_coherent和dma_free_coherent。它分配一段内存,并保证 CPU 和设备看到的内容始终一致,不需要驱动手动做 cache 同步。实现上通常是分配出来的物理内存被映射成不可缓存属性,或者由硬件(比如带 cache-coherent 互联的 SoC、支持 PCIe NoSnoop 的系统)来维持一致性。

streaming 映射,对应dma_map_single、dma_map_sg和dma_unmap_single等。它不做静态的一致性保证,而是在每次映射和取消映射时,配合适当的 cache clean / invalidate 操作,让数据在某个操作时间段内保持一致。用完之后需要主动取消映射。

两者怎么选,基本规则如下表:

对比项coherent 映射streaming 映射
分配方式dma_alloc_coherentdma_map_single / dma_map_sg
内存生命周期分配后长期使用,反复访问一次 DMA 传输期间使用,结束后释放
一致性维护系统自动保证,无需驱动干预驱动在 map/unmap 时配合 cache 操作
典型用途描述符环、控制结构、状态块数据包缓冲区、大块数据搬运
性能特征CPU 访问可能较慢(uncached)CPU 访问快,但每次 map/unmap 有 cache 操作开销

1.3 什么时候才需要 coherent 分配

实际写驱动时,我倾向于遵循一个原则:设备软件要反复读写的控制结构,用 coherent;一次性传输的大块数据,用 streaming。

比如一个网卡驱动,发送描述符、接收描述符、完成队列,这些结构 CPU 和硬件每一包都要读写,如果每次都手动做 cache 同步,光 cache flush 的开销就把性能吃掉了,而且容易漏,漏一次就是难查的数据错乱。这时候直接dma_alloc_coherent分配描述符表,后面所有描述符操作都不需要操心 cache。

而真正的网络数据包 buffer,一般用dma_map_single做 streaming 映射。数据包是一次性从 NIC 收进来或者发出去,映射一次、DMA 一次、取消映射,不需要长期保持一致性映射。这样 CPU 平时访问数据缓存时还能走 cache,性能更好。

搞不清楚该用哪个的驱动,最常见的结果就是两种:一种是无脑全部用 coherent,导致 CPU 访问所有 DMA buffer 都很慢;另一种是控制结构也走 streaming,结果稍一疏忽就出现 cache 同步遗漏,数据静默损坏。

2. dma_alloc_coherent 的完整调用链与底层路径

2.1 驱动侧调用姿势与返回值说明

先看驱动层最典型的调用方法。驱动里分配一块 coherent 内存,通常这么写:

dma_addr_t dma_handle; void *cpu_addr; cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL); if (!cpu_addr) { dev_err(dev, "failed to alloc coherent memory\n"); return -ENOMEM; }

这里返回值的含义值得仔细说清楚。cpu_addr是 CPU 侧的虚拟地址,驱动可以直接读写这个指针来操作内存;dma_handle是设备侧能拿到的地址,驱动需要把这个值写到设备寄存器里,告诉硬件"这块内存的地址是 X"。

关键在于:在没有 IOMMU 的平台上,dma_handle往往就等于物理地址;但在启用了 IOMMU/SMMU 的平台上,dma_handle是 IOVA(IO 虚拟地址),和物理地址没有直接关系。不少老驱动代码习惯用virt_to_phys(cpu_addr)去拿"物理地址"传给硬件,这套做法在没有 IOMMU 的环境下能跑通,一旦开了 IOMMU 就失效了,传过去的地址根本不是设备能访问的地址。正确做法永远是使用dma_handle,不要自己去换算。

用完释放,对应调用:

dma_free_coherent(dev, size, cpu_addr, dma_handle);

这里的size必须和分配时完全一致,cpu_addr和dma_handle也必须用分配时返回的原值,任何一个对不上都会导致内存管理系统出问题,轻则泄漏,重则内核崩溃。

2.2 直连 DMA 路径:dma-direct、CMA 与 atomic pool 的配合

在设备没有连接到 IOMMU 域、走直连 DMA 的情况下,dma_alloc_coherent最终会落到dma_direct_alloc这条路径上。这条路径的分配优先级大致是这样的:

首先检查设备是否声明了自己的 coherent memory。某些设备自带一段内存,比如网卡内置的 buffer、显卡的显存区域,驱动可以通过dma_declare_coherent_memory把这段区域注册给 DMA 子系统。如果注册了,分配会优先从这段区域里出。

然后看设备是否需要物理连续的大块内存。DMA 设备通常要求物理内存连续,普通页面分配在高阶分配失败时很容易得到离散页。所以内核会优先从 CMA(Contiguous Memory Allocator)区域分配,CMA 专门保留了一段物理连续的内存,平时允许被可移动页占用,DMA 需要时再把页挤走腾出来给 DMA 用。这也是为什么内核命令行里常见cma=64M这类参数。

如果芯片架构支持直接重映射,也就是能把普通页面重新映射成不可缓存属性,那么分配出来的普通页面会通过dma_common_contiguous_remap之类的接口重新映射,得到适合 DMA 的 CPU 虚拟地址。这一步在 ARM64 上很常见,x86 上则更多依赖 PAT/MTRR 来设置页面属性。

最后还有一个特殊路径叫 atomic pool。驱动在中断上下文或者持有自旋锁的情况下不能睡眠,不能用GFP_KERNEL分配,只能用GFP_ATOMIC。普通页面分配在原子上下文也许还能勉强工作,但 remap 这类操作往往是不能睡眠的。内核在启动阶段就预创建了一个 atomic pool,里面放了若干物理连续的内存页,专门供原子上下文使用。它的容量不大,如果原子上下文频繁分配大块 coherent 内存,很容易把 pool 耗尽,现象就是分配失败,dmesg 里能看到相关警告。

2.3 IOMMU/SMMU 路径:IOVA 映射与页表属性

当设备挂到 IOMMU 域下时,分配路径会切换到iommu_dma_alloc,这一步和直连路径有本质区别:物理页面分配只是个开始,关键在后面的地址映射。

内核先通过 CMA 或者普通页面分配出物理内存,然后在 IOMMU 的 IOVA 地址空间里找一段空闲区间,把这段 IOVA 和物理页面建立映射,写进 IOMMU 页表。最终返回给驱动的是 IOVA 地址,而不是物理地址。对于设备来说,它只认 IOVA;对于 CPU 来说,通过cpu_addr访问的还是原来的物理页面虚拟地址。

IOVA 映射带来一个额外的好处:即使物理页面不是连续的,只要 IOVA 空间里找到一段连续的地址区间,在设备看来地址依然是连续的。换句话说,IOMMU 可以把离散物理页"拼"成一段连续的 IOVA 空间,极大缓解了物理连续内存的压力。这也是为什么在一个大系统里,开 IOMMU 之后dma_alloc_coherent分配大块内存的成功率明显更高。

页表属性同样影响一致性。IOMMU 映射时会给页表项设置属性,比如IOMMU_CACHE标志代表允许硬件 cache 一致,non-coherent 设备的映射可能就要配合相应的 cache 策略。某些平台还支持在 IOMMU 页表层面直接设置 cacheable 或 write-through 属性,这比 CPU 侧 remap 的灵活性更高。

开发者在这条路径上最容易忽略的一点:分配到的dma_handle是 IOVA,千万别把它当物理地址用,也不要用virt_to_phys反推。调试时如果要查看当前的 IOVA 映射和 iova 分配情况,可以通过 debugfs 下的/sys/kernel/debug/iommu/查看,或者打开内核的 IOMMU debug 选项。

2.4 小块内存的配套工具:dma_pool

dma_alloc_coherent每次分配是直接向底层内存管理要页,适合大块内存。但描述符这类对象通常只有几十字节,如果每个描述符都向底层要一整页,浪费非常严重。内核提供了dma_pool,专门在这种需求下做"二次分配"。

dma_pool_create创建池子时,内部会调用一次dma_alloc_coherent拿到一块较大的内存,然后在这块内存上做小块分配,每个小块都满足指定的对齐要求。之后每次dma_pool_alloc就是从池子里切一小块出来,开销很小,不会每次触发底层页分配。

我个人经验是,凡是设备有固定大小的描述符、门铃、响应结构,优先用dma_pool而不是直接dma_alloc_coherent。比如一个收发队列配几十个描述符,如果用一个 pool 统一管理,分配和释放都快得多,也方便统一做地址对齐。而且dma_pool_alloc返回的结构同样包含cpu_addr和dma_addr两个地址,使用规则和dma_alloc_coherent一致。

不过dma_pool也有个局限:它适合大小相对固定、使用频繁的小对象,不适合大小差异很大的结构。那种情况可以退回去自己用dma_alloc_coherent分配几块不同大小的内存,再在驱动里做简单的内存管理。

3. 设备树、DMA mask 与地址映射的隐形规则

3.1 dma-coherent 属性如何决定一致性策略

设备树里经常能看到某个设备节点上写着dma-coherent;,这行属性含义是:该设备和系统硬件之间是 cache 一致的。也就是说,设备访问内存的时候,硬件互连保证 CPU cache 和设备看到的内容一致,不需要软件额外维护 cache。

这个属性会直接影响dma_alloc_coherent的底层行为。对 coherent 设备,内核可以直接使用普通缓存属性的页面作为 DMA buffer,CPU 侧访问也快,不需要做复杂的 remap;对 non-coherent 设备,内核才需要在分配时把 CPU 虚拟地址映射成不可缓存或者写合并属性,或者依赖 atomic pool、CMA 等特殊内存区域。

调试时有一个判断技巧:如果 dmesg 里某个设备驱动加载时能看到 "coherent mask" 相关日志,或者你在/sys/bus/platform/devices/xxx/下能看到相关信息,基本能确认设备侧的一致性配置。假如一个设备明明是 non-coherent,但驱动不配合做任何 cache 维护,就会出现数据隔三差五错乱、重传率激增这类怪问题。反过来说,设备声明了dma-coherent,驱动却照着 non-coherent 的方式每次都做 cache clean,性能也会白白损耗。所以设备树属性、驱动代码、硬件能力三者必须对齐,这是排查一致性问题的第一检查点。

3.2 DMA mask:地址宽度决定你的内存能不能被访问

设备能访问多大范围的地址,由 DMA mask 决定。驱动初始化时通常这么设置:

ret = dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32));

这句代码的意思是:设备和 coherent 内存分配的地址都不能超过 32 位地址空间。对于没有 IOMMU 的情况,这相当于限定分配的物理页面必须在 4GB 以内,内核在分配时如果找不到满足地址要求的内存,就可能失败或者启用 swiotlb 做缓冲。对于有 IOMMU 的情况,DMA mask 更像一个 IOVA 空间的边界,IOVA 分配器会在上限之下寻找可用区间,超出部分不会分配。

常见问题就是把 mask 设置得太小。比如设备其实支持 40 位地址,但驱动偷懒设成 32 位,大内存系统上分配结果往往离奇失败,或者每次 DMA 都要经过 swiotlb bounce buffer,性能严重下降。反过来设得比硬件真实能力大,设备又可能收到超出自己寻址范围的高地址,导致 DMA 访问挂掉或者静默丢数据。我在某个平台上就遇到过设备标称 32 位寻址,驱动却设成 64 位 mask,结果大块 buffer 分配到了高地址,设备 DMA 一访问就 hang 住的现场。

排查 DMA mask 相关问题时,最直接的手段是看驱动初始化日志里打印的实际 mask,以及/sys/bus/xxx/devices/xxx/of_node里对dma-ranges的解析结果。调试时如果怀疑地址越界,可以在 DMA 分配成功后打印dma_handle,核对是否落在设备 mask 范围内。

3.3 dma-ranges 与总线地址映射

设备树里的dma-ranges描述的是父总线地址空间到子设备地址空间的映射关系。典型的例子是 PCIe host bridge,CPU 看到的物理地址空间和 PCIe 总线地址空间不是直接等同的,需要通过dma-ranges进行范围转换。内核在枚举设备时会解析这组属性,得到设备可访问的总线地址窗口,之后所有 DMA 地址分配都会在这个窗口内进行。

对驱动的理解来说,关键是不要想当然地认为"设备地址就是物理地址"。即使在没有 IOMMU 的平台上,只要存在地址转换机制,dma_handle也可能与 CPU 物理地址不同。设备看到的地址空间和 CPU 的物理地址空间,在总线层面本来就可以是不同的,dma-ranges就是描述这两者映射关系的权威来源。

调试这类问题时,我喜欢用dmesg | grep -i dma配合设备节点解析日志来看最终确定的 DMA 窗口。某个平台上曾经遇到过一次问题:设备树里dma-ranges配错了起始地址,导致驱动里所有的dma_handle都落在不该落的位置,设备 DMA 能访问的只是一段错误的总线窗口,改完属性之后问题立刻消失。

3.4 保留内存与物理地址固定:reserved-memory 与 device CMA

有些使用场景要求 DMA 内存的物理地址必须固定,或者必须从某一段特定地址范围内出。比如一个视频编解码器要求 buffer 落在固定物理地址,或者某个外设只能访问特定片选空间对应的内存。这时可以在设备树里通过reserved-memory节点预留内存,配合dma_pool或者compatible = "shared-dma-pool"使用。

比较常见的做法是这样一段设备树配置:

reserved-memory { #address-cells = <2>; #size-cells = <2>; ranges; dma_pool_a: dma-pool@80000000 { compatible = "shared-dma-pool"; reg = <0x0 0x80000000 0x0 0x4000000>; no-map; }; }; device_node { memory-region = <&dma_pool_a>; };

这里在 0x80000000 处预留了 64MB 内存作为 DMA 区域,no-map表示这段内存不映射进内核页表,系统其他模块不会占用它。设备驱动和 DMA 子系统的协同机制会从这段区域给该设备分配 coherent 内存。这个做法的好处是物理地址固定、连续,坏处是这段内存在系统里被"隔离"了,无法被通用内存管理复用,如果预留过大,可用内存会明显减少。

也有人在全局 CMA 之外再给某个设备单独配 CMA region,本质上跟上面类似,只是 CMA 区域允许空闲时被可移动页面复用,利用率更高。这类配置适合大块连续内存需求多、又希望灵活性高的设备。

4. 实操踩坑记录与性能调优心得

4.1 分配失败的排查思路

dma_alloc_coherent返回 NULL 最让人头疼,因为失败点可以藏在很多层。我总结了一套排查顺序,按照概率从高到低排列。

第一看 CMA 剩余量。系统启动后,/proc/meminfo里能看到CmaTotal和CmaFree。如果CmaFree很小,说明 CMA 区域被大量占用,分配大块连续内存很容易失败。这时可以看/proc/buddyinfo确认高阶页面是否充足,或者用内核的 CMA debugfs 接口看当前 usage。临时解决是增大cma=启动参数,治本方案是检查是不是有驱动长期占用 CMA 页不释放。

第二看是否在原子上下文里分配了过大的内存。如果中断处理里用GFP_ATOMIC调用dma_alloc_coherent,大小超过 atomic pool 的容量,分配必然失败。这种情况有几个出路:一是把大块分配放到线程上下文,提前分配好;二是调整内核里 atomic pool 的容量配置;三是尽量减小原子上下文中的分配大小。

第三看 DMA mask 是否过小。设备 mask 设了 32 位,而系统内存大部分在 4GB 之上,直连 DMA 时分配就会很困难。排查方法是启动参数里临时加swiotlb=force看看是否缓解,或者直接打印 mask 核对地址窗口。

第四看设备树里的 reserved-memory/CMA region 配置是否有问题。region 重叠、大小不足、no-map用法错误,都会造成分配失败。这类问题通常是改设备树后编译时没注意地址范围。实测中这类问题最直观,因为失败是一启动就稳定出现的。

下面这张表可以当速查表用:

现象常见原因快速检查手段
大块分配失败CMA 耗尽/proc/meminfo 的 CmaFree、CMA debugfs
原子上下文分配失败atomic pool 耗尽dmesg 里的相关 warning
地址越界/访问异常DMA mask 设错打印 dma_handle,核对 mask
固定地址分配失败reserved-memory 配置冲突检查设备树节点地址重叠
加 IOMMU 后地址错乱把 IOVA 当物理地址确认 dma_handle 来源,避免 virt_to_phys

4.2 coherent 内存的 CPU 读写性能陷阱

coherent 映射为了保证一致性,很多平台上 CPU 访问这段内存是走不到 cache 的,或者 cache 策略受限。这带来的直接后果是:驱动如果高频读写 coherent 内存里的数据结构,性能会比普通内存慢非常多。

举个例子,一个接收队列的描述符状态字,如果每个包都去读这个状态字,而且这段内存恰好是 uncached 属性,CPU 每次读取都可能要经过总线访问,时延比 cache 命中高一个数量级。整个收包路径的性能可能就从每秒钟几百万包掉到几十万包。

遇到这种情况,我的做法是先看清楚性能瓶颈是不是在 coherent 内存访问上。可以用 perf 统计相关指令周期和 cache miss 情况。如果确认是 coherent 内存读写太慢,可以考虑这几个优化方向:

第一是减少 CPU 对该内存的访问频率。比如描述符状态字不要每次轮询时都从内存里读,而是把关键状态缓存一份在 CPU 可缓存的内存里,批量同步。

第二是考虑把高频访问的结构拆开。真正需要 coherent 的只有设备看到的控制字段,可以把它放一小块 coherent 内存里,其他辅助数据放普通内存。

第三是如果平台支持,可以尝试用dma_alloc_attrs配合DMA_ATTR_WRITE_COMBINE等属性,把内存映射成写合并模式,在某些场景下读写性能会比完全 uncached 好一些。但要注意这种属性对读操作可能更糟,需要实测。

4.3 一个容易忽略的地址陷阱:dma_handle 不是物理地址

这个话题前面提过,但值得单独再讲一次,因为它坑了很多人,包括我自己。

在没有 IOMMU 的直连路径下,dma_alloc_coherent返回的dma_handle通常等于物理地址,所以不少老代码里根本没有 dma_handle,而是用虚拟地址换算物理地址传给硬件。这些代码在传统平台上是能跑的。

但一旦系统里启用了 SMMU/IOMMU,尤其现在 ARM64 服务器和终端平台上基本默认打开,情况就完全变了。dma_alloc_coherent在 IOMMU 路径下做完物理页分配后,还要经过 IOVA 分配和 IOMMU 映射,返回的dma_handle是 IOVA。这个 IOVA 和物理地址之间隔着 IOMMU 页表,设备用自己的总线地址访问时,IOMMU 负责翻译成实际物理地址。

所以我在代码审查时,看到virt_to_phys、phys_to_dma这类函数出现在 coherent 内存处理逻辑里,就会特别警惕。正确的处理方式是:硬件侧永远只用 API 返回的dma_handle,软件侧只用cpu_addr,两者之间不要去换算。如果确实需要物理地址做调试,也要先确认这个设备当前在不在 IOMMU 域里,不能在驱动里硬编码假设。

4.4 一条更顺的实操路径:把 learn 和 debug 打通

学习这套机制最有效的方式,不是只看 API 文档,而是把内核代码对应的章节直接打开,对照实际设备树和内核日志来理解。

我建议在调试环境里做这么几件事:第一,先确认内核开了哪些相关配置,比如CONFIG_DMA_DIRECT_REMAP、CONFIG_CMA、CONFIG_IOMMU_DMA,这决定了dma_alloc_coherent实际会走哪些分支。第二,在驱动的分配路径上临时加一些日志,打印传入的 size、返回的 cpu_addr 和 dma_handle,再打印设备节点的 coherent 状态,这样能快速定位当前走的是哪条底层路径。第三,打开内核的 DMA debug 选项(CONFIG_DMA_API_DEBUG),它会在运行时检测常见的 DMA API 滥用问题,比如重复 unmap、地址不匹配、map/unmap 不对称,这类问题用肉眼排查很痛苦,让内核帮你查高效得多。

真到了 PM 工程师能把一个问题定位到 cache 层级的时候,通常都是先搞清楚"这行代码里的地址到底是谁的视角",然后一步步推过去。这个思路比背任何 API 文档都管用。

我在实际项目里体会最深的一点是:coherent 机制之所以看着复杂,是因为从 CPU cache 到总线到 IOMMU 再到设备,这一整条链路上任何一个环节的地址或一致性策略不一致,都会以极难复现的方式爆发问题。调试的时候抓住两条主线——一是地址视角,搞清楚当前处理的是 CPU 地址、物理地址还是 IOVA;二是一致性策略,确认设备是 coherent 还是 non-coherent,对应的 cache 操作是否到位。这两条线理清楚了,剩下的大多是配置细节。最后再分享一个小技巧:凡是怀疑 DMA coherent 相关的问题,先看设备树里dma-coherent和dma-ranges这两处属性,再看驱动初始化时的 mask 设置,这两个地方出问题的概率比代码逻辑本身高得多。先把配置层的嫌疑排除掉,再往深挖内核路径,能省下大量调试时间。

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

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

立即咨询