DMA与缓存交互问题:绕过、脏数据与陈旧数据

好,咱们进入第四章。这一章要聊的,是嵌入式DMA开发里最容易踩坑的地方——DMA和缓存(Cache)之间的“爱恨情仇”。

很多刚接触DMA的朋友会想:DMA不是直接在内存和外设之间搬数据吗?跟缓存有什么关系?

关系大了去了。我当年第一次在STM32H7上做音频采集,DMA把数据从I2S外设搬到了内存,结果CPU读到的全是乱码。折腾了两天,最后发现是缓存一致性没处理好。嗯,从那以后我再也不敢小看这个问题了。

4.1 核心问题:DMA绕过缓存

先看一张图,把问题说清楚。

DMA与缓存交互问题全景图 CPU 核心 Cache (L1/L2) DMA 控制器 主存 (DDR/SRAM) 外设 (UART/SPI/ADC) 读/写 缓存行回写/填充 DMA 绕过缓存 数据传输 三大问题 ① DMA绕过缓存 → 数据陈旧 ② CPU修改缓存 → 脏数据 ③ 缓存未刷新 → 数据不一致

看到没?CPU读数据走的是Cache,DMA读写数据直接走主存。这两条路,在硬件上是分开的。

说白了,DMA根本不知道Cache的存在。它只管把数据从外设搬到内存,或者从内存搬到外设。而CPU呢?它优先从Cache里拿数据,Cache里没有才去主存。

这就埋下了三个雷:

  • 数据陈旧问题:DMA往主存写了新数据,但Cache里还存着旧副本。CPU一读,拿到的是旧数据。
  • 脏数据问题:CPU改了Cache里的数据,还没来得及写回主存。DMA直接去主存搬数据,搬走的是旧数据。
  • 缓存一致性问题:Cache和主存里的数据不一样了,系统行为变得不可预测。
⚠️ 我曾经踩过的坑: 在Cortex-M7上做双缓冲音频播放,DMA填满一个缓冲区后触发中断,CPU直接处理。结果播放出来的声音断断续续,还夹杂着爆音。查了两天,发现是DMA写完了主存,但CPU的Cache里还是旧数据。每次中断里读到的都是上一帧的数据。

4.2 三种典型场景分析

咱们把问题拆开来看。实际项目中,DMA和缓存的交互问题主要出现在三种场景。

场景 数据流向 核心问题 典型后果
外设→内存 DMA从外设读数据,写入主存 Cache中数据陈旧 CPU读到旧数据,处理结果错误
内存→外设 DMA从主存读数据,发送给外设 Cache中有脏数据未回写 外设收到旧数据,输出错误
内存→内存 DMA在两个内存区域间搬运数据 双方Cache都可能不一致 数据错乱,系统崩溃

你想想看,这三种场景其实覆盖了绝大多数DMA应用。不管是采集传感器数据、驱动显示屏,还是做音频视频处理,都逃不开。

4.3 缓存一致性的硬件机制

不同的芯片架构,处理这个问题的方式不一样。我按常见的三种架构来梳理一下。

4.3.1 Cortex-M3/M4(无缓存)

这类芯片没有Cache,所以根本不存在缓存一致性问题。DMA和CPU都直接操作主存,数据天然一致。

嗯,但代价是性能上不去。CPU每次读写内存都要等,主频再高也跑不快。

4.3.2 Cortex-M7(带L1 Cache)

M7有L1 Cache,而且指令缓存和数据缓存是分开的。这就麻烦了——DMA操作的数据缓存区域,必须手动维护一致性。

ARM提供了几个关键操作:

  • Clean(清理):把Cache中脏数据写回主存
  • Invalidate(失效):把Cache中数据标记为无效,下次读取强制从主存加载
  • Clean & Invalidate:先回写再失效,最保险

我的经验: 在M7上做DMA传输,我习惯在启动DMA之前先Clean数据缓存,在DMA传输完成后再Invalidate。这样虽然多花几个时钟周期,但能保证数据绝对正确。

4.3.3 Cortex-A系列(带MMU和多级Cache)

A系列更复杂。它有MMU,有L1/L2 Cache,还支持多核。缓存一致性不再是手动维护那么简单了。

ARM提供了硬件一致性机制:

  • SCU(Snoop Control Unit):多核间自动维护一致性
  • ACE/CHI总线协议:硬件级别的缓存一致性协议
  • DMA可以配置为“一致性”模式:硬件自动处理

