一、问题现象:为什么数据会“偶尔”错?

在 STM32H7 上使用 DMA 搬运数据时,很多开发者会遇到:

  • 串口 DMA 接收偶尔丢字节或收到旧数据
  • ADC 连续采样 DMA 传输,数据偶尔跳变
  • 以太网、SDMMC 等高速外设 DMA 传输随机出错

这些现象往往与 D-Cache(数据缓存)DMA 的一致性 有关。Cortex-M7 的 D-Cache 默认开启,CPU 访问数据时可能命中缓存,而 DMA 直接访问物理内存,两者看到的数据可能不一致。

二、原理:Cache 与 DMA 为何冲突

2.1 D-Cache 的工作方式

  • CPU 读数据:先查 Cache,命中则直接返回,不访问内存
  • CPU 写数据:默认写回策略(Write-Back),只写 Cache,标记为脏,稍后才写回内存
  • DMA 传输:直接读写物理内存,不经过 Cache

2.2 两种典型不一致

场景 A:DMA 写入内存,CPU 读

DMA 把新数据写入内存,但 CPU 之前读过该地址,Cache 中仍是旧数据。CPU 再次读取时命中旧 Cache,看不到 DMA 的新数据。

场景 B:CPU 写入内存,DMA 读

CPU 写数据只更新了 Cache,内存中还是旧值。DMA 从内存读取,拿到的是旧数据。

三、Clean 与 Invalidate 的正确语义

  • Clean(清理):把 Cache 中的脏数据写回内存。用于 CPU 写 → DMA 读 之前。
  • Invalidate(无效化):丢弃 Cache 内容,强制下次从内存重新加载。用于 DMA 写 → CPU 读 之前。
  • Clean + Invalidate:既写回又丢弃,用于双向传输或不确定方向时。

关键原则:谁写谁负责同步。CPU 写的数据,CPU 负责 Clean;DMA 写的数据,CPU 负责 Invalidate。

四、常见误用与后果

  • 只 Invalidate 不 Clean:CPU 刚写的数据还在 Cache 中,Invalidate 直接丢弃,内存中仍是旧值,DMA 读到错误数据。
  • 只 Clean 不 Invalidate:DMA 写入新数据后,CPU 读时仍命中旧 Cache,读到旧数据。
  • 地址未按 Cache Line 对齐:Cortex-M7 Cache Line 为 32 字节。若操作区域不是 32 字节对齐,Clean/Invalidate 会波及相邻数据,导致其他变量被意外写回或丢弃。
  • 在 DMA 传输过程中操作 Cache:传输未完成就 Clean/Invalidate,可能破坏正在传输的数据。

五、正确配置步骤

  1. 使能 D-Cache:在 SystemInit 或启动代码中调用 SCB_EnableDCache()
  2. 分配 DMA 缓冲区:使用 __attribute__((aligned(32))) 确保 32 字节对齐。
  3. 确定数据流方向
    • CPU 写 → DMA 读:传输前 SCB_CleanDCache_by_Addr()
    • DMA 写 → CPU 读:传输完成后 SCB_InvalidateDCache_by_Addr()
  4. 使用 DMA 传输完成中断:在中断中执行 Invalidate,确保传输已结束。
  5. 避免在传输中操作 Cache

六、完整代码示例

以下示例展示 ADC 连续采样 + DMA 传输到内存,CPU 读取数据的正确流程。

#include "stm32h7xx.h"

#define ADC_BUF_SIZE  256

/* 32 字节对齐的 DMA 缓冲区 */
__attribute__((aligned(32))) static uint16_t adc_dma_buf[ADC_BUF_SIZE];
volatile uint8_t adc_dma_done = 0;

/* DMA 传输完成回调 */
void ADC_DMA_Complete_Callback(void)
{
    /* DMA 已写入内存,CPU 读之前必须 Invalidate */
    SCB_InvalidateDCache_by_Addr((uint32_t *)adc_dma_buf, sizeof(adc_dma_buf));
    adc_dma_done = 1;
}

/* 启动 ADC DMA 采样 */
void ADC_Start_DMA(void)
{
    adc_dma_done = 0;

    /* 若缓冲区之前被 CPU 写过,需先 Clean,确保内存数据最新 */
    SCB_CleanDCache_by_Addr((uint32_t *)adc_dma_buf, sizeof(adc_dma_buf));

    HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_dma_buf, ADC_BUF_SIZE);
}

/* 主循环中处理数据 */
void Process_ADC_Data(void)
{
    if (adc_dma_done) {
        adc_dma_done = 0;
        /* 此时 Cache 已无效化,读取的是 DMA 写入的最新数据 */
        for (int i = 0; i < ADC_BUF_SIZE; i++) {
            uint16_t val = adc_dma_buf[i];
            /* 处理 val ... */
        }
    }
}

若使用 DMA 发送数据(CPU 写 → DMA 读):

__attribute__((aligned(32))) static uint8_t tx_buf[128];

void UART_Send_DMA(void)
{
    /* 填充数据 */
    for (int i = 0; i < 128; i++) {
        tx_buf[i] = i;
    }

    /* CPU 写的数据在 Cache 中,必须 Clean 回内存,DMA 才能读到 */
    SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, sizeof(tx_buf));

    HAL_UART_Transmit_DMA(&huart1, tx_buf, 128);
}

七、排查方法与注意事项

7.1 排查步骤

  • 确认 D-Cache 是否使能:读取 SCB->CCR 的 IC/DC 位
  • 检查 DMA 缓冲区是否 32 字节对齐
  • 检查 Clean/Invalidate 调用时机是否正确
  • 使用 SCB_InvalidateDCache_by_Addr 前确保 DMA 传输已完成
  • 临时关闭 D-Cache 验证问题是否消失(仅用于定位)

7.2 注意事项

  • MPU 配置:可将 DMA 缓冲区配置为 Write-Through 或 Non-Cacheable,从根本上避免一致性问题,但会降低 CPU 访问性能。
  • Cache Line 对齐:操作地址和长度尽量按 32 字节对齐,避免误伤相邻数据。
  • 中断优先级:DMA 完成中断中执行 Invalidate 是安全的,但不要在其中做耗时操作。
  • 多缓冲区:使用双缓冲时,每个缓冲区都要独立同步。
  • 不要过度同步:频繁 Clean/Invalidate 会降低性能,只在必要时调用。

八、总结

STM32H7 的 Cache 与 DMA 一致性问题是嵌入式开发中的经典陷阱。核心记住三点:

  • CPU 写 → DMA 读:Clean
  • DMA 写 → CPU 读:Invalidate
  • 缓冲区 32 字节对齐

掌握这些原则,偶发数据错乱问题将迎刃而解。