DMA总读旧数据?一文讲透Cache一致性及三种解法
干嵌入式驱动这一行多多少少都撞上过一件“邪门”事外设明明已经把数据通过DMA搬到了内存里DMA完成中断也准时到了你兴冲冲地去读那块buffer结果读到的还是上一次的旧数据。更迷惑的是有时候第一次是对的第二次开始就一直落后一帧拿调试器直接看物理内存呢里头的最新值明明就在那儿。这说明DMA没毛病是CPU的Cache在背后捣鬼。这类问题藏得很深排查起来特别容易绕弯。这篇文章就从“为什么DMA总读旧数据”这个现象切入把根因拆开再把实际项目里最常用的三招解决思路讲清楚最后附上我踩过的若干坑和自检方法。适合正在调带Cache的处理器、写外设驱动、或者头一回被DMA加Cache组合拳打懵的朋友参考。1. 一次“灵异事件”DMA搬完了数据CPU却盯着旧值1.1 现象复现内存里是新的CPU读到的是旧的我最早碰到这个问题是在一块带D-Cache的应用处理器上做高速数据采集。外设每处理完一批数据DMA就把结果写进一片预先分配好的内存缓冲区传输完成后触发一个中断CPU在中断服务程序里解析这段buffer更新状态、计算指标。整个流程看起来没什么特别该配的都配了。但现象非常规律第一次上电CPU读到的数据正常第二次DMA传输完成后CPU读到的还是第一次的数据第三次以后永远落后一帧。如果我在中断里人为加一段较长的延时再读有时能读到新数据但更多时候依然是旧值。这种“时好时坏”最折磨人因为你不确定是硬件偶发问题还是软件逻辑问题。有经验的朋友可能很快就猜到Cache头上但我当时的第一反应并不是Cache而是怀疑DMA有没有写错地址、中断是不是在搬运真正完成之前就被触发了。于是挨个翻控制器的状态寄存器、检查描述符、核对地址换算和长度所有寄存器都显示传输正常完成地址也对得上。这就很奇怪了DMA说我搬完了中断也来了为什么我读出来的东西不对1.2 排查路径从DMA配置一路查到缓存策略把完整的排查链路梳理出来给遇到同类问题的人直接照做。第一步确认DMA真的完成了。我查看传输完成状态位确认已经置位又在外设中断里打印校验值确认中断确实是在DMA完全结束之后进入的。这一步排除了“中断比DMA先到”的常见嫌疑。第二步拿调试器直接读物理内存。关键矛盾就在这儿出现了调试器读同一片地址能看到最新、正确、完整的数据而CPU在中断服务里读出来的却是上一次的旧值。同一个内存地址两个观察者看到两个值而且物理内存里就是对的那问题不在DMA搬运而在CPU的读取路径上。第三步临时关闭D-Cache再跑一遍。关掉缓存之后CPU立刻能读到新数据。到这一步结论已经非常明显CPU和DMA对同一片内存的“视图”不一致问题出在Cache一致性上不需要再怀疑DMA本身。顺带说一句后来我在另一个项目里遇到过反向版本CPU往命令缓冲区写好了内容DMA启动后却读到了旧命令——同一个成因方向反过来而已。1.3 问题的本质CPU的Cache是内存的“第二份副本”很多开发者把物理内存当成唯一真相但引入Cache之后CPU看到的“真相”和物理内存里的“真相”就不再保证同步。Cache相当于CPU和内存之间的高速暂存区里面保留着一份和物理内存内容可能不同的副本。CPU读数据时优先读CacheDMA读写时却直接访问物理地址上的内存两边各看各的。一旦Cache里的副本过期就会出现“内存是对的CPU读到旧值”的怪象。理解了这个本质后面所有解法其实都围绕一件事让CPU和DMA在访问同一块内存时看到同一份内容或者从地址属性上让这段内存根本不进入缓存体系。2. 先把数据通路弄清楚DMA为什么看不见CPU的Cache2.1 CPU视角Cache是“写回”型的高速暂存仓库要解决问题先理解Cache的工作方式。现代处理器普遍使用“写回”策略write-backCPU执行写内存指令时并不会立刻把数据刷到物理内存而是先写入对应的Cache Line并把这条Line标记为“脏”。只有当这条Cache Line被换出、或者软件主动清理时脏数据才真正写回物理内存。打个比方Cache就像车站旁边的临时仓库CPU把包裹放进仓库但发车时间还没到外面的人自然是看不见包裹的。此时如果DMA来提货就只能提走上一班已经发车送到内存的旧货。如果你不主动让仓库发车DMA永远拿不到新包裹。反过来也一样DMA往内存放了一箱新货而CPU手里还握着仓库里的旧包装清单Cache一直没失效CPU拆开包裹读到的还是旧信息。2.2 DMA视角它只认识物理内存地址不理会CacheDMA是系统总线上的主设备它访问的都是物理地址直接通过总线读写内存控制器。和CPU访问最大的区别在于DMA不会去查CPU私有的Cache不会做Cache Line的命中判断更不会在读写内存的同时顺手维护CPU缓存的一致性。Cache一致性问题对DMA来说完全是“透明”的——它以为自己读写的都是最新数据但最新数据可能还躺在CPU的Cache里没出来。这里要建立一个关键认知Cache不是系统所有主设备都能看到的公共缓存它只是CPU私有的高速存储。把数据写进Cache不等于写进物理内存把数据写进物理内存也不等于CPU下次读一定拿得到。跨主设备的共享内存只要一方是CPU就必须正视Cache这条看不见的“隔离带”。2.3 方向不同Cache问题也分两种按数据流向划分DMA相关的Cache问题有两类处理方式刚好是镜像对称的。第一种“DMA写入、CPU读取”。外设网卡、ADC、图像传感器等通过DMA把数据写进内存CPU随后去读。如果CPU的Cache里早已缓存了这块地址的旧内容CPU读取时命中旧Cache Line读到的就是旧数据。这就是本文标题说的“总读旧数据”的典型形态。第二种“CPU写入、DMA读取”。CPU把要发送的数据或命令写入内存随后启动DMA让外设读走。如果CPU写的内容还停留在Cache中没有写回内存DMA从物理内存里拿到的就是上一条命令或上一份旧数据。表现为“发出去的永远是上一条”“外设收到的命令滞后一拍”。两种方向成因完全一样只是“哪边读到旧值”不同。下面三招里我会特别说明每一招对哪个方向有效避免大家抄错、用反。3. 第一招手动维护Cache状态把主动权握回手里3.1 CPU读DMA数据前先让旧缓存“作废”当方向是“DMA写入、CPU读取”时CPU读数据之前必须把对应内存范围的Cache Line作废这个操作通常叫invalidate。作废的意思是这段地址的缓存副本失效了下次CPU读必须去物理内存重新取。这样DMA刚写进来的新数据才能被CPU看到。原理很简单就是主动破坏掉那份过期的“仓库清单”。以下是示意逻辑和具体处理器架构的API无关重点看顺序/* DMA完成中断里读出buffer之前先作废对应缓存行 */ status read_dma_done_flag(); cache_invalidate(recv_buffer, recv_len); /* 之后再访问recv_bufferCPU会从内存重新加载最新数据 */ process_new_data(recv_buffer, recv_len);很多朋友图省事把invalidate的范围写成“整片缓存全部作废”或者不管方向“读写都清一遍”。短时间小数据量调试看不出问题但性能和实时性都会受损大缓冲、高频中断场景尤其明显。尽量只在真正需要同步的区间上做精细操作这是第一招的基本功。3.2 CPU给DMA送数据前先把脏内容“冲刷”回去当方向是“CPU写入、DMA读取”时做法正好镜像反过来CPU写完命令或者数据之后、启动DMA之前必须把这段地址对应的Cache Line写回物理内存这个动作通常叫clean也叫flush。只有脏数据真正落到内存里DMA去读的时候拿到的才可能是新值。顺序在这里至关重要先写buffer再clean最后启动DMA。如果把clean放在写buffer之前等于白刷如果DMA先启动DMA可能已经在读旧数据了。代码示意如下/* 启动DMA之前先确认CPU写入的cmd已经落到物理内存 */ write_data_to_txbuf(tx_buffer, cmd, cmd_len); cache_clean(tx_buffer, cmd_len); start_dma(tx_buffer, cmd_len);我见过很多人把clean和invalidate混为一谈。严格说clean是把脏行写回内存但缓存行仍然保留、内容有效invalidate是把缓存行作废下次读取强制从内存重新加载。有些架构的flush等价于“先clean再invalidate”。但命令下发这种场景其实clean就够用了没必要额外做invalidate多一次操作就是多一点开销。3.3 手动操作里的隐藏坑Cache Line对齐和边界处理手动维护Cache有三个容易踩的细节逐个说清楚。第一Cache Line对齐。一条Cache Line通常32字节、64字节甚至更大。CPU按行粒度操作如果你对一个没有对齐的buffer做invalidate哪怕只请求作废1字节硬件也可能把相邻变量所在的整条Line一起作废导致其他数据被强制重新Load甚至把另一个CPU正在用的缓存内容搞乱。所以DMA缓冲区的起始地址和长度在设计阶段就要尽量对齐到Cache Line并且不要跟普通结构体变量紧挨着混放。第二操作长度。某些处理器对clean/invalidate的地址和长度有对齐要求长度不是Cache Line整数倍时行为可能不可预期需要向上取整到Line边界。取整又不能越界到不该碰的区域边界判断代码看着不起眼写错就是内存数据损坏级别的事故。第三操作顺序。Cache指令需要配合内存屏障使用。有些CPU允许指令乱序执行编译器也可能重新排列普通赋值语句和Cache操作的顺序导致clean在DMA启动之后才真正生效。这个问题很隐蔽我会在后续章节专门展开讲。3.4 别忘了DMA描述符本身手动同步时还很容易漏掉一个角色DMA描述符。很多DMA控制器使用链式描述符descriptor ring描述符本身通常也是CPU先写好的。如果只clean了数据缓冲区忘了clean描述符DMA在读取描述符时可能拿到旧值轻则传输地址错乱重则抛出非法访问异常。我在项目里为这个事排查了很久一度以为是DMA控制器的bug最后才发现是描述符区域存在Cache里没同步出去。建议做法是把描述符区域单独划到一致性内存或non-cacheable区域里让它天然脱离Cache问题实在不行也要在更新描述符之后对它做一次clean。写驱动的朋友请把“描述符”三个字刻在脑子里它和数据缓冲区一样需要被同步。4. 第二招从地址映射层面“绕”开缓存让这段内存不缓存4.1 用MMU/MPU把缓冲区配置成不可缓存区域手动Cache操作虽然直接有效但每次DMA来回都要穿插clean/invalidate代码写多了容易漏。更彻底的办法是利用MMU带操作系统的应用处理器或者MPU单片机系统把DMA缓冲区的内存属性配置成不可缓存non-cacheable甚至设备内存device memory。这样CPU访问这段地址时完全不经过Cache直接读写物理内存CPU和DMA看到的视图天然一致不用每次传输都手动同步。配置时有两点必须重视。第一内存属性影响的是整个页典型页大小4KB缓冲区必须按页对齐且不能把普通热点数据跟它放同一页否则那片普通数据也会失去缓存优化带来的性能。第二设备内存属性可能禁止某些访问模式比如未对齐访问可能直接触发异常编译器生成的普通读写不一定完全安全。所以多数场景下我更推荐用non-cacheable的普通内存属性而不是严格意义上的device memory属性。4.2 Write-Through一个单向够用的折中方案除了完全不缓存还有另一种折中属性叫“写通”write-throughCPU读取时仍然正常缓存写入时直接穿透到物理内存Cache里不保留脏数据。这个属性对“CPU写入、DMA读取”的单向场景非常合适CPU写命令DMA立刻能在内存里看到且不需要每次命令下发都手动clean。但对“DMA写入、CPU读取”的场景它解决不了根因CPU读数据时仍然可能命中过期的Cache Line外设通过DMA写进来的新内容在CPU看来还是旧副本。所以用第二招之前一定要先想清楚你的DMA缓冲区是哪条数据走向别选错了内存属性以为配置完就万事大吉。4.3 CPU缓存被绕开后代价到底在哪里把一段内存设置成non-cacheable等同于放弃了对这段地址的缓存加速。如果这段数据恰好是热点路径比如每个中断都要读几百字节做解析性能损失会非常明显。我在一个图像处理任务里做过对比测试把帧缓冲区设成non-cacheable之后CPU每帧解析时间几乎翻倍原因就是每次读取都实打实走总线失去了Cache命中的速度。所以用第二招时建议配合性能统计工具对比cacheable与non-cacheable两种配置下的实际吞吐和CPU占用率。如果性能影响大就要回来考虑第一招的局部clean/invalidate或者改用第三招的专用一致性缓冲。5. 第三招直接使用系统级一致性缓冲省心但有限制5.1 操作系统里的DMA一致性内存接口如果你在写操作系统内核里的驱动大多数系统都提供了一类专门的一致性DMA分配接口常见名称类似dma_alloc_coherent。这类接口专门分配“CPU和DMA能看到一致视图”的内存内部实现可能是non-cacheable映射也可能依赖硬件Cache一致性机制。驱动开发者拿到这种内存后基本不用再手动调用clean/invalidate代码干净很多。使用这类内存时要注意它和普通内存分配是两套池子容量通常有限分配出来的缓冲区一般有对齐要求用完必须及时归还。驱动逻辑上大概是这样/* 伪代码不同内核接口名称略有差异 */ buf coherent_dma_alloc(size, align, dma_addr); /* 之后CPU和DMA都直接操作buf无需手动Cache维护 */这一类机制最适合描述符、命令块、小包数据这类高频控制结构能显著减少漏同步的概率。5.2 不经过Cache的专用内存嵌入式小系统的天然解法在很多带D-Cache的M系列及异构芯片上硬件会专门设计一块不经过Cache总线的紧耦合内存常见名称有DTCM、CCM、专用SRAM等。把这部分内存划给DMA做缓冲天然没有Cache一致性问题也几乎不需要额外软件开销是目前嵌入式小系统里最推荐的做法。使用步骤一般是在链接脚本里单独划出一块区域写一个简单的缓冲管理函数DMA和CPU只在这块内存上交换数据。需要注意的是这类内存容量往往不大从几KB到几十KB不等所以通常优先给高频、小粒度的数据交换使用大块的图像、文件缓冲仍然走普通内存配合第一招做手动同步。5.3 一致性内存也并非全是“免费午餐”第三招听起来省心实际用起来有几条经验值得提前知道。第一容量紧张。一致性内存池和专用SRAM通常不大多个外设同时使用很容易耗尽分配失败的兜底路径必须提前设计好而不是在中断里才想起处理“分配不到”。第二总线压力可能上升。non-cacheable映射虽然保证了一致性但CPU每次访问都走总线高频小字节访问对总线的占用比缓存命中时大得多可能间接拖累其它内存访问。第三硬件一致性不等于“所有设备都支持”。一些高端SoC有总线监听机制能做到真正Cache一致但往往只有部分主设备支持snoop另外一些外设仍然需要软件手动维护。不要想当然地认为板子高端就肯定没有Cache一致性问题还是要查数据手册中外设和内存互连相关的说明。6. 三招怎么选以及谁才是托底的防线6.1 先看数据走向、传输频率和系统约束把三招放到一张表里对比选型思路就清晰了。使用场景推荐方案理由小buffer、低频、命令类数据第一招clean/invalidate开销小实现简单方向灵活高频大流量DMA、CPU侧读取不多第二招non-cacheable映射一劳永逸不需要每次传输都同步多外设、工程化驱动的开发环境第三招一致性DMA内存系统代管大幅降低人为漏同步概率高端SoC自带宽监听机制利用硬件一致性性能和软件复杂度达到最佳平衡三招实际上不互斥成熟工程里往往混用描述符放一致性内存大块数据用non-cacheable映射零星寄存器交互再穿插第一招手动同步。关键是想清楚每一块内存由谁保证一致性、在哪一步保证把这部分画到设计文档里而不是全凭记忆。6.2 内存屏障Cache指令做得再对顺序乱了也白搭手动操作Cache时一个比Cache指令本身更容易被轻视的点是内存屏障。CPU和编译器都可能重排指令编译器可能把对buffer的赋值挪到clean调用之后CPU乱序执行也可能让clean在“启动DMA”这条指令之后才真正执行完。没有屏障兜底你的Cache逻辑在纸面上对在时间线上却错得离谱。这跟锁的道理类似每一句单看都对先后顺序错结果就错。建议在Cache操作与DMA启动/完成判定之间放置合适的屏障。最小规则是启动DMA之前保证“写数据clean”已经排在“写DMA启动寄存器”之前收到DMA完成中断后保证“读DMA完成状态”在所有invalidate之前被观察到。不同体系结构提供的屏障函数名不一样但这条规则是通用的。6.3 实测中容易“翻车”的几个旁路问题最后把我在实测里遇到过、看到过的旁路问题集中列一下权当提醒。第一个是多核场景。不只当前CPU的Cache里有旧副本另一个CPU的Cache里也可能有同一份旧数据。如果只对自己执行invalidate任务切到另一个核之后可能又从那个核的Cache里读到旧值。多核系统要做广播式Cache维护或者让DMA完成回调固定跑在同一个核上。第二个是打印掩盖问题。调试时有人喜欢在中断服务函数里加串口打印串口本身很慢打印期间CPU可能已经把Cache Line悄悄写回内存也可能把内存里的新数据重新加载进Cache导致“加了打印就正常、删掉又开始读旧数据”。这不是玄学是打印改变了时序。定位阶段建议用调试器观察寄存器或者用状态标志位而不是依赖打印验证。第三个是双缓冲切换漏同步。DMA把A缓冲填满后切换到B缓冲此时A缓冲里的数据可能还没被CPU完整消费。如果切换前没有对A做必要的同步操作等CPU回头读A时拿到的可能就是被DMA覆盖或变更过的数据。双缓冲比单缓冲多了一整套需要维护的视图每一轮切换都要梳理清楚两个缓冲区各自的状态。第四个是关Cache验证后忘了恢复。为了确认问题很多人会暂时关闭D-Cache做对比测试这本身没问题但确认结论后必须把缓存重新打开并且把真正需要同步的区间补上正确的cache维护代码。如果把“关Cache一切正常”直接当成交付结论性能损失先不说真正该写的同步代码也被掩盖了。6.4 动手前先做的四步自检根据我的经验任何带Cache处理器上的DMA传输开始前都值得按下面四步做一遍精神检查。第一列出这条DMA通道涉及的所有内存区域包括数据缓冲区、描述符、状态结构一个都不能少。第二按数据流向确定每次同步是clean还是invalidate或者两者都要这个结论不能靠猜。第三在所有Cache操作和DMA控制操作之间安排内存屏障并写清楚屏障的作用对象。第四在真机上分别测试首次传输、连续传输、大buffer、小buffer、中断频繁触发和空闲触发几种情况不能只跑一条路径就宣布完工。说回我自己第一次碰上DMA读旧数据时前前后后折腾了快两天最后定位到Cache一致性既觉得那两天白熬了又觉得这个坑特别值得整理出来。现在再遇到这类问题我第一反应不是翻寄存器而是把数据通路完整过一遍CPU和DMA看到的真的同一份物理内存吗如果答案依赖某个Cache Line的状态那这里就是需要显式管理一致性的地方。最后再分享一个小技巧如果团队里不止一个人在做DMA相关开发建议把整个同步流程封装成两个统一接口比如“发送前同步”和“接收后同步”内部固定好clean/invalidate与屏障的顺序所有驱动都走这两个口而不是每个人各写各的Cache操作。这样既能维持代码风格一致也能把这类坑从“灵光一现”变成常规工程约束系统性压下去。