STM32H7 的 D-Cache 与 DMA 一致性坑:MPU 配置与 Clean/Invalidate 的正确顺序

STM32H7 系列搭载 Cortex-M7 内核,主频可达 480MHz,并配备 L1 数据缓存(D-Cache)。D-Cache 能显著提升 CPU 访问外部存储器的效率,但一旦与 DMA 控制器协同工作,就可能出现数据不一致问题。许多开发者在使用 DMA 传输时发现数据“莫名其妙”出错,根源往往在于 Cache 未正确维护。本文将系统讲解 D-Cache 与 DMA 的冲突原理、MPU 配置方法,以及 Clean/Invalidate 的正确使用顺序。

一、为什么 D-Cache 会与 DMA 冲突?

Cortex-M7 的 D-Cache 是写回(Write-Back)且写分配(Write-Allocate)的。CPU 写数据时,若命中 Cache,数据只写入 Cache,不会立即写回物理内存;CPU 读数据时,若命中 Cache,直接返回 Cache 内容,不访问物理内存。

而 DMA 控制器直接访问物理内存(SRAM、SDRAM 等),它“看不见”Cache。因此:

  • CPU 写 → DMA 读:CPU 写入的数据可能还在 Cache 中,未同步到内存,DMA 读到旧数据。
  • DMA 写 → CPU 读:DMA 将新数据写入内存,但 CPU 可能从 Cache 中读到旧数据。

这就是所谓“Cache 一致性”问题。

二、解决思路:MPU 配置 + Cache 维护

STM32H7 提供了两种手段:

  1. MPU 配置:将 DMA 缓冲区所在内存区域配置为 Non-Cacheable 或 Write-Through,从根源上避免不一致。
  2. 手动 Cache 维护:在 DMA 传输前后调用 SCB_CleanDCache_by_Addr() 和 SCB_InvalidateDCache_by_Addr()。

通常建议对频繁 DMA 的大缓冲区使用 MPU 配置为 Non-Cacheable,对小数据或临时缓冲区使用手动维护。

三、MPU 配置步骤(以 SDRAM 区域为例)

假设 DMA 缓冲区位于 SDRAM 地址 0xC0000000,大小 1MB。配置该区域为 Non-Cacheable:

#include "stm32h7xx_hal.h"

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 区域 0:SDRAM 0xC0000000 - 0xC00FFFFF (1MB) Non-Cacheable
    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.BaseAddress      = 0xC0000000;
    MPU_InitStruct.Size             = MPU_REGION_SIZE_1MB;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.TypeExtField     = MPU_TEX_LEVEL0;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;
    MPU_InitStruct.IsShareable      = MPU_ACCESS_NOT_SHAREABLE;
    MPU_InitStruct.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE; // 关键:Non-Cacheable
    MPU_InitStruct.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

调用 MPU_Config() 后,CPU 对该区域的访问将绕过 D-Cache,DMA 与 CPU 看到的内存完全一致。

四、手动 Cache 维护的正确顺序

若缓冲区仍为 Cacheable,则必须在 DMA 传输前后手动维护 Cache。顺序至关重要:

1. CPU 写 → DMA 读(发送方向)

  • 步骤1:CPU 填充缓冲区数据。
  • 步骤2:调用 SCB_CleanDCache_by_Addr(),将 Cache 中“脏”数据写回内存。
  • 步骤3:启动 DMA 发送。
uint8_t tx_buf[1024] __attribute__((aligned(32)));

// 填充数据
for (int i = 0; i < 1024; i++) tx_buf[i] = i;

// Clean D-Cache,确保数据写入物理内存
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, 1024);

// 启动 DMA 发送
HAL_UART_Transmit_DMA(&huart1, tx_buf, 1024);

2. DMA 写 → CPU 读(接收方向)

  • 步骤1:启动 DMA 接收。
  • 步骤2:等待 DMA 传输完成(中断或轮询)。
  • 步骤3:调用 SCB_InvalidateDCache_by_Addr(),丢弃 Cache 中的旧数据,强制 CPU 从内存重新读取。
  • 步骤4:CPU 读取缓冲区。
uint8_t rx_buf[1024] __attribute__((aligned(32)));

// 启动 DMA 接收
HAL_UART_Receive_DMA(&huart1, rx_buf, 1024);

// 等待传输完成(假设使用中断,此处简化为轮询)
while (HAL_UART_GetState(&huart1) != HAL_UART_STATE_READY);

// Invalidate D-Cache,丢弃旧数据
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, 1024);

// 现在可以安全读取 rx_buf
process_data(rx_buf, 1024);

3. 双向传输(如 SPI 全双工)

  • 发送前:Clean TX 缓冲区。
  • 接收后:Invalidate RX 缓冲区。
  • 注意:若 TX 和 RX 是同一缓冲区,则先 Clean 再启动,传输完成后 Invalidate。

五、关键注意事项

  • 地址对齐:SCB_CleanDCache_by_Addr() 和 SCB_InvalidateDCache_by_Addr() 要求地址 32 字节对齐,长度也建议为 32 字节倍数。使用 __attribute__((aligned(32))) 修饰缓冲区。
  • Invalidate 的风险:如果缓冲区中有些数据是 CPU 刚写入但尚未 Clean 的,Invalidate 会直接丢弃,导致数据丢失。因此 Invalidate 前确保没有未 Clean 的写操作。
  • 不要过度维护:频繁 Clean/Invalidate 会降低性能。对于大数据量、高频率的 DMA,优先使用 MPU 配置为 Non-Cacheable。
  • 中断与 Cache:在 DMA 完成中断中调用 Invalidate 是常见做法,但需确保中断优先级配置正确,避免在 Invalidate 过程中 CPU 访问同一缓冲区。
  • 多缓冲区管理:使用多个缓冲区时,每个缓冲区都要独立维护 Cache,不能遗漏。
  • 编译器优化:使用 volatile 或内存屏障(__DSB())确保操作顺序,但 Cache 维护函数内部已包含屏障。

六、总结

STM32H7 的 D-Cache 与 DMA 一致性问题是嵌入式开发中的经典“坑”。核心原则:DMA 看不到 Cache,CPU 与 DMA 共享数据时必须保证内存与 Cache 同步。优先使用 MPU 将 DMA 缓冲区设为 Non-Cacheable;若必须 Cacheable,则严格遵循“发送前 Clean,接收后 Invalidate”的顺序,并注意地址对齐和操作时机。掌握这些,你的 H7 项目将更加稳定可靠。