☰
DMA总读旧数据?多半是Cache一致性惹的祸:三招排查与解决
2026/10/12 12:08:05 网站建设 项目流程

先讲个我踩过的坑。

有一回调某个采集项目里的DMA搬运,数据源是外设通过DMA直接写入内存,CPU这边负责把结果取出来做处理。启动之后,我反复读同一个缓冲区,读出来的内容永远是上一轮的旧值。新数据明明已经由DMA搬到物理内存了,逻辑分析仪也抓到了总线侧确实有写操作,可CPU在软件层面怎么读都读不到。折腾了大半晚上,试过改DMA描述符、调整中断优先级、甚至怀疑是地址错位,最后才发现根子根本不在DMA,而在Cache一致性上——CPU把数据写进缓存后还没落回内存,DMA直接从内存里取,自然拿到的是旧数据。

如果你也在调DMA相关的外设驱动或者底层固件,遇到这种“明明数据到了、读出来却是旧的”的情况,多半不是DMA配置写错,而是CPU、Cache、DMA三个角色之间的数据同步出了问题。这篇文章我就把这段排查过程、底层原理,以及我最后总结出来的三招解决方案完整讲清楚,希望能帮你把排查时间从一个晚上压缩到五分钟。

1. 现象复盘:DMA搬回来的数据,怎么看都像上一轮的

先说现象本身,因为这类问题的现场往往很有迷惑性,一不小心就把人带偏。

我那个项目里,外设有自己的DMA引擎,会周期性地把一个数据块搬到内存里,搬完后通过中断通知CPU。CPU在中断服务函数里直接读取这块缓冲区,然后做后续处理。第一轮数据是好的,第二轮开始,读出来的内容还停留在第一轮;偶尔重启一下系统,第一帧是新的,第二帧又变成第一帧的旧数据。而且只要我在读取之前故意加一段几百微秒的延时,或者让CPU先做一个无关的内存读写操作,数据就变新了。

这个“加延时就好”的特征特别坑人,因为它会引导你去怀疑时序问题、怀疑DMA还没搬完就触发了中断,于是又开始查GPIO、查中断状态寄存器、查DMA描述符的完成标志位,方向完全跑偏。

我后来把这类问题的共性特征整理了一下,方便对照:

现象特征第一反应实际指向
冷启动或复位后更容易复现,热运行后偶发DMA初始化不完整缓存初始状态不确定,回写时机变化大
读取前人为加延时,问题消失中断提前了给了缓存行足够的自然回写窗口
数据内容保持上一轮的完整快照,而不是乱码地址错位、长度不对缓存行整体命中旧数据,典型一致性问题
同样代码换一块没开Cache的平台上完全正常平台差异玄学没开缓存就没有一致性问题,反向印证

你可以直接拿这张表对照自己的现象。如果命中两条以上,基本就可以把怀疑重心放到Cache上了,不用再去DMA配置里浪费一晚上。

1.1 这类问题为什么特别容易出现在“启动后的前几帧”

一个很不好的消息是,DMA读旧数据这类bug往往是间歇性的,它不会每次必现,而是看运气。

原因是现代CPU的缓存行什么时候被写回内存,并不完全由软件控制。L1、L2缓存行被标记为dirty之后,可能一百个周期后就写回了,也可能一直拖到缓存压力大了才写回。DMA触发的那一刻,缓存行到底写回没有,完全取决于之前的访问历史和替换策略,这就让问题看起来像随机Bug。

但随机Bug才是最讨厌的,因为它会让你的第一版驱动在测试机器上跑得很顺,一到现场、换了一批物料、或者负载一变,问题就冒出来。这也是我建议一旦怀疑到Cache一致性问题,就要彻底处理,而不是加延时糊弄过去的原因。

2. 根因拆解:CPU缓存里刚改的数据,DMA根本看不见

要彻底搞清楚为什么DMA会读到旧数据,得先理解CPU写数据时发生了什么。

现代CPU访问内存并不直接写物理内存,而是先写进Cache Line。当CPU执行一条普通写指令时,如果目标地址在缓存中命中,它只会更新缓存里的值,并把这一行标记为dirty。这个“值已经改了但还在缓存里”的状态,对外部观察者来说,内存里的数据还是旧的。只有当缓存行因为替换、显式clean操作、或者一致性协议被写回,物理内存里的内容才会变成新值。

