☰
DMA读旧数据?三招彻底解决Cache一致性问题
2026/10/19 6:43:06 网站建设 项目流程

1. 从一个让人抓狂的现象说起

搞嵌入式或者底层驱动开发的朋友,大概率都遇到过这种场景:外设通过 DMA 把数据搬到了内存里,你满心欢喜地去读那块缓冲区,结果读出来的还是上一次的旧值,甚至干脆就是初始化时的全零。你反复检查 DMA 配置寄存器,通道使能了,传输长度也对,源地址目的地址都没写错,中断也正常触发了,可 CPU 读到的数据就是不对。更诡异的是,你用调试器单步跟踪,或者加一句打印,数据又神奇地变对了。这种“加了打印就好、去掉打印就错”的玄学问题,十有八九就是 Cache 一致性在作祟。

这篇文章就是围绕这个经典坑展开的。我会把 DMA 读旧数据的根因掰开揉碎讲清楚,然后给出三套经过实际项目验证的解决方案,从原理到代码、从参数计算到避坑经验,全部铺开。不管你是刚接触 DMA 的新手,还是被这个问题折磨过的老手,都能从中找到可以直接抄作业的东西。核心关键词就三个:DMA、Cache 一致性、数据同步。搞懂这三个词之间的关系,这类问题基本就绝迹了。

先说清楚适用人群:如果你在做 ARM Cortex-A 系列、Cortex-M7、部分 RISC-V 带 Cache 的芯片,或者任何带数据缓存且用 DMA 搬运数据的平台,这篇文章就是写给你的。纯 Cortex-M3/M4 那种没有 D-Cache 的芯片,基本不会遇到这个问题,但了解一下原理也没坏处,毕竟架构在升级,迟早要碰。

2. 根因拆解:为什么 DMA 和 Cache 会打架

2.1 先搞明白 Cache 到底在缓存什么

要理解这个问题,得先接受一个反直觉的事实:CPU 访问内存,很多时候并不真的去访问内存。现代处理器为了弥合核心频率和内存频率之间巨大的速度差,在 CPU 和主存之间加了一层或多层高速缓存,也就是 Cache。CPU 读数据时,先看 Cache 里有没有,有就直接拿,这叫命中;没有才去内存取,同时把这一整块数据搬进 Cache,方便下次用。

关键点在于,Cache 操作的最小单位不是字节,而是 Cache Line。常见的 Cache Line 大小是 32 字节或 64 字节。也就是说,哪怕你只想读 1 个字节,硬件也会把包含这个字节的整条 Cache Line 都加载进来。写操作同理,CPU 写数据时可能只写进 Cache,标记为脏,并不立刻同步回内存,等这块 Cache Line 被替换出去时才写回。这套机制对 CPU 来说完全透明,性能提升巨大,但它埋下了一个隐患:内存里的数据,和 Cache 里的数据,可能不一致。

2.2 DMA 是个“不懂 Cache”的搬运工

DMA 的全称是直接内存访问,它的设计初衷就是绕过 CPU,让外设和内存之间直接搬数据,搬完了再通知 CPU 一声。问题就出在这个“绕过”上。DMA 控制器眼里只有物理内存地址,它根本不知道 Cache 的存在,也不会去查 Cache 里有没有更新的数据。它老老实实地把外设数据写到内存的某个地址,或者从内存的某个地址读走数据。

于是矛盾就产生了。假设 DMA 把新数据写到了内存地址 0x2000_0000,但 CPU 之前读过这个地址,Cache 里还留着旧值的副本。CPU 再去读这个地址时,一看 Cache 命中,直接把旧值返回了,根本不去内存看新数据。这就是“DMA 读旧数据”最典型的场景。反过来,CPU 写了数据在 Cache 里还没写回内存,DMA 就从内存读走了旧数据发出去,这是另一个方向的坑,同样致命。

2.3 一张图看清数据流的分叉

我用文字描述一下这个分叉路径,方便你建立空间感。正常路径是 CPU 读写 Cache,Cache 在必要时和内存同步。DMA 路径是外设直接读写内存,完全不经过 Cache。两条路径在内存这个交汇点碰头,但各自看到的“最新数据”定义不一样。CPU 认为 Cache 里的才是最新的,DMA 认为内存里的才是最新的。谁对谁错取决于谁最后写的,但硬件不会自动帮你仲裁,必须由软件来维护一致性。

