DMA与内存竞争:缓存一致性与总线争抢

DMA 这玩意儿,说白了就是帮 CPU 干苦力的。CPU 忙着跑算法、处理中断,DMA 就在后台默默搬数据。听起来挺美,对吧?但我在实际项目中吃过不少亏——DMA 和 CPU 抢内存,抢着抢着就出事了。

这一章咱们就聊聊三个核心问题:缓存一致性、总线争抢,还有双缓冲怎么玩。嗯,都是实战里绕不开的坑。

3.1 缓存一致性问题:CPU 和 DMA 看到的数据不一样

先问个问题:CPU 写了一个数组,然后让 DMA 把这个数组搬到外设。DMA 搬出去的数据,真的是 CPU 刚写的那份吗?

答案可能让你意外——不一定。

原因在于 STM32 的 Cortex-M7 内核有 L1 Cache(指令缓存 I-Cache 和数据缓存 D-Cache)。CPU 写数据时,先写到 Cache 里,不会立刻刷到主存。DMA 是直接访问主存的,它看不到 Cache 里的内容。这就叫缓存一致性问题。

核心结论:DMA 访问的内存区域,必须保证 CPU 和 DMA 看到的是同一份数据。否则你搬出去的是旧数据,或者 DMA 写进来的数据 CPU 读不到。

3.1.1 典型场景:DMA 发送数据

假设你要通过 SPI DMA 发送一个缓冲区:

// 错误示例:CPU 写数据后直接启动 DMA
uint8_t tx_buffer[1024];

// CPU 填充数据
for (int i = 0; i < 1024; i++) {
    tx_buffer[i] = i;
}

// 启动 DMA 传输(问题来了!)
HAL_SPI_Transmit_DMA(&hspi1, tx_buffer, 1024);

这段代码在 Cache 开启的情况下大概率出问题。CPU 写的数据还在 Cache 里,DMA 从主存搬数据,搬的是旧值。

解决办法?手动维护一致性:

// 正确做法:传输前 clean 数据缓存
SCB_CleanDCache_by_Addr((uint32_t *)tx_buffer, 1024);

HAL_SPI_Transmit_DMA(&hspi1, tx_buffer, 1024);

3.1.2 典型场景:DMA 接收数据

反过来,DMA 从外设收到数据写到内存,CPU 去读。这时候 DMA 写的是主存,但 CPU 可能从 Cache 里读到旧数据。

// DMA 接收完成回调
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
    // 错误:直接读 rx_buffer,可能读到 Cache 里的旧数据
    process_data(rx_buffer);
}

// 正确做法:先 invalidate 缓存,再读
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buffer, 1024);
    process_data(rx_buffer);
}

我的习惯:在 DMA 相关的缓冲区上,我一般直接使用 SCB_CleanInvalidateDCache_by_Addr(),一次调用同时完成 clean 和 invalidate,省心。但要注意,这个操作开销不小,高频调用会影响性能。

3.2 DMA 与 CPU 争抢总线

缓存一致性问题搞定了,还有另一个坑——总线争抢。

STM32 内部有多条总线矩阵(Bus Matrix),DMA 和 CPU 通过不同的总线访问内存。但有些情况下,它们会争抢同一条总线。比如 CPU 在跑程序(通过 I-Bus 取指),DMA 在搬数据(通过 D-Bus 或 AHB),如果目标内存挂在同一条总线上,就会产生冲突。

后果是什么?DMA 传输变慢,CPU 执行被卡住。我在一个音频项目中遇到过:DMA 从 ADC 搬数据到内存,CPU 同时在做 FFT 运算。结果 FFT 周期突然变长,音频出现爆音。查了半天,发现是 DMA 和 CPU 在抢 DTCM 总线。

3.2.1 总线矩阵结构

以 STM32H743 为例,它的总线矩阵长这样:

主设备 从设备 冲突风险
CPU (I-Bus) Flash / ITCM 低(独立总线)
CPU (D-Bus) DTCM / SRAM1 中(与 DMA 共享)
DMA1 SRAM1 / SRAM2 高(与 CPU D-Bus 冲突)
DMA2 SRAM3 / AXI-SRAM 低(独立路径)

你看,DMA1 和 CPU 的 D-Bus 都可能访问 SRAM1,这就是冲突点。

3.2.2 避坑指南

我曾经踩过的坑:把 DMA 缓冲区和 CPU 频繁访问的变量放在同一个 SRAM 区域。结果 DMA 传输时,CPU 访问变量被阻塞,导致中断响应延迟。后来我把 DMA 缓冲区放到 AXI-SRAM,CPU 变量放在 DTCM,问题就解决了。

总结几条经验:

  • 分离内存区域:DMA 缓冲区放在独立 SRAM,CPU 核心数据放在 DTCM 或 ITCM
  • 使用 AXI-SRAM:STM32H7 系列的 AXI-SRAM 有独立总线,DMA 和 CPU 可以并行访问
  • 降低 DMA 优先级:非实时 DMA 可以设低优先级,避免影响 CPU 关键任务
  • 分散传输时机:CPU 密集计算时,暂停非关键 DMA 传输

