STM32H7 的 D-Cache 与 DMA 数据一致性:从 MPU 配置到 Clean/Invalidate 的完整避坑指南

一、为什么 DMA 遇上 D-Cache 会出错?

STM32H7 的 Cortex-M7 内核带有一级 D-Cache(通常 16KB 或 32KB),它缓存的是物理内存的副本。当 CPU 写数据时,若命中 Cache,新数据只写入 Cache,并未立即写回 SRAM;当 CPU 读数据时,若命中 Cache,直接返回 Cache 中的旧值,而不去 SRAM 取新值。

DMA 控制器则直接访问 SRAM,完全绕过 Cache。于是出现两类经典错误:

  • CPU 写,DMA 读:CPU 写入的数据还在 Cache 中(Write-Back 模式),DMA 从 SRAM 读到的是旧数据。
  • DMA 写,CPU 读:DMA 已将新数据写入 SRAM,但 CPU 读的是 Cache 中的旧数据。

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

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

最稳妥的做法是将 DMA 缓冲区所在的内存区域通过 MPU 配置为 Non-Cacheable(或 Write-Through,但 Non-Cacheable 更简单)。这样 CPU 和 DMA 看到的都是 SRAM 的真实内容,无需任何 Clean/Invalidate 操作。

2.1 配置步骤

  1. 使能 MPU:SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk; 然后 MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
  2. 选择一个空闲的 MPU Region(0~15)。
  3. 设置基地址、大小、属性(TEX=0, C=0, B=0 即 Non-Cacheable, Normal memory)。
  4. 使能该 Region。

2.2 代码示例

#include "stm32h7xx.h"

// 将地址 0x30000000 开始的 32KB 区域配置为 Non-Cacheable
void MPU_Config_DMA_Buffer(void)
{
    MPU->CTRL = 0; // 先关闭 MPU
    
    // 选择 Region 0
    MPU->RNR = 0;
    // 基地址 0x30000000,必须按区域大小对齐
    MPU->RBAR = 0x30000000 | MPU_RBAR_VALID_Msk | 0;
    // 大小 32KB (2^(15+1)=32KB? 实际 RASR SIZE 字段:size=14 表示 32KB)
    // 属性:TEX=0, C=0, B=0, S=0, AP=011(全访问), XN=0
    MPU->RASR = (0x0 << MPU_RASR_TEX_Pos) |
                (0 << MPU_RASR_C_Pos)   |
                (0 << MPU_RASR_B_Pos)   |
                (3 << MPU_RASR_AP_Pos)  |
                (0 << MPU_RASR_XN_Pos)  |
                (14 << MPU_RASR_SIZE_Pos) | // 2^(14+1)=32KB
                MPU_RASR_ENABLE_Msk;
    
    // 使能 MPU 和默认背景区域
    MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
    __DSB();
    __ISB();
}

注意:MPU 配置必须在访问该内存区域之前完成,通常放在 main() 开头或 SystemInit 中。

三、Clean 与 Invalidate:手动维护一致性

如果因性能原因必须使用 Cache(例如缓冲区很大,频繁访问),则需在 DMA 传输前后手动操作 Cache。

3.1 关键 API

CMSIS 提供了以下函数:

  • SCB_CleanDCache_by_Addr(uint32_t *addr, int32_t dsize):将 Cache 中已修改的数据写回 SRAM。
  • SCB_InvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize):丢弃 Cache 中的内容,强制下次读取从 SRAM 获取。
  • SCB_CleanInvalidateDCache_by_Addr():先 Clean 再 Invalidate。

3.2 操作时机

| 传输方向 | 操作顺序 | |---------|----------| | CPU 写 → DMA 读 | 写数据后,启动 DMA 前:Clean | | DMA 写 → CPU 读 | DMA 完成后,CPU 读之前:Invalidate | | DMA 读 → CPU 写(双向) | 启动前 Clean,完成后 Invalidate |

3.3 代码示例:UART DMA 发送

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

void UART_DMA_Send(uint8_t *data, uint16_t len)
{
    memcpy(txBuffer, data, len);
    
    // 1. Clean D-Cache,确保数据写入 SRAM
    SCB_CleanDCache_by_Addr((uint32_t*)txBuffer, len);
    
    // 2. 启动 DMA 传输
    HAL_UART_Transmit_DMA(&huart1, txBuffer, len);
}

3.4 代码示例:ADC DMA 接收

#define RX_BUFFER_SIZE 256
__attribute__((aligned(32))) uint16_t rxBuffer[RX_BUFFER_SIZE];

void ADC_DMA_Start(void)
{
    // 启动前无需 Clean,但若缓冲区之前被 CPU 写过,最好 Invalidate
    SCB_InvalidateDCache_by_Addr((uint32_t*)rxBuffer, sizeof(rxBuffer));
    HAL_ADC_Start_DMA(&hadc1, (uint32_t*)rxBuffer, RX_BUFFER_SIZE);
}

// DMA 完成回调中
void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc)
{
    // 3. Invalidate D-Cache,丢弃旧缓存,强制从 SRAM 读取新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)rxBuffer, sizeof(rxBuffer));
    
    // 现在可以安全读取 rxBuffer
    ProcessData(rxBuffer, RX_BUFFER_SIZE);
}

四、避坑清单与注意事项

  • 地址对齐:Clean/Invalidate 操作的地址和长度必须是 32 字节对齐,否则可能误伤相邻数据。建议缓冲区使用 __attribute__((aligned(32)))
  • 长度对齐:长度也建议向上取整到 32 字节的倍数,避免遗漏。
  • 不要过度 Invalidate:Invalidate 会丢弃 Cache 中所有未写回的数据,若之前有 CPU 写入未 Clean,会导致数据丢失。
  • DMA 传输期间禁止 CPU 访问缓冲区:否则可能引发 Cache 与 DMA 的竞争,导致不可预知结果。
  • 多缓冲区(Ping-Pong):每个缓冲区独立维护,回调中只 Invalidate 当前完成的缓冲区。
  • MPU 配置优先:若对性能要求不苛刻,强烈建议将 DMA 缓冲区设为 Non-Cacheable,一劳永逸。
  • 使用 __DSB()__ISB():在 MPU 配置或 Cache 操作后插入屏障指令,确保指令顺序执行。
  • 调试时关闭 Cache:若问题诡异,可临时关闭 D-Cache 验证是否为一致性问题。

五、总结

STM32H7 的 D-Cache 是一把双刃剑:用好了大幅提升性能,用不好则调试到怀疑人生。核心原则是:明确数据流向,在正确的时间做正确的 Cache 维护。对于 DMA 缓冲区,优先考虑 MPU 配置为 Non-Cacheable;若必须使用 Cache,则严格遵循“写后 Clean,读前 Invalidate”的规则,并注意对齐与屏障。掌握这些,你就能在 H7 上自如地驾驭 DMA 与 Cache,让系统既快又稳。