为什么 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 数据前,且尽量避开中断上下文做重活。

配置步骤

  1. 使能 D-Cache 与 MPU,把 DMA 缓冲区所在 SRAM 区域配置为 Non-Cacheable 或 Write-Through,从根上规避一致性问题(推荐用于小缓冲)。
  2. 若必须用 Cacheable 缓冲,则按传输方向选择维护 API。
  3. CPU→DMA:填完数据后调用 SCB_CleanDCache_by_Addr,再启动 DMA。
  4. DMA→CPU:DMA 完成中断里只置标志,在任务/主循环中先 SCB_InvalidateDCache_by_Addr 再读数据。
  5. 地址与长度必须按 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,从架构上消除一致性问题。