一、为什么 DCache 会和 DMA 打架?

STM32H7 的 Cortex-M7 内核带有 L1 数据缓存(DCache)。CPU 访问内存时,若命中 Cache,直接读写缓存行,不会立即同步到 SRAM。而 DMA 是独立于 CPU 的总线主设备,它直接访问物理内存,完全绕过 Cache。

这就导致两个典型问题:

  • CPU 写、DMA 读:CPU 把数据写入 Cache,但尚未写回 SRAM,DMA 读到的仍是旧数据。
  • DMA 写、CPU 读:DMA 把新数据写入 SRAM,但 CPU 读的是 Cache 中的旧副本。

更隐蔽的是,Cache 操作以 32 字节缓存行 为单位。如果 DMA 缓冲区没有按 32 字节对齐,clean/invalidate 会误伤相邻数据,造成“莫名其妙”的变量被改写。

二、地址对齐:第一道防线

所有与 DMA 共用的缓冲区必须满足:

  • 起始地址按 32 字节对齐(Cache 行大小)。
  • 缓冲区大小最好是 32 字节的整数倍。
  • 避免将多个 DMA 缓冲区放在同一个 Cache 行内。

推荐使用编译器属性强制对齐:

#include <stdint.h>

#define DMA_BUF_SIZE  256

/* 32 字节对齐的 DMA 发送缓冲区 */
__attribute__((aligned(32))) uint8_t dma_tx_buf[DMA_BUF_SIZE];

/* 32 字节对齐的 DMA 接收缓冲区 */
__attribute__((aligned(32))) uint8_t dma_rx_buf[DMA_BUF_SIZE];

如果使用动态内存,可用 memalign(32, size) 或 aligned_alloc(32, size)。

三、MPU 配置:让 Cache 行为可预测

STM32H7 的 MPU 可以按区域设置内存属性。对于 DMA 缓冲区,推荐配置为 Non-Cacheable 或 Write-Through,从根源上避免一致性问题。

以下示例将 0x30000000 开始的 64KB 区域配置为 Non-Cacheable:

#include "stm32h7xx.h"

void MPU_Config_DMA_Region(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.BaseAddress      = 0x30000000;
    MPU_InitStruct.Size             = MPU_REGION_SIZE_64KB;
    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;   /* 关键 */
    MPU_InitStruct.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

如果缓冲区在 DTCM(0x20000000)中,DTCM 本身不经过 Cache,无需 MPU 配置,但要注意 DMA 能否访问 DTCM(部分 DMA 无法访问)。

四、clean 与 invalidate 的时机

当缓冲区必须使用 Cacheable 内存时,必须在正确时机手动维护一致性。

  • CPU 写 → DMA 读(发送):在启动 DMA 前,对发送缓冲区执行 clean,把 Cache 数据写回 SRAM。
  • DMA 写 → CPU 读(接收):在 DMA 完成中断中,先 invalidate 接收缓冲区,丢弃 Cache 旧数据,再读取。

注意:invalidate 会丢弃未写回的数据,因此接收缓冲区在 DMA 传输期间不能被 CPU 写入。

/* 发送前:clean 发送缓冲区 */
SCB_CleanDCache_by_Addr((uint32_t *)dma_tx_buf, DMA_BUF_SIZE);
HAL_UART_Transmit_DMA(&huart1, dma_tx_buf, DMA_BUF_SIZE);

/* 接收完成回调:invalidate 接收缓冲区 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    SCB_InvalidateDCache_by_Addr((uint32_t *)dma_rx_buf, DMA_BUF_SIZE);
    /* 此时读取 dma_rx_buf 才是 DMA 写入的最新数据 */
    ProcessData(dma_rx_buf, DMA_BUF_SIZE);
}

对于双向传输(如 SPI 全双工),发送和接收缓冲区都要处理:先 clean TX,再 invalidate RX。

五、完整排查清单

遇到 DCache 与 DMA 冲突时,按以下顺序检查:

  • 缓冲区地址是否 32 字节对齐?大小是否为 32 的倍数?
  • 是否将 DMA 区域配置为 Non-Cacheable?若已配置,无需手动 clean/invalidate。
  • 若使用 Cacheable 内存,发送前是否 clean?接收后是否 invalidate?
  • invalidate 是否在 DMA 完成中断内、读取数据之前执行?
  • 是否误对包含其他变量的 Cache 行执行了 invalidate?
  • DMA 是否能访问该内存区域(DTCM 通常不可被通用 DMA 访问)?
  • 使用 SCB_InvalidateDCache_by_Addr 时地址是否 32 字节对齐?否则会触发硬件异常。

六、注意事项

  • 不要对栈上变量做 DMA:栈地址不可控,极易跨 Cache 行。
  • clean/invalidate 的地址必须 32 字节对齐,长度建议向上取整到 32 的倍数。
  • 若使用 SCB_CleanInvalidateDCache() 全量操作,会严重影响性能,仅调试时使用。
  • 在 DMA 传输期间,CPU 不要访问接收缓冲区,否则可能读到脏数据或触发一致性问题。
  • 使用 __DSB() 确保 Cache 操作完成后再启动 DMA。

七、总结

DCache 与 DMA 冲突的本质是“两份数据副本”。解决思路有三层:地址对齐是基础,MPU 配置 Non-Cacheable 是最省心的方案,手动 clean/invalidate 是灵活但需谨慎的兜底手段。理解 32 字节缓存行和操作时机,就能让 STM32H7 的高性能与 DMA 稳定共存。