ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

DMA读旧数据?CPU Cache一致性三招搞定

DMA读旧数据?CPU Cache一致性三招搞定

做嵌入式这些年,最常被问的一句话就是“为什么我的DMA老读到旧数据”。代码翻了几遍没毛病,寄存器配置也对,中断也到了,可数据就是旧的。如果你也卡在这个坑里,大概率不是DMA没干活,而是CPU Cache和DMA之间的一致性没处理好。这篇文章就用一次真实的排查经历,把DMA为何总读旧数据这件事讲透,再给出三招解决Cache问题的通用思路,适合做驱动开发、嵌入式应用和SoC方案验证的朋友参考。

1. 现象复盘:DMA读旧数据的三种典型现场

1.1 第一种现场:CPU写数据,DMA却读到上一次的内容

最早遇到这问题是在做一个简单的SDIO外设驱动。业务逻辑很简单:用户在应用层往某个缓冲填数据,驱动初始化DMA描述符,把缓冲地址给DMA控制器,然后启动传输。理论上外设应该拿到刚填进去的新数据。可实测下来,DMA传递到外设侧的数据,是上一次填入的旧内容,甚至是一串随机值。

一开始我怀疑是缓冲区地址给错了。反复核对DMA描述符里的地址字段,也打印过物理地址和虚拟地址的换算,都没问题。然后又怀疑是DMA还没来得及读内存就被外设拉走,加了几微秒延时,没用。最后无意中把缓冲区改成uncached属性,现象立刻消失。

其实这里的问题不在DMA,而在CPU输出数据时还没有真正落到内存里。CPU写数据通常会先写进Cache,Cache中的脏行(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里还没来得及写回DRAM,DMA去读对应物理地址,看到的是旧DRAM数据,这就是“DMA读旧数据”的发送路径版本。反过来说,当DMA已经从外设收到新数据并写入DRAM,CPU再去读同一地址,如果该地址对应的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
外设 → CPU(DMA写内存)DMA写物理内存CPU读缓冲区失效Invalidate
CPU → 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_DEVICE:

dma_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在收尾阶段执行Invalidate,CPU再访问缓冲区才会读到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写好之后交给DMA,DMA完成之后再交给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(),确保之前的写入对外设可见后再触发DMA:

dma_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操作,看问题是否消失。

以接收方向为例,在中断处理里读缓冲区之前,先对目标缓冲区执行一次Invalidate:

if (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_BIDIRECTIONALClean+Invalidate组合
控制结构、描述符、小型共享区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记录。很多疑难问题到最后一查,就是地址打印里多了一行“len=65未对齐”,或者发现DMA方向参数在某个分支传错了。有日志,三分钟破案;没日志,三天起步。

再分享一条经验:遇到“DMA读旧数据”时,心态上先默认是自己的代码没维护好Cache一致性,而不是怀疑芯片Bug。把这套Clean、Invalidate、一致性映射、内存屏障的组合拳练熟,你会发现这类问题从“玄学”变回“工程学”,处理起来跟修一个空指针一样有条理。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进