一、问题现象:偶发花屏,重启就好

某项目使用 STM32H743 + LTDC 驱动 RGB 屏,帧缓冲放在 SDRAM。运行几分钟后屏幕偶发花屏,重启后恢复。用调试器查看 SDRAM 中帧缓冲数据完全正确,但屏幕显示错乱。

关键线索:

  • 花屏出现时间随机,与负载相关
  • 关闭 D-Cache 后问题消失,但性能骤降
  • 使用 DMA2D 搬运图层数据到帧缓冲

这是典型的 Cache 与 DMA 一致性 问题。

二、原理:为什么 Cache 会“骗”你

Cortex-M7 的 D-Cache 是写回(Write-Back)、写分配(Write-Allocate)的。CPU 写数据时只更新 Cache,不立即写回内存;CPU 读数据时若 Cache 命中,直接返回 Cache 内容,不读内存。

而 DMA 是“老实人”,它直接访问物理内存,完全不知道 Cache 的存在。于是出现两种冲突:

  1. CPU 写、DMA 读:CPU 新数据还在 Cache 里,DMA 读到的是内存中的旧数据。
  2. DMA 写、CPU 读:DMA 把新数据写入内存,但 CPU 读的是 Cache 里的旧数据。

解决手段只有两个:

  • Clean(清理):把 Cache 中脏数据写回内存,让 DMA 能看到最新数据。
  • Invalidate(无效化):把 Cache 行标记为无效,强制 CPU 下次从内存重新读取。

三、致命顺序:Clean 和 Invalidate 不能乱用

很多开发者知道要用 SCB_CleanDCache_by_AddrSCB_InvalidateDCache_by_Addr,但顺序和范围搞错,就会偶发故障。

3.1 发送方向(CPU → DMA)

CPU 准备好数据,交给 DMA 发送:

// 正确:先 Clean,再启动 DMA
SCB_CleanDCache_by_Addr((uint32_t *)buf, len);
HAL_DMA_Start(&hdma, (uint32_t)buf, (uint32_t)&periph, len);

不能先 Invalidate:Invalidate 会丢弃 Cache 中尚未写回的新数据,DMA 读到旧内存,数据直接丢失。

3.2 接收方向(DMA → CPU)

DMA 接收完成后,CPU 要读数据:

// 正确:DMA 完成后先 Invalidate,再读
SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);
process_data(buf);

不能先 Clean:Clean 会把 Cache 中的旧数据写回内存,覆盖 DMA 刚写入的新数据,这就是花屏的元凶之一。

3.3 最隐蔽的坑:Invalidate 的地址对齐

SCB_InvalidateDCache_by_Addr32 字节 Cache Line 操作。如果 buf 起始地址或长度未按 32 字节对齐,Invalidate 会波及相邻数据,把别的变量也无效化,导致随机错误。

// 推荐:缓冲区按 32 字节对齐,长度也补齐
__attribute__((aligned(32))) uint8_t dma_buf[1024];
#define ALIGN_32(x) (((x) + 31U) & ~31U)
SCB_InvalidateDCache_by_Addr((uint32_t *)dma_buf, ALIGN_32(len));

四、根治方案:MPU 配置为 Write-Through 或 Non-Cacheable

如果每次 DMA 传输都要手动 Clean/Invalidate,既繁琐又易错。更稳妥的做法是用 MPU 把 DMA 缓冲区所在内存区域配置为 Non-CacheableWrite-Through,从硬件层面消除一致性问题。

void MPU_Config_DMA_Region(void)
{
    HAL_MPU_Disable();
    MPU_Region_InitTypeDef MPU_Init = {0};

    MPU_Init.Enable           = MPU_REGION_ENABLE;
    MPU_Init.Number           = MPU_REGION_NUMBER0;
    MPU_Init.BaseAddress      = 0xC0000000;   // SDRAM 帧缓冲地址
    MPU_Init.Size             = MPU_REGION_SIZE_16MB;
    MPU_Init.SubRegionDisable = 0x00;
    MPU_Init.TypeExtField     = MPU_TEX_LEVEL0;
    MPU_Init.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_Init.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;
    MPU_Init.IsShareable      = MPU_ACCESS_SHAREABLE;
    MPU_Init.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE; // 关键
    MPU_Init.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;
    HAL_MPU_ConfigRegion(&MPU_Init);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

配置后,该区域读写直接穿透到内存,DMA 与 CPU 看到的数据始终一致,无需手动维护 Cache。代价是访问速度略降,但对帧缓冲这类大块顺序访问影响可接受。

五、完整排查清单

遇到 DMA 相关偶发异常,按以下顺序排查:

  • 确认缓冲区地址:是否落在 Cacheable 区域?查 MPU 配置和链接脚本。
  • 检查对齐:DMA 缓冲区是否 32 字节对齐?长度是否补齐?
  • 核对方向:发送前 Clean,接收后 Invalidate,顺序绝不能反。
  • 确认 DMA 完成标志:必须在传输完成中断或标志置位后再 Invalidate,提前操作等于白做。
  • 多缓冲区场景:双缓冲 ping-pong 时,每个缓冲区都要独立维护。
  • 调试手段:临时关闭 D-Cache 验证问题是否消失;用 SCB_InvalidateDCache() 全无效化对比。

六、注意事项

  • SCB_CleanDCache_by_AddrSCB_InvalidateDCache_by_Addr 的参数是 地址 + 字节数,不是 Cache Line 数。
  • 不要对同一块内存同时 Clean 和 Invalidate,除非你明确知道自己在做什么(例如重新初始化缓冲区)。
  • 使用 __DSB() 确保 Cache 操作完成后再启动 DMA,防止乱序执行。
  • LTDC 读帧缓冲、DMA2D 搬运、以太网描述符、USB 缓冲区,都是高发区,务必逐一检查。
  • 若使用 STM32CubeMX 生成代码,MPU 配置默认可能未开启,需手动添加。

七、小结

STM32H7 的 Cache 是把双刃剑。DMA 一致性问题的核心就一句话:CPU 写后要 Clean,DMA 写后要 Invalidate,顺序不能反,地址要对齐。最省心的方案是用 MPU 将 DMA 缓冲区设为 Non-Cacheable,从根源上避免手动维护。掌握这些,偶发花屏、数据错乱这类“玄学”问题将不再难查。