1. 从一次调试翻车说起:DMA读旧数据的典型现场
搞嵌入式开发的朋友,尤其是做音视频采集、高速ADC采样、网络数据包转发这类场景的,大概率都遇到过一个让人抓狂的现象:明明外设已经把新数据写进了内存缓冲区,CPU这边去读,拿到的却还是上一轮的老数据。你单步调试看寄存器,DMA的传输完成标志置位了,地址也对,长度也对,可数据就是不对。更诡异的是,有时候加一句打印、改一个变量,现象就变了,甚至自己好了——这种“薛定谔的Bug”最折磨人。
这个问题的核心,十有八九出在Cache一致性上。标题里说的“DMA总读旧数据”,准确讲分两种情况:一种是DMA把外设的新数据写进了内存,但CPU读的时候走了Cache,拿到的是Cache里的旧副本;另一种是CPU写了新数据还在Cache里没落盘,DMA直接从内存搬走了旧数据发出去。两种方向,本质是同一个问题——CPU的Cache和DMA看到的内存,不是同一份数据。
这篇内容我打算把这件事彻底讲透。适合谁看?适合所有在带Cache的处理器上做DMA开发的工程师,不管你是用Cortex-A系列跑Linux,还是用Cortex-M7、M33这类带Cache的MCU裸机开发,甚至是RISC-V带Cache的核,底层逻辑都一样。我会先讲清楚为什么会这样,再给出三套可落地的解决方案,最后把我这些年踩过的坑和排查套路整理成速查表。看完你至少能做到:遇到DMA数据不对,十分钟内定位是不是Cache问题,并且知道该用哪招。
2. 为什么DMA和CPU会“看到”两份数据
2.1 一张图理解Cache在中间扮演的角色
要理解这个问题,得先接受一个反直觉的事实:在带Cache的系统里,“内存里的数据”和“CPU读到的数据”可能不是一回事。
CPU为了跑得快,在核心和主存之间加了一层Cache(高速缓存)。CPU读数据时,先看Cache里有没有,有就直接拿(Cache Hit),没有才去主存拿,并且顺手放进Cache(Cache Fill)。CPU写数据时,默认也是先写Cache(Write Back模式),标记这块Cache行为“脏”(Dirty),等将来某个时刻再统一写回主存。
问题就出在这个“等将来某个时刻”。DMA是个老实人,它不懂Cache,它眼里只有物理内存。当DMA把外设数据搬到内存某个地址时,如果这个地址对应的Cache行里恰好有CPU之前读过的旧数据,那么CPU下次读这个地址,Cache Hit,直接返回旧数据,根本不去内存看DMA写的新值。反过来,CPU写了新数据在Cache里没写回,DMA去内存搬,搬走的是旧值。
用生活类比:Cache就像你办公桌上的便签本,主存是档案柜。你(CPU)习惯先看便签本,DMA是个只认档案柜的快递员。快递员把新文件放进档案柜,但你便签本上还记着旧内容,你自然读到旧的。你往便签本写了新内容没抄进档案柜,快递员从档案柜取走的自然是旧的。
2.2 写回(Write Back)与写通(Write Through)的差别
这里必须区分两种Cache写策略,因为它直接决定你要不要处理“CPU写、DMA读”这个方向的问题。
写通(Write Through):CPU每次写Cache,同时把数据写进主存。这种模式下,主存永远是最新的,DMA读主存不会读到旧数据。但代价是每次写都要访问主存,性能打折。很多对一致性要求高的区域会配成写通。
写回(Write Back):CPU写Cache后不立即写主存,只标记Dirty,等Cache行被替换时才写回。性能好,但主存可能是旧的。这是绝大多数高性能处理器的默认策略,也是DMA读旧数据的主要来源。
所以你会发现,“DMA读旧数据”这个方向的问题,只在写回模式下出现。而“CPU读旧数据”(DMA写、CPU读)这个方向,无论写通写回都可能出现,因为DMA写内存不会去更新Cache。
2.3 哪些平台会踩这个坑
不是所有平台都有这个问题。判断标准很简单:CPU核有没有数据Cache(D-Cache),且DMA访问的内存区域是否被Cache覆盖。
- Cortex-M0/M0+/M3/M4(无Cache或只有I-Cache):基本不踩这个坑,因为数据访问直通主存。这也是为什么很多从STM32F1/F4转过来的工程师第一次用H7、M7就翻车。
- Cortex-M7/M33/M55:带D-Cache,必踩。
- Cortex-A系列(A7/A53/A72等)跑Linux/RTOS:必踩,而且更复杂,因为有多核、有MMU。
- 带Cache的RISC-V核、部分DSP:同样必踩。
我见过太多项目,从M4迁到M7,代码一行没改,DMA就时好时坏。原因就是M7默认开了D-Cache,而原来的代码从来没考虑过Cache维护。
3. 第一招:把DMA缓冲区设为非Cache区域
3.1 原理与适用场景
最省心的一招,就是让DMA访问的那块内存根本不进Cache。既然Cache是罪魁祸首,那就把这块内存从Cache的管辖范围里划出去。CPU访问这块内存时,硬件直接走主存,不查Cache也不填Cache,自然就不存在一致性问题。
这招的优点是一劳永逸:配置好之后,代码里完全不用管Cache维护,DMA和CPU看到的永远是同一份主存数据。缺点是性能损失:CPU访问这块内存会变慢,因为每次都要走主存。所以它适合那些“数据量大但CPU不频繁访问”的缓冲区,比如DMA搬运的原始数据区、大块采集缓冲。
3.2 MPU/MMU配置实操
在Cortex-M7这类带MPU的MCU上,做法是配置MPU(内存保护单元),把DMA缓冲区所在的内存区域属性设为Non-Cacheable。
以常见的做法为例,假设DMA缓冲区放在某段SRAM里,地址范围是0x30000000起始、大小0x10000(64KB):
// 配置MPU Region,将DMA缓冲区设为Non-Cacheable // 以下为示意,具体寄存器名依芯片手册 MPU->RNR = 0; // 选择Region 0 MPU->RBAR = 0x30000000 | MPU_RBAR_VALID | 0; MPU->RASR = MPU_RASR_ENABLE | (0x0 << MPU_RASR_AP_Pos) // 权限按需设置 | (0x1 << MPU_RASR_TEX_Pos) // TEX=001, C=0, B=0 => Non-Cacheable | (0x0 << MPU_RASR_C_Pos) | (0x0 << MPU_RASR_B_Pos) | ((16 - 1) << MPU_RASR_SIZE_Pos); // 2^16 = 64KB关键在TEX/C/B这三个位。对于Cortex-M7,TEX=001, C=0, B=0表示Normal memory、Non-Cacheable。配置完记得开MPU并使能。
在Cortex-A跑Linux的场景,通常通过设备树(Device Tree)里的reserved-memory节点配合no-map或shared-dma-pool来预留一块非Cache内存,或者用dma_alloc_coherent申请一致性内存。裸机A核则通过MMU页表项设置。
3.3 注意事项与踩坑记录
注意:MPU区域大小必须是2的幂,且起始地址要按大小对齐。比如64KB区域,起始地址低16位必须为0。我见过有人把缓冲区放在
0x30008000却想配64KB,结果覆盖范围算错,把别的数据也划进去了。
另一个坑是链接脚本和MPU配置要对齐。你MPU划的是0x30000000开始的64KB,但链接脚本把DMA缓冲区放在了0x30010000,那就白配了。我一般会在链接脚本里专门开一段.dma_buffer段,然后用__attribute__((section(".dma_buffer")))把缓冲区放进去,确保地址和MPU配置严格对应。
还有一点,非Cache内存不等于非缓冲(Non-Bufferable)。有些平台写操作会经过写缓冲(Write Buffer),虽然不影响一致性,但可能影响时序。如果对时序敏感,可以把B位也考虑进去,具体查芯片手册的Memory Attributes表。
4. 第二招:手动做Cache维护(Clean/Invalidate)
4.1 Clean和Invalidate到底在干什么
如果不想牺牲性能,那就得在DMA传输前后手动维护Cache。这里有两个动作,名字容易混,我用大白话解释:
- Clean(清理/写回):把Cache里Dirty的数据写回主存。作用是让主存拿到CPU的最新数据。用在DMA读之前——CPU写完数据,先Clean,DMA才能读到新值。
- Invalidate(无效化):把Cache行标记为无效。下次CPU读这块地址时,Cache Miss,被迫去主存拿最新数据。用在DMA写之后——DMA写完内存,CPU读之前先Invalidate,丢掉Cache里的旧副本。
记住口诀:CPU写、DMA读,先Clean;DMA写、CPU读,先Invalidate。两个方向都有,就Clean加Invalidate。
4.2 按地址范围维护的实操代码
以Cortex-M7为例,CMSIS提供了现成的函数:
// 场景一:CPU准备好数据,要交给DMA发送 prepare_tx_buffer(tx_buf, len); SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); // 写回主存 start_dma_tx(tx_buf, len); // 场景二:DMA接收完数据,CPU要读取 wait_dma_rx_done(); SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len); // 丢弃旧Cache process_rx_buffer(rx_buf, len);注意SCB_CleanDCache_by_Addr的第二个参数是字节数,不是Cache行数。CMSIS内部会帮你处理对齐。但这里有个大坑,下面细说。
4.3 对齐问题:最容易翻车的地方
Cache维护是按Cache行(Cache Line)操作的,Cortex-M7通常是32字节一行,A系列常见64字节。你传进去的地址范围,硬件会向上向下取整到整行。
这就带来一个隐蔽的Bug:假设Cache行是32字节,你的接收缓冲区rx_buf起始地址是0x20000010(不是32的倍数),长度20字节。你调用Invalidate,硬件实际会把0x20000000到0x20000020这一整行都无效化。如果0x20000000到0x20000010这段里存着别的、还没写回的Dirty数据,这一Invalidate就把它们直接丢了,数据永久丢失。
注意:Invalidate一个包含Dirty数据的Cache行,Dirty数据会被直接丢弃,不会写回主存。这是数据丢失的经典原因,而且极难排查。
解决办法有两个:一是强制缓冲区按Cache行对齐,用__attribute__((aligned(32)))或aligned(64);二是缓冲区大小也按行对齐,避免尾部跨行影响邻居。我个人的习惯是,所有DMA缓冲区一律aligned(64),长度也补齐到64的倍数,虽然浪费一点内存,但省心。
// 推荐做法:对齐到Cache行 __attribute__((aligned(64))) static uint8_t rx_buf[RX_SIZE];4.4 多核与乱序执行的额外考量
在Cortex-A多核场景,光Clean/Invalidate还不够,还要考虑内存屏障(Memory Barrier)和核间同步。因为编译器可能重排指令,CPU可能乱序执行,导致Cache维护指令和DMA启动指令的实际执行顺序和你写的不一样。
标准做法是在Cache维护后加DSB(数据同步屏障),确保维护操作真正完成,再启动DMA:
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); __DSB(); // 确保Clean完成后再启动DMA start_dma_tx(tx_buf, len);在Linux驱动里,对应的API是dma_sync_single_for_device()和dma_sync_single_for_cpu(),内核帮你处理了屏障和架构差异,能用就用,别自己造轮子。
5. 第三招:用一致性内存分配(Coherent Allocation)
5.1 什么时候该用这招
前两招,一招牺牲性能换省心,一招保性能但代码里到处是维护调用、容易漏。第三招是从分配源头就保证一致性——申请一块硬件保证CPU和DMA看到同一份数据的内存。
在Linux下,这就是dma_alloc_coherent();在裸机下,很多芯片提供了专门的DTCM、AXI SRAM的非Cache别名,或者硬件一致性端口(如Cortex-M7的AXI接口配合某些SoC的一致性互连)。
这招适合控制类、小数据量、频繁双向交互的场景,比如命令队列、描述符环(Descriptor Ring)、状态寄存器镜像。这些数据量小,性能损失可接受,但一致性要求极高,手动维护容易出错。
5.2 Linux下的dma_alloc_coherent实操
// 申请一致性内存 dma_addr_t dma_handle; void *cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL); if (!cpu_addr) { // 处理失败 } // cpu_addr 给CPU用,dma_handle 给DMA用(写进DMA寄存器) // 两者指向同一块物理内存,硬件保证一致性 writel(dma_handle, dev->dma_reg_base + DMA_SRC_ADDR); // 用完释放 dma_free_coherent(dev, size, cpu_addr, dma_handle);关键点:dma_alloc_coherent返回两个地址,CPU用虚拟地址cpu_addr,DMA用总线地址dma_handle。这块内存默认是非Cache的(或硬件一致的),你不需要再手动Clean/Invalidate。
5.3 裸机下的替代方案
裸机没有dma_alloc_coherent,但有几种等效做法:
- 使用DTCM:Cortex-M7的DTCM(数据紧耦合内存)通常不经过Cache,CPU访问还快。把DMA缓冲放DTCM,天然一致。但注意DTCM容量有限(通常几十到几百KB),且有些DMA控制器访问不到DTCM(地址空间问题),用之前查手册。
- 使用非Cache别名:有些SoC把同一块SRAM映射到两个地址,一个Cacheable一个Non-Cacheable。DMA用Non-Cache别名,CPU用Cacheable别名,配合手动维护。这其实是第一招的变体。
- 硬件一致性互连:部分高端MCU的DMA和Cache之间有硬件监听(Snoop),能自动保证一致性。这种最省心,但要看芯片是否支持,且通常需要配置。
5.4 一致性内存的代价
天下没有免费的午餐。一致性内存的代价是CPU访问变慢(因为绕过了Cache),而且分配粒度通常较大(Linux下按页,4KB起)。所以别拿它当万能药,大块数据搬运还是老老实实用前两招。
我一般的选型逻辑是:描述符、命令、状态这类小结构用一致性内存;大块数据流用非Cache区域或手动维护。这样兼顾了正确性和性能。
6. 三招怎么选:一张决策表
光讲原理不够,实际项目里怎么选才是关键。我整理了一张决策表,按场景对号入座:
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 大块数据、CPU访问不频繁 | 第一招:非Cache区域 | 省心,性能损失可接受 |
| 大块数据、CPU需频繁处理 | 第二招:手动Clean/Invalidate | 保性能,但需严格对齐 |
| 小数据、频繁双向交互 | 第三招:一致性内存 | 正确性最高,代码最简 |
| 多核、复杂SoC | 第三招 + 内核DMA API | 避免自己处理屏障和同步 |
| 从无Cache平台迁移 | 先查D-Cache是否开启 | 很多坑源于默认开启 |
再补充一个经验:同一个项目里三招可以混用。比如描述符环用一致性内存,数据缓冲区用非Cache区域,某些特殊路径用手动维护。关键是每块内存的归属要清晰,别一会儿Cache一会儿非Cache,那样更容易乱。
7. 常见问题与排查技巧实录
7.1 问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| DMA写、CPU读旧数据 | Cache未Invalidate | 检查DMA完成后是否Invalidate |
| CPU写、DMA发旧数据 | Cache未Clean | 检查DMA启动前是否Clean |
| 偶发数据错乱 | 缓冲区未对齐Cache行 | 检查地址和长度是否按行对齐 |
| 数据部分正确部分错 | 跨Cache行边界 | 检查缓冲区是否跨行,邻居是否被误伤 |
| 加打印就好、去掉就坏 | 编译器优化或时序 | 加屏障,检查优化等级 |
| 迁移平台后突然出问题 | D-Cache默认开启 | 查启动代码是否开了D-Cache |
| 多核下时好时坏 | 缺内存屏障或核间同步 | 加DSB,检查核间通信 |
7.2 独家避坑技巧
技巧一:用“金丝雀”检测越界。在DMA缓冲区前后各放一个已知值的哨兵变量,传输后检查哨兵有没有被改。如果被改,说明Cache维护的范围越界了,误伤了邻居。这招帮我定位过好几次对齐问题。
技巧二:先关Cache验证。怀疑是Cache问题时,最快速的验证方法是临时关掉D-Cache跑一遍。如果问题消失,基本坐实是Cache一致性。确认后再针对性处理,别一上来就大改代码。
技巧三:Cache维护函数要配对。我见过有人只Clean不Invalidate,或者顺序搞反。建议在代码里成对写注释,比如/* TX: clean before DMA */和/* RX: invalidate after DMA */,形成肌肉记忆。
技巧四:注意DMA描述符本身。描述符环如果放在Cacheable内存,DMA读描述符也可能读到旧的。描述符的维护和数据结构一样重要,别只顾着数据缓冲区。
技巧五:编译器优化会咬人。-O2以上,编译器可能把变量缓存在寄存器里,你Invalidate了Cache,但CPU读的还是寄存器里的旧值。这时候要用volatile或者内存屏障,确保CPU真的去内存读。
7.3 一个真实的排查过程
之前有个项目,音频采集偶尔出现一帧旧数据。现象是每几千帧错一次,概率极低。我先按经验加了Invalidate,没解决。后来用哨兵法检查,发现缓冲区尾部偶尔被改。查下去发现是缓冲区长度不是Cache行整数倍,Invalidate时把相邻的一个DMA描述符给无效化了,导致描述符偶尔读到旧值。把缓冲区长度补齐到64字节倍数后,问题彻底消失。
这个案例说明:Cache问题的表现往往不是“全错”,而是“偶发、局部错”,因为只有特定地址、特定时序才触发。排查时要有耐心,善用哨兵和二分法。
8. 写在最后的一点个人体会
Cache一致性这个问题,说难不难,说简单也不简单。难的是它隐蔽,表现随机,容易让人怀疑人生;简单的是,只要你理解了“CPU和DMA看的是两份数据”这个本质,三招里总有一招能解决。
我个人的习惯是:新项目一开始就把内存规划好,哪些区域给DMA、用什么策略,在链接脚本和MPU配置阶段就定下来,别等出了问题再补。尤其是从无Cache平台转过来的团队,第一件事就是确认D-Cache状态,把DMA缓冲区的归属想清楚。
另外,别迷信“加个Invalidate就行”。对齐、屏障、多核同步,任何一个环节漏了都可能翻车。我现在的做法是,所有DMA缓冲区一律64字节对齐、长度补齐,维护函数成对出现并加屏障,虽然啰嗦,但省下了大量调试时间。毕竟,调试一个偶发的Cache Bug,可能比多写十行代码贵得多。