STM32H7 的 Cache 与 DMA 一致性坑:Clean/Invalidate 顺序写错导致数据错乱的排查实录

一、问题现场:ADC 数据每 3 个点就错 1 个

项目用 STM32H743 + ADC + DMA 采集 1kHz 正弦波,DMA 循环搬运 1024 点。现象很诡异:

  • 前几次采集完全正常,跑几分钟后数据开始错乱;
  • 错乱位置不固定,但总是成片出现;
  • 关闭 D-Cache 后一切正常。

基本可以锁定:DMA 与 D-Cache 的数据一致性问题。

二、原理:为什么 H7 上 DMA 会“看不见”CPU 写的数据

STM32H7 的 Cortex-M7 带 16KB D-Cache。CPU 读写内存时,实际访问的是 Cache,而不是 SRAM。DMA 是总线主设备,它绕过 Cache 直接访问 SRAM。于是产生两类冲突:

  • CPU 写、DMA 读:CPU 写的数据还在 Cache 里(Write-Back 策略),SRAM 里是旧值,DMA 搬走的是旧数据。
  • DMA 写、CPU 读:DMA 把新数据写进 SRAM,但 CPU 读的是 Cache 里的旧副本,读到脏数据。

解决手段只有两个操作:

  • Clean(清理):把 Cache 里的脏数据写回 SRAM,让 DMA 能读到新值。
  • Invalidate(无效化):把 Cache 行标记为无效,强制 CPU 下次从 SRAM 重新加载,读到 DMA 的新值。

三、关键:顺序写错就是灾难

很多教程只讲“要 Clean/Invalidate”,却没强调顺序。正确原则是:

  • DMA 发送前(CPU→DMA):先 Clean,再启动 DMA。
  • DMA 接收后(DMA→CPU):先等 DMA 完成,再 Invalidate,然后 CPU 才能读。

最常见的错误写法:

// 错误示范:先 Invalidate 再启动 DMA 接收
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, len);
HAL_ADC_Start_DMA(&hadc1, (uint32_t*)rx_buf, len/4);

问题在于:Invalidate 只是把 Cache 行标记无效,并不会把 Cache 里的脏数据写回 SRAM。如果这块缓冲区之前被 CPU 写过,脏数据仍留在 Cache 中。DMA 写入 SRAM 后,CPU 读时 Cache 未命中会重新加载——看似没问题,但如果 Invalidate 发生在 DMA 传输过程中,或 Cache 行与 DMA 写入区域部分重叠,就会把 DMA 刚写入的数据连同旧脏数据一起丢弃,造成错乱。

更隐蔽的是:Invalidate 操作本身可能丢弃尚未写回的脏数据。如果缓冲区在 Invalidate 前被 CPU 写过且未 Clean,这些数据就永久丢失了。

四、正确代码:ADC + DMA 双缓冲采集

#include "stm32h7xx_hal.h"

#define BUF_LEN   1024
#define BUF_BYTES (BUF_LEN * sizeof(uint16_t))

// 必须 32 字节对齐,且放在 DMA 可访问的 SRAM 区
__attribute__((aligned(32))) __attribute__((section(".RAM_D2")))
static uint16_t adc_buf[BUF_LEN];

ADC_HandleTypeDef hadc1;
DMA_HandleTypeDef hdma_adc1;

// DMA 接收完成回调
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    // 1. 先无效化 Cache,让 CPU 能读到 DMA 写入的新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)adc_buf, BUF_BYTES);

    // 2. 此时读取才是安全的
    process_adc_data(adc_buf, BUF_LEN);
}

void adc_dma_start(void)
{
    // 启动前先 Clean,确保 SRAM 与 Cache 一致
    SCB_CleanDCache_by_Addr((uint32_t*)adc_buf, BUF_BYTES);

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

如果是 CPU 写、DMA 发送(如 UART 发送):

void uart_dma_send(uint8_t *data, uint16_t len)
{
    // 先 Clean,把 CPU 写的脏数据刷回 SRAM,DMA 才能读到
    SCB_CleanDCache_by_Addr((uint32_t*)data, len);

    HAL_UART_Transmit_DMA(&huart1, data, len);
}

五、避坑清单

  • 缓冲区必须 32 字节对齐:Cortex-M7 Cache 行是 32 字节,未对齐会导致 Clean/Invalidate 波及相邻数据。用 __attribute__((aligned(32)))。
  • 长度按 32 字节向上取整:SCB_*DCache_by_Addr 内部会处理,但自己算长度时要注意。
  • 不要在 DMA 传输过程中 Invalidate:必须在传输完成中断里做,否则会丢数据。
  • MPU 配置为 Write-Through 或 Non-Cacheable:对 DMA 缓冲区最省心,直接免维护。
  • DMA 缓冲区不要和栈、全局变量混放:避免 Cache 行共享导致误伤。
  • 调试时先关 D-Cache 验证:能快速定位是否为一致性问题。

六、MPU 配置:一劳永逸的方案

如果不想每次手动维护 Cache,可以用 MPU 把 DMA 缓冲区设为 Non-Cacheable:

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_Init = {0};
    MPU_Init.Enable           = MPU_REGION_ENABLE;
    MPU_Init.BaseAddress      = 0x30000000;   // SRAM D2 区
    MPU_Init.Size             = MPU_REGION_SIZE_64KB;
    MPU_Init.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_Init.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;
    MPU_Init.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE;
    MPU_Init.IsShareable      = MPU_ACCESS_SHAREABLE;
    MPU_Init.Number           = MPU_REGION_NUMBER0;
    MPU_Init.TypeExtField     = MPU_TEX_LEVEL1;
    MPU_Init.SubRegionDisable = 0x00;
    MPU_Init.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;
    HAL_MPU_ConfigRegion(&MPU_Init);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

把 DMA 缓冲区放到 Non-Cacheable 区域后,CPU 和 DMA 看到的都是同一份 SRAM,彻底告别一致性问题。代价是 CPU 访问该区域会慢一些,但对 DMA 数据流来说完全值得。

七、总结

STM32H7 的 Cache 是把双刃剑:用好了性能翻倍,用错了数据错乱。记住三句话:

  • CPU 写、DMA 读 → 先 Clean;
  • DMA 写、CPU 读 → 后 Invalidate;
  • 拿不准就用 MPU 设 Non-Cacheable。

顺序错一步,调试两行泪。希望这篇实录能帮你少走弯路。