注意:这里说的一致性,指的是 CPU 视角的数据和 DMA 视角的数据保持一致,不是数据库里那种事务一致性,别搞混了。

2.4 为什么加了打印就正常了

很多人最困惑的就是这一点。原因其实很简单:打印函数、调试断点、单步执行,这些操作都会导致 CPU 流水线冲刷、Cache 行为改变,甚至触发 Cache 回写或失效。比如某些调试器在断点处会执行 Cache 清理操作,或者打印函数内部访问内存的方式恰好绕过了那条脏 Cache Line。这属于“碰巧掩盖了问题”,绝不是问题消失了。一旦去掉这些干扰,Cache 一致性缺陷立刻原形毕露。所以千万别用“加打印就好了”来安慰自己,那只是把定时炸弹的引线藏起来了。

3. 三招解决 Cache 一致性问题

3.1 第一招:把 DMA 缓冲区设为非缓存

这是最省心、最不容易出错的一招,尤其适合新手和中小型项目。思路很直接:既然 Cache 和 DMA 会打架,那我干脆让这块内存不走 Cache,大家都直接访问物理内存,谁也别缓存谁,自然就没有一致性问题了。

具体怎么做,取决于你的平台。在 ARM 架构下,通常通过内存属性配置来实现。比如在 MMU 页表里,把 DMA 缓冲区所在的内存区域配置为 Device 或 Strongly-Ordered 属性,或者配置为 Normal Non-Cacheable。在 Cortex-M7 上,可以通过 MPU 把某段内存设为 Non-Cacheable。在 Linux 下,可以用dma_alloc_coherent来分配一致性内存,驱动框架已经帮你处理好属性了。

我以裸机 Cortex-M7 为例,用 MPU 配置一段非缓存区域,代码大概长这样:

// 假设 DMA 缓冲区放在 0x20200000,大小 4KB // 配置 MPU Region,属性设为 Normal Non-Cacheable MPU->RNR = 0; // 选择 Region 0 MPU->RBAR = 0x20200000 | (1 << 4); // 基地址,使能 Region MPU->RASR = (0x3 << 24) | // AP: 全权限 (0x1 << 19) | // TEX=0b001 (0x0 << 17) | // C=0, B=0 -> Non-Cacheable (0x1 << 1) | // 使能 (0x1F << 1); // 大小 4KB

这段配置的核心是 C 位和 B 位都清零,TEX 设为 001,组合起来就是 Normal Non-Cacheable 属性。配好之后,CPU 访问这块内存时不会走 D-Cache,DMA 写进来的数据 CPU 立刻就能看到。

这一招的优点是简单粗暴、一劳永逸,缺点是牺牲了性能。DMA 缓冲区往往数据量不小,每次 CPU 访问都直接怼内存,速度会慢一些。如果你的缓冲区访问频率不高,或者对性能不敏感,这招完全够用。另外要注意,非缓存内存的访问顺序可能和普通内存不同,涉及多字节字段读写时要注意对齐和原子性。

3.2 第二招:手动执行 Cache 清理与失效

如果你既想要 Cache 的性能,又要保证 DMA 数据正确,那就得在关键时刻手动维护一致性。核心操作有两个:Clean 和 Invalidate。Clean 是把 Cache 里的脏数据写回内存,Invalidate 是把 Cache 里的数据标记为无效,下次读时强制从内存重新加载。

方向不同,操作也不同,这个逻辑必须记牢:

数据方向谁写谁读需要做的操作操作时机
外设到内存DMA 写,CPU 读InvalidateCPU 读之前
内存到外设CPU 写,DMA 读CleanDMA 启动之前
双向双方都读写Clean + Invalidate各自访问前

为什么是这个对应关系?因为 DMA 写内存后,Cache 里可能是旧值,CPU 读之前必须让 Cache 失效,强制去内存拿新数据。CPU 写数据后,新值可能还在 Cache 里没落盘,DMA 读之前必须把 Cache 清理,确保内存里是最新的。

