1. 为什么这两个 DMA 分配接口总让驱动工程师犹豫
搞过 Linux 设备驱动的人,基本都在dma_alloc_coherent和dma_alloc_writecombine之间纠结过。明明是两行很像的 API,选错了,轻则性能不好看,重则系统随机花屏、报错,甚至直接 hang 死。这两个函数都是内核给设备驱动程序分配 DMA 内存的入口,但它们的缓存属性、硬件行为、适用场景差别非常大。这篇文章我会把两个接口的底层机制、缓存一致性问题、实际选型思路和一些我踩过的坑一次性说清楚,适合正在写 ARM 平台驱动、视频采集驱动、DMA 控制器驱动,或者只是想把内核内存分配搞明白的工程师。
先给结论:dma_alloc_coherent分配的内存是一致性内存,CPU 和 DMA 设备看到的内容始终同步,不需要手动维护缓存;dma_alloc_writecombine分配的内存允许 CPU 写操作合并,但不保证读一致性,也没有完整的缓存一致性保证。看起来只是“要不要缓存”的区别,实际牵涉到 cache 策略、bus 事务、barrier、MMU 页表属性一整套东西。这里面的坑,比函数名看上去多得多。
2. 两个 API 背后到底是怎么回事
2.1 dma_alloc_coherent 的一致性承诺是怎么做到的
先说dma_alloc_coherent。它在内核里的本质是:从 DMA 可用内存区域分配一块物理内存,同时返回 CPU 的虚拟地址和 DMA 总线地址,并且这块内存的页表属性被设置成 CPU 不缓存,或者由硬件平台通过总线监听机制保证缓存一致性。
不同架构的实现不一样。x86 上 IOMMU 开启时,结构可能走 swiotlb 或 IOMMU 映射;ARM 上则依赖dma_map_ops,常见做法是把页表属性设为MT_DEVICE或MT_UNCACHED,或者用了CMA区域做分配。但行为目标一致:CPU 写一个值,DMA 设备马上能读到;DMA 设备写一个值,CPU 也马上能读到,不需要在软件层手动 flush cache。
我在 ARM 平台调试时,见过实际页表属性变成了强序设备内存的情况。CPU 访问这种内存时,读写都直接发到总线,不会留在 cache 里,所以每一次读写都产生真实的内存事务。这带来的副作用就是,CPU 访问这块 buffer 的性能比普通内存慢得多,尤其是一次读几百字节这种操作,吞吐非常难看。
2.2 dma_alloc_writecombine 的写合并是什么
dma_alloc_writecombine听名字就知道,重点在 write combine。这种内存映射允许 CPU 的写操作在写缓冲器里合并成更大的总线事务,然后再一次性写到内存或外设。典型用途是显存、framebuffer、显示控制器的显存映射,因为显示场景是 CPU 不断写入像素数据,设备只读,写合并能显著减少总线事务数。
但它和dma_alloc_coherent的关键区别是,它并不是完整的一致性内存。CPU 读这块地址时,可能直接穿透到内存,也可能读到未合并的、过时的写缓冲,行为在不同架构上不一样。换句话说,你不要指望通过它实现 CPU 和设备双向交互,然后用它传状态标志。读会很慢,读到的数据也可能不是你想要的。它更适合单向数据流:CPU 写,设备读,偶尔设备写,CPU 基本不读。
还有一个容易误解的点,写合并内存理论上依然不允许普通 cache 缓存。它不是 write-back cache,更不是 write-through cache,它只是把写入缓冲合并了。所以它和“普通内存”是两码事。
2.3 术语口径:coherent、writecombine、non-cached 的区别
很多人把 non-cached 和 writecombine 当成一回事,这不对。non-cached 说的是 CPU 不缓存,每次访问都直接到总线;writecombine 是 CPU 不缓存,但是 CPU 的写操作可以暂存在写合并缓冲里,等凑够了突发长度再发出去。
从 CPU 角度,两种属性下读都不是缓存命中的读;从总线角度,writecombine 写事务是延迟合并的,non-cached 是即时可观察的。从驱动代码角度,绝大多数驱动只需要记住一条规则:
- 需要双向读写、频繁同步的设备内存,用
dma_alloc_coherent。 - 只需要 CPU 写、外设读,且写吞吐量要求高的场景,
dma_alloc_writecombine更合适。
注意:如果从字面理解“writecombine 比 coherent 快”就错了。快不快取决于访问模式。CPU 密集读写的控制结构,放 writecombine 里只会比 coherent 更慢更难受。
3. 影响行为的关键:缓存一致性、总线特性和平台差异
3.1 为什么 DMA 天然和 CPU Cache 冲突
CPU 的 cache 是给 CPU 加速的,但 DMA 设备不经过 cache,它直接读写内存。如果 CPU 把数据放在 cache 里还没写回内存,DMA 设备读内存时读到的就是旧数据;反过来,DMA 设备往内存写了新数据,CPU 的 cache 里还留着旧副本,CPU 读到的也是旧数据。这就是 cache coherence 问题。
解决思路有两种。第一种,硬件的总线监听(snoop)保证一致性,多数桌面级 SoC 在特定地址域里能做到,但嵌入式平台很多不支持;第二种,软件手动维护,比如 CPU 读之前invalidate,写之后clean。dma_alloc_coherent把事情简化,直接分配不缓存的内存,软件不需要管维护了,代价是 CPU 访问性能损失。
还有一层要理解,coherent 不等于内存本身有多么特殊,它就是普通物理内存,只是映射属性变了。你可以在系统里通过/proc/iomem或者分配时打印物理地址看到,它依然落在内存范围内,但它不被 cache。这一点在性能调优时特别重要,因为很多人以为 DMA buffer 都是普通内存,然后拿它跑高频率 CPU 读写,结果性能一塌糊涂。
3.2 缓存行和总线事务决定了写合并适不适合你
总线事务是有开销的。CPU 写一个字,如果走普通内存,通常要经历读缓存行、修改、写回的过程;如果走 uncached 内存,要立刻发一个写事务;如果走 writecombine 内存,写事务可能被合并,比如连续写 16 个 32 位寄存器,最后总线可能只需要发几个突发传输,或者一个大的写事务。
举例来说,你要把一个 1024×768 的 RGBA 图像从 CPU 写进显存,普通 uncached 映射下,每个像素的写都触发总线事务,带宽根本不够;writecombine 映射下,写缓冲会尽量合并,带宽表现接近理想值。反过来,如果你在 writecombine 内存里放一个struct device_desc,驱动每改一个字段都期望设备立刻看到,那就坏了,因为你不知道它被合并到了哪次总线事务里。
再看 CPU 读的场景。writecombine 内存通常不具备缓存能力,CPU 读会穿透,而且有些平台上连续读 writecombine 内存会发生很差的性能表现,所以它完全不适合 CPU 频繁读的缓冲区。比如 DMA 收到的网络包、传感器数据,都需要 CPU 逐字段去解析,这种 buffer 用 writecombine 就是灾难。
3.3 掩码、IOMMU 和 CMA:分配成功背后的隐藏条件
dma_alloc_coherent能否成功,不只看内存够不够。DMA mask 决定可访问的地址范围,比如 32 位设备只能访问低 4GB,驱动里要设置dma_set_mask_and_coherent()。IOMMU 开启后,DMA 地址和物理地址可以不对等,所以 API 返回的dma_addr_t不能当作物理地址直接拿去给 CPU 用;反之,物理连续的要求在 IOMMU 下也被放宽了。很多驱动工程师在调试早期忘了设置 mask,分配出来地址设备无法访问,问题非常隐蔽。
另外,coherent 内存常来自 CMA 区域。CMA 区域的内存可以被页表映射为 uncached 或者 writecombine,分配大小和连续性对性能影响很大。如果驱动里频繁申请大块 coherent 内存,要关注 CMA 碎片。我在一个视频采集驱动里遇到过,连续跑几个小时之后dma_alloc_coherent开始失败,最后排查是 CMA 碎片加上每次申请 16MB 导致长期无法满足连续内存需求。
实操提示:性能敏感的驱动可以在 probe 阶段把大块 DMA buffer 一次性分配好,不要在运行期反复申请。反复申请不只慢,还会加剧碎片。
4. 实际使用时的 API 写法与注意事项
4.1 熟悉函数签名比背结论重要
void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag); void *dma_alloc_writecombine(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag);两个函数的参数完全一样,dev是发起 DMA 的设备,size是字节数,dma_handle是返回的总线地址,flag是分配标志。返回值是 CPU 能访问的虚拟地址,一般用ioremap或直接 vmalloc 映射方式映射出来。
一个容易忽略的点是size最好按页对齐,或者至少按 cache line 对齐。dma_alloc_coherent分配的内存对size会按页粒度处理,但如果你传入一个不是页对齐的 size,内部可能按页对齐分配,也有平台会返回比你要求更大的缓冲。代码里不要假设 size 和实际映射大小完全相等,尽量统一用分配的 size 去访问。
释放时用dma_free_coherent(dev, size, cpu_addr, dma_handle)和dma_free_writecombine(dev, size, cpu_addr, dma_handle)。这里必须保证cpu_addr和dma_handle和分配时保存的一致,不能修改,否则内核会报错甚至 panic。
4.2 size 和设备掩码的选择
size的建议是:控制结构、描述符数组,可以按sizeof(struct xxx)向上取整到 cache line;数据缓冲,按目的功能选择,比如视频一帧width * height * bpp。更严谨的做法是让 size 等于硬件实际读写范围,不要随意申请大块。
如果设备要访问的地址范围超过默认掩码,必须在分配之前设置掩码。常见代码是这样:
if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))) { dev_err(dev, "no suitable DMA mask available\n"); return -ENODEV; }对于 64 位设备,可以尝试 64 位掩码,失败时回退 32 位。掩码设置成功不代表dma_alloc_coherent一定能分配合适地址,还要看平台 IOMMU 的地址窗口,以及 CMA 区域是否落到允许范围内。很多平台会把 DMA 窗口限制在某段物理地址,驱动里可以把返回的dma_handle打印出来,通过dma_to_phys()转换成物理地址,再核对实际布局。
4.3 用 debug 手段验证分配出来的内存到底是什么属性
我常用三种方法验证分配属性。第一,在驱动里打印返回的虚拟地址,然后在/proc/vmallocinfo或/proc/iomem里查它的映射区域;第二,用dma_debug开启内核 DMA API 调试,它能在你误用 DMA API 时报告错误;第三,读写性能和时延测试直接说明问题。
如果怀疑映射属性不对,可以通过 perf 或者简单的时间统计测试连续读写。比如:
static noinline u64 read_test(void *addr, int count) { u64 sum = 0; int loops = count / sizeof(u32); u32 *p = addr; for (int i = 0; i < loops; i++) sum += p[i]; return sum; }这个测试在 uncached 区域和普通 cached 区域的速度差异可以到几倍甚至十几倍。如果dma_alloc_coherent返回的内存其实被 cache 了,但设备期望一致性,就会偶尔出问题。这类问题在驱动开发中极其难排查,因为不是必现。建议在板子 bring-up 阶段就把所有 DMA buffer 属性测一遍,提前暴露问题。
5. 现实中的选择困境:什么时候用哪个
5.1 控制结构频繁读写,选 dma_alloc_coherent
硬件和软件要频繁同步状态的结构,比如 DMA 描述符、环形缓冲区头部、设备寄存器映射,一般用dma_alloc_coherent。这种场景下 CPU 和设备都要反复读写同一个数据结构,需要“改完立即生效”的一致性保证。
举一个典型例子:网络驱动的 TX/RX ring。CPU 在 descriptor 里填好地址和长度,写一个 valid 位,然后通知网卡读取。网卡处理后,把状态字段更新,CPU 轮询状态位。整个过程充满了双向同步,如果这块内存被 CPU cache 或者 writecombine 缓冲,那么 valid 位或状态位的可见性就成问题。你用 writecombine 写的 valid 位可能被合并,网卡迟迟看不到,或者网卡更新的状态被 CPU 读到旧缓存,整个传输永远不完成。
这类控制结构,老老实实用 coherent 是稳妥的。如果觉得 coherent 性能不够,可以考虑分配 normal cacheable 内存,然后用dma_map_single配合dma_sync_single_for_device和dma_sync_single_for_cpu手动维护缓存。这属于进阶玩法,性能上限更高,但出错率也高,适合熟练工。
5.2 大批量数据单向传输,writecombine 是正解
大批量数据如果从 CPU 到设备,且设备只读,比如 framebuffer 的像素数据、显示图层、某些数据加速器的输入 buffer,用dma_alloc_writecombine更合适。CPU 写像素时写缓冲会合并,总线效率高,设备依次读数据,不要求 CPU 再读回来。
反之,如果从设备到 CPU,比如摄像头采集、网卡收包,应该用dma_alloc_coherent加映射,或者干脆用 streaming DMA 映射。因为 CPU 需要读数据,writecombine 内存读起来又慢又可能读到陈旧数据,这是性能重灾区。不要因为“只是往内存里放数据”就顺手用 writecombine,读端才是关键。
很多显示驱动里,dma_alloc_writecombine是官方推荐用法,比如 DRM 的 dumb buffer、fbdev 的 framebuffer。这也说明它不是一个冷门冷门函数,而是有明确场景的常用工具。
5.3 性能对比、缓存行冲突和边界对齐
如果非要在两者之间做性能对比,可以从三个维度看:连续写、随机写、连续读。连续写场景下,writecombine 优势比较明显;随机写和连续读场景,coherent 其实没有想象中那么慢,真正更慢的是 writecombine 的读。理解了这个,就能解释很多驱动性能问题的根因。
还有一个隐藏问题是 cache line 冲突。dma_alloc_coherent分配的内存虽然不缓存,但如果多个 CPU 核同时访问同一块 buffer 的相邻区域,总线竞争是存在的。而 writecombine 的写合并缓冲是每个 CPU 核心独立的,跨核同步问题更明显。当两个核同时写同一个 writecombine 区域时,合并顺序和可见性不是同一个视角,需要额外用 barrier 或锁来保证顺序,但这又和 writecombine 的设计初衷矛盾。
所以设计上要避免在多核 CPU 上共享同一个 writecombine buffer 做频繁的细粒度写。把 buffer 按核拆分,每个核写自己的一块,是更安全的方案。这块 buffer 的起始地址最好做 cache line 对齐,避免和别的数据结构共享 cache line,减少伪共享概率。
注意:别把
dma_alloc_writecombine当成“更快的 coherent”。它的一致性语义比 coherent 弱,用错地方绝不是慢一点的事,而是逻辑错误。
6. 实战体验与调试技巧总结
6.1 一段对比分配的示例代码
这里用一个简单的虚拟驱动片段展示两个 API 的常规用法,便于读者放到自己的项目里做模板。
static struct my_dev { struct device *dev; void *ctrl_cpu; dma_addr_t ctrl_dma; void *fb_cpu; dma_addr_t fb_dma; size_t ctrl_size; size_t fb_size; }; static int my_alloc_buffers(struct my_dev *md) { md->ctrl_size = PAGE_ALIGN(sizeof(struct ctrl_block)); md->ctrl_cpu = dma_alloc_coherent(md->dev, md->ctrl_size, &md->ctrl_dma, GFP_KERNEL); if (!md->ctrl_cpu) return -ENOMEM; md->fb_size = PAGE_ALIGN(1920 * 1080 * 4); md->fb_cpu = dma_alloc_writecombine(md->dev, md->fb_size, &md->fb_dma, GFP_KERNEL); if (!md->fb_cpu) { dma_free_coherent(md->dev, md->ctrl_size, md->ctrl_cpu, md->ctrl_dma); return -ENOMEM; } memset(md->ctrl_cpu, 0, md->ctrl_size); return 0; } static void my_free_buffers(struct my_dev *md) { dma_free_writecombine(md->dev, md->fb_size, md->fb_cpu, md->fb_dma); dma_free_coherent(md->dev, md->ctrl_size, md->ctrl_cpu, md->ctrl_dma); }注意memset只在 coherent 映射上直接做,一般没问题;在 writecombine 上也可以 memset,但性能特征和语义不同。如果要在 writecombine 区域做初始化,我通常会单独写一个循环按 32 位写,或者用memset但心里清楚它可能产生合并写。
6.2 踩过几次坑之后的选型标准
我在做过几个平台之后,逐渐形成了一个自己的选型流程:
- 先判断 buffer 由谁写、谁读:
- CPU 写、设备读,且数据量大:writecombine。
- CPU 和设备都写都读:coherent。
- 设备写、CPU 读:coherent 或 streaming DMA。
- 判断访问频率:
- 高频小粒度访问,比如状态标志、描述符字段,coherent。
- 低频中批量访问,coherent 更稳,writecombine 收益不大。
- 判断是否需要读回:
- 需要读回,别用 writecombine。
- 不需要读回,写性能又是瓶颈,writecombine。
这个流程有时候会被硬件手册打破。比如某些 device 的 DMA 引擎要求描述符必须是 write-back 内存,那你就不能用dma_alloc_coherent,而要用dma_alloc_attrs分配普通内存再设置属性。驱动的世界没有银弹,接口只是起点。
6.3 调试工具和逐层排查经验
我最常用的调试组合是CONFIG_DMA_API_DEBUG、CONFIG_DMA_API_DEBUG_SG和CONFIG_CMA_DEBUG。把内核开上这些选项之后,常见的 DMA API 调用错误会在运行时直接打印出来,包括双重释放、错误地址等。arm64 平台上,还可以检查页表属性,通过CONFIG_DEBUG_PAGEALLOC和CONFIG_PTDUMP观察映射。
遇到疑似缓存一致性问题,我一般分四步排查:
- 打印分配出来的虚拟地址、DMA 地址和物理地址,核对地址范围。
- 在 CPU 写之后、通知设备之前,加
dma_wmb()或wmb()屏障,排除乱序问题。 - 换成
dma_alloc_coherent或dma_alloc_writecombine对比,定位是不是缓存属性导致。 - 不开 DMA API debug,先加
dma_map_single+ 手动 sync 对比,看是不是 coherent 分配本身不满足硬件需求。
最后一个经验是:不要假设所有平台的dma_alloc_coherent都由 IOMMU 做一致性映射。不同平台dma_map_ops差异极大,代码必须通过通用 API 操作,不要自己操作页表。我在迁移驱动时,经常遇到平台从 ARM32 换到 ARM64,dma_alloc_coherent底层从dma-iommu.c换成了dma-direct.c,行为变化明显。凡是自己 hack 过页表属性的驱动,跨平台基本都会翻车。
个人体会:调试这类问题最耗时间的地方不是代码逻辑,而是“你以为你知道内存是 cached,实际它不是;你以为它不是 cached,实际它是”。先把页表属性和平台 DMA 实现搞清楚,能省掉一整天的抓瞎时间。
驱动里选 DMA 分配接口,永远是“场景决定 API”。dma_alloc_coherent保的是可靠,dma_alloc_writecombine冲的是带宽;混用、错用、自行改装,都会在某个夜深人静的调试现场让你付出时间代价。希望这篇文章能让你在下次写驱动时,不用再纠结这两个函数了。