为什么 D-Cache 与 DMA 会打架
STM32H7 的 Cortex-M7 内核带有一级 D-Cache(通常 16KB),开启后 CPU 访问 SRAM 会先查 Cache。而 DMA 是独立于内核的总线主设备,它读写内存时不经过 D-Cache,直接访问物理 SRAM。
于是出现经典矛盾:
- CPU 写数据到 SRAM,可能只落在 Cache 里,物理内存还是旧值,DMA 发出去的是旧数据。
- DMA 把新数据写入 SRAM,CPU 读到的却是 Cache 里的旧副本。
解决手段就是 Cache 维护操作:
- Clean(写回):把 Cache 中脏数据写回 SRAM,用于 CPU→DMA 方向。
- Invalidate(无效化):丢弃 Cache 内容,强制下次从 SRAM 读,用于 DMA→CPU 方向。
- Clean+Invalidate:双向都要时使用。
常见但危险的写法:把维护操作放进 DMA 中断
很多教程会这样写:
void DMA1_Stream0_IRQHandler(void)
{
if (DMA1->LISR & DMA_LISR_TCIF0) {
DMA1->LIFCR = DMA_LIFCR_CTCIF0;
/* 在中断里做 Cache 维护 */
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, RX_LEN);
rx_ready = 1;
}
}
看起来“数据到了才无效化”,逻辑很顺。但它隐藏了三个致命问题。
问题一:Invalidate 会丢掉中断期间 CPU 写入的数据
SCB_InvalidateDCache_by_Addr 是无条件丢弃指定地址范围的 Cache 行。如果在这条语句执行之前,CPU(或更高优先级中断)刚好改写了 rx_buf 覆盖的某个 Cache 行,这些尚未写回的脏数据会被直接抹掉,物理内存里是 DMA 的旧数据,CPU 的修改凭空消失。
这不是理论风险。H7 上 DMA 完成中断优先级通常较高,但仍有更高优先级中断(如 SysTick、其他外设)可能在两者之间插入写操作。
问题二:Clean 在中断里做,已经太晚
如果是 CPU→DMA 方向,必须在启动 DMA 之前 Clean,而不是在完成中断里。中断里再 Clean,DMA 早已把旧数据搬走了。很多“在中断里统一维护”的模板代码,恰恰把方向搞反了。
问题三:中断延迟让维护窗口不可控
Cache 维护本身要遍历 Cache 行,地址范围大时耗时可达数百周期。放在中断里会拉长中断响应,可能影响其他实时任务;更糟的是,维护操作与 DMA 的下一轮传输可能重叠,形成竞态。
正确做法:按方向、按时机分离维护
核心原则:Clean 在启动 DMA 前,Invalidate 在读取 DMA 数据前,且尽量避开中断上下文做重活。
配置步骤
- 使能 D-Cache 与 MPU,把 DMA 缓冲区所在 SRAM 区域配置为 Non-Cacheable 或 Write-Through,从根上规避一致性问题(推荐用于小缓冲)。
- 若必须用 Cacheable 缓冲,则按传输方向选择维护 API。
- CPU→DMA:填完数据后调用
SCB_CleanDCache_by_Addr,再启动 DMA。 - DMA→CPU:DMA 完成中断里只置标志,在任务/主循环中先
SCB_InvalidateDCache_by_Addr再读数据。 - 地址与长度必须按 32 字节(Cache 行大小)对齐,否则会误伤相邻数据。
完整代码示例
#include "stm32h7xx.h"
#define RX_LEN 256
#define TX_LEN 256
/* 32 字节对齐,避免跨 Cache 行误伤 */
aligned(32) static uint8_t rx_buf[RX_LEN];
aligned(32) static uint8_t tx_buf[TX_LEN];
volatile uint8_t rx_done = 0;
/* CPU -> DMA:发送前 Clean */
void uart_send_dma(uint8_t *data, uint16_t len)
{
memcpy(tx_buf, data, len);
/* 把脏数据写回 SRAM,DMA 才能读到新值 */
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);
HAL_UART_Transmit_DMA(&huart1, tx_buf, len);
}
/* DMA 完成中断:只置标志,不做 Cache 维护 */
void DMA1_Stream0_IRQHandler(void)
{
if (DMA1->LISR & DMA_LISR_TCIF0) {
DMA1->LIFCR = DMA_LIFCR_CTCIF0;
rx_done = 1; /* 仅通知,维护放到主循环 */
}
}
/* 主循环:先 Invalidate 再读 */
void app_poll(void)
{
if (rx_done) {
rx_done = 0;
/* 丢弃 Cache 旧副本,强制从 SRAM 读 DMA 新数据 */
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, RX_LEN);
process(rx_buf, RX_LEN);
}
}
注意事项
-
对齐是硬要求:
SCB_*DCache_by_Addr的地址和长度都应是 32 字节倍数,否则相邻变量可能被 Clean 或 Invalidate 波及。 - 不要对同一缓冲同时 Clean 和 Invalidate:除非确实双向复用,否则容易引入额外竞态。
- MPU 配置 Non-Cacheable 更省心:对吞吐要求不高的 DMA 缓冲,直接设为 Non-Cacheable,代码里就不需要维护操作。
- 中断里只做轻量工作:置标志、清中断即可,重活交给任务。
- 多缓冲/双缓冲场景:每个缓冲独立维护,避免维护范围交叉。
小结
把 Cache 维护塞进 DMA 中断,看似“数据到了才处理”,实则违背了维护操作的时序语义:Clean 必须在 DMA 启动前,Invalidate 必须在读取前,且中断上下文做重活会放大竞态与延迟。正确姿势是按方向分离、按时机前置、中断只置标志,必要时用 MPU 把缓冲设为 Non-Cacheable,从架构上消除一致性问题。