为什么 DMA 和 D-Cache 会打架

STM32H7 使用 Cortex-M7 内核,带 16KB 的 D-Cache。CPU 访问内存时优先命中 Cache,而 DMA 是独立于内核的总线主设备,它直接读写 SRAM,完全绕过 Cache。于是同一个地址出现两份数据:Cache 里一份、SRAM 里一份。

  • CPU 写数据:只更新了 Cache,SRAM 还是旧值,DMA 发出去的是旧数据。
  • DMA 收数据:只更新了 SRAM,Cache 里还是旧值,CPU 读到的是旧数据。

这就是数据不一致的根源。解决办法不是关 Cache(H7 关掉 D-Cache 性能损失巨大),而是在正确的时机做 Clean 和 Invalidate

Clean 与 Invalidate 的语义边界

先明确两个操作的准确含义,这是最容易搞混的地方:

  • Clean(清理):把 Cache 中“脏”数据写回 SRAM。方向是 Cache → 内存。用于 CPU 写、DMA 读 的场景。
  • Invalidate(无效化):把 Cache 行标记为无效,下次读时从 SRAM 重新加载。方向是内存 → Cache。用于 DMA 写、CPU 读 的场景。

关键边界:

  • 发送前(CPU 填好缓冲区,交给 DMA 发):必须 Clean,让 SRAM 拿到最新数据。
  • 接收后(DMA 填好缓冲区,CPU 要读):必须 Invalidate,丢弃 Cache 里的旧副本。
  • 接收:如果缓冲区之前被 CPU 读过(Cache 里有副本),也要先 Invalidate,否则 DMA 写入 SRAM 后,CPU 读到的仍是 Cache 旧值。

一句话记忆:谁写谁负责同步,读之前先失效

配置步骤

1. 使能 D-Cache

void Cache_Enable(void)
{
    SCB_EnableICache();
    SCB_EnableDCache();
}

2. MPU 配置 DMA 缓冲区为 Non-Cacheable(推荐方案)

对于频繁 DMA 的缓冲区,最省心的做法是用 MPU 把该区域设为 Non-Cacheable,彻底绕开一致性问题:

void MPU_Config(void)
{
    HAL_MPU_Disable();
    MPU_Region_InitTypeDef init = {0};
    init.Enable           = MPU_REGION_ENABLE;
    init.BaseAddress      = 0x30000000;      /* D2 SRAM,DMA 常用区 */
    init.Size             = MPU_REGION_SIZE_64KB;
    init.AccessPermission = MPU_REGION_FULL_ACCESS;
    init.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;
    init.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE;  /* 关键 */
    init.IsShareable      = MPU_ACCESS_SHAREABLE;
    init.Number           = MPU_REGION_NUMBER0;
    init.TypeExtField     = MPU_TEX_LEVEL1;
    init.SubRegionDisable = 0x00;
    init.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;
    HAL_MPU_ConfigRegion(&init);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

3. 若缓冲区仍可缓存,则手动维护

/* 发送前:把 CPU 写的数据刷到 SRAM */
SCB_CleanDCache_by_Addr((uint32_t *)buf, len);

/* 接收后:丢弃 Cache 旧副本,强制从 SRAM 读 */
SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);

完整代码示例:UART DMA 收发

#include "stm32h7xx_hal.h"

#define BUF_SIZE 128
/* 必须 32 字节对齐,且长度按 32 字节向上取整 */
aligned_32 uint8_t tx_buf[BUF_SIZE];
aligned_32 uint8_t rx_buf[BUF_SIZE];

/* 发送:CPU 填数据 -> Clean -> 启动 DMA */
void Uart_Send_DMA(UART_HandleTypeDef *huart, uint8_t *data, uint16_t len)
{
    memcpy(tx_buf, data, len);
    SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);
    HAL_UART_Transmit_DMA(huart, tx_buf, len);
}

/* 接收前:先 Invalidate,避免读到 Cache 旧值 */
void Uart_Start_Receive(UART_HandleTypeDef *huart, uint16_t len)
{
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len);
    HAL_UART_Receive_DMA(huart, rx_buf, len);
}

/* 接收完成回调:DMA 已写入 SRAM,CPU 读前再 Invalidate */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
    /* 此时读 rx_buf 才是 DMA 收到的真实数据 */
    ProcessData(rx_buf, BUF_SIZE);
}

踩坑复盘

坑一:Invalidate 把未回写的数据丢了

某项目在接收回调里对整块 128 字节缓冲区 Invalidate,但缓冲区前 32 字节是 CPU 刚写的配置头。Invalidate 直接丢弃了 Cache 中未 Clean 的脏数据,配置头变成随机值。

教训:Invalidate 前若该区域有 CPU 写入且未 Clean,必须先 Clean,否则数据丢失。

坑二:地址或长度未按 32 字节对齐

SCB_InvalidateDCache_by_Addr 内部按 Cache 行(32 字节)操作。若地址非 32 字节对齐,或长度不是 32 的倍数,会误伤相邻数据。曾出现缓冲区首字节被清、尾部数据被破坏的诡异现象。

教训:缓冲区用 __attribute__((aligned(32))) 对齐,长度向上取整到 32 的倍数。

坑三:DMA 描述符与数据在同一 Cache 行

DMA 链表描述符紧挨着数据缓冲区,位于同一 32 字节 Cache 行。Clean 数据时把描述符也刷了,Invalidate 时又把描述符改了,导致 DMA 传输错乱。

教训:描述符与数据缓冲区之间留出至少 32 字节间隔,或分别放到 Non-Cacheable 区域。

注意事项

  • 优先用 MPU 把 DMA 缓冲区设为 Non-Cacheable,比手动维护更可靠。
  • 手动维护时,Clean 用于“写后发”,Invalidate 用于“收后读”。
  • 地址与长度务必 32 字节对齐,长度向上取整。
  • Invalidate 会丢弃未回写的脏数据,操作前确认无待写数据。
  • 回调函数中操作 Cache 前,确保 DMA 传输已真正结束(用传输完成中断而非轮询标志)。
  • 调试时若怀疑 Cache 问题,可临时关闭 D-Cache 验证,但正式产品不要长期关闭。

理清 Clean/Invalidate 的方向和时机,配合 MPU 与对齐规范,STM32H7 的 DMA 数据一致性问题就能从“玄学”变成可控的工程问题。