
做嵌入式这些年最常被问的一句话就是“为什么我的DMA老读到旧数据”。代码翻了几遍没毛病寄存器配置也对中断也到了可数据就是旧的。如果你也卡在这个坑里大概率不是DMA没干活而是CPU Cache和DMA之间的一致性没处理好。这篇文章就用一次真实的排查经历把DMA为何总读旧数据这件事讲透再给出三招解决Cache问题的通用思路适合做驱动开发、嵌入式应用和SoC方案验证的朋友参考。1. 现象复盘DMA读旧数据的三种典型现场1.1 第一种现场CPU写数据DMA却读到上一次的内容最早遇到这问题是在做一个简单的SDIO外设驱动。业务逻辑很简单用户在应用层往某个缓冲填数据驱动初始化DMA描述符把缓冲地址给DMA控制器然后启动传输。理论上外设应该拿到刚填进去的新数据。可实测下来DMA传递到外设侧的数据是上一次填入的旧内容甚至是一串随机值。一开始我怀疑是缓冲区地址给错了。反复核对DMA描述符里的地址字段也打印过物理地址和虚拟地址的换算都没问题。然后又怀疑是DMA还没来得及读内存就被外设拉走加了几微秒延时没用。最后无意中把缓冲区改成uncached属性现象立刻消失。其实这里的问题不在DMA而在CPU输出数据时还没有真正落到内存里。CPU写数据通常会先写进CacheCache中的脏行dirty line要等某个时机才会被写回到DRAM。DMA控制器读取的是物理内存地址它不看CPU的缓存自然只能看到“还没有被写回”的旧值。1.2 第二种现场DMA写完内存CPU读到的还是旧值另一类场景更隐蔽外设通过DMA往内存里送来一批数据DMA完成中断已经触发了CPU读缓冲区却还是旧值。比如某个UART接收场景DMA把一包数据放到接收缓冲中断里打印第一字节怎么看都是上一帧残留的内容。再读一次数据就正常了。这个问题也出现在网络收包这类批量数据路径上。当DMA把数据写入DRAM之后CPU去读目标地址如果该地址的Cache行早就被CPU预取或访问过CPU会直接命中Cache拿到一行“新鲜度早已过期”的数据而不是刚从内存写入的新数据。1.3 第三种现场偶发性的“时好时坏”为什么最坑上面两种属于稳定复现还算好查。最让人抓狂的是偶发性同一份代码跑十次有两次出错换一个内存地址或者改变CPU频率现象又会消失。这种偶发我碰过不止一次。最典型的是在某个双核平台上纯收DMA数据中断来了去读大部分时候正常偶尔读回半包旧数据、半包新数据。原因就是CPU在处理中断之前硬件预取器已经把目标缓冲区的一部分Cache行加载进了Cache而DMA刚写入DRAM的数据无法反推回CPU的Cache于是同一段缓冲区里新旧数据按Cache行粒度交错出现。这种“看脸”表现很像内存总线时序问题或者DMA带宽抢占问题容易把人带偏。但只要意识到Cache行粒度的数据交错特征就能很快转向一致性排查。1.4 先排除三个“伪装者”地址、方向和时序有些时候读旧数据确实不是Cache的锅。动手改Cache操作之前一定要先排除三个伪装者。地址问题容易查确认给DMA的地址是不是正确的物理地址长度有没有越界描述符有没有被CPU和DMA同时改写。方向问题更容易忽略DMA方向定义混乱导致“这台设备想读实际配置成了写”表现就变成缓冲区里永远是一堆旧值。时序问题则表现为外设还在传数据或DMA控制器还没真正完成内存操作CPU就开始读了。一般配置正确、且数据按预期更新只是内容滞后才重点怀疑Cache一致性。2. 根因拆解CPU Cache与DMA之间天然存在的一致性鸿沟2.1 Cache的工作模式是关键Write Back意味着数据有多个副本要理解为什么DMA会读旧数据先要知道现代CPU的Cache默认大多工作在Write Back写回模式。在这个模式下CPU每执行一次写操作并不会直接把数据写进DRAM而是先把数据写入Cache中的某一行。只有当该行被释放、或者达到了统一的刷写时机脏行里的数据才会被搬运回DRAM。CPU读操作也是同理它优先命中Cache而不是直接访问DRAM。所以同一个内存地址的数据可能在CPU Cache里有一个版本在DRAM里另有一个版本。对CPU而言Cache版本才算“真值”可是对外设和DMA控制器而言它们只能看到DRAM版本。打个比方就好理解了。CPU是办公室里一个写文件的职员Cache是他桌面上的草稿纸DRAM是走廊尽头的档案柜。他习惯先改桌上的草稿什么时候想起来了才誊抄归档。DMA则是另一个跑来跑去的实习助理他每次都要跑到档案柜去取档、送档而且他完全不知道桌上有草稿这回事。两份内容不一样时实习助理拿到的自然是他从档案柜看到的那份也就是被人遗忘的旧版本。2.2 DMA的视角它绕过CPU只认物理内存地址从系统架构看DMA控制器是一个独立于CPU的主设备master它通过总线直接访问DRAM访问过程不经过CPU的Cache层级。CPU和DMA维护的是同一张物理地址表但两者看到的Memory视图却可能不同步。当CPU已经把新数据写在Cache里还没来得及写回DRAMDMA去读对应物理地址看到的是旧DRAM数据这就是“DMA读旧数据”的发送路径版本。反过来说当DMA已经从外设收到新数据并写入DRAMCPU再去读同一地址如果该地址对应的Cache行仍是旧内容CPU会直接命中Cache跳过DRAM这就是“CPU读到旧数据”的接收路径版本。两条路径的根本逻辑一致CPU Cache和DRAM之间出现了短暂的不同步窗口而恰好DMA工作在这个窗口里。2.3 为什么x86少见、ARM常见硬件一致性策略的差异不少朋友第一次遇到这问题都会困惑为什么我在PC上写DMA驱动从来不用管Cache原因不在代码而在SoC架构的取舍。x86平台上CPU通过总线与DMA控制器之间有一套硬件一致性协议DMA访问内存时通常会走在缓存一致域里能够监听或同步Cache状态。某些x86平台的DMA写入还会触发对应的Cache失效逻辑所以驱动开发者基本不用手动处理缓存一致性问题。数据经过PCIe等总线时的传输路径有大量事务层机制兜底。ARM类平台则不然。很多嵌入式SoC为了功耗、面积和低延迟DMA控制器并不会全程监听CPU的Cache域甚至不同主核的Cache策略也各不相同。芯片团队默认驱动开发者用软件方式维护一致性这就是为什么同一套逻辑从x86迁移到ARM后莫名其妙的读旧数据问题就冒出来了。看芯片手册时重点关注“Cache Coherent Domain”和“DMA Master”的属主关系就能判断自己到底要不要做软件维护。2.4 方向决定一切发送路径和接收路径刚好相反很多人第一次做Cache维护时最容易写反该失效的时候做写回该写回的时候做失效结果数据更乱。这里有一个最朴素的判断表数据流方向谁先写谁后读CPU需要做的操作CPU → 外设DMA读内存CPU写缓冲区DMA读物理内存写回Clean外设 → CPUDMA写内存DMA写物理内存CPU读缓冲区失效InvalidateCPU → DMA → CPU环形回读双方交替读写双方交替读写按阶段分别做Clean和Invalidate核心就一句话谁要读的数据在后台写入方访问之前必须让Cache和DRAM之间同步到“读方想要的最新内容”。发送场景是DMA要读新数据所以CPU要先把自己Cache里的脏行Clean回DRAM接收场景是CPU要读新数据所以CPU要在读之前把旧Cache行Invalidate掉。3. 三招解决从临时“补丁”到架构级根治3.1 第一招发送前的Clean写回把脏数据送回内存第一招解决的问题是“CPU写了新数据DMA没看到”。做法是在启动DMA之前主动执行一次Cache Clean操作把目标缓冲区对应的脏Cache行全部写回DRAM。以ARM平台为例早期内核支持的裸操作会直接操作CP15协处理器接口。真机上这么写/* 示意代码ARMv7平台Cache Clean by MVA */ static void cache_clean_addr(unsigned long mva, unsigned long len) { unsigned long line_size 32; /* 按实际Cache Line大小配置 */ unsigned long start mva ~(line_size - 1); unsigned long end (mva len line_size - 1) ~(line_size - 1); for (; start end; start line_size) { __asm__ volatile(mcr p15, 0, %0, c7, c10, 1 :: r (start)); } }上面这种代码在真实项目中已经不太推荐了更标准的方式是用内核DMA API。dma_map_single已经封装了必要的Cache维护逻辑dma_addr dma_map_single(dev, buf, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_addr)) return -ENOMEM; dma_desc-addr dma_addr; dma_desc-len len; /* 启动DMA传输 */DMA_TO_DEVICE表示数据从CPU视角流向设备。在该语义下DMA API会在DMA发起前执行写回操作确保DMA读内存时拿到的是新数据。这里要强调的是dma_map_single只是把Cache行刷回DRAM不是把缓冲区清零。很多初学者一听Clean就以为是清空数据这是误解Clean的正确翻译应该是“写回”。3.2 第二招接收前的Invalidate失效让CPU别用旧缓存第二招解决的问题是“DMA写入了新数据CPU读到的还是旧数据”。做法是在DMA传输完成、CPU读取数据之前对缓冲区执行Invalidate让CPU后续读取时强制从DRAM重新加载。同样是ARM平台早期实现往往长这样/* 示意图ARMv7平台Cache Invalidate by MVA */ static void cache_invalidate_addr(unsigned long mva, unsigned long len) { unsigned long line_size 32; unsigned long start mva ~(line_size - 1); unsigned long end (mva len line_size - 1) ~(line_size - 1); for (; start end; start line_size) { __asm__ volatile(mcr p15, 0, %0, c7, c6, 1 :: r (start)); } }用标准DMA API时对应语义是DMA_FROM_DEVICEdma_addr dma_map_single(dev, buf, len, DMA_FROM_DEVICE); if (dma_mapping_error(dev, dma_addr)) return -ENOMEM; /* 编程DMA传输 */ dma_unmap_single(dev, dma_addr, len, DMA_FROM_DEVICE); /* 现在安全读取缓冲 */dma_unmap_single在收尾阶段执行InvalidateCPU再访问缓冲区才会读到DMA刚写入的数据。需要注意的是DMA_FROM_DEVICE方向的dma_map_single过程中有些实现也会做一次Clean目的是防止缓冲区内原来还有没写回的脏数据没有脏行时Clean是无害的。3.3 第三招从根源上统一一致性映射与专用DMA缓冲区前两招是“配合”Cache工作每次DMA收发都要小心翼翼地维护。第三招则是改变游戏规则直接让某些缓冲区不进入Cache一致性问题范畴。最通用的做法是使用一致性DMA缓冲区。Linux内核里对应的是dma_alloc_coherent底层实现会在页表里把缓冲区映射为非缓存uncached或配置为硬件一致属性。这样CPU和DMA都直接访问DRAM数据天然一致驱动里不再需要手动Clean和Invalidate。dma_addr_t dma_handle; void *cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); if (!cpu_addr) return -ENOMEM; /* cpu_addr是CPU侧虚拟地址dma_handle是设备侧DMA地址 */dma_alloc_coherent的好处是省心、稳妥特别适合DMA描述符、控制结构、状态标志这类频繁交互的小数据块。但也不能无脑用一致性缓冲区的分配和释放开销较大并且在很多实现里大小受限不适合大流量流式报文的场景。大量网络包、USB批量数据、音视频帧数据走高速DMA路径时通常还是用流式映射的方式按包处理。3.4 组合拳细节Clean Invalidate顺序与Cache Line对齐实际代码并不会总像前两招那么纯粹。常见场景是同一个缓冲区CPU写好之后交给DMADMA完成之后再交给CPU读往返使用。这时在同一个缓冲区上既要做Clean也要做Invalidate顺序至关重要。一个典型的错误是DMA完成后CPU先Invalidate再读取结果读到旧数据。因为Invalidate会让缓存行作废但如果缓存行是脏的作废不会把脏数据写回而是直接把Cache里的新值丢弃。之后CPU再访问该地址虽然会重新加载DRAM内容但如果该地址此前已经被预取进Cache并重新标记为有效就可能还是旧内容。更安全的做法是使用“Clean and Invalidate”的组合操作也就是先把脏数据写回DRAM再把Cache行作废。许多SoC的Cache接口都提供一次调用完成这两步的原语比如ARMv7 CP15的c7, c14操作或dma_map_single在部分实现里自动组合执行。面对双向往返缓冲区务必保证顺序永远是Clean在前、Invalidate在后绝对不要反过来。这些操作必须按Cache Line对齐执行。Cache Line大小通常为32字节或64字节如果只清整条Cache Line的部分地址边界外的部分就会被忽略。缓冲区长度不是Cache Line整数倍时宁可多操作一个行也不能让边界尾巴漏掉。3.5 别忘了屏障DMA描述符与内存屏障的配合读到旧数据有时并不是Cache内容滞后而是CPU的写操作乱序导致DMA启动时序错乱。CPU写描述符、写缓冲区、写DMA启动寄存器这三件事在无屏障约束的情况下可能被重排。如果DMA控制器在缓冲区数据还没写好的瞬间就启动传输表现也是读旧数据。所以第三招的最后一层配合是在前面Cache维护完成之后、真正启动DMA之前插入一次写屏障。Linux内核里可以用dma_wmb()确保之前的写入对外设可见后再触发DMAdma_map_single(dev, buf, len, DMA_TO_DEVICE); /* 写入DMA描述符 */ desc-addr dma_addr; desc-len len; /* 保证上面的写操作全部生效再启动DMA */ dma_wmb(); writel(DMA_START, dma_ctrl_reg);接收路径同理DMA完成中断触发后先读DMA状态寄存器确认传输完成再用dma_rmb()做读屏障防止CPU乱序提前读数据。屏障解决的是“顺序”问题Cache维护解决的是“视图”问题两者缺一不可。4. 排错实战从“读旧数据”定位到Cache问题的完整链路4.1 第一步用内存镜像验证“到底谁读到的是旧数据”怀疑Cache问题后不要急着改代码。先用调试手段做一次对比验证基本操作是把缓冲区的内容抓出来看。以Linux调试环境为例我通常会在DMA传输完成后通过/dev/mem接口读出目标物理地址的内存内容和驱动中断里打印的缓冲区内容做对比。如果/dev/mem读到的内存值是新数据而CPU读到的缓冲区是旧数据几乎可以断定问题出在CPU Cache侧。也可以借助JTAG/ICE工具直接读内存镜像或者用U-Boot的md命令在DMA传输后查看内存。这一步的目的是从硬件侧确认DRAM内容已经正确把排查范围收敛到CPU内部。4.2 第二步用Cache操作做AB测试快速实锤确认DRAM侧正确后最快的实锤手段是做一次“脏测试”在关键路径上临时加一段Cache Invalidate操作看问题是否消失。以接收方向为例在中断处理里读缓冲区之前先对目标缓冲区执行一次Invalidateif (irq_status DMA_COMPLETE) { flush_dcache_range((unsigned long)buf, (unsigned long)buf len); invalidate_dcache_range((unsigned long)buf, (unsigned long)buf len); process_packet(buf, len); }如果加了Invalidate之后“读旧数据”现象显著缓解甚至完全消失说明判断无误后续再优化为正式API。不少项目里我习惯在关键路径上用临时组合操作验证而不是一上来就大改驱动架构。这个“先验后改”的节奏能节省大量调试时间。4.3 一个真实的定位过程跨Cache Line边界的陷阱有一次项目组A同学排查一个诡异问题DMA接收的数据头部正常中间尾部偶尔为旧数据而且只在开-O2优化时出现。按常规加了Invalidate现象反而更糟数据丢失率明显上升。我们怀疑是缓冲区跨了Cache Line边界。把接收缓冲的起始地址和长度打印出来一看起始地址是4字节对齐长度是64字节按32字节Cache Line计算跨越了三条Cache Line而代码里做Invalidate时只处理了起始地址向下取整后的两条Line最后一条Line漏了。缓冲区末端的旧数据被CPU保留在Cache里后续访问又命中Cache导致尾部旧数据。修正方式有两个一是改用dma_alloc_coherent让缓冲区天然进入一致性状态二是手动按完整边界操作把缓冲区起始地址做成Cache Line对齐长度补齐到Cache Line整数倍。后来我们把接收缓冲区申请为32字节对齐并强制长度为Cache Line整数倍同样一串代码稳定运行不再出旧数据。这里我把这个案例写出来是提醒大家Cache操作不像“加一行函数调用”那么简单很多隐性问题来自地址对齐、长度边界。4.4 Linux下推荐使用的DMA API对照速查如果你在Linux里做驱动尽量别直接操作flush_dcache_range这类底层接口。要按DMA方向选择标准API。下面是我常用的速查表使用场景推荐API方向参数对应底层维护一次性流式DMA读内存dma_map_single/dma_unmap_singleDMA_TO_DEVICE写回Clean一次性流式DMA写内存dma_map_single/dma_unmap_singleDMA_FROM_DEVICE失效Invalidate双向往返缓冲区同区回写dma_map_single每次收发前根据实际方向处理DMA_BIDIRECTIONALCleanInvalidate组合控制结构、描述符、小型共享区dma_alloc_coherent无方向一致性映射Scatter-Gather描述符列表dma_map_sg/dma_unmap_sg按方向参数逐SG表项处理另一个容易忽略的点dma_map_single返回的dma_addr是设备侧的DMA地址不能直接当CPU虚拟地址解引用。CPU访问还是用原虚拟地址只有设备编程DMA描述符时才使用dma_handle/dma_addr。5. 写在最后经验排雷与工程习惯5.1 不要手动裸操作Cache API优先走DMA映射接口即便你能熟练使用CP15或ARM64的DC指令也建议优先使用内核DMA API。原因不只是跨平台可移植性还因为DMA API内部会帮你处理IOMMU、总线地址转换、Cache Line对齐、错误校验等一系列细节。手动Clean/Invalidate容易漏边界、漏方向、漏分支一旦出错很难定位。我见过太多现场调用dma_alloc_coherent的代码明显跑DMA正常有人“优化”成kmalloc flush_dcache_range后问题就开始复活。性能优化要建立在正确性之上不要轻易用裸Cache操作替代标准接口。5.2 多核与DMA的额外差异共享缓冲区要假设所有CPU都可能Cache多核平台上缓冲区可能被多个CPU核访问。比如中断跑在CPU0上但收发线程被调度到CPU1。这时只在CPU0上做Invalidate可能不够因为目标缓冲区的Cache行还可能残留在CPU1的Cache里。对涉及共享内存的DMA操作务必保证一致性维护覆盖所有可能访问该缓冲区的核。有些架构提供全核共享的Cache维护操作ARM64上要求针对集群的维护操作序列更稳妥的思路是让同一份DMA缓冲区固定在一个核上访问减少跨核共享。项目实践里我倾向于把网络收包队列做CPU亲和性绑定既减少竞争也从根上避免跨核Cache问题。5.3 我留下的一个习惯每次DMA收发成功都往调试日志里打方向缓冲区地址长度最后一个建议很简单在DMA路径加调试日志记录方向、缓冲区虚拟地址、DMA地址、长度和Cache Line大小。遇到读旧数据问题时这些信息能立刻帮你判断是否越界、是否跨行、是否方向使用不当。日志成本可以做到很低只在驱动调试模式开启时输出或者用trace point记录。很多疑难问题到最后一查就是地址打印里多了一行“len65未对齐”或者发现DMA方向参数在某个分支传错了。有日志三分钟破案没日志三天起步。再分享一条经验遇到“DMA读旧数据”时心态上先默认是自己的代码没维护好Cache一致性而不是怀疑芯片Bug。把这套Clean、Invalidate、一致性映射、内存屏障的组合拳练熟你会发现这类问题从“玄学”变回“工程学”处理起来跟修一个空指针一样有条理。