STM32H7 的 Cache 与 DMA 数据一致性问题:失效/回写操作的正确顺序与实测陷阱

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

STM32H7 基于 Cortex-M7 内核,主频高达 480MHz,为了弥补 CPU 与存储器之间的速度鸿沟,芯片内部集成了 L1 Cache(指令 Cache 与数据 Cache)。当 CPU 访问内存时,数据会被缓存到 Cache 中,后续访问直接命中 Cache,从而大幅提升性能。

然而,DMA 控制器是独立于 CPU 的总线主设备,它直接访问物理内存(SRAM、SDRAM 等),不经过 Cache。这就导致了经典的一致性问题:

  • CPU 写,DMA 读:CPU 将数据写入 Cache,但尚未回写到物理内存,DMA 读取到的仍是旧数据。
  • DMA 写,CPU 读:DMA 将新数据写入物理内存,但 CPU 读取时命中了 Cache 中的旧数据,导致读到脏数据。

解决这一问题的核心就是软件维护 Cache 一致性,即正确使用 Clean(回写)和 Invalidate(失效)操作。

2. 理解 Clean 与 Invalidate

Cortex-M7 提供了维护 Cache 的指令,通常通过 CMSIS 函数调用:

  • SCB_CleanDCache_by_Addr(addr, size):将 Cache 中指定地址范围的数据回写到物理内存。用于 CPU 写 → DMA 读 的场景。
  • SCB_InvalidateDCache_by_Addr(addr, size):将 Cache 中指定地址范围的数据标记为无效。下次 CPU 读取时会从物理内存重新加载。用于 DMA 写 → CPU 读 的场景。
  • SCB_CleanInvalidateDCache_by_Addr(addr, size):先回写再失效,用于双向传输或不确定方向的场景。

关键原则

  • 启动 DMA 传输前,如果 CPU 已经写入了数据到缓冲区,必须执行 Clean 操作,确保物理内存中的数据是最新的。
  • DMA 传输完成后,如果 CPU 要读取 DMA 写入的数据,必须执行 Invalidate 操作,丢弃 Cache 中的旧副本。
  • 如果缓冲区是 DMA 写入 的,在启动 DMA 前执行 Invalidate 是危险的,因为可能丢弃 CPU 尚未回写的数据。

3. 正确操作顺序与代码示例

以下以 UART DMA 接收为例,展示正确的 Cache 维护流程。假设使用 __attribute__((at(0x30000000))) 将缓冲区定位到 SDRAM(非 Cache 区域或可 Cache 区域均可,但需一致处理)。

3.1 定义对齐的缓冲区

Cache 操作要求地址和大小按 Cache Line(32 字节)对齐,否则可能影响相邻数据。

#include "stm32h7xx.h"

#define BUFFER_SIZE  128
#define CACHE_LINE   32

// 按 Cache Line 对齐,并确保大小是 Cache Line 的整数倍
aligned(32) uint8_t dma_rx_buffer[BUFFER_SIZE] __attribute__((section(".sdram")));

3.2 DMA 接收流程

void uart_dma_receive_start(void)
{
    // 1. 失效 Cache,确保 DMA 写入的数据不会与 Cache 中的旧数据冲突
    //    注意:如果缓冲区之前被 CPU 写过,应先 Clean 再 Invalidate
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buffer, BUFFER_SIZE);

    // 2. 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart1, dma_rx_buffer, BUFFER_SIZE);
}

// DMA 传输完成回调函数
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 3. DMA 传输完成,失效 Cache,确保 CPU 读取到最新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buffer, BUFFER_SIZE);

    // 4. 安全读取数据
    process_data(dma_rx_buffer, BUFFER_SIZE);
}

3.3 DMA 发送流程

void uart_dma_send_data(uint8_t *data, uint16_t len)
{
    // 1. 将数据拷贝到 DMA 缓冲区(如果 data 不是 DMA 缓冲区)
    memcpy(dma_tx_buffer, data, len);

    // 2. 回写 Cache,确保物理内存中的数据是最新的
    SCB_CleanDCache_by_Addr((uint32_t*)dma_tx_buffer, len);

    // 3. 启动 DMA 发送
    HAL_UART_Transmit_DMA(&huart1, dma_tx_buffer, len);
}

4. 实测陷阱与注意事项

4.1 地址与大小必须按 Cache Line 对齐

Cortex-M7 的 Cache Line 为 32 字节。如果操作地址未对齐,SCB_InvalidateDCache_by_Addr 可能会失效相邻数据,导致不可预知的错误。务必使用 aligned(32) 定义缓冲区,并确保操作大小是 32 的整数倍

4.2 Invalidate 前必须 Clean

如果缓冲区在 DMA 传输前被 CPU 写过,直接 Invalidate 会丢弃尚未回写的数据。正确做法是:

SCB_CleanDCache_by_Addr((uint32_t*)buffer, size);
SCB_InvalidateDCache_by_Addr((uint32_t*)buffer, size);

或者直接使用 SCB_CleanInvalidateDCache_by_Addr

4.3 避免在中断中频繁操作 Cache

Cache 维护操作本身有一定开销,在高速中断中频繁调用可能导致性能下降。建议合理规划缓冲区,减少维护次数。

4.4 MPU 配置非 Cache 区域

对于频繁与 DMA 交互的缓冲区,可以通过 MPU 将其配置为 Non-CacheableWrite-Through 模式,从而避免软件维护。例如:

MPU_Region_InitTypeDef MPU_InitStruct;
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_ENABLE;
HAL_MPU_ConfigRegion(&MPU_InitStruct);

4.5 使用 SCB_InvalidateDCache() 全局失效的代价

全局失效会清空整个 D-Cache,导致后续访问全部未命中,性能骤降。务必使用按地址失效的函数

4.6 调试时关闭 Cache 的假象

在调试器中关闭 Cache 后程序正常,开启后异常,这往往就是一致性问题。不要依赖关闭 Cache 来“解决”问题,而应正确维护。

5. 总结

STM32H7 的 Cache 与 DMA 是一对需要精心协调的伙伴。牢记以下要点:

  • CPU 写 → DMA 读:启动 DMA 前执行 Clean
  • DMA 写 → CPU 读:DMA 完成后执行 Invalidate
  • 双向或不确定:使用 CleanInvalidate
  • 地址与大小必须 32 字节对齐
  • 优先考虑 MPU 配置非 Cache 区域,简化软件设计。

掌握这些原则,你就能在 STM32H7 上驾驭高性能 DMA 传输,避免那些令人抓狂的随机错误。