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 接收为例:
- 定义两个缓冲区
buf_a和buf_b - DMA 使用循环模式(Circular Mode),配合双缓冲功能
- 每次 DMA 传输完成中断时,切换当前使用的缓冲区
- 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 知识结构总览
下面这张图把本章的核心逻辑串起来了:
3.5 实战检查清单
最后,给你一份我在项目中自用的检查清单。每次做 DMA 相关的设计,我都会过一遍:
- □ DMA 缓冲区是否开启了 Cache?如果开了,有没有做 clean/invalidate?
- □ DMA 缓冲区和 CPU 频繁访问的变量是否放在同一个 SRAM?
- □ 是否使用了双缓冲?如果用了,半传输中断有没有处理?
- □ 缓冲区大小是否满足 CPU 处理时间要求?会不会被 DMA 追上?
- □ 缓冲区地址是否对齐到 32 字节(Cache Line 大小)?
- □ 是否在中断服务函数里做了缓存维护?还是只在主循环做?
重要提醒:缓存维护函数 SCB_CleanInvalidateDCache_by_Addr() 的参数地址必须 32 字节对齐,长度也必须是 32 的倍数。否则会触发硬件错误(HardFault)。我刚开始用的时候就被这个坑过,查了半天才发现是地址没对齐。