为什么 STM32H7 的 Cache 会成为 DMA 的“猪队友”?

STM32H7 搭载 Cortex-M7 内核,主频高达 480MHz,为了弥补内核与存储器之间的速度鸿沟,芯片内部集成了 L1 Cache(I-Cache 和 D-Cache)。开启 D-Cache 后,CPU 访问 SRAM 或外部 SDRAM 时,数据会先被缓存到 Cache Line(32 字节)中。

问题来了:DMA 是独立于 CPU 的总线主设备,它直接访问物理内存,不经过 Cache。当 CPU 写数据到缓冲区后,数据可能还停留在 D-Cache 中未写回物理内存,此时启动 DMA 发送,DMA 读到的就是旧数据;反之,DMA 接收数据写入物理内存后,CPU 读到的却可能是 D-Cache 中的旧副本。这就是典型的 Cache 一致性(Coherency)问题。

核心原理:Clean 与 Invalidate 到底在做什么?

Cortex-M7 提供了两条关键维护指令:

  • Clean(清理):将 D-Cache 中“脏”的数据写回物理内存。执行后,Cache 行变为“干净”状态,但数据仍在 Cache 中。
  • Invalidate(无效化):将 D-Cache 中的对应行标记为无效。下次 CPU 读取时会强制从物理内存重新加载。

关键原则:

  • CPU 写 → DMA 读(如发送):先 Clean,确保数据落盘。
  • DMA 写 → CPU 读(如接收):先 Invalidate,丢弃旧缓存,让 CPU 重新加载。
  • DMA 写 → CPU 读 → CPU 写 → DMA 读(双向):先 Invalidate,再 Clean,顺序不能反!

配置步骤:以 UART DMA 收发为例

1. 使能 D-Cache 并配置 MPU

默认情况下,STM32H7 的 SRAM 区域是 Write-Back, Write-Allocate 属性,这最容易引发一致性问题。推荐将 DMA 缓冲区所在区域配置为 Non-Cacheable 或 Write-Through,可一劳永逸。

// MPU 配置:将 D2 SRAM 区域设为 Non-Cacheable
void MPU_Config(void)
{
    HAL_MPU_Disable();
    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress      = 0x30000000; // D2 SRAM
    MPU_InitStruct.Size             = MPU_REGION_SIZE_128KB;
    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);
}

2. 若必须使用 Cache,手动维护

若缓冲区仍在 Cacheable 区域,则必须在 DMA 传输前后调用维护函数。

// 发送前:Clean 缓冲区
void DMA_Send_Prepare(uint8_t *buf, uint32_t len)
{
    SCB_CleanDCache_by_Addr((uint32_t *)buf, len);
    // 启动 DMA 发送...
}

// 接收后:Invalidate 缓冲区
void DMA_Recv_Complete(uint8_t *buf, uint32_t len)
{
    SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);
    // 此时 CPU 可安全读取 buf
}

3. 完整收发示例(UART + DMA)

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

void UART_DMA_Init(void)
{
    // 初始化 UART 和 DMA(略)
}

void UART_Send_DMA(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);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE); // 丢弃旧缓存
    // 处理 rx_buf 数据...
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
}

实测波形:顺序错误导致的“幽灵数据”

我们使用逻辑分析仪抓取 UART TX 引脚波形,对比两种场景:

  • 场景 A(正确):发送前 Clean,波形显示数据完整,首字节为 0xAA。
  • 场景 B(错误):发送前未 Clean,波形显示首字节为 0x00(旧数据),后续字节错位。

实测发现,未 Clean 时,DMA 读到的前 32 字节(一个 Cache Line)全部为旧值,因为 CPU 写入的数据还在 Cache 中未同步。

注意事项与避坑指南

  • 地址对齐:SCB_CleanDCache_by_Addr 要求地址 32 字节对齐,长度建议为 32 的整数倍,否则可能误伤相邻数据。
  • 避免在中断中频繁维护:Cache 维护指令耗时较长,高频中断中调用会严重影响实时性。
  • DMA 描述符也要注意:若使用链表式 DMA,描述符本身也需放在 Non-Cacheable 区域或手动 Clean。
  • 双缓冲(Ping-Pong)陷阱:切换缓冲区时,务必对“即将被 DMA 读”的缓冲区 Clean,对“刚被 DMA 写”的缓冲区 Invalidate。
  • 调试时关闭 Cache:若问题诡异,可先关闭 D-Cache 验证是否为一致性问题,再逐步开启优化。

掌握 Clean/Invalidate 的正确顺序,是 STM32H7 高性能应用开发的必修课。希望本文的代码与波形能帮你少走弯路。