一、为什么 STM32H7 的 Cache 会让 DMA 出错?

STM32H7 内置 16KB 的 L1 Cache(I-Cache 和 D-Cache),CPU 访问内存时会优先命中 Cache,从而大幅提升性能。但 DMA 控制器是直接访问物理内存(SRAM、SDRAM 等),并不经过 Cache。这就导致两个经典问题:

  • 写丢失:CPU 写数据到 Cache 后,DMA 从内存读到的仍是旧数据。
  • 读脏:DMA 将新数据写入内存,但 CPU 读到的却是 Cache 中的旧副本。

更麻烦的是,STM32H7 的 Cache 是写回(Write-Back) 策略,且支持写分配(Write-Allocate),使得问题更加隐蔽。

二、核心原理:Cache 与 DMA 的三种一致性场景

| 方向 | 风险 | 正确操作 | |------|------|----------| | CPU 写 → DMA 读 | Cache 中数据未回写 | 发送前 Clean | | DMA 写 → CPU 读 | Cache 中旧数据未失效 | 接收后 Invalidate | | CPU 读写同一缓冲区 | 读写顺序错乱 | 先 Clean 再 Invalidate |

关键点:Clean 是把 Cache 数据写回内存,Invalidate 是丢弃 Cache 数据并从内存重新加载。顺序错误会导致数据被覆盖或丢失。

三、MPU 配置:为 DMA 缓冲区划定“安全区”

最稳妥的做法是通过 MPU 将 DMA 缓冲区配置为 Non-CacheableWrite-Through,从而避免手动维护一致性。

3.1 MPU 配置步骤

  1. 使能 MPU:SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk; 并调用 ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);
  2. 定义区域基址、大小(必须是 2 的幂,最小 32 字节)。
  3. 设置属性:ARM_MPU_ATTR(ARM_MPU_AP_FULL, 0) 表示 Non-Cacheable。
  4. 加载区域并启用。

3.2 代码示例:将 0x24000000 开始的 64KB 设为 Non-Cacheable

#include "cmsis_compiler.h"

void MPU_Config(void)
{
    ARM_MPU_Disable();

    /* 区域 0:DMA 缓冲区,Non-Cacheable, Non-Shareable */
    ARM_MPU_SetRegion(
        0,                          // 区域编号
        0x24000000,                 // 基址 (AXI SRAM)
        ARM_MPU_RASR(0,             // 禁止指令访问
                     ARM_MPU_AP_FULL, // 全权限
                     0,              // TEX=0
                     0,              // C=0, B=0 -> Non-Cacheable
                     0,              // S=0
                     0,              // 子区域禁用
                     ARM_MPU_REGION_SIZE_64KB)
    );

    ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);
    __DSB();
    __ISB();
}

注意:若使用 SDRAM 作为 DMA 缓冲区,同样需将其配置为 Non-Cacheable,或使用 Write-Through 模式并配合手动 Clean。

四、手动维护一致性:Clean 与 Invalidate 的正确用法

当无法使用 MPU 时(例如缓冲区与 CPU 频繁交互),必须手动调用 CMSIS 提供的函数:

void SCB_CleanDCache_by_Addr(uint32_t *addr, int32_t dsize);
void SCB_InvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize);
void SCB_CleanInvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize);

4.1 发送场景(CPU → DMA)

// 填充数据到缓冲区
fill_buffer(tx_buf, LEN);

// 关键:将 Cache 写回内存
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, LEN);

// 启动 DMA 发送
HAL_DMA_Start(&hdma, (uint32_t)tx_buf, (uint32_t)&UART->TDR, LEN);

4.2 接收场景(DMA → CPU)

// 启动 DMA 接收(注意:接收前先 Invalidate,避免 Cache 中旧数据干扰)
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, LEN);
HAL_DMA_Start(&hdma, (uint32_t)&UART->RDR, (uint32_t)rx_buf, LEN);

// 等待 DMA 完成(中断或轮询)
while (!dma_done);

// 关键:使 Cache 失效,强制 CPU 从内存读取新数据
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, LEN);

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

4.3 双向场景(CPU 与 DMA 交替读写)

// 先 Clean 确保旧数据写回,再 Invalidate 丢弃 Cache 副本
SCB_CleanInvalidateDCache_by_Addr((uint32_t*)buf, LEN);

五、完整排查路径与注意事项

5.1 排查清单

  • 确认缓冲区地址:是否落在 Cacheable 区域?
  • 检查 MPU 配置:DMA 缓冲区是否已设为 Non-Cacheable?
  • 检查 Clean/Invalidate 调用:发送前是否 Clean?接收后是否 Invalidate?
  • 检查对齐:地址和长度是否 32 字节对齐?CMSIS 函数要求 32 字节对齐,否则可能误伤相邻数据。
  • 检查 DMA 传输完成标志:必须在 DMA 完成后再 Invalidate,否则会读到部分数据。
  • 检查中断优先级:DMA 中断与 Cache 操作可能被高优先级中断打断,导致顺序错乱。

5.2 常见陷阱

  • Invalidate 前未 Clean:如果 CPU 曾写过该缓冲区,Invalidate 会丢弃未回写的数据。
  • 缓冲区跨 Cache 行:长度不是 32 字节倍数时,Invalidate 可能影响相邻变量。
  • DMA 双缓冲模式:两个缓冲区都要独立维护一致性。
  • 使用 __DSB():在 Clean/Invalidate 后、启动 DMA 前插入 __DSB() 确保操作完成。

5.3 推荐实践

  • 优先使用 MPU 将 DMA 缓冲区设为 Non-Cacheable,一劳永逸。
  • 若必须使用 Cacheable 内存,将缓冲区按 32 字节对齐,并封装统一的 dma_send() / dma_recv() 函数,内部自动处理 Clean/Invalidate。
  • 在调试时,可临时关闭 D-Cache 验证问题是否由 Cache 引起。

六、总结

STM32H7 的 Cache 与 DMA 一致性问题是嵌入式开发中的经典“暗坑”。理解 Clean 与 Invalidate 的语义、合理配置 MPU、严格遵循操作顺序,才能确保数据正确。记住:发送前 Clean,接收后 Invalidate,双向操作先 Clean 再 Invalidate。掌握这些,你就能在 H7 的高性能与数据一致性之间游刃有余。