引言:一次“幽灵”数据异常

最近在调试一块基于 STM32H743 的数据采集板,外置 ADC 通过 SPI DMA 将采样结果存入内存,CPU 负责后续处理。功能逻辑看似简单,但运行一段时间后,CPU 读到的数据偶尔出现“旧值”或“全零”,且毫无规律。起初怀疑 SPI 时序或 DMA 配置错误,但逻辑分析仪显示波形完全正确,DMA 传输完成中断也正常触发。最终,在查阅参考手册和无数论坛帖子后,矛头指向了——L1 Cache。

原理:Cache 与 DMA 的“信息孤岛”

1. 什么是 Cache 一致性?

STM32H7 内置 32KB 的 L1-Cache(I-Cache 和 D-Cache 各 16KB),用于加速 CPU 对主存(SRAM/Flash)的访问。当 CPU 读取内存时,若数据已在 Cache 中,则直接命中,无需访问低速总线;写入时,若采用 write-back 策略,数据先暂存于 Cache,待合适时机才回写主存。

DMA 控制器则直接通过总线访问主存,完全不经过 Cache。这就造成了两个“视图”:

  • CPU 视角:看到的是 Cache 中的副本(可能已修改但未回写)。
  • DMA 视角:看到的是主存中的真实数据(可能已过时)。

当两者交互时,若未同步,便产生数据不一致。

2. 本项目中的冲突场景

  • DMA 写,CPU 读:ADC 通过 DMA 将数据写入内存缓冲区(主存)。CPU 在中断中读取该缓冲区。若 CPU 在 DMA 写入前已缓存了该地址的旧数据,则 DMA 写入主存后,CPU 仍会从 Cache 中读到旧值(Cache 命中),导致数据“不更新”。
  • CPU 写,DMA 读:CPU 准备好数据后,DMA 将其发送到外设。若 CPU 写入的数据还停留在 Cache 中,DMA 从主存读取时可能拿到旧数据,导致发送内容错误。

本项目属于第一种情况。

排查:从现象到根因的推理链

1. 现象特征

  • 数据错误仅在高频采样(>100kHz)时出现,低频时正常。
  • 错误数据往往是上一次采样的值(旧值)或全零。
  • 使用调试器暂停时,读到的内存值是正确的,但运行时不正确。

2. 关键怀疑点

  • DMA 配置:检查了传输方向、地址递增、数据宽度,均正确。
  • 中断优先级:确保 DMA 中断不会丢失。
  • 内存对齐:缓冲区已 32 字节对齐(Cache line 大小)。

3. 决定性证据

在代码中临时禁用 D-Cache(SCB_DisableDCache()),问题立即消失。这证实了 Cache 一致性是元凶。

修复:两种标准方案

方案一:使用 Cache 维护函数(推荐)

在 DMA 传输前后,调用 CMSIS 提供的函数来维护 Cache 一致性。

配置步骤

  1. 确保缓冲区地址按 32 字节对齐(__ALIGNED(32))。
  2. 在 DMA 启动前,若 CPU 之前写过该缓冲区,需调用 SCB_CleanDCache() 将脏数据回写主存。
  3. 在 DMA 完成后,CPU 读取前,调用 SCB_InvalidateDCache_by_Addr() 使 Cache 中对应行失效,强制从主存重新加载。

代码示例

#include "stm32h7xx.h"

// 缓冲区 32 字节对齐
__ALIGNED(32) uint32_t adc_buffer[256];

void DMA_Transfer_Start(void)
{
    // 若之前 CPU 写过 adc_buffer,需要 Clean(回写)
    SCB_CleanDCache_by_Addr((uint32_t*)adc_buffer, sizeof(adc_buffer));
    
    // 启动 DMA 传输(外设到内存)
    HAL_DMA_Start_IT(&hdma_adc, (uint32_t)&adc_value, (uint32_t)adc_buffer, 256);
}

void DMA_Transfer_Complete_Callback(DMA_HandleTypeDef *hdma)
{
    // DMA 完成后,使 Cache 行失效,确保 CPU 读到主存最新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)adc_buffer, sizeof(adc_buffer));
    
    // 现在可以安全读取 adc_buffer
    Process_Data(adc_buffer);
}

方案二:配置 MPU 将内存区域设置为非 Cacheable

如果该缓冲区仅用于 DMA 交互,且不要求 CPU 高频访问,可将其配置为 non-cacheable,彻底避免一致性问题。

配置步骤

  1. MPU_Config() 中定义区域,设置属性为 MPU_REGION_NO_CACHE
  2. 确保该区域不与其它 Cacheable 区域重叠。

代码示例(MPU 配置片段)

void MPU_Config(void)
{
    MPU_Region_InitTypeDef MPU_InitStruct = {0};
    
    HAL_MPU_Disable();
    
    // 配置 SRAM 区域为 non-cacheable
    MPU_InitStruct.Enable = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress = 0x24000000; // 例如 AXI SRAM 起始地址
    MPU_InitStruct.Size = MPU_REGION_SIZE_256KB;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.IsBufferable = MPU_REGION_NOT_BUFFERABLE;
    MPU_InitStruct.IsCacheable = MPU_REGION_NO_CACHE;
    MPU_InitStruct.IsShareable = MPU_REGION_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);
}

注意事项与最佳实践

  • 对齐至关重要:Cache 操作是按行(32 字节)进行的,缓冲区地址和大小必须对齐到 32 字节,否则会误伤相邻数据。
  • Clean 和 Invalidate 的区别:Clean 将脏数据写回主存,但不改变 Cache 内容;Invalidate 将 Cache 行标记为无效,下次读取时从主存重新加载。对于 DMA 写,只需 Invalidate;对于 DMA 读,需要先 Clean。
  • 中断上下文中的 Cache 操作:在中断中调用 SCB_InvalidateDCache_by_Addr() 是安全的,但注意耗时(约几十个周期),若对实时性要求极高,可考虑在 DMA 完成中断中只设置标志,在主循环中处理。
  • 使用双缓冲:在高速采集时,可采用双缓冲交替使用,避免在 DMA 传输期间 CPU 访问同一缓冲区。
  • 调试技巧:若怀疑 Cache 问题,可临时禁用 D-Cache 验证;或使用调试器查看 Cache 状态寄存器(如 DCCSR)。

总结

STM32H7 的 L1 Cache 在提升性能的同时,也引入了数据一致性的挑战。通过理解 Cache 与 DMA 的工作机制,掌握 CleanInvalidate 的正确使用时机,或利用 MPU 配置非缓存区域,可以彻底解决此类问题。本文的实战案例展示了从现象到根因的完整排查过程,希望能为你的嵌入式开发之路扫清一个常见的“暗坑”。