STM32H7 的 D-Cache 与 DMA 一致性:从 Cache 行回写到 MPU 配置的完整避坑指南

1. 为什么 DMA 遇上 D-Cache 会“翻车”

STM32H7 的 Cortex-M7 内核最高运行在 480MHz,为了弥补内核与 SRAM/外部存储器之间的速度鸿沟,芯片内置了 D-Cache(数据缓存)。CPU 读写数据时,实际访问的是 Cache,而不是直接访问 SRAM。

DMA 控制器则是一个“独立搬运工”,它直接访问物理内存,完全不知道 Cache 的存在。这就导致两类经典问题:

  • DMA 读取(外设→内存):DMA 把新数据写入 SRAM,但 CPU 可能仍从 Cache 中读到旧数据(因为 Cache 里的副本没更新)。
  • DMA 发送(内存→外设):CPU 刚写入的数据还在 Cache 中(写回策略下未同步到 SRAM),DMA 却从 SRAM 读到旧值,导致发送错误数据。

根本原因:Cache 与 DMA 看到的内存视图不一致。

2. Cache 行与回写机制

Cortex-M7 的 D-Cache 以 Cache Line(缓存行) 为单位管理,STM32H7 的 Cache Line 大小为 32 字节。所有维护操作都必须按 32 字节对齐进行。

  • 写回(Write-Back):CPU 写数据只更新 Cache,标记为“脏”;只有 Cache 行被驱逐时才写回 SRAM。
  • 写通(Write-Through):CPU 写数据同时更新 Cache 和 SRAM,一致性较好但性能略低。

STM32H7 默认使用 Write-Back,因此 DMA 发送前必须手动将 Cache 行“清洗”到 SRAM。

3. 软件维护:Clean / Invalidate 的正确姿势

CMSIS 提供了三个关键函数:

  • SCB_CleanDCache_by_Addr(addr, size):将 Cache 中脏数据写回 SRAM。
  • SCB_InvalidateDCache_by_Addr(addr, size):丢弃 Cache 内容,强制下次从 SRAM 读取。
  • SCB_CleanInvalidateDCache_by_Addr(addr, size):先清洗再无效。

使用规则:

  • DMA 发送前:CleanDCache,确保 SRAM 数据最新。
  • DMA 接收后:InvalidateDCache,丢弃旧缓存,让 CPU 读到 DMA 写入的新数据。
  • 若缓冲区可能被 CPU 修改过,接收前应先 Clean 再 Invalidate,防止脏数据覆盖 DMA 结果。

注意:地址和长度必须 32 字节对齐,否则会误伤相邻数据。

#include "stm32h7xx.h"

#define DMA_BUF_SIZE  128
#define CACHE_LINE    32

// 按 32 字节对齐的缓冲区
__attribute__((aligned(CACHE_LINE))) uint8_t dma_tx_buf[DMA_BUF_SIZE];
__attribute__((aligned(CACHE_LINE))) uint8_t dma_rx_buf[DMA_BUF_SIZE];

// DMA 发送前:清洗 Cache
void dma_send_prepare(void *buf, uint32_t len)
{
    uint32_t addr = (uint32_t)buf;
    uint32_t size = (len + CACHE_LINE - 1) & ~(CACHE_LINE - 1);
    SCB_CleanDCache_by_Addr((uint32_t *)addr, size);
}

// DMA 接收后:无效 Cache
void dma_recv_complete(void *buf, uint32_t len)
{
    uint32_t addr = (uint32_t)buf;
    uint32_t size = (len + CACHE_LINE - 1) & ~(CACHE_LINE - 1);
    SCB_InvalidateDCache_by_Addr((uint32_t *)addr, size);
}

4. MPU 配置:一劳永逸的 DMA 缓冲区方案

软件维护虽然有效,但每次传输都要调用函数,容易遗漏。更优雅的方式是使用 MPU(内存保护单元) 将 DMA 缓冲区配置为 Non-Cacheable 或 Write-Through 属性。

推荐配置:

  • Non-Cacheable:CPU 访问该区域不经过 Cache,DMA 与 CPU 天然一致,但 CPU 访问速度下降。
  • Write-Through + Read-Allocate:折中方案,写操作同步到 SRAM,读操作仍可缓存。

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

void MPU_Config_DMA_Region(void)
{
    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    HAL_MPU_Disable();

    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);
}

配置后,将 DMA 缓冲区放入该区域(例如使用链接脚本定位到 0x30000000),即可省去手动维护 Cache 的步骤。

5. 完整示例:UART DMA 接收 + Cache 维护

#define RX_SIZE 64
__attribute__((aligned(32))) uint8_t uart_rx[RX_SIZE];

void UART_DMA_Init(void)
{
    // 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart1, uart_rx, RX_SIZE);
}

// DMA 接收完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        // 无效 Cache,确保 CPU 读到 DMA 新数据
        SCB_InvalidateDCache_by_Addr((uint32_t *)uart_rx, RX_SIZE);
        // 处理数据...
    }
}

6. 避坑清单

  • 地址对齐:所有 Cache 维护操作必须 32 字节对齐,缓冲区用 __attribute__((aligned(32)))。
  • 长度向上取整:长度不是 32 的倍数时,向上取整到 Cache Line 边界。
  • 顺序不能反:发送前 Clean,接收后 Invalidate;接收前若缓冲区被 CPU 写过,先 Clean 再 Invalidate。
  • 不要对栈变量做 DMA:栈变量地址可能未对齐,且生命周期不可控。
  • MPU 区域不要重叠:多个 MPU 区域重叠时,编号高的优先,配置时注意顺序。
  • 中断中调用 Cache 维护函数:这些函数执行时间较长,避免在高频中断中调用。
  • 调试时关闭 Cache:若问题诡异,可先关闭 D-Cache 验证是否为一致性问题。

掌握以上原理与配置,STM32H7 的 D-Cache 与 DMA 就能和谐共处,既享受高性能,又保证数据可靠。