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 读 | Invalidate | CPU 读之前 |
| 内存到外设 | CPU 写,DMA 读 | Clean | DMA 启动之前 |
| 双向 | 双方都读写 | 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 就不再是敌人,而是能配合得很好的搭档。