一、为什么 H7 上 Cache 和 DMA 会打架?

STM32H7 的 Cortex-M7 内核带有一级数据缓存(D-Cache),默认写回(Write-Back)、写分配(Write-Allocate)。CPU 访问内存时,数据可能只停留在 Cache 里,并没有真正写到 SRAM。而 DMA 是独立于 CPU 的总线主设备,它直接读写物理内存,不经过 Cache。

这就导致两个经典问题:

  • CPU 写、DMA 读:CPU 把数据写入缓冲区,数据还在 Cache 中(脏行),DMA 从 SRAM 读到的是旧数据。
  • DMA 写、CPU 读:DMA 把新数据搬到 SRAM,但 CPU 读同一地址时命中了 Cache 里的旧行,读到旧数据。

解决思路只有两个:要么让该区域不走 Cache(MPU 配置为 Device/Strongly-Ordered 或 Non-Cacheable),要么在恰当时机手动维护 Cache(Clean / Invalidate)。

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

  • Clean(清理):把 Cache 中的脏行写回 SRAM。用于“CPU 写、DMA 读”之前,确保 SRAM 拿到最新数据。
  • Invalidate(无效化):把 Cache 中的行标记为无效。下次 CPU 读取时会重新从 SRAM 加载。用于“DMA 写、CPU 读”之前,丢弃旧缓存。
  • Clean & Invalidate:两者都做,常用于双向缓冲区或不确定方向的场景。

注意:Invalidate 是危险操作。如果 Cache 中有尚未 Clean 的脏数据,直接 Invalidate 会永久丢失这些修改。所以顺序很关键。

三、正确时机:一张表说清楚

| 场景 | 操作 | 时机 | |------|------|------| | CPU 填充发送缓冲区 → 启动 DMA 发送 | Clean | 启动 DMA 之前 | | DMA 接收完成 → CPU 读取接收缓冲区 | Invalidate | DMA 完成中断里,读取数据之前 | | 双向缓冲区(如 SPI 全双工) | Clean & Invalidate | 启动 DMA 前 Clean,完成中断里 Invalidate |

核心口诀:“发之前 Clean,收之后 Invalidate”。

四、MPU 配置:更省心的方案

如果不想每次手动维护,可以把 DMA 缓冲区所在 SRAM 区域配置为 Non-Cacheable。以 STM32H7 的 AXI SRAM(0x24000000)为例,使用 HAL 的 MPU 配置:

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};
    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress      = 0x24000000;
    MPU_InitStruct.Size             = MPU_REGION_SIZE_512KB;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;
    MPU_InitStruct.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE;
    MPU_InitStruct.IsShareable      = MPU_ACCESS_SHAREABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.TypeExtField     = MPU_TEX_LEVEL1;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

这样该区域所有访问都直达 SRAM,无需手动 Clean/Invalidate。代价是失去缓存加速,适合 DMA 频繁的大缓冲区。

五、完整代码示例:ADC + DMA 采集

下面以 ADC1 使用 DMA 循环采集为例,演示手动维护 Cache 的标准写法。缓冲区放在可缓存区域(如 DTCM 以外的 AXI SRAM)。

#include "stm32h7xx_hal.h"

#define ADC_BUF_LEN  256
/* 必须 32 字节对齐,Cache 行大小为 32 字节 */
aligned(32) static uint16_t adc_buf[ADC_BUF_LEN];

ADC_HandleTypeDef hadc1;
DMA_HandleTypeDef hdma_adc1;

void ADC_DMA_Init(void)
{
    /* 启动 DMA 前:若缓冲区有 CPU 预填数据,先 Clean */
    SCB_CleanDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf));

    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_buf, ADC_BUF_LEN);
}

/* DMA 传输完成回调 */
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    /* 读取前:Invalidate,丢弃 Cache 中的旧数据 */
    SCB_InvalidateDCache_by_Addr((uint32_t *)adc_buf, sizeof(adc_buf));

    /* 此时 adc_buf 中才是 DMA 搬来的最新数据 */
    ProcessAdcData(adc_buf, ADC_BUF_LEN);
}

如果使用 HAL 的 HAL_DMAEx_... 或 LL 库,原理相同,只是 API 名称不同。

六、必须注意的坑

  • 地址与长度必须 32 字节对齐:Cortex-M7 的 Cache 行是 32 字节。SCB_InvalidateDCache_by_Addr 内部会按行处理,但若缓冲区跨行且未对齐,可能误伤相邻数据。
  • 缓冲区不要和栈、其他变量共享 Cache 行:否则 Invalidate 会丢掉邻居的脏数据。用 aligned(32) 并单独定义。
  • Invalidate 前确保没有脏数据:如果 CPU 刚写过该缓冲区,先 Clean 再 Invalidate,或直接用 Clean & Invalidate。
  • DTCM 不走 Cache:STM32H7 的 DTCM(0x20000000)默认不经过 Cache,DMA 也无法访问 DTCM。DMA 缓冲区应放在 AXI SRAM 或 SRAM1/2/3。
  • 中断里操作要快:Clean/Invalidate 是逐行操作,大缓冲区会耗时,必要时用 MDMA 或分块处理。
  • 不要迷信“自动维护”:H7 没有硬件缓存一致性,一切靠软件或 MPU。

七、总结

STM32H7 的 Cache 与 DMA 一致性,本质是“谁先看到谁”的时序问题。记住三句话:

  1. CPU 写、DMA 读 → 启动 DMA 前 Clean;
  2. DMA 写、CPU 读 → DMA 完成后 Invalidate;
  3. 拿不准就配 MPU 为 Non-Cacheable,用性能换省心。

把 Clean/Invalidate 放在正确的步骤,你的 H7 项目就能既跑得快,又不出玄学 Bug。