但要注意,不是所有DMA都支持一致性模式。很多SoC里的DMA还是走普通总线,该手动维护还是得手动维护。

4.4 Rust中的缓存操作实践

在Rust里操作缓存,不能直接用内联汇编或者C库函数。我们需要用cortex-m系列的crate提供的API。

以STM32H7(Cortex-M7)为例:

use cortex_m::asm;
use cortex_m::peripheral::DCB;

// 假设我们有一个DMA缓冲区
#[repr(align(32))]
struct DmaBuffer {
    data: [u8; 1024],
}

static mut DMA_BUF: DmaBuffer = DmaBuffer { data: [0u8; 1024] };

// 启动DMA传输前:清理缓存,确保脏数据回写
unsafe {
    // 清理DMA缓冲区对应的缓存行
    // 参数:起始地址,长度
    cortex_m::cache::clean_dcache_by_address(
        &DMA_BUF as *const _ as usize,
        core::mem::size_of::<DmaBuffer>(),
    );
}

// 启动DMA...
// dma_start(&DMA_BUF);

// DMA传输完成后:失效缓存,强制从主存重新加载
unsafe {
    cortex_m::cache::invalidate_dcache_by_address(
        &DMA_BUF as *const _ as usize,
        core::mem::size_of::<DmaBuffer>(),
    );
}

// 现在可以安全地读取DMA_BUF中的数据了
let first_byte = unsafe { DMA_BUF.data[0] };

💡 小技巧: 缓存操作是按缓存行对齐的。Cortex-M7的缓存行大小是32字节。所以DMA缓冲区最好32字节对齐,否则操作会影响到相邻内存区域。我一般用#[repr(align(32))]来保证对齐。

4.5 性能与正确性的权衡

每次Clean和Invalidate都有开销。如果频繁操作,DMA带来的性能提升就被抵消了。

我个人习惯的做法是:

  1. 批量操作:不要每传输一个字节就刷一次缓存。等DMA完成一整块传输后再统一处理。
  2. 使用非缓存内存区域:有些芯片支持将特定内存区域配置为“非缓存”或“强序”属性。DMA缓冲区放在这里,就省去了手动维护的麻烦。
  3. 双缓冲+乒乓操作:一个缓冲区让DMA写,CPU读另一个。切换时只刷当前使用的缓冲区。

举个例子,在Rust中配置非缓存内存区域:

// 使用linker script将DMA缓冲区放在非缓存区域
// 然后在Rust中声明外部静态变量
extern "C" {
    static mut __dma_noncache_start: u8;
    static mut __dma_noncache_end: u8;
}

// 或者使用cortex-m-rt的attribute
#[link_section = ".noncacheable"]
#[repr(align(32))]
static mut DMA_BUF_NONCACHE: [u8; 4096] = [0u8; 4096];

这样,DMA和CPU都直接操作主存,不需要手动维护一致性。代价是访问速度稍慢——但比起刷缓存的开销,很多时候反而更快。

4.6 调试缓存一致性问题的技巧

这类问题最难排查,因为错误不是每次都复现。我分享几个经验:

  • 现象特征:数据偶尔错、高负载时必错、优化等级越高越容易出错。这些都是缓存一致性问题的典型信号。
  • 验证方法:临时关闭数据缓存(设置DCB寄存器),如果问题消失,基本就是缓存一致性问题。
  • Rust中的调试:用defmt或rtt打印关键数据,对比DMA写入前后的值。

⚠️ 我曾经犯过的错: 有一次在调试时,我关闭了所有优化,问题就不出现了。我以为是代码写对了。结果一开-O2,问题又回来了。后来才明白,低优化等级下编译器生成的代码访问内存更频繁,反而“碰巧”绕过了缓存问题。高优化等级下,编译器大量使用寄存器缓存,问题就暴露了。

所以,调试缓存问题一定要在最终优化等级下测试。这是血的教训。

4.7 本章小结

DMA和缓存的交互问题,说白了就是“两条路走数据,中间没人同步”。

解决思路其实就三条:

  • 手动维护(Clean/Invalidate)
  • 硬件自动维护(一致性总线)
  • 绕开缓存(非缓存内存区域)

具体用哪种,取决于你的芯片架构、性能要求和开发周期。我个人建议:项目初期先用非缓存区域,稳定后再优化到手动维护。别一上来就追求极致性能,先把功能跑通再说。