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

STM32H7 基于 Cortex-M7 内核,主频可达 480MHz,为了弥补 CPU 与存储器之间的速度鸿沟,内核集成了 D-Cache(数据缓存)。D-Cache 以 缓存行(Cache Line) 为单位与主存交互,M7 的缓存行固定为 32 字节。

当 CPU 访问数据时,若命中缓存则直接读写缓存,不会立即同步到 SRAM;而 DMA 控制器直接访问 SRAM,不经过 Cache。这就导致两类问题:

  • CPU 写,DMA 读:CPU 写入的数据可能还在 Cache 中(Write-Back 策略),DMA 从 SRAM 读到的是旧数据。
  • DMA 写,CPU 读:DMA 将新数据写入 SRAM,但 CPU 缓存中仍是旧数据,读取时得到错误值。

解决思路是软件维护一致性:在 DMA 传输前后,按缓存行对齐的地址范围执行 Clean(写回)或 Invalidate(无效化)操作。

二、缓存行对齐的缓冲区设计原则

维护操作的最小单位是 32 字节,因此缓冲区必须按 32 字节对齐,且长度最好是 32 的整数倍。否则 Clean/Invalidate 会波及相邻变量,造成数据损坏。

  • 使用 __attribute__((aligned(32))) 强制对齐。
  • 缓冲区大小向上取整到 32 的倍数。
  • 避免将多个 DMA 缓冲区放在同一缓存行内。
#define DMA_BUF_SIZE  128
__attribute__((aligned(32))) uint8_t dma_tx_buf[DMA_BUF_SIZE];
__attribute__((aligned(32))) uint8_t dma_rx_buf[DMA_BUF_SIZE];

三、MPU 配置:将 DMA 缓冲区设为 Non-Cacheable

更优雅的方案是利用 MPU 将 DMA 缓冲区所在区域配置为 Non-Cacheable,这样 CPU 和 DMA 都直接访问 SRAM,无需手动维护。但 Non-Cacheable 会降低 CPU 访问速度,适合对性能不敏感的大块数据。

若追求性能,仍建议使用 Cacheable + 手动维护。下面给出 MPU 配置示例(以 0x30000000 开始的 64KB 区域为例):

void MPU_Config(void)
{
    HAL_MPU_Disable();
    MPU_Region_InitTypeDef MPU_Init = {0};
    MPU_Init.Enable = MPU_REGION_ENABLE;
    MPU_Init.BaseAddress = 0x30000000;
    MPU_Init.Size = MPU_REGION_SIZE_64KB;
    MPU_Init.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_Init.TypeExtField = MPU_TEX_LEVEL1;
    MPU_Init.IsCacheable = MPU_ACCESS_NOT_CACHEABLE;
    MPU_Init.IsBufferable = MPU_ACCESS_BUFFERABLE;
    MPU_Init.IsShareable = MPU_ACCESS_SHAREABLE;
    MPU_Init.Number = MPU_REGION_NUMBER0;
    MPU_Init.SubRegionDisable = 0x00;
    MPU_Init.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE;
    HAL_MPU_ConfigRegion(&MPU_Init);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

四、手动维护:Clean 与 Invalidate 的正确时机

若缓冲区仍为 Cacheable,必须手动维护。CMSIS 提供了以下函数:

  • SCB_CleanDCache_by_Addr(addr, size):将 Cache 数据写回 SRAM。
  • SCB_InvalidateDCache_by_Addr(addr, size):丢弃 Cache 数据,下次读取从 SRAM 加载。
  • SCB_CleanInvalidateDCache_by_Addr(addr, size):先写回再无效化。

关键规则:

  • CPU 写 → DMA 读:发送前调用 SCB_CleanDCache_by_Addr。
  • DMA 写 → CPU 读:接收后调用 SCB_InvalidateDCache_by_Addr。
  • 若缓冲区在 DMA 传输期间被 CPU 访问,需先 Clean 再 Invalidate。

注意:addr 和 size 必须按 32 字节对齐,否则函数内部会向上取整,可能误伤相邻数据。

五、完整代码示例:UART DMA 收发

以 UART 空闲中断 + DMA 接收为例,展示完整流程。

#include "stm32h7xx_hal.h"

#define RX_BUF_SIZE  128
__attribute__((aligned(32))) uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t rx_flag = 0;

void UART_DMA_Init(void)
{
    // 假设 huart1 已初始化,DMA 已配置为循环模式
    HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

// 空闲中断回调(在 stm32h7xx_it.c 中调用)
void UART_IDLE_Handler(void)
{
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        // 停止 DMA 以获取准确长度
        HAL_UART_DMAStop(&huart1);
        uint16_t len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        // 无效化 Cache,确保 CPU 读到 DMA 写入的新数据
        SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, RX_BUF_SIZE);
        // 处理数据
        Process_Data(rx_buf, len);
        // 重新启动接收
        HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    }
}

// 发送函数
void UART_Send_DMA(uint8_t *data, uint16_t len)
{
    // 确保数据已写回 SRAM
    SCB_CleanDCache_by_Addr((uint32_t*)data, len);
    HAL_UART_Transmit_DMA(&huart1, data, len);
}

六、注意事项与常见坑

  • 对齐是生命线:任何手动维护的地址和长度必须 32 字节对齐,否则会破坏相邻变量。
  • Invalidate 前先 Clean:如果缓冲区在 DMA 传输期间被 CPU 修改过,直接 Invalidate 会丢失修改。
  • 避免在 DMA 传输中访问缓冲区:否则需要额外的同步操作。
  • 使用 __DSB() 确保操作完成:CMSIS 函数内部已包含,但自定义操作时需注意。
  • MPU 与手动维护二选一:不要对 Non-Cacheable 区域调用 Cache 维护函数,虽无害但浪费周期。
  • 调试时关闭 Cache:若问题诡异,可先关闭 D-Cache 定位是否一致性问题。

七、总结

STM32H7 的 D-Cache 与 DMA 一致性维护并不复杂,核心就是 32 字节对齐 + 正确的 Clean/Invalidate 时机。推荐优先使用 MPU 将 DMA 缓冲区设为 Non-Cacheable,简单可靠;若追求极致性能,则严格按本文方法手动维护。掌握这些技巧,你就能在 H7 上放心使用 DMA,充分发挥其高性能优势。