一块主流嵌入式SoC,跑着某项采集任务,DMA中断按时来了,CPU读了缓冲区却发现数据是几百毫秒前的旧货。这种情况不少调过DMA和Cache的人都遇见过:外设从内存DMA读的仍是老值,或者DMA写进DDR的新值到CPU这边却一直读不出来。问题并不在DMA本身,而在Cache把数据“扣”了一层。 无论你是在做网卡驱动、摄像头采集,还是串行总线中转,只要设备有DMA、CPU又开了缓存,就会有这个坑。这篇文章适合想把Cache-DMA一致性彻底理清、又要快速找到可用方案的工程师;我会先拆根因,再给出三招解决思路和一套现场排查步骤。很多网上教程只告诉你“要flush cache”,但真正要命的是“什么时候flush、什么时候invalidate、顺序反了会怎样”,这些才是工程里踩出来的经验。
1. 先把根子挖清楚:DMA为什么总能看到旧数据
1.1 Cache 不是“简化版内存”,它是第二个室友手里的复印件
理解这个问题,你要先把内存访问路径拆成两层:CPU 平时读写数据,并不直接面对 DDR,而是面对 Cache。Cache 里存的是 DDR 里某个地址的一段拷贝,系统以“缓存行”为单位管理,常见行大小是 32 字节或 64 字节。CPU 改了 Cache 里的数据后,如果没有触发写回,DDR 里的老数据并不会立刻被改动。
打个比方:你和另一个同事共用一个文件柜,Cache 就像你桌上摊开的文件复印件。你把复印件改了,文件柜里还是旧版;只要没人把复印件放回柜子,柜子永远是旧内容。DMA 引擎就是一个完全不看你桌面的新同事,它只认文件柜里的纸质版,所以它读到的当然可能是你改之前的内容。反过来,如果 DMA 把新文件直接放进柜子,而你桌上还摊着昨天顺手打出来的复印件,你一抬头看到的自然还是旧版。
这个比喻基本就是 DMA 和 Cache 矛盾的全部本质。很多人在第一次遇到“DMA 读旧数据”时,第一反应是“时序不对”“要延时”,于是加延时、加轮询、加重传,最后问题依旧,为什么?因为问题不是 DMA 慢,而是 DMA 永远看不到缓存里的新数据。
1.2 两个方向的不一致:CPU 写后 DMA 读,DMA 写后 CPU 读
把“读旧数据”再拆细一点,你会发现其实有两个相反方向的问题。
方向一说的是发送侧:CPU 在 Cache 里把待发送的数据写好,然后启动 DMA,让 DMA 去 DDR 里把数据搬给外设。如果 Cache 里还有脏行没有回写,DMA 从 DDR 读到的就是旧内容。这个问题的典型表现是“第一次发出去数据是正确的,第二次以后就开始发旧包”,因为第一次 Cache 里没有对应缓存行,CPU 不得不去 DDR 读,之后数据停留在 Cache 里,不及时做清理,DMA 就只能拿到陈年老数据。
方向二说的是接收侧:DMA 已经把外设新收到的数据写进了 DDR,CPU 再通过 Cache 去读。如果 CPU 之前在 Cache 里缓存过同一块地址的内容,它不会自动感知 DDR 已经被 DMA 更新,拿到的依旧是第一次缓存时的旧值。嵌入式开发里最常见的“图像有拖影”“网络包总是重样”,多半就是这个原因。
还有更隐蔽的第三种情况:CPU 只改了缓存行里一个字段,DMA 在同一行里也写了新数据,两边都觉得自己在正常工作,结果缓存行回写时把对方的数据盖掉了。它不一定是“旧数据”,更可能是“新旧数据混在一起”,排查起来也更难受。
1.3 为什么有的芯片不处理也能正常,有的芯片一开 DMA 就翻车
有些工程师会问:我在别的板子上从来没写过 Cache 维护代码,DMA 不也好好的?这通常不是因为别人运气好,而是因为硬件替你做了部分一致性工作。现在不少处理器外设总线带有“一致性端口”或“缓存窥探”机制,DMA 访问内存时会在总线上检查 Cache 里有没有对应行,有就直接读缓存或更新缓存。硬件一致性一旦生效,CPU 和 DMA 看到的自然就是同一份新数据。
问题在于,大量成本敏感的嵌入式 SoC 和 MCU 并没有完整的硬件一致性机制,或者一致性只对特定总线段有效。这种情况下,Cache 是否参与 DMA 缓冲区的访问,完全取决于 CPU 的 MMU 页表属性和 Cache 策略。如果你把某块内存配置成允许缓存,那么乱序、延迟回写、行合并这些动作都是正常行为,软件就必须补位。
我的建议很简单:默认按“没有硬件一致性”来写软件。即使你的平台支持硬件一致性,正确的 Cache 维护操作最多只是多花几个时钟,但一旦依赖了硬件特性,代码换到另一颗芯片上就可能静默出错。接下来要说的三招,就是建立在软件要自己接手的前提上。
2. 第一招:用 Cache 维护函数做显式的清理和失效
先说最通用、最保底的方法:在 CPU 和 DMA 交接缓冲区之前,显式执行 Cache 维护操作。这一招任何平台都能用,也是后两招的基础。
2.1 先搞懂三个动作:Invalidate、Clean、Clean+Invalidate
Cache 维护指令按动作分,基本就是三类。
Clean 的作用是把 Cache 里的脏行写回到 DDR,但 Cache 里的内容继续保留。这一步解决“CPU 改了缓存,DMA 却在内存里读到旧数据”的问题。因为只做回写,CPU 接下来读同一块数据仍然命中缓存,所以如果你马上还要用这份数据,就很合适。
Invalidate 的作用是把 Cache 里的行标记为无效,强行丢弃。下次 CPU 访问这块地址时,会重新去 DDR 里加载最新数据。这一步解决“DMA 已经把新数据写进内存,CPU 却还在读缓存里的旧行”的问题。
Clean+Invalidate 是前两者的组合:先把脏行写回,再把行置为无效。它的好处是双方向都安全,但开销也最大。有些平台的底层封装里,一个接口就能完成这组动作,你会看到类似clean_and_invalidate或flush的叫法。注意,不同架构指令名称差异很大,有的叫dcache_clean,有的叫flush_dcache_range,但语义基本是一样的。
提示:Clean 不等于 Invalidate,很多初学驱动的人把“flush”当成“invalidate”用,导致发送侧重复发旧包、接收侧一直读不到新包。动手之前,先确认你调的那个函数到底是“回写”“失效”还是“回写+失效”。
2.2 顺序不能反:先用 for_device,再用 for_cpu
现在很多驱动框架把 Cache 维护封装成两个语义清晰的方向:给设备用的维护操作和给 CPU 用的维护操作。你不需要死记“发送该 flush 还是 invalidate”,只要记住一句话:数据交给设备之前,按设备方向做同步;数据从设备手里拿回来之后,按 CPU 方向做同步。
如果缓冲区的数据是 CPU 写好、准备给 DMA 去读,那么在启动 DMA 之前,你要执行一次面向设备的同步操作,底层往往是 Clean。如果缓冲区的数据是 DMA 写进内存、准备给 CPU 读,那么 DMA 完成后,你要先执行面向 CPU 的同步操作,底层往往是 Invalidate。
逻辑示例可以写成下面这样,具体函数名以你平台的 SDK 为准:
/* CPU 把数据写进 buf,接下来要交给 DMA 外发 */ cache_sync_for_device(buf, len); dma_start(buf, len); /* DMA 完成中断里收数据 */ dma_wait_irq(); cache_sync_for_cpu(buf, len); process(buf);为什么顺序不能反?假如你先对发送缓冲区做 Invalidate,而 Cache 里还有 CPU 刚写入的脏行,这些脏数据会被直接丢掉而不是回写,DMA 拿到的反而更旧。假如你 DMA 完成后来不及 Invalidate 就直接读,CPU 读的又是缓存里的旧行。所以顺序本质上是由“数据所有权”决定的:数据从 CPU 切给 DMA,就做一遍面向设备的清理;数据从 DMA 切回 CPU,就做一遍面向 CPU 的失效。
2.3 多核和各级 Cache:别只维护了你眼前的那一份
现在的嵌入式 SoC 哪怕再便宜,也常有多个 CPU 核,甚至还有 L1、L2 多级缓存。Cache 维护操作要特别注意作用范围。如果你在核 0 上做了 Invalidate,但核 1 自己私有的缓存里还留着旧数据,核 1 去读缓冲区照样读旧值。这也是“为什么核 A 跑得好好的,核 B 一读就错”的常见原因。
多级缓存的情况更复杂一些。有些指令只维护本级缓存,你要一路往下刷到某个共享的统一级别;有些平台要求你按 Inner Shareable 范围广播,否则另一个核不会收到一致性的“通知”。我自己遇到这类问题,会先查芯片手册里关于 Cache maintenance 的 “scope” 和 “shareability” 部分,不要在代码里假设默认帮你做全部。
另外,它和内存屏障不是一回事。有人以为在 DMA 完成中断里加一条内存屏障就能搞定向,结果该旧还是旧。内存屏障只保证 CPU 对访存指令的前后顺序,它不负责把脏数据写回内存,也不负责把缓存行失效。特定场景下你确实两个都要用,但概念上必须分开:缓存维护处理数据新旧,内存屏障处理访问顺序。
3. 第二招:把缓冲区做成“不缓存”或“硬件一致”
第一招虽然通用,但每次都手动去做 Clean/Invalidate,容易漏、容易错,还会增加中断处理时间。于是有了第二招:从内存属性和硬件配套上,让 DMA 缓冲区不参与普通 Cache 缓存,或者让硬件帮你保证一致性。
3.1 用一致性缓冲区分配接口,省掉日常同步
主流嵌入式内核和很多 RTOS 的驱动框架都会提供一个“一致性缓冲区分配接口”,常见叫法如dma_alloc_coherent之类。这个接口做的事,本质上就是给你一块物理地址,并且把这快内存的 MMU 属性直接设置成不适合 CPU 缓存的类型。CPU 访问这块地址时会直接落到 DDR,DMA 访问时也访问同一块 DDR,两边天然看到的都是最新值,不需要每次同步。
这类缓冲区非常适合描述符、状态字、小型控制块,也适合对吞吐要求高但不适合频繁 Cache 维护的接收缓冲。它的弱点也很明显:因为绕过 Cache,CPU 频繁读写它时性能会明显下降。你每读一次都得穿过总线去拿内存里的真实数据,DMA 大数据量搬运时,DMA 自己频繁写内存也会占用更多总线带宽。
注意:一致性缓冲区一般不适合当普通堆内存随便分配,它的底层内存数量有限制,而且多数场景要求物理连续。内部数据结构要小心访问顺序,免得编译器或 CPU 把多次访问重排。
3.2 在 MMU 属性层面,把缓冲区标成“不可缓存”
即使系统没有提供现成的一致性分配接口,你也可以自己动手:在 MMU 页表里把某一段 DMA 缓冲区映射成不可缓存内存,或者映射成设备内存类型。这样 CPU 对这段地址的读写都不经过 Cache,DMA 直接访问 DDR,数据一致性问题从根本上消失。
这种做法的开销和 3.1 是一样的:CPU 访问不可缓存内存会慢不少。因此工程上更常见的组合是,把必须实时更新的元数据放在不可缓存区,比如 DMA 描述符、状态标志、环形缓冲区的写索引;把大量真实业务数据仍放在普通可缓存区,交接时用第一招做一次同步。
实际操作时,有两点坑比较隐蔽。第一,MMU 属性必须和 CPU 实际访问的地址一致,如果你给虚拟地址配了不可缓存,但代码里用映射后的地址去访问,必须保证是同一个映射。第二,改成设备内存或强序内存后,可能有比缓存更严格的内存序约束,某些循环优化会被打断,编译器行为也会有变化,这类问题排查起来比“旧数据”还要头疼。
3.3 硬件一致性端口:要确认“真一致”而不是“看起来接近”
有些 SoC 的 DMA 控制器或外设总线支持访问一致性端口,也就是 DMA 在发起访问时,总线会自动检查 CPU 的 Cache 并执行窥探更新。启用这个特性后,第一招和第二招的大部分手动工作都能省掉。听起来很完美,但你要看的不是宣传页上有没有写“Coherent”,而是要看 DMA 请求实际走的是哪条总线路径。如果 DMA 仍然直接挂在普通内存总线上,所谓一致性机制并没有参与,软件照样要维护。
就算你的 DMA 真的走了一致性端口,也有端口带宽和使能配置的限制。常见的做法是,让大块流量走一致性但只在特定方向启用,或者只让部分 DMA 通道启用。真正到了量产阶段,芯片 errata 里往往还会写出一堆“一致性条件下偶发问题”,所以我不建议把代码的命脉完全押在硬件一致性上。
三招之间的选择,可以用下面这个表来快速对。
| 方案 | 适合场景 | 主要代价 | 风险点 |
|---|---|---|---|
| 软件显式 Clean/Invalidate | 几乎所有 DMA 驱动,数据量适中 | 每次交接的额外开销,代码容易漏 | 顺序写反、多核漏维护 |
| 不可缓存/一致性缓冲区 | 描述符、状态字、小控制块 | CPU 访问慢,总线占用高 | 属性配错、编译器优化异常 |
| 硬件一致性端口 | 持续大流量 DMA,SoC 明确支持 | 配置复杂、带宽受限、上线前难验证 | errata 多,依赖芯片细节 |
4. 第三招:在驱动和数据流设计上别让 Cache 较劲
如果说前两招是在“治疗”,第三招就更接近“预防”。很多 DMA 读旧数据问题不是单一某次维护没做,而是驱动设计上让 CPU 和 DMA 同时访问同一片内存,缓存行来回打架。这一招的核心是:别让双方在同一时间碰同一块数据,把共享缓冲设计成明明白白的“传球交接”。
4.1 把数据访问权当成球来交接:所有权模型
一个简单的模型是:规定某块缓冲区每一个时刻都有唯一“所有者”,要么是 CPU,要么是 DMA。CPU 在处理数据时,DMA 不能碰这块区域,反过来,DMA 在搬运数据时,CPU 别去改写它。交接点才是唯一需要做 Cache 维护的地方。这样做最大的好处是,缓存维护次数大幅减少,也不容易出现“两边同事在同一个抽屉里改文件”的混乱。
实际驱动里最常见的实现是双缓冲区或环形缓冲区。两个 half buffer,CPU 使用 A 时,DMA 往 B 里搬;CPU 处理完 A 后,只在换手时对 A 做一次同步,再让 DMA 重新使用 A。伪代码结构大致是:
if (owner == CPU) { process(buf); cache_sync_for_device(buf, len); owner = DMA; dma_start(buf, len); }用这种结构以后,“先 Clean 还是先 Invalidate”就变得非常直觉:谁要把数据交出去,谁就做面向接收方的同步。你不用在中途反复刷缓存,因为同一个时间段内只有一个角色在改这份数据。
4.2 缓存行对齐:一场“行”的拼缝战
很多时候问题不在于内存一致性,而在于你缓冲区起始地址和缓存行边界错位。CPU 和 DMA 都以“行”为粒度互相影响。比如一个接收缓冲区,CPU 只改了第 40 个字节,整行依然会被标成脏行,回写时会把整行 64 字节全写回 DDR;如果 DMA 刚好更新了同一行的另外几个字节,你的回写就会把它们覆盖掉。
我排查“偶发新旧数据混合”“数据随机丢字节”时,第一件事就是看地址对齐。大多数平台要求 DMA 缓冲区的起始地址和长度对齐到某个 Cache 行。驱动里给结构体加缓存行对齐也是常见做法,只是不同编译器的写法不一样。对齐之后,CPU 改的行和 DMA 改的行会分到不同行,相互污染的概率立刻小很多。
提示:很多平台要求 DMA 描述符也不能跨缓存行。一个描述符占 32 字节,你硬把它放在偏移 16 字节处,它就可能横跨两个缓存行,外设更新描述符时 CPU 读到的内容就可能一半新一半旧。设计描述符表格时,先算行大小,再定结构体布局。
4.3 数据拷贝:最不“炫技”但有时最划算
不要觉得做驱动的用 memcpy 很丢人。在特定场景下,把 DMA 搬到一块不可缓存的小缓冲区,再用 CPU 拷到普通可缓存缓冲区处理,可能是最省心也最稳定的方案。
比如某个 CPU 需要频繁访问几十字节的包描述信息,但 DMA 搬下来的原始数据是几 KB。你如果让 CPU 直接在 DMA 缓冲区上做精细操作,每次都要处理缓存行同步和多核共享问题,时间长还容易出错。不如让 DMA 先落到独立区域,然后 CPU 把头部几十字节拷到自己的 Cache 友好的结构体里,后续访问全部命中缓存。
这样做的代价是拷贝耗时,收益是逻辑线清晰,几乎不用再担心 Cache 一致性问题。在数据量小、实时性要求不极端的场景里,这种“笨办法”反而比复杂的同步设计更容易维护、更容易验证。
5. 现场排查:一步步定位“DMA 读旧数据”的根因
理论说再多,不如给一套可以照着做的排查步骤。这里的每一条都是我在实际调板过程中踩过的坑总结,按顺序走,多数问题半小时内能找到头绪。
5.1 一条可以照着走的排查路径
第一步,先在 DMA 启动前和完成中断里分别打印缓冲区物理地址和首几个字节。如果物理地址上的内容已经是新的,而 CPU 读变量得到的还是旧值,问题几乎可以锁定在 Cache 或内存序上。关键是要打印物理地址,不要只打印虚拟地址,有些问题恰恰是两个地址没对应上。
第二步,把这块缓冲区临时映射成不可缓存,或者用一致性缓冲接口临时换掉,然后重跑同一测试场景。如果问题消失,说明就是普通 Cache 行为引起的,接下来去查维护函数和交接顺序。如果问题还在,那就要回到 DMA 配置、外设时序、中断丢失这些更基础的方向。
第三步,查对齐。打印缓冲区物理地址的最低六位,如果低于 64 位,先补到 64 字节对齐;长度也要对齐。很多芯片要求 DMA 描述符和数据缓冲都按行对齐,没对齐时表现出来的就是偶发错误。
第四步,检查所有访问路径是否都用了同一套同步语义。比如发送路径做了 Clean,接收路径却没有做 Invalidate,那你就会看到“发包正常,收包老旧”。中断处理里漏一次同步的代价,往往要等到下一帧数据才暴露出来,排查时很容易误判成外设问题。
第五步,确认多核场景中的实际执行核。如果缓冲区会被多个核处理,看看你的失效操作是否只作用在某个核的私有缓存上。至少要在代码里加个核号打印,确认中断处理函数这次跑在哪个核上。
5.2 症状速查表:先对照现象,再决定往哪查
| 现象 | 最可能原因 | 优先处理办法 |
|---|---|---|
| 第一次正常,后续一直发旧包 | 发送缓冲区存在脏 Cache 行未回写 | DMA 启动前做 Clean/面向设备同步 |
| DMA 完成后 CPU 读到的仍是旧值 | DMA 写入 DDR 后,CPU 缓存行未失效 | DMA 完成中断里做 Invalidate |
| 偶尔出现新旧数据混合 | CPU 和 DMA 修改同一缓存行 | 对齐缓存行、调整缓冲区布局 |
| 把缓冲区改成不可缓存后正常 | 页表属性允许了缓存 | 确认缓冲区属性,避免部分缓存 |
| 核 A 正常、核 B 读旧值 | 私有缓存未同步 | 按共享域做失效,或统一到同一核处理 |
| 加内存屏障没用 | 屏障不等于 Cache 维护 | 改为 Clean/Invalidate,再做屏障 |
这张表不能代替具体调试,但能帮你快速决定下一步切入点。我见过不少同事卡在一个现象里反复查外设寄存器,最后发现仅仅是因为中断回调里少了一次invalidate。
5.3 几个容易让人翻车的误区
第一个误区是“在中断里调 Cache 维护函数天然安全”。实际上 Cache 维护函数如果内部要操作多个循环、访问全局寄存器,它也需要一定临界区保护。如果 DMA 的完成处理还涉及命令队列的读写,你和主循环可能同时操作同一条队列,这时候不是 Cache 函数本身不安全,而是整个操作流程缺少锁。
第二个误区是“已经加了内存屏障,为什么还不对”。内存屏障解决的是 CPU 访问内存指令的执行顺序,但它并不会把 Cache 里的脏行强制写回,也不会让 Cache 行失效。有些使用 DMA 的应用确实需要屏障,但那是为了保证配置寄存器和缓冲区维护之间的顺序,和“数据新不新”是两码事。
第三个误区是“只对我的应用用到的几字节做 Invalidate 就够”。失效操作也作用于整个缓存行,如果你只打算失效一个 4 字节变量,而它旁边还有其他数据,那这行里的其他数据也会被一并丢弃。反过来,如果你只清理一行里的部分内容,脏行标记会迫使整个行回写。所以日常开发里,统一按行对齐来处理缓冲区能省很多啰嗦。
第四个误区是“把缓冲区设成不可缓存就没内存序问题”。不可缓存内存虽然避免了“读到旧数据”,但当多个 CPU 核同时访问同一块缓冲区时,仍然需要原子操作和适当的同步机制。非缓存不代表自动解锁并发,它只是让每次访问都直接落到内存,内存里的竞争并不会消失。
最后分享一个我自己的习惯:每次接到“DMA 读旧数据”的排查任务,先不急着改代码,而是把 CPU 和 DMA 对缓冲区的所有权交接点画在一张图上。哪一刻 CPU 写、哪一刻 DMA 读、哪一刻 DMA 写完、哪一刻 CPU 读,把交接点标出来之后,再决定用第一招、第二招还是第三招。通常光是这一步,就能省下两三个晚上的反复试错。这个思路我一直用到现在,也希望能帮你少踩几个坑。