Linux DMA内存分配:dma_alloc_coherent与dma_alloc_writecombine怎么选?
发布时间:2026/9/23 7:22:38 作者:尧图编辑部 阅读量:1,286

做Linux驱动开发的人迟早会遇到一个诡异的处境CPU和设备共享同一块内存软件里明明把数据写进去了设备端读到的却是旧值设备刚写完的数据CPU一读回来全是乱码。我当年第一次调网卡驱动时就被这个问题折腾了两天最后发现罪魁祸首就是CPU Cache。要解决这类问题Linux内核提供了两把钥匙——dma_alloc_coherent和dma_alloc_writecombine。这两兄弟都能分配设备可用的DMA内存但行为、性能、适用场景完全不同选错一个轻则性能暴跌重则数据错乱。这篇文章不打算背手册我会结合自己在嵌入式平台和x86平台上的实际调试经历把这两个接口背后的一致性原理、缓存语义、架构差异和踩坑经验一次说透。1. DMA内存分配的核心问题CPU与设备眼中的同一块内存1.1 为什么普通内存不能直接给DMA用平时写驱动分配内存用的都是kmalloc或kzalloc它们返回的物理内存理论上设备也能访问。但一旦设备通过DMA读写这块内存问题就来了CPU有Cache设备却没有。举一个最典型的下行场景。你要把一组描述符写到共享内存然后通知网卡去读取。CPU执行memcpy把数据拷贝到某个地址后这些数据还躺在CPU Cache里并没有真正落到DRAM。网卡的DMA引擎直接从物理内存读数读到的自然是旧内容。反过来也一样设备通过DMA把采集到的数据写进内存数据已经到了DRAM但CPU Cache里还保留着这块地址的旧缓存行CPU一读命中的是缓存看到的仍然是旧数据。这就是一致性问题。解决思路无非两条一是在每次DMA操作前后手动做Cache刷新和失效操作dma_map_single那一套二是分配一块硬件上就不一致的内存让CPU的Cache行为从根本上不会干扰这块内存的数据传递。dma_alloc_coherent和dma_alloc_writecombine都属于第二类。1.2 两个函数解决的是同一个矛盾但方式不同从接口角度看这两个函数几乎一模一样给设备指针和大小返回CPU虚拟地址同时通过参数返回DMA总线地址。但从Cache属性上看两者指向了不同的方向分配接口CPU Cache属性典型用途一致性保证dma_alloc_coherent多数架构上为不可缓存或具有一致性保证的特殊映射需要CPU和设备频繁双向访问的共享内存完全一致不需要手动flushdma_alloc_writecombine不可缓存但允许写合并以CPU写为主、设备读为主的单向数据流写入最终可见但存在次序与延迟问题dma_alloc_coherent保证的是怎么用都不会错dma_alloc_writecombine则是在某些场景下更快和某些场景下很慢之间做了一个取舍。用一句话先概括如果这块内存CPU和设备都要频繁访问老老实实用dma_alloc_coherent如果数据的流向基本固定是CPU写、设备读而且CPU基本不做读操作才考虑dma_alloc_writecombine。1.3 分配到的内存具备哪些物理属性这两个函数分配出的内存还有几个共同特点很多新手容易忽略。第一物理地址是连续的。因为设备DMA引擎通常需要连续的物理地址块来传输数据函数内部实际上是从连续内存区域里分配物理页经过DMA地址映射后得到设备可见的总线地址。第二内存本身是经过对齐的。返回的DMA地址满足最小DMA_MINIMUM_IO_TRANSPORT_SIZE的对齐要求实际使用中常常还要用ALIGN宏再强制对齐到Cache Line大小或设备要求的边界。第三分配的内存默认被映射在内核地址空间可以直接用返回的虚拟地址读写。不要把它当作高端内存动态映射使用也不要试图用kmap之类的东西去二次映射一个dma_alloc_coherent返回的地址。曾有位同事在x86上分配了coherent内存后又手动去调用set_memory_uc改属性结果和DMA层已有的映射冲突系统直接挂掉。后来我排查半天发现他根本没必要做这一步coherent映射在x86上本来就是Uncached属性。2. dma_alloc_coherent 的机制与行为2.1 函数原型与基本参数void *dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag);参数说明devDMA设备的struct device指针。这个参数在调试和IOMMU场景下很重要正常驱动直接传pdev-dev之类即可。对于ISA设备这类没有真实device节点的老设备可以传NULL但尽量别这么做。size要分配的内存大小单位是字节。dma_handle输出参数返回DMA总线地址。注意它不一定是物理地址在启用IOMMU或DMA地址转换的平台上它可能和CPU物理地址完全不同。驱动中所有发给设备的地址都应当使用这个值。flag分配内存的GFP标志常见的有GFP_KERNEL、GFP_ATOMIC。对应的释放接口是void dma_free_coherent(struct device *dev, size_t size, void *cpu_addr, dma_addr_t dma_handle);释放时size、cpu_addr、dma_handle必须与分配时完全一致少传或多传一个字节都可能引发不可预料的错误因为DMA层要按这个size去归还页表映射和DMA地址空间。2.2 函数内部到底做了什么dma_alloc_coherent的执行过程可以简化为四步根据size分配物理页默认首选用__get_free_pages即直接分配连续的物理页。为这块物理内存建立内核虚拟地址映射并在DMA地址映射域建立页表。根据平台架构把这段地址的Cache属性设置为 不可缓存 或 一致 的类型。返回CPU虚拟地址并通过dmahandle返回DMA层映射出的总线地址。在ARM平台上如果启用了IOMMUDMA地址和物理地址之间还会经过IOVA映射设备看到的是IOVACPU用的物理地址隐藏在IOMMU的页表后面。在没有IOMMU的朴素ARM平台上DMA地址通常就等于物理地址很多老驱动直接拿dma_handle当物理地址用一旦移植到带IOMMU的平台上就立刻翻车。2.3 coherent内存的缓存语义很多人把dma_alloc_coherent理解为分配了一块不可缓存的内存这个说法在大多数ARM处理器上是成立的但在x86上语境有点不一样在ARM64上又不太一样。ARM32DMA一致内存通常映射为MT_DEVICE或MT_MEMORY_NONCACHEDCPU的Cache完全不介入。ARM64除非显式采用CMA等特殊分配器否则一致内存可能位于Normal memory区域并携带DMA_COHERENT标志由系统级一致性机制如ACE/AMBA4 CHI保证共享一致性。x86x86原生不提供CPU和PCIe设备间硬件自动一致性所以dma_alloc_coherent实际映射为PAT的不可缓存类型UC效果上等价于不可缓存。无论采取哪种底层实现对驱动作者来说不需要关心这些细节。使用dma_alloc_coherent分配的内存无需在每次DMA操作前调用dma_map_single也不需要手动flush Cache驱动代码里可以把它当作一块随时可用的安全的共享内存。2.4 使用限制与性能代价稳定是要付代价的。dma_alloc_coherent分配出的内存每次CPU读写都会直接访问DRAM没有Cache加速。对于描述符环、控制结构这类小而访问频繁的内存性能损失尚可接受。但如果是数据量大的缓冲区比如视频帧缓冲、大块包缓冲读写的性能可能比Cache型内存慢一个数量级。另外dma_alloc_coherent分配的是物理连续页size越大分配失败概率越高尤其在系统长时间运行产生碎片后。内核文档建议强制使用CMA连续内存分配器作为后端通过CMA_SIZE_MBYTES预留一段区域专门满足coherent分配需求。我曾在某项目上遇到系统运行几小时后视频采集卡频繁报内存分配失败最后给内核加了cma64M启动参数才解决。3. dma_alloc_writecombine 的定位与缓存语义3.1 函数原型与实际定义void *dma_alloc_writecombine(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag);这个函数在多数内核版本里并不是一个独立的函数实现而是通过带DMA属性标志的通用接口封装出来的宏。以较新的内核为例实际上是#define dma_alloc_writecombine(d, s, h, g) \ dma_alloc_attrs(d, s, h, g, DMA_ATTR_WRITE_COMBINE)如果某平台不支持DMA_ATTR_WRITE_COMBINE属性DMA层会退回到普通coherent映射行为但这一般是异常情况。大多数主流ARM和x86平台都支持这种属性映射。3.2 写组合Write-Combine到底是什么写组合是CPU对内存写入的一种合并优化。CPU执行连续地址的多次写操作时允许先在一个写缓冲区里合并这些写再一次性提交到DRAM总线上。省去了多次窄总线事务对顺序写类场景提升明显。但Write-Combine属性有一个重要特征该区域完全绕过CPU Cache并且对读操作极不友好。从这块内存读数据CPU无法通过Cache命中每一次读都是一次完整的DRAM总线事务延迟非常高吞吐也非常差。所以如果你在writecombine映射的内存上做大量memcpy读取性能会惨不忍睹。3.3 writecombine内存的典型适用场景第一类场景是帧缓冲。LCD控制器或者GPU通过DMA周期性扫描内存来刷新屏幕CPU驱动只需要往帧缓冲里写像素很少需要读回。而这种写入通常是大块顺序写Write-Combine的写合并特性刚好能提升写带宽。很多图形驱动在Framebuffer映射时用的就是pgprot_writecombine或类似属性。第二类场景是发送描述符或发送缓冲区。设备要读取一块由CPU生成的网络包数据CPU侧只写不读设备侧只读不写。典型如网卡的TX Ring以及部分DMA发送引擎。只要驱动能保证写入顺序正确writecombine内存可以减少CPU访问DRAM的频次提升写性能。第三类场景是发送数据缓冲区的填充分发型场景。比如音频驱动把PCM数据写到DMA缓冲区声卡控制器读走。3.4 用writecombine内存时最容易被坑的地方最容易被坑的是CPU需要读描述符状态的场景。有些驱动把TX/RX描述符放在writecombine内存里还指望通过CPU读描述符字来判断设备是否完成处理。这种操作在Write-Combine映射下极其昂贵而且由于读操作绕过Cache且总线事务被合并读到数据可能是陈旧的。更糟的是读操作的完成顺序跟处理器内存模型的交互非常微妙在ARM上可能与DMA写入产生顺序重排导致读到半新半旧的状态。我调试过一个PCIE采集卡驱动硬件设计者图省事把状态寄存器也映射在了writecombine区域内驱动每次readl轮询状态都极慢吞吐直接掉了30%。后来我把状态寄存器单独映射为Device内存数据缓冲保留writecombine问题才解决。所以我的经验是writecombine内存尽量只放写给设备看的数据不要放CPU需要轮询回读的状态位。还有一个容易忽略的问题writecombine内存里做原子操作是无意义的。因为它不参与缓存一致性协议也没有Lock前缀在内存总线上执行Read-Modify-Write的强保证不要指望atomic_inc或cmpxchg在writecombine映射上正常工作。驱动里如果需要对共享计数加锁放到普通内存去。4. 选择判断什么时候该用哪个4.1 先用数据方向判断在考虑性能优化前先画一张数据流向图。DMA内存的访问可以抽象为两个角色CPU访问和Device访问再分别看读和写。CPU写、Device读两者都可考虑数据量小的控制结构选coherent大块顺序写数据可以尝试writecombine。Device写、CPU读只能选coherent。writecombine对读是灾难。CPU和设备双向频繁访问必须选coherent。偶尔访问的控制结构优先选coherent代码简单不容易错。用一个表格总结场景推荐选择原因描述符环两端都要CPU更新/检查dma_alloc_coherent需要读写都高效且一致CPU写、Device读的大块DMA缓冲dma_alloc_writecombine写合并能提升写带宽Device写、CPU读的采集缓冲dma_alloc_coherentwritecombine读性能极差帧缓冲/Framebufferdma_alloc_writecombine典型顺序写读很少小控制结构、寄存器dma_alloc_coherent访问频繁且零散状态轮询区dma_alloc_coherent或者普通Device映射不要在writecombine上轮询4.2 性能差异的真实边界在哪里可能有人会问writecombine真的比coherent快吗答案是看场景。当CPU写大块数据时writecombine允许相邻写在一个写缓冲区内合并生成的DRAM总线事务更少总线效率高因而写带宽通常好于纯不可缓存的coherent内存。但如果CPU写的地址跳跃性很强写合并机制几乎起不到作用甚至因为额外的地址翻译和缓冲区管理性能会接近coherent甚至更差。coherent内存虽然在每次CPU读上可能和普通内存差距巨大但它带来的好处是简单、确定。I/O路径的复杂度上升以后与其为了省几十MB/s带宽去引入一致性bug不如老实选coherent把CPU时间花在更值得优化的大模块上。我在一个USB3控制器驱动上做过实测将控制结构从writecombine改为coherent后整体吞吐提高了5%-8%因为原驱动在writecombine内存上做了大量读改写操作频繁访问总线的开销抵消了写合并带来的收益。4.3 成本和风险的权衡选错接口的代价不只是慢一点的问题选dma_alloc_writecombine却频繁CPU读造成莫名卡顿、性能悬垂、甚至读到脏数据。选dma_alloc_coherent做帧缓冲通常只是性能稍低不会出功能错误。如果在writecombine内存上部署依赖Cache一致性的事务整个驱动可能变得行为不可预测。所以初始开发阶段除非你能明确说出数据流是单向的、CPU基本不读、但需要高顺序写带宽否则一律先用dma_alloc_coherent跑通功能。让功能稳定之后再用perf和总线计数器决定是否需要针对热路径换用writecombine。5. 不可忽视的边界问题屏障、同步与组合写顺序5.1 DMA API与内存屏障的配套使用dma_alloc_coherent解决的是缓存一致性问题但它不保证CPU与DMA操作之间的全局顺序。CPU向共享描述符写了一个已填充标志随后立即通知设备去读必须确保设备看到的是写完标志之后的一致状态。这时需要内存屏障来约束编译器和CPU重排。DMA层提供了三个配套屏障dma_mb()等价于通用mb()保证DMA设备可见的世界里读写都有序。dma_wmb()只保证写操作的顺序。dma_rmb()只保证读操作的顺序。比如向网卡TX描述符写入数据后先写描述符的内容再写一个ready标志位中间要有一个dma_wmb()以免CPU把写ready标志提前重排到写数据内容之前。设备看到ready置位时数据可能还没写完就会读到半截内容。5.2 在writecombine内存中使用屏障的额外要求writecombine内存的写入是先进入写合并缓冲区的如果驱动不主动加屏障合并缓冲区可能需要很长时间才会把数据冲刷到DRAM。因此在这种内存上写完数据后不仅要做dma_wmb()在必要时还要执行一次“擦写”操作来强制触发缓冲区刷出比如往某个设备寄存器写一个无关值或者调用mb()来保证缓冲区被刷新。这里有个历史坑某些ARM芯片上dma_wmb()只编译成编译屏障对writecombine内存不一定能产生强制刷写效果。安全做法是在关键点上使用mb()。当然过度加屏障也会损失性能所以建议在描述符提交路径上集中使用不要在数据填写循环里做。5.3 实际踩过的顺序坑当年做FPGA加速卡驱动时CPU先往DMA缓冲里写512字节数据然后置描述符flag为1。设备侧收到中断后读取flag偶尔会发现flag是1但数据只有前256字节是新的。一开始怀疑设备时序问题后来我加了dma_wmb()在写数据和置flag之间又用mb()在置flag和设备通知之间之后问题完全消失。这类bug的可怕之处在于它不是必现的。加载顺序、CPU频率、总线频率稍有不同重现概率就变。如果驱动里看到偶发数据半新半旧的现象优先怀疑屏障缺失或缓冲区属性错误。6. ARM与x86的平台差异同一个接口完全不同的底层手感6.1 x86平台I/O一致性历来靠缓存类型模拟x86架构下dma_alloc_coherent依赖PATPage Attribute Table把内存标记为Uncached(UC)或者Uncached Write-Combining(UCWC)。设备访问物理RAM时不会经过CPU Cache所以CPU侧保持一致性。x86还有一个特点很多设备的DMA地址能力可以通过IOMMU如Intel VT-d做地址翻译。只要IOMMU工作dma_alloc_coherent拿到的DMA地址可以和物理地址不同但在早期无IOMMU的平台上DMA地址基本等于物理地址。因此在x86上不少人误把dma_handle当成物理地址直接传给用户态工具这在老平台没问题可一旦遇到VT-d地址就错位了。驱动里永远别做这种假设。6.2 ARM32/ARM64平台一致性映射的两条路线ARM32传统平台上dma_alloc_coherent主要是从consistent区域或CMA获得物理页后建立不可缓存的映射。而ARM64则走上了一条不同的路线如果SoC具备硬件一致性端口例如常见的高通、三星、联发科的一些SoCCPU和DMA设备可以通过系统级缓存协议共享Cache那么coherent内存可以是Normal Cacheable的内存Cache一致由硬件保证。这对驱动作者的影响是同样的设备驱动在ARM64硬件一致性平台上运行良好在ARM32或低端ARM64平台上则可能因为coherent实现差异表现不同。比如你在高通的SDM845上调试正常的DMA代码换到树莓派的BCM2835上可能就出现Cache一致性问题。最典型的例子是Bcm2835上如果不做DMA内存屏障或使用正确的DMA属性USB控制器的数据会莫名其妙损坏。6.3 设备树与DMA掩码对分配行为的影响在设备树驱动的平台dma_alloc_coherent的行为受DMA掩码和DMA范围约束影响。如果驱动没有正确设置dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))有些平台会把缓冲区分配到不符合设备地址能力的高端内存设备访问不到表现出来就是DMA传输出错。这跟用哪个分配函数无关但很多人在写新驱动时漏了这步然后怪到缓存属性头上排查半天。经验是在调用任何DMA分配函数之前一定要先设置DMA掩码。错误提示往往是dma_alloc_coherent: allocation failed如果物理内存充足还反复失败先检查DMA掩码。7. 从代码层面看两个函数的使用姿势与常见错误7.1 一个典型驱动的初始化示例下面用一个简化过的伪设备驱动来说明常见的初始化流程struct my_dev { void __iomem *regs; void *dma_buf; dma_addr_t dma_buf_addr; size_t dma_buf_size; }; static int my_probe(struct platform_device *pdev) { struct my_dev *priv; int ret; priv devm_kzalloc(pdev-dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv-dma_buf_size 4096; ret dma_set_mask_and_coherent(pdev-dev, DMA_BIT_MASK(32)); if (ret) return ret; priv-dma_buf dma_alloc_coherent(pdev-dev, priv-dma_buf_size, priv-dma_buf_addr, GFP_KERNEL); if (!priv-dma_buf) return -ENOMEM; /* 初始化描述符、控制块等 */ memset(priv-dma_buf, 0, priv-dma_buf_size); dmawmb(); writel(priv-dma_buf_addr, priv-regs REG_DMA_ADDR); writel(ENABLE_BIT, priv-regs REG_DMA_CTRL); platform_set_drvdata(pdev, priv); return 0; }注意几点初始化共享控制块时用memset清零后必须跟一个AIA屏障否则设备可能看到未清零的残留值。给设备写寄存器时用writel它自带设备IO屏障在多数架构上足够。清理时用dma_free_coherent或devm版本自动释放。7.2 常见错误一只保存DMA地址不保存CPU地址有人图省事只保存了dma_handle释放时再想找回CPU地址已经晚了。释放接口要求同时给出CPU虚拟地址和DMA地址缺一个都不行。最好在驱动私有结构体里把这对地址一起保存并保持同步更新。7.3 常见错误二把DMA地址当作CPU地址使用这属于两类地址混淆。CPU访问必须用dma_alloc_*返回的虚拟地址设备访问必须用dma_handle返回的总线地址。在启用了IOMMU的平台上这两个地址未必相等。如果直接拿dma_handle当指针解引用轻则崩溃重则静默写坏别的内存。7.4 常见错误三在writecombine内存上做CPU读操作如果非要在writecombine内存上读性能极差且行为可能不符合预期。如果只是临时确认写入是否生效可以用readl((void __iomem *)cpu_addr)这种IO读指令它其实就是普通读内存但至少不会经过Cache。如果用来轮询状态还是切换成coherent内存更稳。7.5 常见错误四忘记处理size对齐设备端DMA引擎往往要求缓冲起始地址对齐到Cache Line大小甚至更大的边界。dma_alloc_*返回的地地址本身对齐到页或更强但驱动内部再切分成子缓冲区时要自行保证每个子缓冲的对齐和长度合规。很多DMA引擎对地址和长度同时有对齐要求忽略之后会出现无法解释的传输异常。7.6 用devm版本减少泄漏推荐优先使用资源管理接口随设备解除绑定自动释放void *dmam_alloc_coherent(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag); void *dmam_alloc_attrs(struct device *dev, size_t size, dma_addr_t *dma_handle, gfp_t flag, unsigned long attrs);dmam_alloc_attrs配合DMA_ATTR_WRITE_COMBINE属性就是writecombine的devm版本能少写不少错误处理代码。8. 调试与性能调优当分配失败或数据异常时怎么办8.1 先检查内核配置和启动参数如果dma_alloc_coherent频繁失败第一反应不是改代码而是查看内核是否开启了DMA调试和CMA。确认CONFIG_DMA_API_DEBUGy它会在使用DMA API时检查错误比如不匹配的size、重复释放、越界操作。确认CONFIG_CMAy并给内核传cma128M之类参数预留连续内存。很多设备驱动在内存碎片化的系统上分配大块coherent内存失败增加CMA是最直接有效的办法。检查dmesg里是否有DMA-API: device driver ... has overflow之类提示DMA API调试框架给出的信息通常很具体。8.2 数据错乱时的排查顺序设备端读到的数据是乱的按下面顺序排查确认DMA掩码设置正确。确认驱动使用的是dma_handle去和设备通信而不是物理地址。确认共享内存用的是coherent还是writecombine以及当前场景是否匹配。检查数据写完成后是否加了dma_wmb()或mb()。确认设备的中断处理和驱动轮询之间是否有竞争。用CONFIG_DMA_API_DEBUG捕捉常见API误用。其中第3步是我遇到过最多问题的地方。比如本来应该用coherent的轮询状态区为了图性能用了writecombine设备已经更新了内存CPU读回却还是旧值因为writecombine读绕过Cache且很可能命中某个总线上未刷新的合并缓冲。8.3 用perf验证两块内存的实际差异如果系统有perf可以观测总线访问事件例如ARM平台上可以用perf stat -e mem_stores或bus_access这一类事件来比较不同内存属性的读写行为。x86上的perf stat -e offcore_response也能观察到Uncached访问量。我做过一个小实验分配1MB的coherent内存和1MB的writecombine内存CPU侧循环写满数据测量耗时。在arm64的测试板上writecombine内存的写入比coherent内存快约17%但是如果改为循环读writecombine内存比coherent内存慢约8倍。这种量级的数据能很好指导驱动选型。8.4 一个实用的折中方案coherent控制结构 writecombine数据缓冲大型DMA驱动里很少只用一种内存。典型的做法是控制结构描述符环、状态区用dma_alloc_coherent保证双向访问稳定。数据缓冲区帧数据、网络包数据用dma_alloc_writecombine让CPU填充时享受写合并优势。设备回写数据用的数据区仍用dma_alloc_coherent。这种组合兼顾了稳定和性能。很多网卡驱动的TX路径就是这么设计的TX描述符用coherentTX数据缓冲用writecombine或普通DMA映射dma_map_single避免不必要的Cache flush。9. 最后再分享一点实战经验dma_alloc_writecombine看似只是coherent的一个变种实际上它是为性能而生的接口但也是一把双刃剑。我见过太多人因为听说writecombine快就不加分析地换来用结果掉进读性能陷阱或者被屏障问题折磨得写出一堆奇怪的空操作来稳定现象。如果你的性能测试说不出瓶颈到底在CPU写侧还是设备读侧先把功能做对用coherent保底。性能优化永远在正确性的后面。另外如果拿到了新平台的开发板启动后第一件事情是执行一下dmesg | grep -i dma和cat /proc/meminfo里的CmaTotal字段确认DMA框架的基本配置是健康的。很多时候驱动起不来的原因不在你的代码而是平台上的CMA配置、DMA掩码或者设备树里dma-ranges写得不对导致分配出来的地址设备根本无法访问。先把地基清理好再谈选择coherent还是writecombine整个驱动调试过程会顺很多。