一、为什么 DMA 与 D-Cache 会“打架”?

STM32H7 搭载 Cortex-M7 内核,主频可达 480MHz,为了匹配高速内核与相对较慢的 SRAM/外部存储器,芯片默认开启了 D-Cache(数据缓存)。D-Cache 位于 CPU 与总线矩阵之间,CPU 读写数据时实际访问的是 Cache,而非直接访问物理内存。

DMA 则绕过 CPU,直接在内存与外设之间搬运数据。当 CPU 修改了 Cache 中的数据但尚未写回内存(写回策略),或 DMA 更新了内存但 Cache 中仍是旧数据时,就会产生数据一致性问题。典型现象:

  • DMA 发送的数据不是最新值(Cache 未写回)
  • DMA 接收的数据被 CPU 读成旧值(Cache 未失效)
  • 数据“随机”出错,调试时正常、全速运行异常

二、Cache 维护操作:Clean 与 Invalidate

解决一致性问题的核心是在正确的时机执行 Cache 维护操作。Cortex-M7 提供以下指令:

  • Clean(清理):将 Cache 中已修改的数据写回物理内存。用于 CPU 写、DMA 读 的场景(如 DMA 发送)。
  • Invalidate(无效化):将 Cache 行标记为无效,下次读取时从内存重新加载。用于 DMA 写、CPU 读 的场景(如 DMA 接收)。
  • Clean & Invalidate:先写回再无效化,用于双向传输或不确定方向的场景。

CMSIS 提供了封装函数(需包含 core_cm7.h):

void SCB_CleanDCache_by_Addr(uint32_t *addr, int32_t dsize);
void SCB_InvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize);
void SCB_CleanInvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize);

关键点:地址必须按 32 字节对齐(Cache 行大小),长度建议向上取整到 32 字节的倍数,否则可能误伤相邻数据。

三、DMA 传输中的正确操作顺序

3.1 DMA 发送(内存 → 外设)

  1. CPU 填充发送缓冲区
  2. Clean D-Cache(确保数据写回内存)
  3. 启动 DMA 发送
  4. 等待传输完成
#define BUF_SIZE 128
__attribute__((aligned(32))) uint8_t tx_buf[BUF_SIZE];

void dma_send(uint8_t *data, uint16_t len) {
    memcpy(tx_buf, data, len);
    // 清理 D-Cache,将 tx_buf 写回内存
    SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, BUF_SIZE);
    // 启动 DMA(以 UART 为例)
    HAL_UART_Transmit_DMA(&huart1, tx_buf, len);
}

3.2 DMA 接收(外设 → 内存)

  1. Invalidate D-Cache(丢弃旧缓存,避免读到脏数据)
  2. 启动 DMA 接收
  3. 等待传输完成
  4. CPU 读取数据(此时从内存加载新数据)
__attribute__((aligned(32))) uint8_t rx_buf[BUF_SIZE];

void dma_receive(void) {
    // 无效化 D-Cache,确保后续读取从内存获取
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // 传输完成后再次无效化,保证数据可见
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
    // 此时读取 rx_buf 是 DMA 写入的最新数据
    process_data(rx_buf, BUF_SIZE);
}

四、MPU 配置:一劳永逸的方案

频繁的 Cache 维护会带来性能开销,且容易遗漏。更优雅的方式是使用 MPU(内存保护单元) 将 DMA 缓冲区所在的内存区域配置为 Non-Cacheable(非缓存)Write-Through(写透)

4.1 配置步骤

  1. 使能 MPU:HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
  2. 定义 MPU 区域,设置基地址、大小、属性
  3. 将 DMA 缓冲区放入该区域

4.2 完整代码示例

#include "stm32h7xx_hal.h"

// 定义非缓存区域,用于 DMA 缓冲区
#define DMA_BUFFER_ADDR  0x30000000  // 例如 D2 SRAM 起始地址
#define DMA_BUFFER_SIZE  MPU_REGION_SIZE_64KB

void MPU_Config(void) {
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 配置区域 0:DMA 缓冲区,非缓存
    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.BaseAddress      = DMA_BUFFER_ADDR;
    MPU_InitStruct.Size             = DMA_BUFFER_SIZE;
    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);
}

// 将 DMA 缓冲区放在非缓存区域
__attribute__((section(".dma_buffer"), aligned(32)))
uint8_t dma_buffer[4096];

在链接脚本中定义 .dma_buffer 段,将其定位到 0x30000000

.dma_buffer (NOLOAD) : {
    . = ALIGN(32);
    *(.dma_buffer)
    . = ALIGN(32);
} > RAM_D2

五、注意事项与避坑清单

  • 地址对齐:Cache 维护函数要求地址 32 字节对齐,长度建议为 32 的倍数。
  • 内存屏障:在 Cache 维护后、启动 DMA 前,插入 __DSB() 确保操作完成。
  • 避免栈上缓冲区:DMA 缓冲区不要放在栈上,栈可能被 Cache 覆盖且地址不固定。
  • 多缓冲区管理:若使用多个 DMA 缓冲区,确保每个都正确对齐和维护。
  • 性能权衡:非缓存区域会降低 CPU 访问速度,仅将 DMA 缓冲区设为非缓存,其余内存保持缓存。
  • 调试陷阱:调试器可能自动执行 Cache 维护,导致问题在调试时消失,务必全速运行验证。
  • H7 的 AXI SRAM:默认映射为 Cacheable,若用作 DMA 缓冲区,必须通过 MPU 重配置。

六、总结

STM32H7 的 D-Cache 与 DMA 一致性问题是嵌入式开发中的经典“坑”。掌握 Clean/Invalidate 的适用场景正确的操作顺序,并利用 MPU 将 DMA 缓冲区配置为非缓存,即可从根本上避免数据错乱。建议在项目初期就规划好内存布局,将 DMA 缓冲区统一放在非缓存区域,既保证性能又提升可靠性。