在 Cortex-A 系列上,可以用内联汇编调用 Cache 维护指令:

// 按地址范围做 Clean 和 Invalidate static inline void cache_clean_invalidate(void *addr, unsigned int size) { unsigned long start = (unsigned long)addr & ~(CACHE_LINE_SIZE - 1); unsigned long end = ((unsigned long)addr + size + CACHE_LINE_SIZE - 1) & ~(CACHE_LINE_SIZE - 1); for (unsigned long p = start; p < end; p += CACHE_LINE_SIZE) { asm volatile("mcr p15, 0, %0, c7, c14, 1" :: "r"(p)); // DCCIMVAC } dsb(); isb(); }

这里的CACHE_LINE_SIZE要根据具体芯片填,常见是 32 或 64。注意地址要向下对齐到 Cache Line 边界,结束地址要向上对齐,否则会漏掉部分 Cache Line。dsb和isb是内存屏障,保证 Cache 操作完成后再继续执行,这两条指令不能省,省了就可能出现“指令发出去了但没生效”的诡异情况。

在 Cortex-M7 上,CMSIS 提供了现成的函数,用起来更省事:

SCB_CleanDCache_by_Addr((uint32_t *)buf, size); SCB_InvalidateDCache_by_Addr((uint32_t *)buf, size); SCB_CleanInvalidateDCache_by_Addr((uint32_t *)buf, size);

这一招的优点是保留了 Cache 性能,灵活可控。缺点是容易漏操作、错操作,而且地址对齐和范围计算稍有不慎就会出问题。我见过太多人因为 Invalidate 的范围没覆盖全,导致最后几个字节还是旧数据。

3.3 第三招:用内存屏障配合双缓冲

前两招解决的是“数据一致性”,但还有一个隐藏问题:访问顺序。有时候你明明做了 Cache 维护,数据还是不对,原因是编译器和 CPU 都可能对指令重排。编译器觉得你的 Cache 维护指令和后面的数据读取没关系,给你调了个顺序;CPU 的乱序执行也可能让数据读取先于 Cache 失效完成。这时候就需要内存屏障来兜底。

内存屏障分几种:编译屏障告诉编译器别乱动,asm volatile("" ::: "memory")就是干这个的;写屏障dsb保证前面的写操作全部完成;指令同步屏障isb保证流水线冲刷。在 DMA 场景下,典型的顺序是:先做 Cache 维护,再执行dsb,然后才去读写数据。

双缓冲则是从架构层面减少一致性维护的频率。思路是准备两块缓冲区,DMA 写 A 的时候 CPU 读 B,DMA 写 B 的时候 CPU 读 A,交替进行。这样 CPU 读的那块缓冲区在 DMA 写另一块期间已经完成了 Cache 维护,不会和 DMA 的写操作撞车。配合乒乓标志位和内存屏障,可以实现高效且安全的数据流。

volatile int dma_buf_index = 0; uint8_t buf[2][BUF_SIZE] __attribute__((aligned(64))); // DMA 完成中断里切换缓冲区 void DMA_IRQHandler(void) { int done = dma_buf_index; int next = 1 - done; // 对刚写完的缓冲区做 Invalidate,准备给 CPU 读 SCB_InvalidateDCache_by_Addr((uint32_t *)buf[done], BUF_SIZE); __DSB(); // 启动下一次 DMA 到另一块缓冲区 start_dma(buf[next], BUF_SIZE); dma_buf_index = next; }

这一招的优点是性能好、实时性强,适合高速数据采集场景。缺点是实现复杂度高,缓冲区大小和切换逻辑要仔细设计,否则容易出现数据覆盖或丢帧。

4. 实操过程与关键参数计算

4.1 缓冲区地址对齐:别小看这个细节

Cache Line 对齐是很多问题的隐形杀手。假设 Cache Line 是 64 字节,你的缓冲区起始地址是 0x2000_0010,大小是 100 字节。做 Invalidate 时,如果只从 0x2000_0010 开始操作,那么 0x2000_0000 到 0x2000_000F 这段属于同一条 Cache Line 的数据不会被失效,而你的缓冲区尾部 0x2000_0074 到 0x2000_007F 也可能涉及下一条 Cache Line。正确做法是把起始地址向下对齐到 64 字节边界,结束地址向上对齐。

