DMA总读旧数据?CPU与Cache一致性问题的三种解决策略
做DMA相关开发久了迟早会遇到这么个诡异的现象代码明明先把新数据写到了缓冲区DMA把数据搬出去接收端拿到的却是上一批内容。反过来也一样DMA搬进来一批数据CPU读到的偏偏是改动前的旧值。我最近在某块嵌入式平台上就被这种“DMA总读旧数据”的问题折腾过排查了一圈后发现根子不在DMA反而在CPU那一侧的Cache上。这篇文章就把问题的来龙去脉、三种主流解决办法以及我实际调试时踩过的坑一次性说清楚希望对同样在做底层驱动、音视频采集、网络收包的朋友有帮助。1. 现象和根因DMA读到的为什么不是“当前值”1.1 一个典型的故障现场先还原一下问题现场。某采集项目里用DMA把传感器数据连续搬运到DDR缓冲区CPU负责做后续处理。逻辑很简单DMA完成中断置一个标志CPU收到中断后从缓冲区读数据。结果让我很意外CPU每次读到的都是DMA搬运之前那一次的数据也就是说永远是“上一帧”而缓冲区里最新的数据反而读不到。把内存内容dump出来一看DDR里确实是新数据但CPU一读就是旧值。当时第一反应是DMA地址配错了检查了好几遍描述符地址没错。又怀疑是中断同步问题加了一堆延迟和屏障也没用。最后才发现DMA已经把新数据成功写入了DDR但CPU读数据时命中的是Cache里的旧副本压根没去DDR里取。这个例子是“CPU读旧数据”同一类问题的镜像版本是“DMA搬旧数据”。CPU把新数据写进缓冲区然后让DMA把缓冲区内容搬出去结果对方收到的是上一次的旧数据。原因一样CPU写的新值还在Cache里没有落回DDRDMA直接读DDR自然看到的是旧值。1.2 双视角下的数据不一致很多刚接触DMA的人容易有一个默认假设所有数据都在内存里谁读都一样。问题恰恰出在这CPU和DMA眼中的“内存”不是同一个东西。CPU要读数据时硬件会先去Cache里找命中则直接返回没命中才去DDR。这个过程对驱动的意义是CPU读到的内容可能比DDR新也可能比DDR旧。DMA则是独立于CPU Cache之外的“外部访客”它通常直接通过系统总线发起对DDR的读写根本不经过CPU的Cache。当只有一个主设备时所有读写都按顺序经过同一路径没问题。但CPU和DMA是两个独立的主设备它们看到同一个物理地址却可能处在不同状态。CPU看到的是Cache里的副本DMA看到的是DDR里的正本。两个副本一旦不同步就是标题里说的“DMA总读旧数据”。这就像两个做交接的人各有一本自己维护的记录本一个人把新信息记在自己的本子上另一个人却默认去翻公共台账自然也找不到最新记录。1.3 为什么“心存侥幸”的人总是踩坑还有一个现实原因让这个问题变得普遍不是所有DMA都一定会触发Cache一致性问题。在小体量裸机工程里数据量小Cache常常处于关闭状态或者buffer恰好落在配置成non-cacheable的内存区域。这时CPU和DMA都是直接读写DDR你永远碰不到这个问题。等工程复杂度上来CPU启动了L1/L2 Cache缓冲区落在普通DDR或者数据量一大、DMA开始用描述符环形队列做长时间搬运时问题就开始密集出现。这也是为什么很多人反映“之前从来没事换个方案就出问题”——不是方案变了是Cache路径变了。2. Cache一致性本质上是一道“多副本同步题”2.1 Cache是CPU的“私人笔记本”理解Cache一致性之前需要先明确一个观点Cache不是内存的镜像而是独立于DDR的一份“副本”。CPU写数据时如果策略是写回write-back新值只先写在Cache行里标记为脏dirty并不会立刻落到DDR。CPU读数据时如果Cache里已经有副本也可能不到DDR去读。这种方式让CPU运行速度远高于DDR的访问速度代价就是存了多份数据。在单CPU环境下多副本问题不严重因为所有读写都经过同一个Cache一旦引入DMA这类外部主设备多副本之间的同步就成了驱动开发者绕不开的课题。2.2 DMA是绕过Cache的“外部访客”这里要强调一个关键架构事实大多数DMA控制器的访问路径是“主设备到内存控制器”它会经系统总线发起传输但不会主动探测CPU Cache。即使数据已经在Cache里躺着DMA也看不见。所以CPU和DMA对同一个物理地址的操作可能产生四种情况场景CPU动作DMA动作后果场景1CPU写入了数据DMA读取DMA读到旧值场景2DMA写入了数据CPU读取CPU读到旧值场景3DMA读取CPU未参与一般无一致性问题场景4DMA写入CPU未参与一般无一致性问题前两种是典型的Cache一致性问题第三种和第四种看似安全其实后面还有坑下面会专门讲。2.3 有硬件一致性不代表所有平台都有不是所有系统都必须靠软件维护Cache。x86平台的PCIe DMA路径上硬件总线协议自带snoop侦测机制CPU Cache会被硬件自动嗅探DMA读写时能直接维护Cache一致性。所以在这类平台上跑DMA很少遇到“读旧数据”这导致很多在x86上开发惯了的人换到嵌入式平台后第一反应是“平台坏了”。而常见的嵌入式SoC就没这么幸福了。DMA控制器通常直接挂在内存端走的是没有硬件一致性的路径需要软件在DMA操作前后手动维护Cache状态。还有一类SoC在设计上做得更完善DMA设备挂在带一致性协议的硬件接口上例如支持硬件缓存嗅探的总线端口这时软件也可以少操心一大半。关键是你要先看清自己的平台属于哪一种。软件层的三招本质是在不同平台约束下寻找“正确维护Cache一致性”的方案。3. 三招解决Cache问题从软件到硬件逐层拆解3.1 第一招手动维护Cache状态该刷的刷该废的废第一招最直观也是最常用的手段在DMA开始前把CPU相关Cache里的脏数据写回DDR术语叫clean或flush在DMA完成后把CPU的Cache行标记为无效让CPU再次读时重新从DDR加载术语叫invalidate。在Linux内核里这一套已经封装成标准API。以最常用的dma_map_single为例驱动分配一个普通缓冲区然后把地址和长度交给DMA映射函数。关键是根据DMA方向决定sync时机方向为DMA_TO_DEVICECPU数据要交给DMA送出去时启动DMA前要调用dma_sync_single_for_device(dev, dma_addr, size, DMA_TO_DEVICE)本质是clean把CPU写的脏Cache写回DDR。方向为DMA_FROM_DEVICEDMA要把数据放回内存给CPU用时DMA完成后要调用dma_sync_single_for_cpu(dev, dma_addr, size, DMA_FROM_DEVICE)本质是invalidate让CPU读不到旧Cache副本。完整代码样子大致如下/* DMA发送方向 */ dma_addr_t dma_addr dma_map_single(dev, buffer, len, DMA_TO_DEVICE); dma_sync_single_for_device(dev, dma_addr, len, DMA_TO_DEVICE); /* 配置DMA描述符并启动传输 */ /* DMA接收方向中断处理中 */ dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE); /* 这时CPU才能安全地读buffer */ dma_unmap_single(dev, dma_addr, len, DMA_FROM_DEVICE);在裸机或RTOS环境下没有内核API需要直接操作Cache控制器寄存器或者调用厂商SDK提供的Cache维护函数。嵌入式平台一般都有类似cache_clean、cache_invalidate、cache_clean_and_invalidate的接口用法和内核里的一致核心就一句话DMA启动前替CPU把该写回的写回DMA完成后替CPU把该失效的失效。3.2 第二招一致性DMA映射直接把缓冲区变成非缓存手动同步能解决问题但要在每一个进出DMA的路径上小心维护漏一次就出一次bug。第二个思路更彻底向系统申请一块“不走Cache”或“走硬件一致性保证”的内存让CPU和DMA永远看到同一个DDR值。Linux里提供dma_alloc_coherent接口void *cpu_addr dma_alloc_coherent(dev, size, dma_addr, GFP_KERNEL);返回的cpu_addr是CPU访问的虚拟地址dma_addr是给DMA寄存器用的总线地址。这块内存在支持的平台上是按非缓存属性映射的CPU读写直接落在DDR因此CPU和DMA之间不存在缓存副本差。使用时不需要dma_sync_*DMA操作前后都不必维护Cache极大减少漏维护的风险。使用这个方案要承担明显的性能代价。CPU一旦访问这块buffer每次都要切实取DDR遇到大数据量的CPU频繁处理时速率会受到内存延迟制约。所以它更适合“DMA是主要生产者、CPU只是偶发访问”的场景比如视频采集帧缓冲、音频环形缓冲。如果数据量不大CPU访问频率也不高用coherent分配反而是一种省心的正确性选择。我做过的某图像采集项目里一帧就是几兆字节DMA每帧都往同一个coherent缓冲区写CPU只做指针切换和少量帧头解析压力和带宽都能接受。3.3 第三招让硬件来管一致性把软件维护减到零第三招往底层走一步不要手动维护Cache而是让SoC的硬件机制替你做。第一个硬件路径是使用具备一致性能力的总线接口。部分SoC的DMA控制器或挂载DMA的设备支持通过带snoop功能的总线端口访问DDR。CPU侧Cache会收到一致性广播DMA写内存后Cache里的对应行自动失效DMA读内存时脏数据在可见性范围内也被写回。驱动只要有正确的描述符甚至不需要额外维护。大量数据处理场景例如网络控制器、高速存储控制器硬件设计者都会走这条路。第二个硬件路径依赖SMMU/IOMMU。某些平台虽然DMA没有直接挂在一致性端口上但系统有IOMMU。驱动可以把DMA地址配置成强制non-cacheable属性或在设备树、固件描述中把设备标记为dma-coherent。Linux内核看到dma-coherent后会跳过dma_sync_*直接认为设备具备一致性。这就是为什么同一个驱动有时在A平台需要手动sync在B平台什么都不用做也能正常工作。第三招和第一招并不冲突。很多正式驱动里是“混合使用”描述符队列用coherent映射数据缓冲区用streaming映射加手动sync既保证控制路径可靠又保证数据路径有性能。三招之间是层次关系不是互斥关系。3.4 三招选型对比方案原理适用场景风险点手动维护Cache操作前后clean/invalidate普通strcpy缓冲区、一次性DMA传输、裸机开发漏维护、方向搞反、多核下操作错核一致性DMA映射分配non-cacheable内存描述符、环形队列、频繁DMA控制块CPU访问性能差、地址转换复杂硬件一致性总线snoop/IOMMU强制属性高速网络、存储、视频数据依赖平台实现需正确配置设备树实际项目中第一招是基本功第二招是兜底第三招是追求性能上限时的方向。不会第一招后面两招出了问题也排查不动。4. 选型容易犯的错方向、核、屏障三个细节能卡死人4.1 把DMA方向搞反越sync越乱很多人在DMA_TO_DEVICE和DMA_FROM_DEVICE上栽过跟头。方向不是看“数据从哪里来”而是看“谁需要看到最新的数据”。DMA_TO_DEVICE意味着数据由CPU产生DMA负责把它搬出去。CPU写在Cache里的脏数据DMA看不到所以DMA启动前要做flush。如果此时反而先做invalidate那等于把Cache里的新值丢了DMA搬出去的还是DDR里的旧数据。DMA_FROM_DEVICE意味着DMA负责把数据搬进来CPU之后要读。DMA写入后Cache里可能还有读过的旧副本不invalidate的话CPU读到的就是旧值。如果此时误做flush反而把DMA新写入的数据“写回”Cache里行为同样不对。我自己的习惯是在写驱动前先画一条简单箭头数据从哪一侧产生要到达哪一侧然后确定每个同步点该flush还是该invalidate。光靠背API名字很容易出错画一遍关系再清晰不过。4.2 多核环境下Cache操作是对“某个核”做的Cache维护函数在CPU侧执行时通常只作用于当前核。多核场景下缓冲区可能被多个核访问DMA搬运完成后另一个核上来读Cache里可能还留着旧数据而这个核根本不知道要做invalidate。Linux内核虽然提供了on_each_cpu之类的跨核操作但多数驱动不会对所有核做Cache维护。更常见的做法是严格规定缓冲区只能由一个核访问或所有访问它的核都走同一条同步路径。否则就会出现“单核跑没问题多核一上数据时新时旧”的现象。这也解释了为什么有些问题在单核调试时死活复现不了一上SMP就崩溃。不是竞争条件而是Cache维护的覆盖面不够。4.3 内存屏障不是Cache操作别混为一谈排查这类问题时有人会习惯性加mb()、wmb()、rmb()发现没用于是觉得Cache问题更难解。这里必须分清楚内存屏障解决的是CPU访问顺序问题确保读写指令在可见性上的先后关系而Cache维护解决的是“CPU与DMA看到同一物理地址的不同副本”问题。内存屏障管不了DMA因为DMA不是CPUDMA不需要按CPU的乱序规则访问DDR。在程序员眼里DMA看到的是“内存当前的内容”而这个内容是否包含Cache里的最新脏数据只有Cache维护函数有决定权。正确做法是先保证Cache状态正确再保证访问顺序正确。两者不是同一层的事情。加屏障之前先问自己“这一步是防止乱序还是防止副本不一致”。4.4 频繁flush/invalidate带来的性能黑洞手动维护Cache表面简单一旦高频调用性能会直线下滑。尤其在高吞吐网络场景每个包都要sync而invalidate还可能导致Cache行被反复踢出后续CPU读数据时全部missDDR带宽压力骤增。实测过一个案例某网络驱动为了省事对每个小包都做完整sync结果吞吐量直接掉了四成。后面改用“批量同步”把多个包集中到一个内存池在轮询批次边界统一sync一次性能才恢复。这种优化思路在驱动层很常见同步的粒度要跟数据流的结构对齐不能把每个描述符都独立成一次同步。性能敏感场景下第一招用好了有收益用得太碎也会拖垮整个系统。5. 排错实录四个真实问题附排查手段5.1 问题一数据看起来“一半新一半旧”某次DMA采集后CPU读缓冲区发现前一部分是新数据后一部分是旧数据。排查后发现缓冲区在分配时跨越了多个Cache行而sync操作时长度参数少算了一段后半段的Cache行没有被invalidate自然读到旧值。这种问题最容易发生在缓冲区由多个小区域拼接、内存对齐不严格的场景。解决办法是保证缓冲区和长度按Cache行对齐或者干脆使用dma_alloc_coherent分配。还有一个细节有些Cache的line size是64字节但SoC不同核之间可能不一样裸机上直接用寄存器读取line size更可靠。5.2 问题二换了一块板子问题自动消失同一套代码在一块板子上DMA工作正常换到另一块板子上频繁出旧数据、错数据。很多人以为代码被“环境干扰”了实际上往往是第一块板子的SoC支持硬件一致性DMA挂在带snoop的总线端口上所以驱动的不规范操作被掩盖了第二块板子的SoC没有这个端口问题暴露无遗。排查思路是打开平台手册查DMA控制器在总线结构中的位置确认是否有Cache一致性支持。Linux环境下看设备树或驱动里dma-coherent标志是否存在也可以直接在内核日志里找相关提示。知道自己平台属于哪一类很多怪问题的排查方向都能省一大半。5.3 问题三加了内存屏障旧数据纹丝不动看着现象像DMA“延迟”了于是加wmb()、rmb()结果旧数据照样出现。原因是屏障只影响CPU视角的访问顺序不会把Cache里的脏数据写回DDR更不会让DMA绕过Cache直接访问DDR。我当时用一个笨办法验证把DDR里的内容和CPU读到的内容同时dump出来对比。两者不同就说明是Cache副本问题下一步该做Cache维护而不是加屏障。对比dump是排查这个问题的第一现场证据务必养成习惯。5.4 问题四改用coherent映射性能明显变差某项目原本用dma_map_single加手动sync正确性没问题但吞吐不满意于是把缓冲区改成dma_alloc_coherent结果CPU侧处理帧头的速度大幅下降因为CPU访问缓冲区时每次都命中不了Cache全部走DDR。后来把核心业务拆成两部分描述符和帧头用coherent映射真正的帧数据用streaming映射加手动syncCPU侧有了缓存加速DMA侧也没有一致性风险。这类问题的经验是coherent映射不是万灵药它牺牲的是CPU的缓存收益。越是CPU需要大量访问的数据越要权衡是否值得放弃缓存。5.5 工具与手段小结调试器直接读物理地址对比Cache和DDR内容。平台自带的Cache调试接口看某段地址的缓存状态是干净还是脏。总线追踪工具确认DMA访问是否真的到达了DDR。Linux环境下/sys/kernel/debug/dma-api/能辅助确认dma mapping的统计情况。裸机环境下用不同Cache维护函数的组合做二分试验快速锁定方向。排查方向的优先级我一般这样定先确认DMA地址和描述符正确再对比DDR和CPU读数然后检查Cache操作是否覆盖完整最后才怀疑中断同步和竞争条件。6. 我在实际项目中的一些体会踩过几次坑之后我给自己定了一套定式所有DMA buffer必须先明确归属和方向再决定用哪种映射凡是不确定平台是否具备硬件一致性一律按最保守的方式手动维护Cache凡是能用dma_alloc_coherent兜底的控制路径绝不等到出了bug再来补sync。这套定式在最近几个项目里都帮我省下了大量调试时间。DMA和Cache的一致性问题说到底是CPU世界和外部设备世界之间的“共享数据契约”。弄明白自己平台在哪个层面提供保障、在哪一层需要补软件动作之后再遇到“读旧数据”不会慌照着三招对应排查就够了。