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

STM32H7 的 Cortex-M7 内核包含 L1 数据缓存(D-Cache)和指令缓存(I-Cache)。D-Cache 的存在让 CPU 访问频繁使用的数据时无需每次都读/写外部 SRAM 或 SDRAM,从而大幅提升性能。然而,DMA 控制器是独立于 CPU 的总线主设备,它直接访问物理内存,不经过 Cache

这就导致两个典型问题:

  • CPU 写数据,DMA 读:CPU 将数据写入 Cache(写回策略下可能未同步到内存),DMA 从内存读取旧数据,导致传输错误。
  • DMA 写数据,CPU 读:DMA 将新数据写入内存,但 CPU 读取时命中 Cache 中的旧数据,同样出错。

解决思路有两种:禁用 Cache(牺牲性能)或正确维护 Cache 一致性(推荐)。后者需要结合 MPU 配置和 Cache 维护操作。

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

MPU(Memory Protection Unit)可以将特定内存区域配置为非缓存(Non-cacheable)写通(Write-through),从而避免一致性问题。对于 DMA 缓冲区,推荐以下两种策略:

  • 完全非缓存:CPU 访问该区域时不使用 Cache,性能略低但无需手动维护。
  • 写通 + 读分配:写操作直接穿透到内存,读操作仍可缓存,适合读多写少的场景。

2.1 MPU 配置步骤(以 SDRAM 地址 0xC0000000 为例)

  1. 使能 MPU 并配置 Region。
  2. 设置基地址、大小、属性。
  3. 使能 Region 和 MPU。
#include "stm32h7xx.h"

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 配置 SDRAM 区域为 Write-through, no write allocate
    MPU_InitStruct.Enable = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress = 0xC0000000;
    MPU_InitStruct.Size = MPU_REGION_SIZE_64MB;
    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);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

注意:若将区域设为 Non-cacheable,则 IsCacheable 应设为 MPU_ACCESS_NOT_CACHEABLE,且无需后续 Clean/Invalidate 操作。

三、Clean 与 Invalidate:顺序决定成败

当无法使用 MPU 非缓存区域时,必须手动维护 Cache。核心操作有两个:

  • Clean:将 Cache 中已修改的数据写回内存。
  • Invalidate:将 Cache 中的对应行标记为无效,下次读取时从内存重新加载。

3.1 正确顺序

  • CPU 写 → DMA 读:先 Clean,再启动 DMA。
  • DMA 写 → CPU 读:先 Invalidate(在 DMA 启动前),DMA 完成后再次 Invalidate(可选但推荐),再读取数据。

关键陷阱:Invalidate 操作会丢弃 Cache 中未写回的数据。如果 CPU 在 DMA 传输前写入了缓冲区,且未 Clean 就 Invalidate,这些数据将永久丢失。因此,Invalidate 前必须确保没有脏数据,或先 Clean 再 Invalidate。

3.2 完整代码示例(DMA 接收)

#define DMA_BUFFER_SIZE 1024
__attribute__((aligned(32))) uint8_t dma_rx_buffer[DMA_BUFFER_SIZE];

void DMA_Receive_Start(void)
{
    // 1. 若缓冲区之前被 CPU 写过,先 Clean 确保数据写回
    SCB_CleanDCache_by_Addr((uint32_t*)dma_rx_buffer, DMA_BUFFER_SIZE);

    // 2. Invalidate 缓冲区,确保 DMA 写入后 CPU 读到新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buffer, DMA_BUFFER_SIZE);

    // 3. 启动 DMA 接收
    HAL_DMA_Start(&hdma_usart1_rx, (uint32_t)&USART1->RDR, (uint32_t)dma_rx_buffer, DMA_BUFFER_SIZE);
}

void DMA_Receive_Complete_Callback(void)
{
    // 4. DMA 完成后,再次 Invalidate,确保 CPU 读取最新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buffer, DMA_BUFFER_SIZE);

    // 5. 安全读取数据
    process_data(dma_rx_buffer, DMA_BUFFER_SIZE);
}

3.3 发送场景(CPU 写 → DMA 读)

void DMA_Transmit_Start(uint8_t *data, uint32_t len)
{
    memcpy(dma_tx_buffer, data, len);

    // 必须 Clean,确保数据写回内存
    SCB_CleanDCache_by_Addr((uint32_t*)dma_tx_buffer, len);

    HAL_DMA_Start(&hdma_usart1_tx, (uint32_t)dma_tx_buffer, (uint32_t)&USART1->TDR, len);
}

四、注意事项与最佳实践

  • 地址对齐:Cache 操作以 32 字节为单位,缓冲区地址和长度应 32 字节对齐,否则可能影响相邻数据。
  • 避免频繁维护:Clean/Invalidate 有一定开销,尽量将 DMA 缓冲区集中管理,减少调用次数。
  • 使用 MPU 优先:若性能允许,将 DMA 缓冲区设为 Non-cacheable 是最简单可靠的方式。
  • 双缓冲区技巧:对于高速数据流,可采用双缓冲,一个用于 DMA,一个用于 CPU 处理,交替使用并配合 Cache 维护。
  • 调试技巧:若数据异常,可先禁用 D-Cache 验证是否为一致性问题,再逐步启用并添加维护操作。

五、总结

STM32H7 的 Cache 与 DMA 一致性问题是嵌入式开发中的经典坑。核心解决思路是:MPU 配置非缓存区域手动 Clean/Invalidate。牢记顺序:写后 Clean,读前 Invalidate,且 Invalidate 前确保无脏数据。结合对齐和缓冲区管理,即可在享受 Cache 性能的同时保证数据可靠传输。