做嵌入式驱动开发的朋友,大概率都遇到过这种诡异现象:DMA明明已经把新数据写进了内存,CPU去读的时候却拿到上一次的旧数据;或者CPU把要发送的数据往缓冲区一放,DMA再从内存往外搬,搬出去的竟然是缓冲区初始化时的全零。我当年第一次碰上这个问题,折腾了整整一个通宵,一度怀疑是DMA控制器坏了,换了块板子还是一样,最后才发现,根子在CPU的Cache身上。
这篇文章把DMA和Cache一致性问题的来龙去脉讲透,并给出三招可落地的解法:软件刷Cache、修改内存映射属性、利用描述符协议与硬件一致性机制兜底。适合正在做网卡、串口、SDIO、音视频采集这类外设驱动,或者用DMA搬运数据的嵌入式开发者。无论是裸机、RTOS还是Linux平台,思路都通用,只是换成各自平台的接口而已。
1. DMA和Cache之间的"信息差"
1.1 先看现象:第一次永远拿到旧数据
我遇到的场景是给一块带局域网控制器的板子写驱动。CPU通过DMA把收到的以太网帧搬到内存缓冲区,中断来了以后CPU再从缓冲区里把数据读出来处理。诡异的是,每次上电后接收到的第一帧数据永远是零填充的初始值,第二帧才是真正的第一帧;更离谱的是,如果连续接收,缓冲区里总有一个"旧帧"残留,就好像数据永远慢了一拍。当时我打印缓冲区地址、检查DMA描述符,全都正常,百思不得其解。
发送方向的症状更隐蔽。我把一包新数据memcpy到一个DMA发送缓冲区,启动DMA,抓包工具看到的却是上次发送的老内容。因为CPU认为数据"已经写好了",实际上那些字节只是躺在了Cache里,主存里还是老版本。换句话说,CPU的写操作被Cache"截胡"了,DMA读到的自然就是旧数据。这个方向正是标题里"DMA读旧数据"的直译场景。
为什么第一次最容易暴露?因为CPU在初始化缓冲区的时候,不管是清零还是填充魔数,都会先访问这块内存,Cache里已经缓存了这块地址对应的行。DMA把新数据写到主存之后,Cache里的那一行并不会自动失效。中断来了,CPU去读缓冲区,Cache一看"我有这行数据",直接命中返回——里面还是初始化时的值。这就是"第一次读旧数据"的真相。
1.2 根因:Write-Back Cache的工作方式
CPU和内存之间的速度差距太大了。现代处理器会把最近访问过的地址放在Cache里,读写都优先跟Cache打交道。关键在写策略上:很多处理器默认是写回(Write-Back)模式。CPU写一个地址,先只修改Cache里对应的行,标记为"脏"(Dirty),并不会立刻同步到主存。什么时候才写回?可能是这个Cache行即将被淘汰的时候,也可能是在执行某个同步指令强制触发的时候。
这意味着,从CPU写数据到数据真正出现在主存里,中间存在一个时间窗口。在这个窗口内,DMA去访问主存,拿到的自然是旧值。反过来也一样,DMA把数据写进主存,CPU的Cache里还保留着旧的副本,CPU下次读的时候一旦Cache命中,也会得到旧值。一句话概括:DMA访问的是内存,CPU优先访问的是Cache,两边对同一个地址的"印象"产生了分歧。
打个比方:Cache就是CPU手边的一张草稿纸,它先把计算结果写在草稿纸上,还没誊到正经账本里。DMA是另外一个查账的人,他只看正经账本,所以总以为账目没更新。问题不是账本坏了,而是CPU的"誊写"动作没有同步。
1.3 两个方向,一个都不能漏
Cache一致性问题必须分两个方向对待:
- CPU写、DMA读:典型场景是网络发包、SD卡写入、串口发送。CPU修改了数据,数据还留在Cache里,DMA去主存读,拿到旧数据。解决方式是"清理(Clean)",把Cache里的脏行强制写回主存。
- DMA写、CPU读:典型场景是网络收包、音频采样、ADC采集。DMA把新数据写进了主存,CPU的Cache里却还是旧副本。解决方式是"失效(Invalidate)",让Cache中对应地址的行作废,下次CPU访问时被迫从主存重新加载。
很多新手只做了其中一个方向,或者两个方向搞反了,数据要么还是旧的,要么直接被损坏。方向判断其实很简单:谁最后修改了这块内存,就围绕谁做文章。CPU最后改的,交给DMA之前做Clean;DMA最后改的,CPU读取之前做Invalidate。双方向复用的缓冲区,两个操作都得做。
另外补一句:别指望volatile能解决这个问题。volatile只是告诉编译器不要对这个变量的访问做优化,但CPU硬件的Cache行为它管不了。DMA写入主存后,CPU读变量命中Cache,照样返回旧值,volatile在这里完全帮不上忙。这个误区很常见,我见过有人为了修这个bug,把整个结构体所有字段都加上volatile,最后还是没用。
2. 第一招:软件维护Cache一致性
2.1 什么时候Clean,什么时候Invalidate
先记住两句话:发射之前Clean,接收之后Invalidate。所谓发射,就是CPU把数据交给DMA之前,必须保证你的修改已经对主存可见,所以执行Clean;所谓接收,就是DMA把数据写进内存之后、CPU开始读之前,必须强制CPU从主存重新取,所以执行Invalidate。
如果你有一块缓冲区需要同时做收发,比如双工的串口DMA或者乒乓缓冲,最稳妥的做法是:准备接收时Invalidate,准备发送时Clean,并且在每次DMA完成中断里再做一次对应的操作。宁可多做一次操作也不要漏,Cache行级操作本身开销不大,但漏一次问题就非常难查。
这里还要澄清一个常见误区:有人以为Clean和Invalidate是同一件事,或者Clean之后缓存行就失效了。其实Clean只是把脏数据写回,Cache行仍然保留有效副本;Invalidate只是作废缓存行。对刚Clean过的行执行Invalidate没问题,但反过来就危险了:发送方向如果错误地用了Invalidate,等于把还没写回主存的修改直接丢掉了,数据必然会坏。
2.2 实操代码示例
以带Cache的ARM内核为例,多数M内核处理器的SDK会提供CMSIS风格的函数:SCB_CleanDCache_by_Addr用于清理指定地址区间,SCB_InvalidateDCache_by_Addr用于失效指定地址区间。典型发送流程像这样:
/* 1. CPU把待发送数据拷到DMA缓冲区 */ memcpy(tx_buf, packet, packet_len); /* 2. 交给DMA之前,把该区间的脏行写回主存 */ SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, packet_len); /* 3. 启动DMA传输 */ DMA_Start_TX(tx_buf, packet_len);接收完成的中断里,处理流程是这样:
/* 1. DMA已经完成,数据在主存里 */ DMA_Stop_RX(); /* 2. CPU读取之前,作废该区间的Cache行,强制从主存重读 */ SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, rx_len); /* 3. 现在读到的一定是DMA写入的新数据 */ process_packet(rx_buf, rx_len);如果你在Linux下写驱动,内核已经封装好了对应的接口。分配时可以这样:
dma_addr_t dma_handle; void *buf = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);如果用的是普通内存映射,则需要每次搬运前后做同步:
dma_map_single(dev, buf, len, DMA_TO_DEVICE); /* 发送前,内部等价Clean */ dma_map_single(dev, buf, len, DMA_FROM_DEVICE); /* 接收前,内部等价Invalidate */ dma_unmap_single(dev, dma_handle, len, DMA_FROM_DEVICE); /* CPU读前再失效一次 */这些接口本质上就是在Cache操作之上加了必要的内存屏障,并且考虑了IOMMU等场景,比我手动操作寄存器和汇编指令可靠得多。能用上层接口就不要自己顺手写汇编,除非是在裸机或RTOS环境下不得不手写。
2.3 Cache Line对齐和内存屏障,两个隐形坑
第一个隐形坑是Cache Line对齐。Cache操作的基本单位是一个Cache Line,常见大小是32或64字节,具体看CPU手册。如果你只操作中间某一段地址,需要把首尾所在的整个Cache Line包含进来。很多CMSIS函数会做内部对齐,但如果你直接操作寄存器或者写精简实现,就必须自己处理边界。
#define CACHE_LINE_SIZE 64 void clean_range_aligned(uintptr_t addr, size_t len) { uintptr_t start = addr & ~(CACHE_LINE_SIZE - 1); uintptr_t end = (addr + len + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1); /* 对start到end之间的每一行执行Clean */ }如果不对齐,会带来两种结果。要么首尾数据的修改没有写回主存,DMA发出去的还是旧内容;要么把Cache Line里不属于你的邻居数据也一起写回或者失效,导致别的变量、别的缓冲区被莫名其妙破坏。这两种现象都极具迷惑性,特别是后者,会让你一度怀疑有人在后台乱改你的内存。
第二个坑是内存屏障。Cache操作指令执行本身需要时间,CPU流水线可能不等它完成就继续往后跑。如果Clean指令还没真正生效,DMA就已经被启动,数据传输照样使用旧数据。正确的做法是在Cache操作和启动DMA之间插入数据同步屏障,ARM上常用DSB,保证之前所有缓存维护操作完成后再继续执行。Linux API里这些屏障已经被包含,但裸机代码里很多人忘了加。你可以在代码里这么写:
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len); __DSB(); /* 确保Clean完成 */ DMA_Start_TX(tx_buf, len);编译器优化层面也要注意。如果你开了-O3,并且缓存维护是通过内联汇编或直接写寄存器完成的,要防止编译器把"写缓冲区"的代码挪到Cache操作之后。使用内联汇编时加上内存clobber描述,或者干脆把相关操作封装成一个编译屏障函数,避免乱序。
3. 第二招:从映射层面解决(Non-Cacheable内存区间)
3.1 原理:干脆不缓存这块内存
软件刷Cache虽然能解决问题,但每次收发都要做,代码容易被方向、对齐、屏障各种细节纠缠。性能敏感的场景还要担心频繁Cache操作影响吞吐。第二个思路更彻底:把DMA缓冲区所在的物理内存映射成不可缓存或写透传(Write-Through),让CPU和DMA看到的始终是同一个主存视图。
- Non-Cacheable(不可缓存):CPU每次读写都直接访问主存,Cache完全不参与。没有任何一致性问题,但CPU对这块区域的读写延迟明显变高。
- Write-Through(写透传):CPU写数据时同时更新Cache和主存,读的时候仍然可以利用Cache。一致性较好,写性能略差。
- Write-Back(写回):默认模式,性能最好但必须手动维护一致性,对应第一招的内容。
DMA缓冲区的特点是数据体量大但CPU访问次数相对少,访问模式很规律,所以绝大多数情况下把DMA缓冲区设成Non-Cacheable是划算的。代价只是CPU往这块区域写入时每次都直接落到主存,而数据本来就是要给DMA的,这个代价基本等于提前完成了"写回"。
3.2 Linux驱动里的做法
Linux平台不用自己手动画页表。内核提供两个常用途径:
- dma_alloc_coherent:分配一块一致性内存,内部保证CPU和DMA看到的内存是一致的,本质就是Non-Cacheable映射(或者在支持硬件一致性的SoC上利用总线监听)。返回的虚拟地址可以直接用,dma_handle就是给DMA用的物理地址。
- dma_map_single / dma_unmap_single:针对已有的普通内存,在每一次DMA传输前后做同步,内部封装了Clean/Invalidate操作,相当于把第一招封装成标准接口。
对应的方向语义整理成表:
| 内核接口 | 方向 | 内部动作 |
|---|---|---|
| DMA_TO_DEVICE | CPU写、DMA读 | Clean |
| DMA_FROM_DEVICE | DMA写、CPU读 | Invalidate |
| DMA_BIDIRECTIONAL | 双向 | Clean + Invalidate |
在驱动初始化时,也可以设置固定的DMA内存池,通过dma_pool_create管理大小固定的缓冲区,配合dma_alloc_from_coherent使用,性能比反复分配一致性内存更好,特别适合描述符这类小对象。描述符和数据缓冲区的缓存策略我建议分开:描述符用不可缓存,数据缓冲区按需用Cacheable加软件同步。这样可靠性粒度更细,也不会牺牲太多性能。
3.3 没有MMU时的MCU方案:MPU配置
很多带Cache的MCU内核(比如Cortex-M7)支持MPU而非MMU,但思路是一样的。可以配置一个MPU Region,把DMA缓冲区的起始地址和大小覆盖进去,属性设为Normal Memory、Non-Cacheable,优先级设为最高。之后CPU访问这个区域不会进Cache,DMA读写也直接面对主存,软件里针对这个缓冲区的Clean/Invalidate都可以删掉。
配置伪代码大致是这样:
/* 选择Region编号 */ REGION_NUM = 8; REGION_BASE = DMA_BUF_ADDR; /* 起始地址按Region大小对齐 */ REGION_SIZE = REGION_32KB; REGION_ATTR = NORMAL_MEM_NON_CACHEABLE; REGION_ENABLE = 1;要注意Region大小必须是2的幂次,起始地址要对齐到Region大小,不能随手填一个"大约"的尺寸。MPU配置一旦出错,轻则内存访问异常,重则系统直接跑飞,所以配置前先把起始地址和大小算清楚,并且用调试器确认Region生效。
我见过有人图省事,直接把整片RAM都设成Non-Cacheable。这样做绝对安全,但CPU频繁访问的变量、栈、堆全都没有了缓存加速,程序性能肉眼可见地下降,实测某些场景整体吞吐比原来慢了一半以上。折中做法是:DMA缓冲区单独圈一块Non-Cacheable区间,其余RAM保持Cacheable,同时注意数据不要跨多个Region访问,否则又要面对半缓存半不缓存的尴尬状态。
4. 第三招:硬件一致性与描述符协议设计
4.1 硬件一致性总线:CCI/ACE
在多核处理器或带GPU的应用处理器级SoC上,纯粹靠软件维护Cache太痛苦了。厂商引入了Cache一致性互连总线,像CCI、ACE这类名字。简单说,CPU的Cache和系统总线之间加了一个监听过滤器(Snoop Filter)。DMA发起读写时,总线会替它检查各个CPU的Cache:
- DMA要读的地址在某个CPU的Cache里是脏行,总线要求该CPU先把数据写回主存,DMA再读取,于是DMA拿到的一定是最新值。
- DMA向主存写了新数据,总线会把CPU Cache中对应的行标记为失效,CPU下次访问时被迫从主存重新加载。
于是CPU和DMA看到的内存天然一致,软件里那些Clean/Invalidate根本不用写。硬件一致性的好处是DMA缓冲区可以用普通Cacheable内存,CPU访问性能不受影响,驱动代码也简洁很多。代价是IP成本高、总线功耗大,一般只在应用处理器级别SoC上出现,普通MCU基本别想。
如果你的平台支持硬件一致性,但驱动里还是出现旧数据,多半不是总线问题,而是访问描述符和状态位时缺了内存屏障,或者没有使用专门的一致性接口去触发屏障。别急着把缓冲区全改成Non-Cacheable,先确认描述符的状态同步是否完整。
4.2 描述符同步:只刷数据缓冲区是不够的
这一步是很多人的盲区。DMA传输的发起,往往不是靠直接写数据区,而是靠更新内存里的描述符:地址、长度、控制位,DMA完全按描述符执行。如果描述符本身也被Cache缓存着,CPU更新完描述符后不做同步,DMA去读描述符,拿到的可能是旧地址、旧长度,甚至旧的控制标志位,传输过程直接乱套。
反过来,DMA处理完传输后会把完成状态(DD位、中断标志)写进描述符或者独立的状态区。CPU在中断里读这个状态时如果不做Invalidate,可能反复读到"任务还未完成",中断一进就卡住,或者轮询永远等不到完成。
我的建议是:小数据结构(描述符、状态字、门铃标志)和大量数据体分开处理。描述符区在上层配置阶段直接做成Non-Cacheable,或者每次更新描述符后做一次精确的Clean;数据区为了性能继续保持Cacheable,到真正搬运前后再做行级Cache操作。这种"分层一致性"策略在网卡驱动里特别常用。
下面是一个简化的发送描述符同步流程:
struct dma_desc { uint32_t buf_addr; uint32_t len; uint32_t ctrl; uint32_t status; /* DD位由DMA写 */ }; /* CPU填写描述符 */ desc->buf_addr = dma_handle; desc->len = len; desc->ctrl = TX_ENABLE; /* 关键:写完后先把描述符刷出去,再发门铃通知DMA */ clean_descriptor_range(desc); write_doorbell(desc_index);4.3 协议设计来兜底:门铃、状态轮询与校验
硬件一致性是平台红利,不是每个环境都有。软件层面上,即使没有硬件一致性,也可以通过协议设计把一致性风险限制在可控范围。核心思想是:把所有"谁改了什么、谁来看"的时序变成显式的握手。
- 门铃机制:CPU把工作描述符写完后,再写一个固定的硬件寄存器(门铃)通知DMA"描述符好了,可以来取"。DMA只有看到门铃之后才读描述符,从协议上保证CPU对描述符的修改先于DMA读取。
- 状态轮询:DMA完成传输时,在约定的状态字里写完成标记;CPU轮询这个标记,读到标记才访问数据缓冲区。轮询本身是一种同步,但配合Cache操作才可靠。
- 校验字段:在描述符和关键数据头部加CRC或奇偶校验,CPU和DMA各自维护校验值。一旦出现因为一致性导致的"半新半旧"数据,校验一定对不上,至少能快速暴露问题,而不是产生无法解释的脏数据。
这三招组合起来,就是在没有硬件一致性平台的条件下,用软件把"读旧数据"的概率压到最低。最理想的状态是:描述符区Non-Cacheable,配合门铃中断,数据区按需做Clean/Invalidate,状态字带完整校验。你会发现这其实就是很多商用网卡驱动已经采用的套路,只是它们把这些逻辑封装到了HAL层里。
5. 常见问题与排查技巧实录
5.1 我踩过的几个典型坑
踩坑一:Clean和Invalidate方向用反。发送方向错误地用了Invalidate,结果CPU刚memcpy的数据还没写回主存就被当垃圾丢了,抓包发现发出去的全是乱码和上一包残留。排查方法很简单:把发送缓冲区的数据用DMA读回来和原始缓冲区对比,如果前半段是新的后半段是旧的,基本就是Cache维护做了一半或者方向错了。
踩坑二:地址没有对齐到Cache Line。我当时自封装的缓存操作函数偷懒,只按传入的起始地址处理,没有把首尾行补齐。结果每次发送的前32字节永远正确,后面64字节偶尔串数据。后来才发现,Clean操作只覆盖了我给的地址区间,首尾Cache Line里属于数据包的那部分字节没有一并写回。
踩坑三:描述符忘同步。数据缓冲区做了全套Clean/Invalidate,但描述符更新完没有刷。DMA偶尔读到一个旧的描述符,把数据长度搞成0,驱动读不到包,状态卡死在等待。这个问题最气人,因为它不定期出现,完全取决于Cache行什么时候被淘汰。
踩坑四:关了Cache一切正常,开了Cache就坏。这是最经典的判断信号,基本锁定就是Cache一致性问题。不要慌,也不要直接关Cache交付,应该先确认方向、对齐、描述符三件事,全部排除后再看是不是硬件一致性接口没有生效。
5.2 问题速查表
| 现象 | 可能原因 | 处理建议 |
|---|---|---|
| DMA发送总发出旧包或全零包 | CPU写的数据还在Cache里没有Clean | 发送前对缓冲区执行Clean,或改用Non-Cacheable映射 |
| 接收数据总是旧帧或初始化值 | CPU的Cache保留旧副本,没有Invalidate | 接收完成中断里先Invalidate再读数据 |
| 数据时好时坏,传输长度不对 | 描述符Cache未同步 | 描述符区独立设为Non-Cacheable,或更新后Clean |
| 发送首尾数据偶尔错乱 | Cache操作没有对齐到Cache Line | 按Cache Line扩展首尾区间 |
| 关闭Cache后正常,开启后异常 | 典型Cache一致性冲突 | 按方向、对齐、屏障、描述符顺序排查 |
| Linux下偶尔丢数据 | 忘记unmap或方向参数错误 | 检查map/unmap是否一一对应,方向是否匹配 |
5.3 排查思路总结
遇到诡异数据问题,我现在的排查路径很固定:先临时把DMA缓冲区或整个DCache关掉,确认问题是否消失;如果消失,就基本锁定了一致性问题。然后回答三个问题:这组数据是CPU写还是DMA写?谁最后改的?谁先读?答案清晰之后选择对应的Cache操作。再检查Cache Line对齐和内存屏障。最后单独检查描述符这类小对象的同步状态。
这套思路适用于裸机、RTOS、Linux,区别只是换成对应平台的Cache操作接口。我个人的体会是,90%的"神秘旧数据"问题,在回答完"谁最后改的"那一刻就已经有了答案。剩下10%基本都在描述符没同步这个点上兜圈子。下次再遇到DMA读到旧数据,别急着怀疑硬件,先从Cache下手,三招按顺序过一遍,问题大概率当场现形。