STM32H7 的 Cache 与 DMA 一致性实战:MPU 配置与 Clean/Invalidate 时机踩坑记录

STM32H7 系列凭借 480MHz 主频和 L1 Cache 成为高性能嵌入式首选,但 Cache 的引入也让 DMA 传输变得“暗藏杀机”。如果你曾遇到 DMA 发送的数据错乱、接收的数据被覆盖,或者明明内存里数据正确却传输错误,那么本文正是为你准备的。

一、为什么 Cache 会导致 DMA 数据不一致?

Cortex-M7 的 L1 Cache 分为 D-Cache 和 I-Cache。D-Cache 采用**写回(Write-Back)**策略:CPU 写数据时只更新 Cache,不立即写回主存;读数据时若 Cache 未命中,则从主存加载并缓存。

DMA 控制器直接访问主存(AXI SRAM、DTCM 等),不经过 Cache。因此:

  • CPU 写 → DMA 读:CPU 写入的数据可能还在 Cache 中,主存仍是旧值,DMA 读到旧数据。
  • DMA 写 → CPU 读:DMA 将新数据写入主存,但 CPU 可能从 Cache 中读到旧数据。

解决思路:在 DMA 传输前后,手动维护 Cache 一致性。

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

最优雅的方案是利用 MPU 将 DMA 缓冲区配置为非缓存(Non-Cacheable)写通(Write-Through),从而避免手动 Clean/Invalidate。

2.1 MPU 配置步骤

  1. 使能 MPU:SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk; 并调用 ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);
  2. 定义区域基址和属性。
  3. 调用 ARM_MPU_SetRegion() 配置。

2.2 示例:将 0x30000000 开始的 64KB 设为 Non-Cacheable

#include "cmsis_compiler.h"

void MPU_Config(void)
{
    ARM_MPU_Disable();

    // 区域 0:DMA 缓冲区,Non-Cacheable, Non-Shareable
    ARM_MPU_SetRegion(
        0,                          // 区域编号
        0x30000000,                 // 基址
        ARM_MPU_RASR(               // 属性
            0,                      // 禁用指令访问
            ARM_MPU_AP_FULL,        // 全权限
            1,                      // 可共享?0=Non-shareable
            0,                      // 可缓存?0=Non-cacheable
            0,                      // 可缓冲?0=Non-bufferable
            0,                      // 子区域禁用
            ARM_MPU_REGION_SIZE_64KB
        )
    );

    ARM_MPU_Enable(MPU_CTRL_PRIVDEFENA_Msk);
}

注意:ARM_MPU_RASR 宏参数顺序因 CMSIS 版本略有差异,请以实际头文件为准。

配置后,该区域所有访问都直接读写主存,DMA 与 CPU 看到的数据始终一致。

三、手动 Clean/Invalidate 的正确时机

若无法使用 MPU(如缓冲区动态分配),则必须手动维护 Cache。关键函数:

  • SCB_CleanDCache_by_Addr(addr, size):将 Cache 中脏数据写回主存。
  • SCB_InvalidateDCache_by_Addr(addr, size):丢弃 Cache 内容,强制下次从主存读取。
  • SCB_CleanInvalidateDCache_by_Addr(addr, size):先写回再无效。

3.1 发送(CPU → DMA)

CPU 填充缓冲区后,必须 Clean,确保数据写入主存,再启动 DMA。

uint8_t tx_buf[256];
// ... 填充数据 ...
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, sizeof(tx_buf));
HAL_DMA_Start(&hdma_memtomem, (uint32_t)tx_buf, (uint32_t)dest, sizeof(tx_buf));

3.2 接收(DMA → CPU)

DMA 传输完成后,必须 Invalidate,丢弃 Cache 中的旧数据,让 CPU 从主存读取新数据。

uint8_t rx_buf[256];
HAL_DMA_Start(&hdma_memtomem, (uint32_t)src, (uint32_t)rx_buf, sizeof(rx_buf));
// 等待传输完成...
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, sizeof(rx_buf));
// 现在可以安全读取 rx_buf

致命陷阱:Invalidate 操作会丢弃 Cache 中尚未写回的数据。如果缓冲区在 Invalidate 前被 CPU 写过,这些修改将永久丢失!因此,接收缓冲区在 DMA 传输期间不应被 CPU 写入。

四、完整实战代码:UART DMA 收发

以 UART 空闲中断 + DMA 接收为例,展示完整流程。

#define RX_BUF_SIZE 128
__attribute__((aligned(32))) uint8_t rx_buf[RX_BUF_SIZE]; // 32 字节对齐

void UART_DMA_Init(void)
{
    // ... 初始化 UART 和 DMA ...
    HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
}

// 空闲中断回调
void HAL_UART_IdleCallback(UART_HandleTypeDef *huart)
{
    if (huart == &huart1) {
        // 停止 DMA 以获取已接收长度
        HAL_UART_DMAStop(huart);
        uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);

        // 关键:Invalidate Cache
        SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, len);

        // 处理数据
        ProcessData(rx_buf, len);

        // 重启接收
        HAL_UART_Receive_DMA(huart, rx_buf, RX_BUF_SIZE);
    }
}

五、踩坑记录与注意事项

  • 坑 1:地址未对齐。Clean/Invalidate 要求地址 32 字节对齐,长度也建议对齐。使用 __attribute__((aligned(32))) 修饰缓冲区。
  • 坑 2:Invalidate 范围过大。若缓冲区与其它变量共享 Cache 行,Invalidate 可能丢弃相邻变量的修改。务必按 Cache 行(32 字节)对齐并精确操作。
  • 坑 3:DMA 传输中 CPU 访问缓冲区。传输期间 CPU 读写缓冲区会导致数据竞争,应使用双缓冲或信号量隔离。
  • 坑 4:MPU 配置后忘记使能ARM_MPU_Enable 必须在配置后调用,且需设置 PRIVDEFENA 使能默认背景区域。
  • 坑 5:DTCM 与 Cache。DTCM 不经过 Cache,无需维护一致性,但 DMA 通常无法访问 DTCM(除非通过 AHBS 总线),建议将 DMA 缓冲区放在 AXI SRAM。

六、总结

STM32H7 的 Cache 与 DMA 一致性并非洪水猛兽,核心原则:要么让 DMA 缓冲区绕过 Cache(MPU),要么在正确时机手动 Clean/Invalidate。理解数据流向,严格对齐地址,避免传输期间 CPU 访问,就能写出稳定可靠的代码。希望本文的实战经验能帮你少走弯路。