1. 从一个让人抓狂的现象说起
搞嵌入式或者底层驱动开发的朋友,大概率都遇到过这种场景:外设通过 DMA 把数据搬到了内存里,你满心欢喜地去读那块缓冲区,结果读出来的还是上一次的旧值,甚至是一堆莫名其妙的脏数据。你反复检查 DMA 配置寄存器,通道使能了,传输长度也对,源地址目的地址都没写错,中断也正常触发了,可数据就是不对。更诡异的是,你加个打印、加个延时,或者单步调试的时候,数据又神奇地变对了。这种"薛定谔的数据"折磨过太多人,而它的根源,十有八九就藏在 Cache 一致性这个问题上。
这篇内容我想把 DMA 读旧数据这件事彻底讲透。核心围绕三个层面展开:第一,为什么 DMA 和 Cache 会打架,这个矛盾的物理本质是什么;第二,三招解决 Cache 问题的具体做法,分别是非缓存内存、手动维护 Cache 和硬件一致性机制;第三,每一招在什么场景下用、怎么用、有哪些坑。不管你是刚接触 DMA 的新手,还是被这个问题反复咬过的老手,我都尽量把原理、操作和踩坑经验讲清楚,让你下次遇到类似问题能直接定位、直接解决。
需要先说明的是,DMA 读旧数据这个问题在不同架构上表现不一样。ARM Cortex-A 系列、Cortex-M7、RISC-V、MIPS 这些带 Cache 的处理器都可能遇到,但具体表现和解决手段有差异。我下面讲的内容以常见的嵌入式场景为主,涉及具体寄存器操作时会说明是通用思路,你落地时需要对到自己的芯片手册上。
2. DMA 与 Cache 为什么会打架
2.1 先搞清楚 DMA 和 Cache 各自在干什么
要理解这个矛盾,得先把两个角色的工作方式说清楚。DMA 的全称是直接内存访问,它的核心价值是让数据搬运不经过 CPU。外设(比如网卡、串口、ADC、SD 卡控制器)要和内存交换数据时,如果让 CPU 一个字节一个字节地搬,那 CPU 就被占死了。DMA 控制器的出现就是把这个搬运活儿接过去,CPU 只需要配置好源地址、目的地址、传输长度,然后该干嘛干嘛,搬完了 DMA 发个中断通知一下就行。
Cache 则是另一套逻辑。CPU 跑程序的速度远快于内存访问速度,为了填平这个鸿沟,在 CPU 和主存之间加了一层小而快的缓存,也就是 Cache。CPU 读数据时,先看 Cache 里有没有,有就直接拿(命中),没有就去主存拿一份并放进 Cache(填充)。CPU 写数据时,如果采用写回策略,数据先写在 Cache 里,标记为脏,等这块 Cache 行被替换出去的时候才真正写回主存。
问题就出在这里:DMA 搬运数据是直接和主存打交道的,它不知道 Cache 的存在,也不会去更新 Cache。而 CPU 读数据是优先看 Cache 的。于是当 DMA 把新数据写进主存后,CPU 的 Cache 里可能还留着这块地址的旧副本,CPU 一读就命中了旧数据,新数据根本看不见。反过来,CPU 写了数据还在 Cache 里没落盘,DMA 就去主存搬,搬走的也是旧数据。这就是所谓的一致性问题,本质是同一块内存地址存在两份副本,一份在主存,一份在 Cache,两边不同步。
2.2 一个生活化的类比帮你记住
你可以把主存想象成一个公共仓库,Cache 想象成你办公桌上的一个小抽屉。DMA 是个快递员,直接把货放进仓库。你(CPU)要取货时习惯先翻抽屉,抽屉里有就不去仓库了。如果快递员往仓库放了新货,但你抽屉里还摆着旧货,你自然拿到的就是旧的。要解决这个问题,要么你别用抽屉(非缓存内存),要么每次快递员放货后你把抽屉清空或更新(手动维护 Cache),要么安排一个机制让快递员放货时顺便通知你抽屉失效(硬件一致性)。
这个类比基本能覆盖后面三招的逻辑。记住这个画面,后面讲具体操作时你会更容易对上号。
2.3 哪些场景最容易踩这个坑
不是所有 DMA 使用都会出问题,得看数据流向和 Cache 策略。我整理了几种典型情况,你可以对照自己的项目看看属于哪一类。
| 数据流向 | Cache 状态 | 是否容易出问题 | 原因 |
|---|---|---|---|
| DMA 写主存,CPU 读 | Cache 有旧副本 | 极易出问题 | CPU 命中旧数据 |
| DMA 读主存,CPU 写 | Cache 有脏数据未回写 | 极易出问题 | DMA 读到旧数据 |
| DMA 写主存,CPU 读 | 该地址未被缓存 | 一般不出问题 | CPU 直接读主存 |
| DMA 读主存,CPU 写 | 写直达策略 | 一般不出问题 | 数据已同步到主存 |
| DMA 与 CPU 操作不同区域 | 无交集 | 不出问题 | 无共享地址 |
从表里能看出来,核心判断标准就两条:这块内存是否被 Cache 缓存了,以及 CPU 和 DMA 是否操作同一块地址。只要两个条件同时满足,就必然要处理一致性问题。很多新手栽跟头,是因为代码里 DMA 缓冲区是个全局数组,编译器把它放在普通可缓存区域,自己完全没意识到这块内存会被 Cache。
提示:判断你的缓冲区是否可缓存,最直接的办法是查链接脚本和 MPU/MMU 配置。如果这块地址落在可缓存区域,且你没做任何维护操作,那出问题是迟早的事。
3. 第一招:从源头切断,使用非缓存内存
3.1 非缓存内存的思路和适用场景
最省心的办法就是让这块内存压根不进 Cache。既然矛盾的根源是存在两份副本,那我直接取消 Cache 这一份,CPU 和 DMA 都直接访问主存,天然一致,不需要任何额外维护。这招的优点是简单、可靠、没有维护开销,缺点是 CPU 访问这块内存会变慢,因为每次都要走主存。
所以这招最适合的场景是:DMA 缓冲区不大、访问频率不高、对性能不敏感。比如串口收发的缓冲区、ADC 采样的小块数据、命令交互的报文。这些场景下,慢一点无所谓,稳定压倒一切。反过来,如果是高速网卡收包、大块图像数据搬运这种吞吐量敏感的场景,全用非缓存内存会拖垮性能,就得考虑后面两招。
3.2 怎么配置非缓存内存
配置方式因架构而异,我分几种常见情况说。
在带 MPU(内存保护单元)的 Cortex-M 系列上,你可以把某段地址区域配置为 Normal Non-cacheable。典型做法是在 MPU 里划一块区域,属性设为非缓存,然后把 DMA 缓冲区放到这个区域。具体寄存器操作各芯片不同,但思路一致:先选一个 MPU region,设置基地址和大小,属性字段里把 cacheable 位清掉,使能 region。
在带 MMU 的 Cortex-A 或 RISC-V 上,通常通过页表项属性来控制。你可以把某段虚拟地址映射为非缓存,或者在页表描述符里设置相应的 attribute。Linux 下常见做法是用dma_alloc_coherent申请一致性内存,驱动层则可能用ioremap加非缓存标志。
还有一种更简单的做法,如果芯片支持,直接在链接脚本里把 DMA 缓冲区段放到一块物理上就不带 Cache 的 SRAM 区域。有些 MCU 内部有专门的 TCM 或者非缓存 SRAM,放这里最省事。
/* 以某 Cortex-M7 为例的非缓存区域配置思路,具体寄存器名以手册为准 */ /* 1. 选择 MPU region,设置基地址 */ MPU->RBAR = (DMA_BUFFER_BASE & MPU_RBAR_ADDR_MASK) | MPU_REGION_NUMBER | MPU_RBAR_VALID; /* 2. 设置属性:非缓存、可共享、可读写 */ MPU->RASR = MPU_RASR_ENABLE | MPU_RASR_SIZE_32KB | MPU_RASR_AP_FULL_ACCESS | MPU_RASR_S /* 共享 */ | MPU_RASR_C_DISABLE /* 非缓存 */ | MPU_RASR_B_DISABLE;上面这段是示意,重点是属性字段里把 C(Cacheable)位关掉。实际写的时候一定要对着手册确认每个位的含义,不同厂商的 MPU 实现有差异。
3.3 这招的坑和注意事项
第一个坑是对齐和大小。MPU region 的基地址通常要求按 region 大小对齐,比如你配 32KB 的 region,基地址必须是 32KB 对齐。缓冲区如果没对齐,可能覆盖到别的数据,或者配置直接失败。我见过有人把缓冲区定义在栈上然后想配非缓存,这基本行不通,因为栈地址不受你控制。
第二个坑是非缓存不等于无性能代价。有些朋友以为配了非缓存就万事大吉,结果发现 CPU 频繁读写这块内存时系统变卡。尤其是那种在中断里反复读 DMA 缓冲区的代码,每次读都走主存,累积起来很可观。所以非缓存区域要尽量小,只放真正需要 DMA 交互的数据。
第三个坑是编译器优化。即使内存是非缓存的,编译器也可能把对它的访问优化掉,比如把循环里的重复读取提到循环外。这时候需要用volatile修饰指针,告诉编译器这块内存可能被外部改变,每次都老老实实去读。
注意:
volatile解决的是编译器优化问题,不解决 Cache 问题。这两个是不同层面的东西,别混为一谈。非缓存内存配好了,该加volatile还是要加。
4. 第二招:手动维护 Cache,精准控制同步时机
4.1 手动维护的核心操作:clean 和 invalidate
如果性能敏感,不能用非缓存内存,那就得在 DMA 传输前后手动维护 Cache。核心就两个操作:clean 和 invalidate。
Clean 是把 Cache 里的脏数据写回主存。什么时候用?当 CPU 写了数据,要交给 DMA 搬走之前。因为数据可能还在 Cache 里没落盘,DMA 去主存搬会搬到旧的,所以先 clean,把最新数据刷到主存。
Invalidate 是把 Cache 里的副本标记为无效。什么时候用?当 DMA 把新数据写进主存,CPU 要去读之前。因为 Cache 里可能还有旧副本,invalidate 之后 CPU 再读就会 miss,从而去主存拿新数据。
还有一个操作叫 clean and invalidate,两个一起做,适合那种既要保证主存最新、又要让 Cache 失效的场景。
4.2 按数据流向决定操作顺序
这里最容易搞混的就是什么时候 clean、什么时候 invalidate。我总结了一个简单的判断方法,按数据流向走。
DMA 读(内存到外设):数据从内存流向 DMA。CPU 可能刚写过这块内存,Cache 里有脏数据。所以传输前要 clean,确保主存是最新的。传输后不需要 invalidate,因为 DMA 只读不写,主存没变。
DMA 写(外设到内存):数据从 DMA 流向内存。传输前,如果这块内存之前被 CPU 读过,Cache 里可能有旧副本,但更重要的是传输后 CPU 要读新数据。所以传输后要 invalidate,让 CPU 下次读时去主存拿。传输前一般不需要操作,除非这块内存之前有脏数据需要先处理。
双向传输:传输前 clean,传输后 invalidate。
| 传输方向 | 传输前操作 | 传输后操作 | 原因 |
|---|---|---|---|
| 内存到外设 | clean | 无 | 确保主存有最新数据供 DMA 读 |
| 外设到内存 | 无(或 clean) | invalidate | 确保 CPU 读到 DMA 写入的新数据 |
| 双向 | clean | invalidate | 两个方向都要保证一致 |
4.3 操作粒度:按 Cache 行对齐
手动维护 Cache 有个非常关键的细节:操作必须按 Cache 行(cache line)对齐和整行操作。Cache 行是 Cache 管理的最小单位,常见的有 32 字节和 64 字节。你不能只 invalidate 一个字节,硬件会整行处理。
这就带来一个隐患:如果你的 DMA 缓冲区和别的数据共享同一个 Cache 行,invalidate 的时候可能把别人的脏数据也丢掉,或者 clean 的时候把无关数据也刷了。更严重的是,如果缓冲区头部和尾部没对齐到 Cache 行边界,invalidate 可能丢掉相邻的有用数据。
所以正确做法是:DMA 缓冲区起始地址按 Cache 行大小对齐,缓冲区大小也按 Cache 行大小对齐。多出来的部分当填充,不用就是了。这样每次维护操作都精确覆盖缓冲区,不会误伤邻居。
/* 按 Cache 行对齐的缓冲区定义,假设 cache line 为 32 字节 */ #define CACHE_LINE_SIZE 32 #define BUF_SIZE 1024 /* 用 aligned 属性保证起始对齐,大小向上取整到行边界 */ static uint8_t dma_buf[BUF_SIZE] __attribute__((aligned(CACHE_LINE_SIZE))); /* 维护操作示意,实际用芯片提供的 CMSIS 或内核 API */ void dma_write_prepare(void *addr, uint32_t len) { /* 传输前 clean,确保主存最新 */ SCB_CleanDCache_by_Addr((uint32_t *)addr, len); } void dma_read_finish(void *addr, uint32_t len) { /* 传输后 invalidate,让 CPU 读到新数据 */ SCB_InvalidateDCache_by_Addr((uint32_t *)addr, len); }上面用的是 CMSIS 里的接口,不同平台函数名不一样,但语义相同。注意len参数一般要求是 Cache 行大小的整数倍,传之前最好自己对齐一下。
4.4 手动维护的坑和注意事项
第一个坑是忘记维护或者维护顺序搞反。这个太常见了,尤其是代码路径多的时候,某个分支漏了一次 invalidate,数据就错了。我的建议是把维护操作封装成函数,和 DMA 启动、完成回调绑定在一起,形成固定套路,减少遗漏。
第二个坑是在中断里做维护的性能问题。Cache 维护操作本身有一定开销,如果在高频中断里每次都 clean/invalidate 一大块,会明显增加中断延迟。这时候要么减小缓冲区,要么考虑用非缓存内存,要么用硬件一致性机制。
第三个坑是多核场景下的复杂性。单核上 clean/invalidate 自己管的 Cache 就行,多核上还要考虑其他核的 Cache。这时候手动维护就不够了,得配合核间通信或者硬件一致性。这个后面第三招会讲。
第四个坑是DMA 传输过程中 CPU 访问缓冲区。如果 DMA 还在搬数据,CPU 就去读或者写这块内存,无论怎么维护都可能出问题。正确做法是用信号量或者标志位保证 DMA 传输期间 CPU 不碰这块缓冲区,传输完成后再访问。
提示:手动维护 Cache 最容易出错的地方不是操作本身,而是时机和边界。建议在项目初期就把 DMA 缓冲区的对齐、维护函数、访问互斥都设计好,别等到出问题再补。
5. 第三招:借助硬件一致性机制
5.1 硬件一致性是什么
前两招本质都是软件在弥补硬件的不一致。而第三招是让硬件自己保证一致,CPU 和 DMA 看到的内存视图始终相同。这通常靠两种机制实现:一是硬件 Cache 一致性互联,二是 IOMMU 或类似的总线级一致性单元。
在带硬件一致性的系统上,DMA 访问内存时,一致性单元会自动处理 Cache 的 clean 和 invalidate,软件完全不用管。你申请一块一致性内存,DMA 和 CPU 随便访问,数据永远是对的。这听起来很美好,但代价是硬件复杂度高、成本高,通常出现在中高端应用处理器上,低端 MCU 很少有。
5.2 怎么判断和利用硬件一致性
判断你的平台有没有硬件一致性,最直接的是查芯片手册里关于 Cache 一致性和总线互联的描述。如果提到"硬件维护一致性"、"IO coherent"、"ACE"之类的字眼,那大概率支持。ARM 的 ACE 接口、部分 Cortex-A 多核系统、一些带 CCI 互联的芯片都属于这类。
在软件层面,Linux 下的dma_alloc_coherent在支持硬件一致性的平台上会直接返回一致性内存,不需要额外的 sync 操作。而在不支持一致性的平台上,这个函数返回的内存虽然叫 coherent,但实际可能还是需要驱动自己做维护,或者它内部用了非缓存映射。所以不能光看函数名,要看平台实现。
5.3 硬件一致性的局限
硬件一致性不是万能的。首先它增加功耗和面积,低端芯片用不起。其次,即使有硬件一致性,某些场景下仍然需要软件屏障来保证顺序,比如 DMA 描述符的写入顺序。再者,硬件一致性通常只覆盖 CPU 和 DMA 之间的数据一致性,不覆盖指令 Cache 和数据 Cache 之间的协调,也不解决编译器优化问题。
所以我的经验是:有硬件一致性就用,能省很多事;没有也别慌,前两招足够应付绝大多数场景。关键是要清楚自己的平台属于哪种,别想当然。
6. 三招怎么选:场景对照与决策
6.1 一张表帮你快速决策
| 场景特征 | 推荐方案 | 理由 |
|---|---|---|
| 缓冲区小、频率低、性能不敏感 | 非缓存内存 | 简单可靠,无维护开销 |
| 缓冲区大、吞吐高、性能敏感 | 手动维护 Cache | 兼顾性能和正确性 |
| 平台支持硬件一致性 | 硬件一致性 | 软件最省心 |
| 多核共享 DMA 缓冲区 | 硬件一致性优先,否则需核间协调 | 手动维护多核复杂易错 |
| 低端 MCU 无 MPU/MMU | 手动维护或查是否有非缓存 SRAM | 配置手段有限 |
6.2 混合使用的实际案例
实际项目里往往不是单一方案。比如一个视频采集系统,图像数据量大,用非缓存内存会拖垮带宽,所以主缓冲区用手动维护 Cache。但控制命令这种小数据、低频交互,直接用非缓存内存,省得维护。再比如有些芯片有专门的 DMA 描述符区域,硬件要求描述符必须非缓存,那就单独划一块。
我做过的一个项目里,网卡收包用硬件一致性(平台支持),串口通信用非缓存内存,SD 卡大数据传输用手动维护。三种方案并存,各取所长。关键是要在架构设计阶段就把每块 DMA 缓冲区的属性定清楚,别写到一半才发现某块内存用错了方案。
6.3 选型时的几个判断问题
每次遇到 DMA 缓冲区,我会问自己几个问题:这块内存会被 CPU 频繁访问吗?数据量大吗?平台有硬件一致性吗?有 MPU/MMU 可以配非缓存吗?DMA 传输和 CPU 访问会并发吗?把这几个问题答清楚,方案基本就定了。
7. 常见问题与排查技巧实录
7.1 数据时对时错,加打印就对了
这是最典型的症状。原因通常是编译器优化或者 Cache 时序问题。加打印改变了代码执行时序,可能让 Cache 恰好同步了,或者阻止了编译器优化。解决办法不是加打印,而是老老实实做 Cache 维护,并用volatile修饰缓冲区指针。别被"加打印就好了"骗了,那只是掩盖问题。
7.2 只有部分数据错,头部或尾部是脏的
这基本是 Cache 行对齐问题。缓冲区没按行对齐,invalidate 或 clean 的时候误伤了相邻数据,或者没覆盖到边界。检查缓冲区起始地址和长度是否都是 Cache 行大小的整数倍,不是就调整。
7.3 单核正常,多核就出错
多核场景下,每个核有自己的 Cache,手动维护只能管自己这个核。如果 DMA 缓冲区的数据被多个核访问,需要核间同步机制,或者用硬件一致性。排查时先确认是不是多核访问同一块缓冲区,再决定加锁还是换方案。
7.4 DMA 传输中 CPU 读数据导致错乱
这是并发访问问题,不是 Cache 问题。DMA 传输期间 CPU 不应该访问缓冲区。用标志位或信号量保证互斥,传输完成中断里再通知 CPU 去读。
7.5 排查速查表
| 症状 | 可能原因 | 排查方向 |
|---|---|---|
| 全量旧数据 | 完全没做 Cache 维护 | 检查是否配了非缓存或做了 clean/invalidate |
| 部分数据错 | 对齐问题 | 检查缓冲区地址和长度是否按行对齐 |
| 加打印就正常 | 编译器优化或时序 | 加 volatile,检查维护时机 |
| 多核出错 | 核间 Cache 不一致 | 检查是否需要硬件一致性或核间同步 |
| 传输中读错 | 并发访问 | 检查 DMA 和 CPU 访问是否互斥 |
| 偶发错误 | 维护遗漏或竞态 | 检查所有代码路径是否都做了维护 |
7.6 几个我踩过的坑
有一次调试一个 SPI DMA 接收,数据总是差最后一个字节。查了半天发现是 invalidate 的长度没算对,少了一个字节导致最后一个 Cache 行没失效。改成按行对齐后正常。这个教训是:长度参数一定要按行向上取整,别用实际数据长度。
还有一次是 CPU 写配置数据给 DMA,DMA 读到的总是旧值。原因是配置数据在栈上,栈是可缓存的,写完没 clean 就启动 DMA。后来把配置结构体改成静态的并对齐,传输前 clean,问题解决。栈上放 DMA 相关数据是个坏习惯,能避就避。
最后一个坑是编译器把对 DMA 状态寄存器的轮询优化成了死循环。因为编译器觉得那个变量不会变,只读一次。加volatile后正常。这个和 Cache 无关,但经常和 Cache 问题一起出现,排查时容易混淆。
8. 写在最后的一点个人体会
DMA 和 Cache 的一致性问题,说到底是个"副本同步"问题。你只要时刻记住"同一块内存可能存在两份副本"这个事实,遇到数据不对时先问一句"CPU 和 DMA 看到的是同一份吗",方向就不会错。三招里,非缓存内存最省心但性能有代价,手动维护最灵活但最容易出错,硬件一致性最优雅但要看平台脸色。实际项目里往往是组合使用,关键是每块缓冲区都要有明确的策略,别留模糊地带。
我个人习惯是在项目初期就画一张 DMA 缓冲区清单,标清楚每块缓冲区的地址范围、大小、对齐、Cache 策略、访问方、维护时机。这张表看起来麻烦,但能省掉后期大量调试时间。毕竟 DMA 读旧数据这种问题,一旦发生,排查成本远高于前期设计成本。