2026/10/12 4:18:39

DMA总是读到旧数据?一文搞懂Cache一致性及三种解决方法

DMA总是读到旧数据?一文搞懂Cache一致性及三种解决方法 先说一个我前段时间在某嵌入式平台上调试网卡驱动时遇到的怪事中断来了DMA描述符里的长度字段也已经更新可我打印缓冲区内容看到的还是上一次收包的旧报文。一开始我以为是DMA配置错了翻来覆去查描述符、查内存地址、查寄存器折腾了大半天最后才意识到问题不在DMA本身而在CPU的Cache。数据明明已经落到DDR里了可CPU刚写进去的内容还躺在Cache中没回写DMA从DDR里读到的是老数据——这就是典型的“DMA总读旧数据”场景。这篇文章就从这个现象讲起把原因彻底拆开再给出我验证过的三招解决办法希望能帮到正在和Cache一致性搏斗的朋友。1. 先搞懂“DMA读旧数据”到底是什么层面的问题1.1 现象描述不是DMA不干活而是它和CPU不在一个频道上假设你有一个采集系统CPU先把一组控制参数写入某个内存缓冲区然后启动DMA让DMA把这个缓冲区里的数据搬给外设。或者反过来DMA从外设收到一批数据写入内存中断通知CPU读取。你满怀期待地去读那个缓冲区却拿到了一堆“穿越时空”的数据——上一次请求的、旧的、没更新的内容。最典型的场景是网卡驱动。驱动在内存里准备好一个发送描述符和一个数据缓冲告诉DMA“去这个地址读数据”DMA执行完之后硬件的完成标志正常置位一切看上去都对。但你用软件在发送完成后再去读那个缓冲发现内容似乎不是刚写的。另一个经典场景是ADC采集DMA把采样值写进内存CPU中断里读取采样结果读到的总是一两个周期之前的旧数据。很多人第一反应是DMA地址配错了或者描述符格式没写对。但如果你把打印的地址和实际物理地址对比一遍发现地址完全正确DMA也确实搬运了数据那就要把怀疑对象转到CPU的Cache上。1.2 根因Cache和DDR之间隔着一道看不见的墙要理解这个问题的本质先要建立两个视图的概念。CPU读写内存时实际上读写的是Cache不是DDR。现代处理器的Cache一般采用写回策略CPU向某个地址写入数据数据首先被写进Cache Cache行并标记为脏只有等到这个缓存行被替换、或者被软件显式刷新时脏数据才会真正写回DDR。从那以后CPU再读同一个地址会优先命中Cache也就根本不会去DDR里取数。而DMA恰恰是一个绕过CPU的外设它直接和DDR打交道完全看不到Cache内部的内容。当CPU以为自己已经把数据“写进内存”了实际上数据可能还躺在Cache里DMA去DDR里读读到的自然是老数据。反方向也一样DMA把新数据写进了DDR但CPU的Cache里还保留着这块区域的旧缓存行CPU读完Cache就直接返回了旧值看起来DMA“没写进去”。所以这个问题也可以理解成两个视图之间存在时间差CPU看到的是一套数据DMA和DDR是另一套数据。中间差的那一段就是Cache回写和缓存失效的时机没有对上专业说法叫Cache一致性问题英文叫Cache Coherency。别小看这个时间差它可能只有几微秒但足以让一个驱动表现得像随机故障一样时而正常、时而抽风。2. 定位它三步确认是不是Cache一致性在作怪2.1 先排除DMA配置和描述符的问题别冤枉了Cache我踩过好几次不假思索直接去刷Cache的坑结果最后发现是长度字段配错了。所以到了一个新平台遇到数据不对先别急着怀疑Cache按这个顺序排除检查DMA是否真的完成了传输。看完成中断标志、剩余传输计数值确认不是DMA还没搬运完你就去读了。检查源地址和目的地址确认没有把物理地址和虚拟地址混用也没有把描述符地址填错。检查DMA的突发长度、字节序配置排除外设和DMA配置层面的干扰。如果以上都对中断也正常触发但读到的数据就是旧的这时Cache一致性才成为头号嫌疑犯。2.2 用非缓存映射做对照直接看DDR里的真实内容一个很有效的定位手段是绕过Cache去读DDR。在Linux下可以用devmem工具直接读物理地址这个工具对物理内存的访问默认不走Cache或者说它的映射方式没有被缓存能够看到DMA实际写入DDR后的内容。命令类似这样devmem 0x30000000 32如果devmem读出来的值已经是新数据而你的驱动代码里读出来的还是旧值这就直接证明了数据在DDR里有了是CPU侧Cache坏了事。在裸机平台上可以临时调用一个非缓存映射的物理地址访问函数或者直接在汇编层面用uncached模式读回。只要能拿到DDR里的真实内容做对比问题一下就清晰了。2.3 关闭Cache做一次对照实验复现和定位一步到位还有一种更暴力的方法干脆临时把该内存区域的Cache关掉或者直接把整个MMU缓存关闭重新跑一遍功能测试。如果问题消失几乎可以确定是Cache一致性问题。在Linux上可以临时把某一页映射改成uncached做实验虽然正式代码不应该这么干在裸机上可以把页表里的Cacheable清掉。这种方法不能用于生产环境只是用来快速验证判断方向。我习惯先做这个实验一旦验证成立再换成正式的解决方案省得在错误方向上空耗时间。3. 第一招主动出击软件维护Cache一致性3.1 Clean和Invalidate别把这对兄弟搞反了软件层面解决Cache一致性的核心操作有两个Cache Clean和Cache Invalidate。Clean的作用是把缓存行里脏数据写回DDR同时缓存行里仍然保留这份数据清完以后DDR和Cache是一致的。Invalidate的作用是让缓存行失效下一次CPU访问这个地址时只能去DDR里重新取把旧缓存行丢掉。这两个操作的方向完全不同用错了会出大问题。在DMA发起传输之前如果CPU往缓冲区写过数据你必须先做Clean保证DMA去DDR里读到的是新数据。反过来DMA把数据写入内存之后CPU要去读这些数据你必须先做Invalidate逼CPU的Cache失效再去DDR取。如果你在DMA完成后做了Clean而不是InvalidateCPU读到的还是Cache里的旧值。很多芯片把这两个操作合并成一个同步操作叫CleanAndInvalidate也叫flush。它先把脏数据写回DDR再让缓存行失效。这个操作虽然方便但也有代价它比单独的Clean多了一次失效动作性能开销更大。我建议根据传输方向精确选择操作类型别图省事全程用flush。3.2 刷Cache的时机和顺序比操作本身更重要先看DMA发送方向的典型时序/* 1. CPU写入待发送的数据到缓冲区 buf */ memcpy(buf, packet, len); /* 2. 刷Cache把脏数据写回DDR */ cache_clean(buf, len); /* 3. 启动DMA让DMA去DDR里读 */ dma_start(desc);再看DMA接收方向的时序/* 1. 启动DMA外设数据写入DDR */ dma_start(desc); /* 2. 等DMA完成中断 */ wait_done(); /* 3. 刷Cache让Cache失效CPU下次读DDR */ cache_invalidate(buf, len); /* 4. CPU读取数据 */ process(buf);注意接收方向不能先读再刷也不能在DMA还没完成时就刷。如果DMA还在搬运途中你先做了InvalidateDMA后续写入DDR又是新的但Cache已经被清掉了下一次CPU读的时候会去DDR取理论上问题不大。但如果你在Invalidate之后、DMA完成之前又访问了缓冲区CPU可能会把老数据重新缓存那就前功尽弃了。所以严格的做法是等DMA彻底完成再做Invalidate再做后续访问。在裸机平台上的实现细节以某Cortex-A系列处理器的标准操作为例可以用CP15协处理器指令或者直接操作L2 Cache控制寄存器。伪代码大概是这个意思/* 发送方向 */ dsb(); /* 保证前面所有写操作完成 */ clean_cache_range(buf, len); dsb(); start_dma(desc); /* 接收方向 */ wait_dma_done(); dsb(); invalidate_cache_range(buf, len); dsb();那组DSB屏障指令不能省。Cache维护操作要保证对后续的外部访问有效必须有屏障指令确保执行顺序否则乱序执行可能让Clean还没完成DMA就已经启动了。这个细节是我调试一个高速接口时踩过的坑少了DSB问题会变成偶发性的极难查。3.3 在Linux驱动里用标准DMA API代替裸寄存器操作如果在Linux内核驱动里做DMA传输一般来说不需要你手动去操作Cache寄存器。内核已经封装好了标准API底层的Cache维护逻辑由DMA层完成。使用方式大致如下。发送方向用dma_map_single把缓冲区映射成DMA地址dma_addr_t dma_addr dma_map_single(dev, buf, len, DMA_TO_DEVICE);它会在底层对缓冲区做Cache Clean把脏数据写回DDR。DMA完成以后用dma_unmap_single释放。接收方向DMA完成之后调dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE);再访问buf此刻底层已经做了InvalidateCPU能读到DDR里的新数据。直接裸写Cache寄存器在Linux驱动里是大忌因为内核可能启用了多个Cache域手工操作很容易漏掉某个域。只要平台总线结构比较复杂用标准API比自己刷Cache要安全得多。4. 第二招釜底抽薪让DMA缓冲区“拒绝缓存”4.1 MMU属性里的Cacheable位决定了一块内存能不能进Cache软件刷Cache虽然有效但毕竟每一次传输都要手动维护同步写多了容易漏漏了就会出现那种“跑2000次错一次”的疑难杂症。第二种思路更直接干脆把DMA缓冲区所在的物理页映射成不可缓存的属性也就是uncached。这需要从MMU页表属性说起。CPU页表项里存在各种内存属性位决定了一段地址空间是否可以被Cache缓存、是否允许写合并、是否按设备内存方式访问。把Cacheable位置0这段内存就不进CacheCPU每次访问都直接走DDR。对DMA来说因为整个缓冲区根本不存在“缓存副本”CPU写入和DMA写入的时刻就是到达DDR的时刻一致性天然成立。这种方案把问题从“需要维护同步”变成了“不需要缓存”代码逻辑会简单不少。它的代价是访问性能下降。CPU读写这块缓冲区时每一次都要真正访问DDR不能享受Cache带来的加速如果这块缓冲区被频繁访问性能损失会很明显。4.2 Linux下最常用的uncached方案dma_alloc_coherentLinux内核提供了dma_alloc_coherent接口分配出的缓冲区就是一致性内存。它本质上就是一种uncached映射内核在用的时候保证CPU和DMA看到的是一致的视图dma_addr_t dma_handle; void *cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL);返回的cpu_addr是CPU侧的虚拟地址dma_handle是DMA侧能用的总线地址。用这个接口分配出来的缓冲区CPU访问和DMA访问都会命中DDR的真实数据不需要再做任何Cache刷新。在裸机平台上没有这个接口你需要自己配置MMU页表把DMA缓冲区的属性改成Non-Cacheable。并且要注意有些处理器架构里uncached的访问是带缓冲的写有的是不缓冲的写前者对DMA发起的时间会有轻微影响但不影响正确性。很多RTOS的DMA驱动里描述符表和缓冲区本来就是固定放在一段不被Cache的RAM里的就是这个道理。4.3 什么场景选uncached什么场景别选我在实际项目中的经验是对于DMA描述符、控制块、环形队列的头部等那些需要频繁同步的元数据强烈建议用uncached或一致性映射来放。这类内存数据量很小但每次传输都要用一致性维护特别频繁一旦漏刷就是疑难杂症。对于大块业务数据缓冲区比如一个4KB的发送缓冲区或者32KB的接收队列就不太建议全部做成uncached。因为业务数据往往要被CPU反复读改写uncached会让每个字节的访问都变成DDR事务性能下降非常明显。大块数据缓冲区更适合用普通缓存映射配上第一招里的DMA API按需同步。很多平台的标准做法就是混合策略描述符用一致性内存数据载荷用普通内存。也有一种折中方案是配置成Write-Through模式。它对读操作仍然缓存对写操作直接透过Cache写DDR读性能还行写性能略低。这个模式比较适合“CPU写、DMA读”的方向因为写穿模式保证了DMA去DDR读到的永远是CPU刚写进去的值。但是反方向DMA写DDR、CPU读就需要Invalidate操作所以Write-Through只能解决一半的问题我用的时候会特别留意传输方向。5. 第三招借力硬件让总线替你保持Cache一致5.1 Snoop机制让DMA去“看见”CPU缓存里的最新数据前两招都是软件层面的策略要么主动去刷新Cache要么干脆不缓存。第三招要讲的是硬件帮你解决在某些SoC里DMA控制器或者总线互连结构本身支持Cache一致性协议最常见的就是Snoop机制。Snoop的大致原理是DMA发起读操作时总线控制器会先在所有CPU核心的Cache里查找这个地址是否存在脏数据或者有效副本。如果存在脏数据总线会先让这份脏数据写回DDR再让DMA去读如果存在新副本但没有写回也会通过硬件把这些数据转发给DMA确保DMA拿到的就是CPU最新写过的那份。这样CPU写Cache后无需手动CleanDMA也能读到最新值。反方向的Snoop同样有效DMA写入DDR时总线会同时把其他CPU缓存中对应的旧行失效掉这样CPU下次读取时就不得不从DDR取新数据。整个同步过程完全由硬件完成软件不用调用任何Cache维护操作。某些ARM架构平台里的CCI总线、带有Snoop控制单元的DMA控制器都支持这种机制。5.2 开启Snoop的代价不是所有DMA都适合硬件一致性听着完美但它不是免费的午餐。Snoop操作需要总线去查询每个CPU核心的Cache这个查询动作本身会占用总线带宽、增加延迟。对于高频、大数据量的DMA传输如果每个访问都要被Snoop带宽开销会明显上升有时候DMA吞吐量反而下降。所以现实中的做法是按需配置。适合开Snoop的场景是DMA描述符、状态标志、控制块等小数据量且频繁同步的访问开销小收益大。大块连续的数据传输则不一定适合软件层面做好一次性的Cache维护DMA纯跑DDR效率更高。还有一个前提必须是DMA控制器或总线具备Snoop能力才能用这招。很多低成本MCU的DMA根本不带Snoop你只能回到第一招和第二招。具体某个平台支不支持要看SoC手册里DMA控制器的总线接口类型和Snoop使能位。5.3 我在这三招之间的选择逻辑简单分享一下我的选择习惯。拿到一个DMA相关的驱动任务我首先看SoC手册和驱动框架。如果历史代码里其他模块已经用dma_alloc_coherent分配一致性内存或者硬件本身支持Snoop那我就优先走硬件一致性路线。如果平台比较老DMA不带Snoop那就看传输方向双向同步精小数据用uncached大批量传输用第一套手动刷新方案。这套优先级逻辑在多个项目里帮我避开了很多问题。6. 真正容易翻车的地方细节坑和经验补充6.1 多核之间的Cache一致性比单核复杂得多前面讲的都是“CPUCacheDMA”三者之间的对齐但如果你跑的是多核系统还要考虑两个核之间的Cache同步。例如CPU0写发了数据DMA完成后在CPU1上触发中断并读取数据如果两个核的L1/L2缓存是独立的CPU1的Cache里可能还保留着DDR里的旧值甚至CPU0写完后没有把数据同步到一致性的共享缓存域里。Linux内核对这种情况有内部的屏障和Cache维护机制DMA API会处理。但裸机开发时你需要注意手动执行的Cache操作必须作用于正确的缓存域必要时要在所有相关核上执行Cache维护。这块特别容易漏我也是在一个双核平台上排查了整整一天才想起来原来问题出在中断被调度到了另一个核上。6.2 Cache行对齐和Padding被很多人忽略Cache操作以缓存行为单位一般常见的是32字节或64字节。如果你要刷Cache的内存范围没有对齐到缓存行边界可能会出现两种坑一种是Clean时把同一缓存行里相邻变量的脏数据也一起写回这其实影响不大另一种更隐蔽的是Invalidate时把这个缓存行里其他数据也失效了如果那里面还有未落盘的脏数据可能造成数据丢失。解决办法有两个方向。一是保证DMA缓冲区地址和长度都按缓存行对齐分配缓冲区时额外多分配一个缓存行大小做对齐修正。二是在DMA缓冲区所在结构体里做Padding确保前后没有其他会被并发修改的数据和它共享同一个缓存行尤其是那些用于核间通信的标志位千万不能和DMA缓冲区放在挨着的那64字节里。6.3 双向缓冲区别只配一套同步策略有些DMA缓冲区承担双向任务比如一个共享内存区域CPU向里面写命令让DMA发给外设同时外设也会通过DMA往这片内存里写状态数据。对这种双向缓冲区简单地把整块内存配置成uncached是最省心的但性能最差用软件手动同步则要格外小心传输前后的Clean和Invalidate都要做而且要特别注意不能在Invalidate之后马上又写数据。Linux下的DMA API对于双向传输有DMA_BIDIRECTIONAL标志dma_map_single的时候可以用。它会选择同时包含Clean和Invalidate的保守策略。我用过一次之后发现这个标志看着方便但实际上每次同步两边都做性能开销翻倍如果业务上能明确方向尽量用方向性的标志。6.4 别忘记关掉编译器和CPU的过度优化还有一个非常现实的坑。你在DMA完成中断里读了缓冲区之后编译器有可能认为这个缓冲区内容没变化直接复用寄存器里的旧值。这种情况通常要加volatile限定或者用内存屏障指令确保每一次都从内存重新取数。虽然它和Cache一致性不完全是一回事但表现症状非常像都是“读到旧数据”。我在调试一个中断驱动的收包逻辑时就曾经因为编译器优化把一次看似多余的读操作优化没掉了导致数据看起来总是旧的。后来在里面加了读屏障问题才消失。如果遇到刷Cache的代码逻辑看起来很对但现象依旧建议顺手把相关变量的volatile加回去试试成本很低却能排除掉一个干扰项。回到开头那个网卡驱动的问题最后我在DMA完成中断里增加了正确的Cache Invalidate操作缓冲区指向不变数据就一直是新的了。回看整个排查过程最耗时间的反而不是解决方案本身而是确认“数据其实已经正确到达DDR”这一步。希望这篇内容能帮你在遇到类似问题时少走这段弯路直接把问题定位到Cache一致性这个关键点上。