DMA控制器则完全不同。它是总线上的一个主设备,发起访问时走的是系统总线协议,直接操作物理内存地址,完全不经过CPU的Cache。所以CPU刚写完新数据,DMA紧跟着去读内存,看到的很可能是写回之前的老数据。反过来也一样,DMA刚把外设的数据搬进内存,CPU去读自己那一块带着旧数据的缓存行,也会命中旧值,读不到DMA刚写入的新内容。

这两个方向的问题统称缓存一致性问题。网上喜欢把它叫“Cache Coherency”,核心争吵点就一句话:CPU的内部视图和内存的真实内容,在任意时刻并不是同步的。

2.1 拿图书馆做类比

你可以把Cache理解成CPU私有的小笔记本,把物理内存理解成图书馆的总账本。

CPU的写操作就像在笔记本上记录“第100页改成888”。活儿干完了,但总账本没改,物理内存里还是旧值。DMA是个只认总账本的临时工,他去查第100页,看到的当然是旧的。只有CPU先把笔记本上的改动誊回总账本——也就是执行一次Cache Clean——DMA再去看,才能看到888。

反向场景也容易理解。DMA这个临时工往总账本第101页写了一行997,但CPU手里的小笔记本上,第101页还抄着旧值895。CPU不去重新翻总账本,而是直接看小笔记本,自然就读到895了。

整个问题的本质就是:CPU和DMA这两个访问者,一个看小笔记本,一个看总账本,两边信息不同步,又没有一个明确的“同步节点”,于是各种读旧值就出现了。

2.2 一致性点(Point of Coherency)也需要了解一点

在ARM、RISC-V这些体系结构里,有一个术语叫Point of Coherency,可以理解成“所有主设备都能看到同一份数据的位置”。L1 Cache之下、与DMA等总线主设备交汇的地方,就是一个一致性点。

CPU的缓存维护指令,比如Cache Clean和Cache Invalidate,本质上是把缓存行里的数据和某个一致性点做同步。理解了这一点,你就明白为什么“加延时”有时候能起作用——因为缓存行可能在这一大段时间里因为逐出、预取等机制被动写回了,凑巧让DMA读到了新值。但运气成分很大,你不可能让产品靠延时活着。

2.3 为什么有时候CPU读DMA写完的数据也是旧的

第二个方向同样值得强调。很多时候大家以为只要解决了DMA读旧数据,反向就不会出问题,其实反过来的坑更隐蔽。

DMA把数据搬进内存后,如果CPU的那段地址早就被缓存了,而且缓存行还是clean状态,CPU读的时候会直接命中缓存。此时物理内存里已经被DMA更新了,但CPU看到的是缓存里的旧值。只有让CPU先把缓存行作废,下次访问时重新从内存加载,才能读到DMA搬运进来的新内容。

我最初排查时,也是先解决了“DMA读不到CPU写的数据”,又过了两天才发现“CPU读不到DMA写的数据”是另一个问题。所以你排查时一定别偏科,两个方向都要覆盖。

3. 第一招:把DMA缓冲区的内存属性改成uncached,用性能换一致

第一招逻辑最简单,既然问题是缓存造成的不一致,那干脆让这段内存不做缓存。

在CPU开启MMU的系统中,每一页内存都有缓存策略属性。把DMA要用到的缓冲区映射成uncached,也就是non-cacheable或者Device类型,CPU对这片地址的读写就会直接穿透到外部的存储总线,不发散到Cache里。CPU写多少,内存立刻就有多少;DMA写多少,CPU去读的时候因为没有缓存可命中,也是直接读内存,两边天然一致。

3.1 具体怎么配置

在裸机或者RTOS环境下,你需要去改页表:

  • ARM32上,可以把这段区域映射为 unbuffered / non-cacheable,控制位里关掉Cache和Buffer相关的比特位;
  • ARM64上,页表条目里选择MT_NORMAL_NC或MT_DEVICE_nGnRnE这类内存类型;
  • x86上则是通过PAT/MTRR把区域的缓存类型设为UC或者UC-。

在Linux驱动环境里,常用的方式包括ioremap映射设备寄存器区域,或者像dma_alloc_coherent这样直接申请一致性内存。后者实际上的表现通常就是给CPU一个non-cacheable的虚拟地址映射,给DMA一个对应的设备地址,CPU和设备任一方访问都直接打到内存。