3.3 双缓冲机制:让 DMA 和 CPU 各干各的

前面说的都是怎么避免冲突。但有没有一种机制,让 DMA 和 CPU 能同时工作,互不干扰?

有,就是双缓冲(Double Buffer)。

思路很简单:准备两个缓冲区。DMA 往缓冲区 A 写数据时,CPU 处理缓冲区 B 的数据。等 DMA 写满 A,切换到 B,CPU 处理 A。如此循环。

双缓冲的核心价值:消除数据搬移和处理之间的等待时间,实现流水线作业。

3.3.1 双缓冲实现步骤

以 STM32H7 的 SPI DMA 接收为例:

  1. 定义两个缓冲区 buf_a 和 buf_b
  2. DMA 使用循环模式(Circular Mode),配合双缓冲功能
  3. 每次 DMA 传输完成中断时,切换当前使用的缓冲区
  4. CPU 在中断中处理已完成的缓冲区数据
#define BUF_SIZE  1024
uint8_t buf_a[BUF_SIZE] __attribute__((section(".sram3")));
uint8_t buf_b[BUF_SIZE] __attribute__((section(".sram3")));

volatile uint8_t active_buf = 0;  // 0: buf_a, 1: buf_b

void HAL_SPI_RxHalfCpltCallback(SPI_HandleTypeDef *hspi) {
    // 半传输完成:buf_a 已满
    SCB_InvalidateDCache_by_Addr((uint32_t *)buf_a, BUF_SIZE);
    process_data(buf_a, BUF_SIZE);
}

void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) {
    // 全传输完成:buf_b 已满
    SCB_InvalidateDCache_by_Addr((uint32_t *)buf_b, BUF_SIZE);
    process_data(buf_b, BUF_SIZE);
}

// 初始化双缓冲 DMA
void init_double_buffer_spi(void) {
    HAL_SPIEx_ChangeCallback(&hspi1, HAL_SPI_RX_HALFCPLT_CB_ID,
                             HAL_SPI_RxHalfCpltCallback);
    
    // 设置 DMA 双缓冲
    hdma_spi1_rx.Instance->CR |= DMA_SxCR_DBM;  // 使能双缓冲模式
    hdma_spi1_rx.Instance->M0AR = (uint32_t)buf_a;
    hdma_spi1_rx.Instance->M1AR = (uint32_t)buf_b;
    
    HAL_SPI_Receive_DMA(&hspi1, buf_a, BUF_SIZE);
}

3.3.2 双缓冲的注意事项

要点 说明
缓冲区对齐 建议 32 字节对齐,配合 Cache Line 大小
内存位置 放在独立 SRAM 区域,避免总线争抢
中断处理 半传输和全传输中断都要处理,否则丢数据
缓存维护 每次切换缓冲区前必须 invalidate 或 clean
时序要求 CPU 处理时间必须小于 DMA 填满一个缓冲区的时间

我个人的经验:双缓冲配合 DMA 循环模式,是处理高速数据流(比如音频、摄像头)的黄金组合。但要注意,如果 CPU 处理太慢,缓冲区会被 DMA 追上,造成数据覆盖。这时候要么加大缓冲区,要么优化处理算法。

3.4 知识结构总览

下面这张图把本章的核心逻辑串起来了:

DMA与内存竞争核心知识结构 缓存一致性 总线争抢 双缓冲机制 CPU写→DMA读:需Clean DMA写→CPU读:需Invalidate API:SCB_CleanInvalidateDCache_by_Addr DMA1 + CPU D-Bus 共享SRAM1 后果:传输变慢 + CPU卡顿 解法:分离内存区域 DMA写BufA → CPU处理BufB 循环模式 + 半/全中断 流水线作业,消除等待 核心原则:DMA和CPU访问的内存区域,要么保证一致性,要么物理隔离 双缓冲是解决“既要又要”的最佳实践

3.5 实战检查清单

最后,给你一份我在项目中自用的检查清单。每次做 DMA 相关的设计,我都会过一遍:

  • □ DMA 缓冲区是否开启了 Cache?如果开了,有没有做 clean/invalidate?
  • □ DMA 缓冲区和 CPU 频繁访问的变量是否放在同一个 SRAM?
  • □ 是否使用了双缓冲?如果用了,半传输中断有没有处理?
  • □ 缓冲区大小是否满足 CPU 处理时间要求?会不会被 DMA 追上?
  • □ 缓冲区地址是否对齐到 32 字节(Cache Line 大小)?
  • □ 是否在中断服务函数里做了缓存维护?还是只在主循环做?

重要提醒:缓存维护函数 SCB_CleanInvalidateDCache_by_Addr() 的参数地址必须 32 字节对齐,长度也必须是 32 的倍数。否则会触发硬件错误(HardFault)。我刚开始用的时候就被这个坑过,查了半天才发现是地址没对齐。