一、为什么 H7 上 DMA 会踩 Cache 的坑?

STM32H7 基于 Cortex-M7,主频高,为了弥补内存访问延迟,芯片内置了 D-Cache(数据缓存)。CPU 访问 SRAM 时,数据可能只存在于 Cache 中,并未写回物理内存。而 DMA 是独立于 CPU 的总线主设备,它直接读写物理内存,完全看不到 Cache。

于是出现两类经典问题:

  • 发送方向(内存→外设):CPU 写好缓冲区,数据还在 Cache 里(Write-Back 策略),DMA 从物理内存读到的却是旧数据。
  • 接收方向(外设→内存):DMA 把新数据写入物理内存,但 CPU 读缓冲区时命中了 Cache 里的旧数据,读到“脏”内容。

解决手段就是 Clean(清理/写回) 和 Invalidate(无效化)。但顺序一旦搞反,问题更隐蔽。

二、Clean 与 Invalidate 到底做了什么?

  • Clean(CleanDCache):把 Cache 中“脏”行写回物理内存,Cache 内容保留。
  • Invalidate(InvalidateDCache):把 Cache 行标记为无效,下次读取强制从物理内存加载。
  • Clean+Invalidate:先写回再无效,常用于接收缓冲区。

关键原则:

  • 发送前:对缓冲区做 Clean,确保物理内存是最新数据。
  • 接收后:对缓冲区做 Invalidate(或 Clean+Invalidate),丢弃 Cache 旧副本。

三、顺序错了会怎样?

3.1 发送时先 Invalidate 再 Clean

Invalidate 会把尚未写回的脏数据直接丢弃,物理内存仍是旧值,DMA 发出错误数据。更糟的是,若该 Cache 行后续被 CPU 再次修改,可能触发不可预测的写回,造成数据错乱。

3.2 接收时先 Invalidate 再启动 DMA

如果在 DMA 启动之前就 Invalidate,看似没问题,但若 CPU 在 Invalidate 后、DMA 完成前又访问了该缓冲区,Cache 会重新加载旧数据并可能标记为脏,DMA 完成后这些脏行可能覆盖新数据。正确做法是:DMA 启动前不要动 Cache,DMA 完成中断里再 Invalidate。

3.3 最危险的:接收后只 Clean 不 Invalidate

Clean 只写回,不丢弃 Cache。CPU 读到的仍是 Cache 里的旧数据,DMA 新数据被“屏蔽”,表现为数据永远不变。

四、正确配置步骤

  1. 使能 D-Cache(默认开启),并确保 MPU 将 DMA 缓冲区所在 SRAM 配置为 Non-Cacheable 或 Write-Through 可简化问题;若坚持用 Cacheable,必须手动维护。
  2. 发送缓冲区建议用 __attribute__((aligned(32))) 对齐到 Cache 行(32 字节),避免伪共享。
  3. 发送流程:填充数据 → SCB_CleanDCache_by_Addr() → 启动 DMA。
  4. 接收流程:启动 DMA → 等待完成 → SCB_InvalidateDCache_by_Addr() → 读取数据。
  5. 若接收缓冲区在 DMA 期间可能被 CPU 访问,改用 SCB_CleanInvalidateDCache_by_Addr()。

五、完整代码示例

#include "stm32h7xx.h"
#include "stm32h7xx_hal.h"

#define BUF_SIZE  256
#define CACHE_LINE 32

/* 32 字节对齐,避免跨 Cache 行 */
__attribute__((aligned(CACHE_LINE))) uint8_t tx_buf[BUF_SIZE];
__attribute__((aligned(CACHE_LINE))) uint8_t rx_buf[BUF_SIZE];

/* 发送:Clean 后启动 DMA */
void dma_send(uint8_t *data, uint16_t len)
{
    memcpy(tx_buf, data, len);

    /* 关键:把 Cache 脏行写回物理内存 */
    SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);

    /* 启动 DMA 传输(以 SPI 为例) */
    HAL_SPI_Transmit_DMA(&hspi1, tx_buf, len);
}

/* 接收完成回调:Invalidate 后再读 */
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi->Instance == SPI1) {
        /* 关键:丢弃 Cache 旧副本,强制从物理内存加载 */
        SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);

        /* 此时读取才是 DMA 写入的新数据 */
        process_data(rx_buf, BUF_SIZE);
    }
}

/* 启动接收 */
void dma_receive(void)
{
    /* 启动前不要 Invalidate,避免 CPU 误访问 */
    HAL_SPI_Receive_DMA(&hspi1, rx_buf, BUF_SIZE);
}

六、注意事项与避坑清单

  • 地址与长度必须 32 字节对齐:SCB_*DCache_by_Addr 要求地址对齐,长度建议向上取整到 32 的倍数,否则可能漏刷或越界。
  • 不要对栈上变量做 DMA:栈变量地址不固定且可能跨行,维护困难。
  • 中断里避免频繁 Invalidate 大块内存:影响实时性,可分段处理。
  • MPU 配置 Non-Cacheable 最省心:若 DMA 缓冲区固定,直接配成 Non-Cacheable,无需手动维护,但会牺牲 CPU 访问性能。
  • 双缓冲/环形缓冲要小心:DMA 正在写的区域不能被 CPU 访问,否则 Invalidate 会丢数据。
  • 调试时关 Cache 正常、开 Cache 出错:基本可判定是 Cache 一致性问题。

七、总结

STM32H7 的 Cache 与 DMA 一致性没有捷径:发送先 Clean,接收后 Invalidate,顺序不能反,地址要对齐。理解“Cache 是 CPU 的私有副本,DMA 只认物理内存”这一本质,就能避开这个 H7 开发中最隐蔽的坑。