一个大致的裸机页表配置思路是这样的:

// 伪代码示意:把物理地址 base 开始的 32KB 映射成非缓存 void setup_uncached_region(uintptr_t base, size_t size) { for (uintptr_t pa = base; pa < base + size; pa += PAGE_SIZE) { uint32_t pte = pa & PAGE_MASK; pte |= MMU_FLAG_NON_CACHEABLE | MMU_FLAG_READ_WRITE; mmu_set_pte(virt_to_va(pa), pte); } /* 配置完成后需要做 TLB invalidate,让旧映射失效 */ tlb_invalidate_all(); }

3.2 这块区域的代价是什么

uncached映射不是免费的午餐,代价很直接:CPU访问这段区域时,每次读写都会变成一次真实的总线事务。如果CPU只是偶尔写一下描述符、读一下状态,完全没问题;但如果CPU要高频读写大块数据,比如反复搬运几MB的数据帧,这个性能损失会非常明显。

实际工程中,我一般只在两种场景用这一招:

  • 控制通道类的小数据结构,比如描述符、环形缓冲区头部、状态字;
  • 数据块本身不大、访问频率不高,而这个平台又很难方便地做Cache维护。

大部分场景下,数据缓冲区还是希望保留Cache的性能,这就引出第二招。

4. 第二招:手动做Cache Clean和Invalidate,操作方向千万别弄反

第二招是嵌入式底层开发里最常用、也最容易踩坑的一套操作:软件自己维护Cache一致性。核心就两条指令的语义:

  • Cache Clean:把脏缓存行的数据写回内存,让内存变成新值;
  • Cache Invalidat:把缓存行直接作废,让CPU下次必须从内存重新加载。

对应到DMA两个方向上的动作是固定的:

场景正确操作为什么
CPU写数据,准备交给DMA读取先 Clean把CPU缓存里的脏数据先刷到内存,DMA才能读到新值
DMA写完了数据,CPU准备读取先 Invalidate把CPU缓存里的旧行作废,强制从内存加载DMA写入的新值
混合使用同一块缓冲每次交接前都执行对应操作保证每个访问者看到的都是最新状态

我一直记这么一句口诀:“交给DMA之前刷一遍,从DMA手里拿回来之前扔一遍。”方向反了,后果很严重。如果在DMA写完后还做Clean,等于把内存里可能正确的数据用缓存里的旧值覆盖回去;如果在CPU写完、DMA还没读之前就做Invalidate,会把还带着新数据的脏缓存行直接丢掉,数据直接损坏。

4.1 一个标准的操作时序

假设Driver里有一个64字节的缓冲区,DMA要从中读出描述符。正确顺序是:

/* CPU 更新描述符内容 */ memcpy(desc, data, 64); /* 1. 先把脏数据clean到内存,确保DMA能看到 */ cache_clean(desc, 64); /* 2. 启动DMA */ dma_start(desc_dma_addr); /* 等待DMA完成中断 ... */ /* 3. DMA完成后,先把旧缓存行扔掉,再读描述符 */ cache_invalidate(desc, 64); process_desc(desc);

这里的cache_clean和cache_invalidate在ARM这类架构上通常用缓存维护指令实现,有的SoC还要求操作必须按Cache Line大小对齐,我在代码里写的是伪代码,实际移植时要用平台提供的接口。

4.2 为什么要按Cache Line对齐、按需操作

这部分可以说是整个方案的灵魂,也是最容易被忽略的细节。

常见架构的Cache Line是64字节,也可能32字节、128字节。缓存维护的本质是“以行为单位操作”,所以你的DMA缓冲区起始地址和长度最好都对齐到Cache Line。否则会出现一种尴尬情况:你clean了第0行到第1行,但第1行里既有DMA缓冲区末尾的数据,又有别的变量,clean的时候顺手把别的地方的脏数据也写回内存,虽然不一定会出错,但行为已经变得很难分析了。

invalidate方向更危险。如果你invalidate的范围超过了DMA实际写的区域,把还在Cache里的其他变量的脏数据也一并作废了,那些变量就静默丢数据了。这种情况比读旧值还隐蔽,因为现场毫无报错。所以我一般建议DMA缓冲区独立成段、按行对齐,哪怕浪费一点内存都值得。

4.3 手动方案的实际体验

