Linux DMA分配接口详解:dma_alloc_coherent与dma_alloc_writecombine的选型

Linux DMA分配接口详解:dma_alloc_coherent与dma_alloc_writecombine的选型 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 换到 ARM64dma_alloc_coherent底层从dma-iommu.c换成了dma-direct.c行为变化明显。凡是自己 hack 过页表属性的驱动跨平台基本都会翻车。个人体会调试这类问题最耗时间的地方不是代码逻辑而是“你以为你知道内存是 cached实际它不是你以为它不是 cached实际它是”。先把页表属性和平台 DMA 实现搞清楚能省掉一整天的抓瞎时间。驱动里选 DMA 分配接口永远是“场景决定 API”。dma_alloc_coherent保的是可靠dma_alloc_writecombine冲的是带宽混用、错用、自行改装都会在某个夜深人静的调试现场让你付出时间代价。希望这篇文章能让你在下次写驱动时不用再纠结这两个函数了。