一、为什么 Cache 会让 DMA 数据“错乱”?

STM32H7 搭载 Cortex-M7,主频高达 480MHz,为了匹配高速内核,芯片内部集成了 L1 Cache(分为 D-Cache 和 I-Cache)。D-Cache 默认关闭,但一旦开启,CPU 访问 SRAM 或外部 SDRAM 时,数据会先被缓存到 Cache 中。

DMA 控制器则直接访问物理内存,不经过 Cache。这就导致两个经典问题:

  • CPU 写,DMA 读:CPU 写入数据后,数据可能还在 Cache 中(Write-Back 模式),未同步到物理内存。DMA 启动后从物理内存读到的仍是旧数据。
  • DMA 写,CPU 读:DMA 将新数据写入物理内存,但 CPU 读取时命中了 Cache 中的旧数据,导致读到“过期”内容。

解决思路有两种:硬件层面用 MPU 将 DMA 缓冲区配置为 Write-Through 或 Non-Cacheable软件层面在 DMA 传输前后手动执行 Clean/Invalidate 操作。两者需结合使用,才能既保证性能又确保数据一致。

二、MPU 配置:为 DMA 缓冲区划定“安全区”

MPU(Memory Protection Unit)可以将特定内存区域设置为不同的缓存策略。对于 DMA 缓冲区,推荐两种方案:

  • Non-Cacheable:完全绕过 Cache,CPU 和 DMA 都直接访问物理内存。简单可靠,但 CPU 访问速度会下降。
  • Write-Through:CPU 写操作同时更新 Cache 和物理内存,读操作仍可缓存。比 Non-Cacheable 性能好,但写操作会占用总线带宽。

下面以将 0x30000000 开始的 32KB 区域配置为 Write-Through 为例,给出 MPU 配置代码。

#include "stm32h7xx.h"

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    /* 配置 D1 SRAM 区域为 Write-Through,允许 DMA 访问 */
    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress      = 0x30000000;
    MPU_InitStruct.Size             = MPU_REGION_SIZE_32KB;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.IsBufferable     = MPU_ACCESS_BUFFERABLE;   // 允许缓冲写
    MPU_InitStruct.IsCacheable      = MPU_ACCESS_CACHEABLE;    // 允许缓存
    MPU_InitStruct.IsShareable      = MPU_ACCESS_NOT_SHAREABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.TypeExtField     = MPU_TEX_LEVEL0;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.DisableExec      = MPU_INSTRUCTION_ACCESS_ENABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    /* 若需 Non-Cacheable,改为:
       IsCacheable = MPU_ACCESS_NOT_CACHEABLE;
       IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; */

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

关键点:MPU 配置必须在使能 Cache 之前完成,且区域不能与其它外设映射冲突。

三、Clean 与 Invalidate:何时用,怎么用?

即使配置了 MPU,若区域仍为 Cacheable(如 Write-Back),仍需手动维护一致性。Cortex-M7 提供以下操作:

  • Clean:将 Cache 中“脏”数据写回物理内存。用于 CPU 写 → DMA 读 场景。
  • Invalidate:丢弃 Cache 中数据,强制下次读取从物理内存获取。用于 DMA 写 → CPU 读 场景。
  • 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);

注意:地址必须 32 字节对齐,长度建议为 32 的整数倍,否则可能误操作相邻数据。

四、完整实战代码:DMA 串口发送与接收

以 UART DMA 发送和接收为例,展示正确的一致性维护流程。

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

/* 发送前:Clean 确保数据写入物理内存 */
void UART_DMA_Send(uint8_t *data, uint16_t len)
{
    memcpy(tx_buf, data, len);
    SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, len);
    HAL_UART_Transmit_DMA(&huart1, tx_buf, len);
}

/* 接收完成回调:Invalidate 丢弃旧缓存,读取新数据 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    if (huart->Instance == USART1) {
        SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, DMA_BUF_SIZE);
        /* 此时 rx_buf 中为 DMA 写入的最新数据 */
        process_data(rx_buf, DMA_BUF_SIZE);
        HAL_UART_Receive_DMA(&huart1, rx_buf, DMA_BUF_SIZE);
    }
}

避坑:在 HAL_UART_RxCpltCallback 中,必须先 Invalidate 再读取数据,且 Invalidate 的地址范围要覆盖整个 DMA 缓冲区,不能只覆盖部分。

五、常见陷阱与注意事项

  • 地址对齐:Clean/Invalidate 操作要求 32 字节对齐,缓冲区定义时使用 __attribute__((aligned(32)))
  • 长度对齐:长度最好是 32 的倍数,否则可能影响相邻变量。
  • 中断中操作:在 DMA 完成中断中执行 Invalidate 是安全的,但注意不要嵌套调用导致性能下降。
  • 多缓冲区:若使用双缓冲,每个缓冲区都要独立维护一致性。
  • MPU 与手动维护二选一? 建议对频繁小数据用 Non-Cacheable,大数据用 Write-Back + 手动维护。
  • 调试时关闭 Cache:若问题诡异,可先关闭 D-Cache 验证是否为一致性问题。
  • 不要忘记 I-Cache:如果 DMA 搬运的是代码(如外部 Flash 加载),需同时处理 I-Cache。

六、总结

STM32H7 的 Cache 与 DMA 一致性问题是嵌入式开发中的“高级坑”,但只要掌握 MPU 配置和 Clean/Invalidate 的适用场景,就能游刃有余。核心原则:CPU 写后 Clean,DMA 写后 Invalidate,地址长度要对齐,MPU 配置要先行。希望本文能帮你少走弯路,让 H7 的性能与稳定性兼得。