手动方案是我在裸机和RTOS环境下的主力方案,原因是没有现成的DMA通用框架可用。但它有一个隐性问题:你必须保证DMA启动前和完成中断里的缓存操作,都与实际DMA流动方向严格一致。

我印象很深的一次踩坑,是在某个网络驱动的收发路径上。发送方向我把clean做了,接收方向也做了invalidate,看起来完美。但问题是这个缓冲会被复用,接收方向每次invalidate前,缓冲区里可能还有上一轮dirty的数据,直接invalidate就先把这轮数据弄丢了。正确做法是先判断是否有尚未写回的内容,或者统一用“clean+invalidate”的组合指令,保证干净落地。这类细节讲起来简单,实际调试时见过太多版本。

5. 第三招:用DMA映射API,让框架替你把一致性管起来

如果你在Linux内核里开发驱动,那么手动做Cache维护其实属于“可以但没必要”。因为内核已经把这套逻辑封装成了标准DMA API,直接用就行。这第三招是Linux环境里最推荐的解法,也是我后来默认的第一选择。

为什么推荐直接使用API?因为DMA API不只是做了Cache维护这么简单,它还会同时处理IOMMU/SMMU的地址映射、总线地址转换、内存屏障、设备树中一致性标记等一堆琐碎事务。你手写代码也许能处理孤立的Cache问题,但平台一复杂就容易漏。

5.1 一致性内存和流式DMA映射的区别

Linux里有两类跟DMA相关的内存使用方式,用途完全不一样,别混用。

第一类是一致性内存映射,典型接口是:

dma_addr_t dma_handle; void *cpu_addr = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);

它返回给CPU一个虚拟地址,同时给你一个设备侧能直接使用DMA地址。CPU和设备访问这块内存,在框架层面保证一致性。很多底层实现就是把这个内存映射成了uncached,CPU访问性能不算高,但天然一致,适合缓冲区生命周期长、长期复用的场景,比如描述符队列。

第二类是流式DMA映射,对应临时的一次性搬运:

dma_addr_t dma_addr = dma_map_single(dev, cpu_buf, size, direction);

这里direction表示数据流动方向,取值有:

  • DMA_TO_DEVICE:CPU写、DMA读。API内部会在map时做clean;
  • DMA_FROM_DEVICE:DMA写、CPU读。API内部会在unmap时做invalidate;
  • DMA_BIDIRECTIONAL:双向使用,框架会做最保守的处理,性能通常较差,能不用就不用。

这跟手动方案是一脉相承的,只不过框架把你容易漏掉的方向和时序都藏起来了。你只需要在正确的时间调用map和unmap,剩下的交给框架。

5.2 一个典型的Linux DMA收发流程

下面是一个发送方向的完整示例,代码不长,关键在调用时机。

static int my_dev_send(struct device *dev, const void *data, size_t len) { dma_addr_t dma_addr; void *cpu_buf; cpu_buf = kzalloc(len, GFP_KERNEL); if (!cpu_buf) return -ENOMEM; memcpy(cpu_buf, data, len); /* 映射:内部负责把脏缓存clean到内存 */ dma_addr = dma_map_single(dev, cpu_buf, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_addr)) { kfree(cpu_buf); return -EIO; } /* 把 dma_addr 填进硬件描述符,启动发送 */ write_descriptor_and_start(dma_addr, len); /* 等待发送完成中断 ... */ /* 发送结束,解除映射,之后才能安全释放或复用cpu_buf */ dma_unmap_single(dev, dma_addr, len, DMA_TO_DEVICE); kfree(cpu_buf); return 0; }

接收方向也大致相同,只是方向换成DMA_FROM_DEVICE,而且更关键的一点是:读取cpu_buf里的数据,必须放在dma_unmap_single之后。否则框架还没做invalidate,你提前读,依然可能拿到旧数据。

5.3 需要反复使用同一块缓冲区时,用sync接口

流式映射一般建议用完就unmap,因为框架在map/unmap期间可以比较激进地做缓存维护。但如果你的驱动场景是同一个缓冲区反复被DMA和CPU交替使用,每次都map/unmap成本太高,可以用同步接口:

/* CPU 写完,准备交给DMA */ dma_sync_single_for_device(dev, dma_addr, len, DMA_TO_DEVICE); /* 启动DMA ... */ /* DMA完成后,CPU准备读 */ dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE);

