STM32H7 的 D-Cache 与 DMA 一致性:从 Cache 维护操作到 MPU 配置的完整避坑指南

STM32H7 系列凭借 480MHz 主频和 Cortex-M7 内核,成为高性能嵌入式应用的首选。但许多开发者从 F1/F4 迁移到 H7 后,会遇到一个诡异现象:DMA 传输的数据偶尔出错,且错误随机、难以复现。这通常是 D-Cache 与 DMA 的一致性冲突 导致的。本文将带你从原理到实践,彻底解决这个问题。

一、为什么会有数据一致性问题?

Cortex-M7 的 D-Cache 位于 CPU 和总线矩阵之间。当 CPU 访问内存时,数据可能被缓存在 D-Cache 中,而不会立即写回主存。DMA 控制器则直接访问主存,不经过 Cache。

  • CPU 写,DMA 读:CPU 写入的数据可能还在 Cache 中(Write-Back 模式),DMA 从主存读到的是旧数据。
  • DMA 写,CPU 读:DMA 将新数据写入主存,但 CPU 可能从 Cache 中读到旧数据。

解决思路有两种:Cache 维护操作(软件手动同步)和 MPU 配置(将 DMA 缓冲区设为非缓存或写穿透)。

二、Cache 维护操作:Clean 与 Invalidate

CMSIS 提供了核心函数:

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);
  • Clean:将 Cache 中已修改的数据写回主存。适用于 CPU 写 → DMA 读 的场景。
  • Invalidate:丢弃 Cache 中的内容,强制下次读取从主存获取。适用于 DMA 写 → CPU 读 的场景。
  • CleanInvalidate:先写回再丢弃,适用于双向传输。

关键原则

  • 在启动 DMA 传输 之前,对源缓冲区执行 Clean(如果 CPU 写过)。
  • 在 DMA 传输 完成之后,对目标缓冲区执行 Invalidate(如果 CPU 要读)。
  • 地址必须 32 字节对齐,长度建议按 32 字节向上取整。

三、完整代码示例:DMA 串口收发

以 UART DMA 发送和接收为例,展示正确的维护操作。

#include "stm32h7xx.h"

#define BUF_SIZE 128
__attribute__((aligned(32))) uint8_t tx_buf[BUF_SIZE];
__attribute__((aligned(32))) uint8_t rx_buf[BUF_SIZE];

/* UART DMA 发送 */
void uart_dma_send(uint8_t *data, uint16_t len) {
    memcpy(tx_buf, data, len);
    // 1. 清理 D-Cache,确保数据写入主存
    SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);
    // 2. 启动 DMA 传输
    HAL_UART_Transmit_DMA(&huart1, tx_buf, len);
}

/* DMA 接收完成回调(在中断中调用) */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // 1. 无效化 D-Cache,丢弃旧数据
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
    // 2. 此时 CPU 读取 rx_buf 才能获得 DMA 写入的新数据
    process_data(rx_buf, BUF_SIZE);
    // 重新启动接收
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
}

注意SCB_InvalidateDCache_by_Addr 会丢弃指定地址范围内的所有 Cache 行。如果该范围内有 CPU 尚未写回的数据,将永久丢失。因此,Invalidate 前必须确保没有待写回的数据,或使用 CleanInvalidate。

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

频繁的 Cache 维护操作会增加 CPU 开销,且容易遗漏。更优雅的方式是使用 MPU 将 DMA 缓冲区所在的内存区域配置为 Non-CacheableWrite-Through

方案一:Non-Cacheable(非缓存)

  • 优点:无需任何维护操作,CPU 和 DMA 始终看到一致的数据。
  • 缺点:CPU 访问该区域速度下降(无 Cache 加速)。
  • 适用:小容量、频繁 DMA 的缓冲区。

方案二:Write-Through(写穿透)

  • 优点:CPU 写操作立即写入主存,读操作仍可缓存。
  • 缺点:写性能略低于 Write-Back,但比 Non-Cacheable 好。
  • 适用:以 CPU 写、DMA 读为主的场景。

MPU 配置代码示例

void MPU_Config(void) {
    HAL_MPU_Disable();
    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 配置 DMA 缓冲区区域:0x30000000,大小 64KB
    MPU_InitStruct.Enable = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress = 0x30000000;
    MPU_InitStruct.Size = MPU_REGION_SIZE_64KB;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
    MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; // 非缓存
    MPU_InitStruct.IsShareable = MPU_ACCESS_SHAREABLE;
    MPU_InitStruct.Number = MPU_REGION_NUMBER0;
    MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

将 DMA 缓冲区定义到该区域:

__attribute__((section(".dma_buffer"))) uint8_t dma_buf[4096];

并在链接脚本中分配 .dma_buffer0x30000000

五、避坑清单

  • 地址对齐:Cache 维护操作要求地址 32 字节对齐,长度按 32 字节向上取整。
  • Invalidate 风险:Invalidate 会丢弃未写回的数据,确保操作前无脏数据。
  • 中断中调用:Cache 维护函数执行时间较长,避免在高速中断中频繁调用。
  • 多缓冲区:若使用多个 DMA 缓冲区,确保每个缓冲区都正确维护或配置 MPU。
  • DMA 描述符:对于链式 DMA,描述符本身也可能被缓存,需一并处理。
  • 调试器影响:调试时 Cache 行为可能不同,建议在 Release 模式下验证。

六、总结

STM32H7 的 D-Cache 与 DMA 一致性问题是高性能开发的必经之路。小规模、临时性 场景可使用 Clean/Invalidate 手动维护;大规模、长期 场景强烈推荐 MPU 配置为非缓存或写穿透。理解原理、遵循对齐规则、结合 MPU,才能让 H7 的性能与稳定性兼得。