做DMA相关开发久了,迟早会遇到这么个诡异的现象:代码明明先把新数据写到了缓冲区,DMA把数据搬出去,接收端拿到的却是上一批内容。反过来也一样,DMA搬进来一批数据,CPU读到的偏偏是改动前的旧值。我最近在某块嵌入式平台上就被这种“DMA总读旧数据”的问题折腾过,排查了一圈后发现根子不在DMA,反而在CPU那一侧的Cache上。这篇文章就把问题的来龙去脉、三种主流解决办法,以及我实际调试时踩过的坑一次性说清楚,希望对同样在做底层驱动、音视频采集、网络收包的朋友有帮助。
1. 现象和根因:DMA读到的为什么不是“当前值”
1.1 一个典型的故障现场
先还原一下问题现场。某采集项目里用DMA把传感器数据连续搬运到DDR缓冲区,CPU负责做后续处理。逻辑很简单:DMA完成中断置一个标志,CPU收到中断后从缓冲区读数据。结果让我很意外,CPU每次读到的都是DMA搬运之前那一次的数据,也就是说永远是“上一帧”,而缓冲区里最新的数据反而读不到。
把内存内容dump出来一看,DDR里确实是新数据,但CPU一读就是旧值。当时第一反应是DMA地址配错了,检查了好几遍描述符,地址没错。又怀疑是中断同步问题,加了一堆延迟和屏障,也没用。最后才发现,DMA已经把新数据成功写入了DDR,但CPU读数据时命中的是Cache里的旧副本,压根没去DDR里取。
这个例子是“CPU读旧数据”,同一类问题的镜像版本是“DMA搬旧数据”。CPU把新数据写进缓冲区,然后让DMA把缓冲区内容搬出去,结果对方收到的是上一次的旧数据。原因一样:CPU写的新值还在Cache里,没有落回DDR,DMA直接读DDR,自然看到的是旧值。
1.2 双视角下的数据不一致
很多刚接触DMA的人容易有一个默认假设:所有数据都在内存里,谁读都一样。问题恰恰出在这,CPU和DMA眼中的“内存”不是同一个东西。
CPU要读数据时,硬件会先去Cache里找,命中则直接返回,没命中才去DDR。这个过程对驱动的意义是:CPU读到的内容可能比DDR新,也可能比DDR旧。DMA则是独立于CPU Cache之外的“外部访客”,它通常直接通过系统总线发起对DDR的读写,根本不经过CPU的Cache。
当只有一个主设备时,所有读写都按顺序经过同一路径,没问题。但CPU和DMA是两个独立的主设备,它们看到同一个物理地址却可能处在不同状态。CPU看到的是Cache里的副本,DMA看到的是DDR里的正本。两个副本一旦不同步,就是标题里说的“DMA总读旧数据”。
这就像两个做交接的人各有一本自己维护的记录本,一个人把新信息记在自己的本子上,另一个人却默认去翻公共台账,自然也找不到最新记录。
1.3 为什么“心存侥幸”的人总是踩坑
还有一个现实原因让这个问题变得普遍:不是所有DMA都一定会触发Cache一致性问题。在小体量裸机工程里,数据量小,Cache常常处于关闭状态,或者buffer恰好落在配置成non-cacheable的内存区域。这时CPU和DMA都是直接读写DDR,你永远碰不到这个问题。
等工程复杂度上来,CPU启动了L1/L2 Cache,缓冲区落在普通DDR,或者数据量一大、DMA开始用描述符环形队列做长时间搬运时,问题就开始密集出现。这也是为什么很多人反映“之前从来没事,换个方案就出问题”——不是方案变了,是Cache路径变了。
2. Cache一致性,本质上是一道“多副本同步题”
2.1 Cache是CPU的“私人笔记本”
理解Cache一致性之前,需要先明确一个观点:Cache不是内存的镜像,而是独立于DDR的一份“副本”。CPU写数据时,如果策略是写回(write-back),新值只先写在Cache行里,标记为脏(dirty),并不会立刻落到DDR。CPU读数据时,如果Cache里已经有副本,也可能不到DDR去读。
这种方式让CPU运行速度远高于DDR的访问速度,代价就是存了多份数据。在单CPU环境下,多副本问题不严重,因为所有读写都经过同一个Cache;一旦引入DMA这类外部主设备,多副本之间的同步就成了驱动开发者绕不开的课题。
2.2 DMA是绕过Cache的“外部访客”
这里要强调一个关键架构事实:大多数DMA控制器的访问路径是“主设备到内存控制器”,它会经系统总线发起传输,但不会主动探测CPU Cache。即使数据已经在Cache里躺着,DMA也看不见。
所以,CPU和DMA对同一个物理地址的操作可能产生四种情况:
| 场景 | CPU动作 | DMA动作 | 后果 |
|---|---|---|---|
| 场景1 | CPU写入了数据 | DMA读取 | DMA读到旧值 |
| 场景2 | DMA写入了数据 | CPU读取 | CPU读到旧值 |
| 场景3 | DMA读取 | CPU未参与 | 一般无一致性问题 |
| 场景4 | DMA写入 | CPU未参与 | 一般无一致性问题 |
前两种是典型的Cache一致性问题,第三种和第四种看似安全,其实后面还有坑,下面会专门讲。
2.3 有硬件一致性,不代表所有平台都有
不是所有系统都必须靠软件维护Cache。x86平台的PCIe DMA路径上,硬件总线协议自带snoop(侦测)机制,CPU Cache会被硬件自动嗅探,DMA读写时能直接维护Cache一致性。所以在这类平台上跑DMA,很少遇到“读旧数据”,这导致很多在x86上开发惯了的人,换到嵌入式平台后第一反应是“平台坏了”。
而常见的嵌入式SoC就没这么幸福了。DMA控制器通常直接挂在内存端,走的是没有硬件一致性的路径,需要软件在DMA操作前后手动维护Cache状态。还有一类SoC在设计上做得更完善,DMA设备挂在带一致性协议的硬件接口上,例如支持硬件缓存嗅探的总线端口,这时软件也可以少操心一大半。
关键是,你要先看清自己的平台属于哪一种。软件层的三招,本质是在不同平台约束下寻找“正确维护Cache一致性”的方案。
3. 三招解决Cache问题:从软件到硬件逐层拆解
3.1 第一招:手动维护Cache状态,该刷的刷,该废的废
第一招最直观,也是最常用的手段:在DMA开始前把CPU相关Cache里的脏数据写回DDR,术语叫clean或flush;在DMA完成后把CPU的Cache行标记为无效,让CPU再次读时重新从DDR加载,术语叫invalidate。
在Linux内核里,这一套已经封装成标准API。以最常用的dma_map_single为例,驱动分配一个普通缓冲区,然后把地址和长度交给DMA映射函数。关键是根据DMA方向决定sync时机:
- 方向为
DMA_TO_DEVICE(CPU数据要交给DMA送出去)时,启动DMA前要调用dma_sync_single_for_device(dev, dma_addr, size, DMA_TO_DEVICE),本质是clean,把CPU写的脏Cache写回DDR。 - 方向为
DMA_FROM_DEVICE(DMA要把数据放回内存给CPU用)时,DMA完成后要调用dma_sync_single_for_cpu(dev, dma_addr, size, DMA_FROM_DEVICE),本质是invalidate,让CPU读不到旧Cache副本。
完整代码样子大致如下:
/* DMA发送方向 */ dma_addr_t dma_addr = dma_map_single(dev, buffer, len, DMA_TO_DEVICE); dma_sync_single_for_device(dev, dma_addr, len, DMA_TO_DEVICE); /* 配置DMA描述符并启动传输 */ /* DMA接收方向,中断处理中 */ dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE); /* 这时CPU才能安全地读buffer */ dma_unmap_single(dev, dma_addr, len, DMA_FROM_DEVICE);在裸机或RTOS环境下没有内核API,需要直接操作Cache控制器寄存器,或者调用厂商SDK提供的Cache维护函数。嵌入式平台一般都有类似cache_clean、cache_invalidate、cache_clean_and_invalidate的接口,用法和内核里的一致,核心就一句话:DMA启动前替CPU把该写回的写回,DMA完成后替CPU把该失效的失效。
3.2 第二招:一致性DMA映射,直接把缓冲区变成非缓存
手动同步能解决问题,但要在每一个进出DMA的路径上小心维护,漏一次就出一次bug。第二个思路更彻底:向系统申请一块“不走Cache”或“走硬件一致性保证”的内存,让CPU和DMA永远看到同一个DDR值。Linux里提供dma_alloc_coherent接口:
void *cpu_addr = dma_alloc_coherent(dev, size, &dma_addr, GFP_KERNEL);返回的cpu_addr是CPU访问的虚拟地址,dma_addr是给DMA寄存器用的总线地址。这块内存在支持的平台上是按非缓存属性映射的,CPU读写直接落在DDR,因此CPU和DMA之间不存在缓存副本差。使用时不需要dma_sync_*,DMA操作前后都不必维护Cache,极大减少漏维护的风险。
使用这个方案要承担明显的性能代价。CPU一旦访问这块buffer,每次都要切实取DDR,遇到大数据量的CPU频繁处理时,速率会受到内存延迟制约。所以它更适合“DMA是主要生产者、CPU只是偶发访问”的场景,比如视频采集帧缓冲、音频环形缓冲。
如果数据量不大,CPU访问频率也不高,用coherent分配反而是一种省心的正确性选择。我做过的某图像采集项目里,一帧就是几兆字节,DMA每帧都往同一个coherent缓冲区写,CPU只做指针切换和少量帧头解析,压力和带宽都能接受。
3.3 第三招:让硬件来管一致性,把软件维护减到零
第三招往底层走一步:不要手动维护Cache,而是让SoC的硬件机制替你做。
第一个硬件路径是使用具备一致性能力的总线接口。部分SoC的DMA控制器或挂载DMA的设备支持通过带snoop功能的总线端口访问DDR。CPU侧Cache会收到一致性广播,DMA写内存后,Cache里的对应行自动失效;DMA读内存时,脏数据在可见性范围内也被写回。驱动只要有正确的描述符,甚至不需要额外维护。大量数据处理场景,例如网络控制器、高速存储控制器,硬件设计者都会走这条路。
第二个硬件路径依赖SMMU/IOMMU。某些平台虽然DMA没有直接挂在一致性端口上,但系统有IOMMU。驱动可以把DMA地址配置成强制non-cacheable属性,或在设备树、固件描述中把设备标记为dma-coherent。Linux内核看到dma-coherent后,会跳过dma_sync_*,直接认为设备具备一致性。这就是为什么同一个驱动有时在A平台需要手动sync,在B平台什么都不用做也能正常工作。
第三招和第一招并不冲突。很多正式驱动里是“混合使用”:描述符队列用coherent映射,数据缓冲区用streaming映射加手动sync,既保证控制路径可靠,又保证数据路径有性能。三招之间是层次关系,不是互斥关系。
3.4 三招选型对比
| 方案 | 原理 | 适用场景 | 风险点 |
|---|---|---|---|
| 手动维护Cache | 操作前后clean/invalidate | 普通strcpy缓冲区、一次性DMA传输、裸机开发 | 漏维护、方向搞反、多核下操作错核 |
| 一致性DMA映射 | 分配non-cacheable内存 | 描述符、环形队列、频繁DMA控制块 | CPU访问性能差、地址转换复杂 |
| 硬件一致性 | 总线snoop/IOMMU强制属性 | 高速网络、存储、视频数据 | 依赖平台实现,需正确配置设备树 |
实际项目中,第一招是基本功,第二招是兜底,第三招是追求性能上限时的方向。不会第一招,后面两招出了问题也排查不动。
4. 选型容易犯的错:方向、核、屏障,三个细节能卡死人
4.1 把DMA方向搞反,越sync越乱
很多人在DMA_TO_DEVICE和DMA_FROM_DEVICE上栽过跟头。方向不是看“数据从哪里来”,而是看“谁需要看到最新的数据”。
DMA_TO_DEVICE意味着数据由CPU产生,DMA负责把它搬出去。CPU写在Cache里的脏数据,DMA看不到,所以DMA启动前要做flush。如果此时反而先做invalidate,那等于把Cache里的新值丢了,DMA搬出去的还是DDR里的旧数据。
DMA_FROM_DEVICE意味着DMA负责把数据搬进来,CPU之后要读。DMA写入后,Cache里可能还有读过的旧副本,不invalidate的话,CPU读到的就是旧值。如果此时误做flush,反而把DMA新写入的数据“写回”Cache里,行为同样不对。
我自己的习惯是在写驱动前先画一条简单箭头:数据从哪一侧产生,要到达哪一侧,然后确定每个同步点该flush还是该invalidate。光靠背API名字很容易出错,画一遍关系再清晰不过。
4.2 多核环境下,Cache操作是对“某个核”做的
Cache维护函数在CPU侧执行时,通常只作用于当前核。多核场景下,缓冲区可能被多个核访问,DMA搬运完成后,另一个核上来读,Cache里可能还留着旧数据,而这个核根本不知道要做invalidate。
Linux内核虽然提供了on_each_cpu之类的跨核操作,但多数驱动不会对所有核做Cache维护。更常见的做法是:严格规定缓冲区只能由一个核访问,或所有访问它的核都走同一条同步路径。否则就会出现“单核跑没问题,多核一上,数据时新时旧”的现象。
这也解释了为什么有些问题在单核调试时死活复现不了,一上SMP就崩溃。不是竞争条件,而是Cache维护的覆盖面不够。
4.3 内存屏障不是Cache操作,别混为一谈
排查这类问题时,有人会习惯性加mb()、wmb()、rmb(),发现没用,于是觉得Cache问题更难解。这里必须分清楚:内存屏障解决的是CPU访问顺序问题,确保读写指令在可见性上的先后关系;而Cache维护解决的是“CPU与DMA看到同一物理地址的不同副本”问题。
内存屏障管不了DMA,因为DMA不是CPU,DMA不需要按CPU的乱序规则访问DDR。在程序员眼里,DMA看到的是“内存当前的内容”,而这个内容是否包含Cache里的最新脏数据,只有Cache维护函数有决定权。
正确做法是:先保证Cache状态正确,再保证访问顺序正确。两者不是同一层的事情。加屏障之前,先问自己“这一步是防止乱序,还是防止副本不一致”。
4.4 频繁flush/invalidate带来的性能黑洞
手动维护Cache表面简单,一旦高频调用,性能会直线下滑。尤其在高吞吐网络场景,每个包都要sync,而invalidate还可能导致Cache行被反复踢出,后续CPU读数据时全部miss,DDR带宽压力骤增。
实测过一个案例,某网络驱动为了省事,对每个小包都做完整sync,结果吞吐量直接掉了四成。后面改用“批量同步”:把多个包集中到一个内存池,在轮询批次边界统一sync一次,性能才恢复。这种优化思路在驱动层很常见:同步的粒度要跟数据流的结构对齐,不能把每个描述符都独立成一次同步。性能敏感场景下,第一招用好了有收益,用得太碎也会拖垮整个系统。
5. 排错实录:四个真实问题,附排查手段
5.1 问题一:数据看起来“一半新一半旧”
某次DMA采集后,CPU读缓冲区发现前一部分是新数据,后一部分是旧数据。排查后发现,缓冲区在分配时跨越了多个Cache行,而sync操作时长度参数少算了一段,后半段的Cache行没有被invalidate,自然读到旧值。
这种问题最容易发生在缓冲区由多个小区域拼接、内存对齐不严格的场景。解决办法是保证缓冲区和长度按Cache行对齐,或者干脆使用dma_alloc_coherent分配。还有一个细节:有些Cache的line size是64字节,但SoC不同核之间可能不一样,裸机上直接用寄存器读取line size更可靠。
5.2 问题二:换了一块板子,问题自动消失
同一套代码,在一块板子上DMA工作正常,换到另一块板子上频繁出旧数据、错数据。很多人以为代码被“环境干扰”了,实际上往往是第一块板子的SoC支持硬件一致性,DMA挂在带snoop的总线端口上,所以驱动的不规范操作被掩盖了;第二块板子的SoC没有这个端口,问题暴露无遗。
排查思路是打开平台手册查DMA控制器在总线结构中的位置,确认是否有Cache一致性支持。Linux环境下看设备树或驱动里dma-coherent标志是否存在,也可以直接在内核日志里找相关提示。知道自己平台属于哪一类,很多怪问题的排查方向都能省一大半。
5.3 问题三:加了内存屏障,旧数据纹丝不动
看着现象像DMA“延迟”了,于是加wmb()、rmb(),结果旧数据照样出现。原因是屏障只影响CPU视角的访问顺序,不会把Cache里的脏数据写回DDR,更不会让DMA绕过Cache直接访问DDR。
我当时用一个笨办法验证:把DDR里的内容和CPU读到的内容同时dump出来对比。两者不同,就说明是Cache副本问题,下一步该做Cache维护,而不是加屏障。对比dump是排查这个问题的第一现场证据,务必养成习惯。
5.4 问题四:改用coherent映射,性能明显变差
某项目原本用dma_map_single加手动sync,正确性没问题但吞吐不满意,于是把缓冲区改成dma_alloc_coherent,结果CPU侧处理帧头的速度大幅下降,因为CPU访问缓冲区时每次都命中不了Cache,全部走DDR。后来把核心业务拆成两部分:描述符和帧头用coherent映射,真正的帧数据用streaming映射加手动sync,CPU侧有了缓存加速,DMA侧也没有一致性风险。
这类问题的经验是:coherent映射不是万灵药,它牺牲的是CPU的缓存收益。越是CPU需要大量访问的数据,越要权衡是否值得放弃缓存。
5.5 工具与手段小结
- 调试器直接读物理地址,对比Cache和DDR内容。
- 平台自带的Cache调试接口,看某段地址的缓存状态是干净还是脏。
- 总线追踪工具确认DMA访问是否真的到达了DDR。
- Linux环境下,
/sys/kernel/debug/dma-api/能辅助确认dma mapping的统计情况。 - 裸机环境下,用不同Cache维护函数的组合做二分试验,快速锁定方向。
排查方向的优先级我一般这样定:先确认DMA地址和描述符正确,再对比DDR和CPU读数,然后检查Cache操作是否覆盖完整,最后才怀疑中断同步和竞争条件。
6. 我在实际项目中的一些体会
踩过几次坑之后,我给自己定了一套定式:所有DMA buffer必须先明确归属和方向,再决定用哪种映射;凡是不确定平台是否具备硬件一致性,一律按最保守的方式手动维护Cache;凡是能用dma_alloc_coherent兜底的控制路径,绝不等到出了bug再来补sync。这套定式在最近几个项目里都帮我省下了大量调试时间。DMA和Cache的一致性问题,说到底是CPU世界和外部设备世界之间的“共享数据契约”。弄明白自己平台在哪个层面提供保障、在哪一层需要补软件动作,之后再遇到“读旧数据”,不会慌,照着三招对应排查就够了。