一、为什么 DMA 和 D-Cache 会打架?

STM32H7 的 Cortex-M7 内核带有一级 D-Cache(通常 16KB)。当 CPU 访问内存时,数据会被缓存到 D-Cache 中,后续读写直接命中缓存,不再访问物理内存。而 DMA 是独立于 CPU 的总线主设备,它直接读写 SRAM 或外设 FIFO,完全绕过 D-Cache

这就导致两个经典问题:

  • CPU 写,DMA 读:CPU 写数据到缓存,但尚未回写到物理内存,DMA 读到的还是旧数据。
  • DMA 写,CPU 读:DMA 把新数据写入物理内存,但 CPU 读的是缓存里的旧数据。

解决思路只有两条:要么让 DMA 缓冲区不经过 Cache(MPU 配置为 Write-Through/Non-Cacheable),要么在 DMA 传输前后手动维护 Cache 一致性(clean/invalidate)

二、MPU 配置:给 DMA 缓冲区划一块“安全区”

最省心的做法是用 MPU 将 DMA 缓冲区所在的内存区域配置为 Non-CacheableWrite-Through。这样 CPU 和 DMA 看到的内存始终一致,无需手动 clean/invalidate。

2.1 配置步骤

  1. 使能 MPU:SCB_EnableMPU() 前先 SCB_DisableMPU()
  2. 选择一个空闲的 MPU Region(0~15)。
  3. 设置基地址、大小、属性。
  4. 使能该 Region 和 MPU。

2.2 完整代码示例

#include "stm32h7xx.h"

// 假设 DMA 缓冲区放在 D2 SRAM 的 0x30000000 起始,大小 32KB
#define DMA_BUF_ADDR   0x30000000
#define DMA_BUF_SIZE   0x8000

void MPU_Config_DMA_Buffer(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 选择 Region 0
    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.BaseAddress      = DMA_BUF_ADDR;
    MPU_InitStruct.Size             = MPU_REGION_SIZE_32KB;
    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_SHAREABLE;
    MPU_InitStruct.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE; // 关键:非缓存
    MPU_InitStruct.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

注意:BaseAddress 必须按 Region 大小对齐。32KB 区域基地址必须是 32KB 的整数倍。

三、手动维护一致性:clean 与 invalidate

如果 DMA 缓冲区必须放在可缓存区域(例如为了 CPU 高速访问),则需在传输前后调用 CMSIS 提供的函数。

3.1 两个核心函数

  • SCB_CleanDCache_by_Addr(addr, size):将 Cache 中已修改的数据写回物理内存。用于 CPU 写 → DMA 读 之前。
  • SCB_InvalidateDCache_by_Addr(addr, size):将 Cache 中对应行作废,强制下次读取从物理内存加载。用于 DMA 写 → CPU 读 之后。

3.2 正确使用顺序

// 场景:CPU 填充缓冲区,然后启动 DMA 发送
fill_buffer(tx_buf, len);
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, len); // 写回
HAL_DMA_Start(&hdma, (uint32_t)tx_buf, (uint32_t)&UART->TDR, len);

// 场景:DMA 接收完成,CPU 读取数据
// 在 DMA 完成中断中:
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, len); // 作废
process_data(rx_buf, len);

3.3 地址与长度对齐陷阱

Cache 操作以 32 字节 Cache Line 为单位。如果 addr 未对齐或 size 不是 32 的倍数,函数内部会向上取整,可能误伤相邻数据

  • 缓冲区首地址建议 32 字节对齐:__attribute__((aligned(32)))
  • 长度建议补齐到 32 的倍数。
__attribute__((aligned(32))) uint8_t rx_buf[1024];

四、实战避坑清单

  • 坑1:只 clean 不 invalidate。DMA 写回后 CPU 读旧值,必须 invalidate。
  • 坑2:invalidate 在 DMA 传输完成前调用。DMA 还在写,invalidate 后 CPU 可能读到半新半旧数据。应在 DMA 完成中断中调用。
  • 坑3:缓冲区跨 Cache Line 且与其他变量共享。invalidate 会丢弃相邻变量修改,务必对齐并隔离。
  • 坑4:忘记配置 MPU 背景区域。默认背景区域是 Cacheable,若 DMA 缓冲区落在其中且未单独配置,问题依旧。
  • 坑5:使用 SCB_InvalidateDCache() 全局作废。这会清掉所有缓存,性能骤降,且可能影响其他任务,应使用 by_Addr 版本。
  • 坑6:DMA 描述符本身在 Cache 中。若使用链表模式,描述符也要 clean/invalidate 或放在非缓存区。

五、总结

STM32H7 的 D-Cache 与 DMA 一致性没有银弹:简单场景用 MPU 划非缓存区,高性能场景用 clean/invalidate 精准维护。牢记“CPU 写后 clean,DMA 写后 invalidate”,并注意 32 字节对齐,就能避开绝大多数数据错乱问题。建议在项目初期就规划好 DMA 缓冲区的内存布局,后期调试会轻松很多。