这两个接口的作用和map/unmap内部的缓存维护逻辑完全一致,只是不会解绑地址映射。它们的使用条件有一个前提:这块缓冲区必须是被DMA API映射过的,而且你没有中途解映射。使用过程中方向同样不能乱写。如果你分不清改填哪个方向参数,最简单的方式是场景里谁刚写完就选对应的方向,别偷懒直接填DMA_BIDIRECTIONAL,否则性能翻倍地掉。

6. 实操排查链路与边界陷阱:怎样用五分钟确认是Cache问题

前面三招是解决方案,这一节讲讲我怎么在一分钟内确认问题是不是Cache导致的,以及一些容易让人误判的边界情况。

6.1 五分钟定位法

先把这段缓冲区换成dma_alloc_coherent申请,或者直接把页表属性改成uncached再跑一遍。如果问题消失,说明基本就是Cache一致性问题。

如果不能用上一招,可以用思路类似的判别实验:在CPU读完缓冲区后,立刻把这个地址读取分成两步——先用一段内联汇编执行真正的Cache Invalidate,再读数据;如果此时新数据出现,那就可以下结论了。用文字表达就是:

  1. 复现问题,打印旧数据;
  2. 读取之前手动执行一次invalidate(或者换成uncached属性);
  3. 如果读到新数据,确认是Cache过期;
  4. 如果是DMA没搬完、地址错等真实问题,这两步不会改变结果。

这个方法我已经用过很多次,百试百灵。另外要提醒一句,普通变量加个volatile并不能绕过缓存,只能防止编译器优化。真正绕过Cache的只有非缓存映射或者显式Cache维护指令,所以别拿volatile做判断依据。

6.2 用翻转标记压测

另外一个针对性的测试用例也推荐使用:在缓冲区里预先填充0x00,DMA每轮搬运填入0xA5,周期翻转,多跑几十轮,打印每一轮读到的值。

如果输出永远是00,说明每一轮读取都在命中旧的缓存行;如果读出旧一轮的0xA5而不是最新一轮的0x5A,说明invalidate缺少了;如果有时序混叠,则还要考虑DMA完成标志是否可靠。这个测试简单直观,能直接区分方向和时序两类问题。

6.3 要注意那些“伪装成Cache问题”的边界情况

有些问题表面上很像Cache一致性,实际原因是别的,来自我实际排查中遇到过的三处。

一是结构体跨Cache Line的边界问题。DMA只写了结构体里的4字节字段,但这个字段横跨了两条Cache Line,驱动只invalidate了其中一条,结果一半新、一半旧。这种问题靠现象很难看出来,只能通过打印十六进制逐个字节去对。

二是CPU读缓冲区时,编译器做了乱序,处理器又做了重排。尤其在ARM上,你需要在DMA完成标志位的读操作和缓冲区数据的读操作之间插入dma_rmb()之类屏障,保证CPU不会提前把数据Loading出来。这个坑跟Cache无关,但现象上完全一致,容易一起踩。

三是平台硬件本身可能有一套总线一致性机制。比如系统中存在支持snoop或点对点一致性协议的总线互连,让某个地址范围天然保持一致。这时候如果你设备树或配置里没正确声明,框架要么多做了维护造成性能损耗,要么没有利用好硬件能力。很多开发板默认支持,但手册写得含糊,建议仔细看SoC的顶层总线框图,确认你用的DMA节点是否走了一致性通道。

最后再分享一个我自己的Checklist

现在每次处理DMA相关Bug,我都会按固定顺序过一遍,基本没有翻过车:

  1. DMA缓冲区来自哪里?是不是用DMA API申请或映射的;
  2. 缓冲区地址和大小有没有按Cache Line对齐;
  3. CPU写给DMA看之前,有没有做clean;
  4. DMA写完后,CPU读之前,有没有做invalidate;
  5. 有没有在DMA还在传输时就提前读了缓冲区;
  6. 有没有插入足够的内存屏障保证访问顺序。

三步走方案之间并不冲突。实际产品里我经常混用:控制描述符用uncached一致映射,大块数据用流式映射加API维护,裸机环境才退回去手动clean/invalidate。希望这篇能帮你少走弯路,在遇到“DMA总读旧数据”时,第一反应就是去查Cache,而不是又一次怀疑DMA配置。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询