#define ALIGN_DOWN(x, a) ((x) & ~((a) - 1)) #define ALIGN_UP(x, a) (((x) + (a) - 1) & ~((a) - 1)) void dma_invalidate(void *addr, size_t size) { uintptr_t start = ALIGN_DOWN((uintptr_t)addr, CACHE_LINE_SIZE); uintptr_t end = ALIGN_UP((uintptr_t)addr + size, CACHE_LINE_SIZE); SCB_InvalidateDCache_by_Addr((uint32_t *)start, end - start); }

我建议所有 DMA 缓冲区都用__attribute__((aligned(64)))强制对齐到 Cache Line 边界,这样能省掉很多对齐计算的麻烦,也能避免缓冲区和其他变量共享 Cache Line 导致的伪共享问题。

4.2 参数计算:缓冲区大小怎么定

缓冲区大小不是随便拍的,要考虑三个因素:DMA 传输速率、CPU 处理延迟、Cache 维护开销。假设你的外设采样率是 1MHz,每次采样 2 字节,那么数据产生速率是 2MB/s。如果 CPU 处理一帧数据需要 500 微秒,那么缓冲区至少要能装下 500 微秒的数据,也就是 1MB。但实际不会开这么大,通常用双缓冲,每块缓冲区装 1 毫秒的数据,即 2KB,这样 CPU 有 1 毫秒的处理窗口。

Cache 维护开销也要算进去。Invalidate 一条 Cache Line 大概几十个时钟周期,2KB 数据按 64 字节一行算,是 32 行,开销大概几百到一千个时钟周期。如果 CPU 主频 200MHz,这点开销也就几微秒,完全可以接受。但如果缓冲区开到几百 KB,每次全量 Invalidate 的开销就不可忽视了,这时候要么缩小缓冲区,要么改用非缓存内存。

4.3 完整实操流程:从初始化到数据读取

我把一个典型的外设到内存 DMA 采集流程完整走一遍,你可以对照自己的项目改。

第一步,分配对齐的缓冲区,并确保它位于可 Cache 的内存区域:

#define BUF_SIZE 2048 static uint8_t dma_buffer[BUF_SIZE] __attribute__((aligned(64)));

第二步,配置 DMA 通道,源地址设为外设数据寄存器,目的地址设为dma_buffer,传输长度设为BUF_SIZE,开启传输完成中断。

第三步,在 DMA 启动之前,如果缓冲区之前被 CPU 写过,先做一次 Clean,确保内存里是最新数据。如果是纯接收场景,缓冲区只被 DMA 写,可以省略这步。

第四步,DMA 传输完成中断触发后,先对缓冲区做 Invalidate,再执行__DSB(),然后才允许 CPU 读取数据:

void DMA_Complete_Callback(void) { SCB_InvalidateDCache_by_Addr((uint32_t *)dma_buffer, BUF_SIZE); __DSB(); __ISB(); process_data(dma_buffer, BUF_SIZE); }

第五步,处理完数据后,如果要把缓冲区交还给 DMA 继续接收,且期间 CPU 写过这块内存,需要先 Clean 再重新启动 DMA。

这个流程看起来简单,但每一步的顺序都不能乱。Invalidate 必须在 CPU 读之前,Clean 必须在 DMA 读之前,屏障必须在 Cache 操作之后。顺序错了,问题依旧。

4.4 实测数据:三招的性能对比

我在一块 Cortex-M7 平台上做过简单测试,主频 200MHz,DMA 搬运 2KB 数据,对比三种方案的单次开销:

方案单次开销CPU 占用实现复杂度适用场景
非缓存内存约 0.5us最低低中低速、简单项目
手动 Cache 维护约 3us中中高速、性能敏感
双缓冲+屏障约 1.5us低高高速连续采集

非缓存内存虽然单次访问慢,但省掉了 Cache 维护开销,综合下来在小数据量场景反而有优势。手动维护灵活但每次都要算地址范围,容易出错。双缓冲把维护开销摊薄到切换时刻,适合连续高速数据流。选哪个取决于你的具体需求,没有银弹。

5. 常见问题与排查技巧实录

5.1 问题速查表

现象可能原因排查方向
CPU 读到的全是旧值没做 Invalidate检查读之前是否失效 Cache
前几个字节对,后面不对Invalidate 范围不够检查地址对齐和长度计算
加打印就正常调试操作碰巧清了 Cache去掉打印复现,别被假象骗
DMA 发出去的数据是旧值没做 Clean检查 DMA 启动前是否清理 Cache
偶尔错,不是每次都错竞态或屏障缺失加 dsb/isb,检查操作顺序
多核场景下数据不一致核间 Cache 未同步考虑核间通信和共享内存属性

5.2 独家避坑经验

第一个坑,Invalidate 之前一定要先 Clean。这个反直觉,但很重要。如果一块 Cache Line 里既有 CPU 刚写的脏数据,又有 DMA 要写的新数据,直接 Invalidate 会把 CPU 的写操作丢掉。正确顺序是先 Clean 把脏数据写回,再 Invalidate 让整行失效。很多人只知道 Invalidate,结果丢了数据还找不到原因。

第二个坑,别在中断里做全量 Cache 维护。有些朋友图省事,在 DMA 中断里对整个数据区做 Clean+Invalidate,数据量一大,中断执行时间飙升,影响系统实时性。正确做法是只维护本次传输涉及的那段缓冲区,精确到 Cache Line。

第三个坑,注意编译器的“优化”。你写的 Cache 维护函数如果被编译器判定为没有副作用,可能直接被优化掉。所以内联汇编里必须加volatile,函数最好加__attribute__((noinline))或者用内存屏障约束,确保它老老实实执行。

第四个坑,DMA 缓冲区别和普通变量放一起。如果缓冲区和某个频繁读写的标志变量共享一条 Cache Line,CPU 读写标志变量时可能把缓冲区所在的整条 Cache Line 弄脏或失效,引发意想不到的一致性问题。用aligned(64)把缓冲区单独隔开,能避免这类伪共享。

5.3 调试手段:怎么确认是 Cache 问题

怀疑是 Cache 一致性问题时,最快的验证方法有两个。一是临时把缓冲区改成非缓存内存,如果问题消失,基本可以确诊。二是用调试器查看内存物理地址的值和 CPU 读到的值是否一致,如果不一致,那就是 Cache 里的旧值在捣乱。这两个方法我用了无数次,屡试不爽。

还有一个进阶手段,在关键位置插入 Cache 维护指令后加dsb,然后用示波器或者 GPIO 翻转测量时间,确认维护操作确实执行了。有时候指令写了但没生效,就是因为少了屏障,这种问题光看代码看不出来,必须实测。

6. 我个人的几点实操体会

Cache 一致性问题最坑的地方在于它的隐蔽性和偶发性。它不像空指针那样一跑就崩,而是时好时坏,让你怀疑人生。我的经验是,只要项目里用了 DMA 且芯片带 D-Cache,就要在架构设计阶段把一致性问题考虑进去,而不是等出了问题再补。缓冲区怎么分配、用哪种方案、维护点放在哪里,这些想清楚了,后面能省掉大量调试时间。

另外,别迷信“网上说加个 Invalidate 就行”。不同芯片的 Cache 行为、指令集、内存模型都有差异,照搬代码很容易踩坑。一定要对着自己芯片的参考手册,确认 Cache Line 大小、维护指令、内存属性配置方式。我见过有人在 Cortex-A 上用了 Cortex-M 的写法,编译能过,跑起来全错,查了两天才发现指令用错了。

最后说一句,非缓存内存和手动维护不是对立的,实际项目里经常混用。比如描述符、标志位这种小数据结构放非缓存区,大数据缓冲区用手动维护,各取所长。关键是理解原理,知道每种方案在解决什么问题、代价是什么,然后根据场景做取舍。这套思路理顺了,DMA 和 Cache 就不再是敌人,而是能配合得很好的搭档。

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

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

立即咨询