STM32H7 L1 Cache 未使能导致 DMA 数据不一致:从踩坑到彻底修复

一、问题现象:数据像“幽灵”一样丢失

最近在调试一个基于 STM32H743 的数据采集系统,使用 SPI DMA 接收传感器数据。代码逻辑很简单:DMA 将数据搬运到内存缓冲区,主循环解析。但发现一个诡异现象:DMA 中断标志已置位,缓冲区却偶尔出现旧数据或全零。更奇怪的是,如果关闭优化或加延时,问题就消失。

排查了 DMA 配置、SPI 时序、中断优先级,均无异常。最后怀疑到 L1 Cache 头上——因为 STM32H7 的 Cortex-M7 内核带有 L1 Cache,而默认复位后 Cache 是关闭的,但我在初始化代码中使能了它(为了提升性能)。

二、原理剖析:Cache 与 DMA 的“信息孤岛”

2.1 什么是 L1 Cache?

Cortex-M7 内置了 I-Cache(指令缓存)和 D-Cache(数据缓存),用于加速 CPU 对内存的访问。当 CPU 读取数据时,会先查 Cache,命中则直接返回,未命中则从 RAM 加载到 Cache。写操作则可能采用 write-back 策略:数据先写入 Cache,标记为脏,延迟写回 RAM。

2.2 DMA 的“旁路”特性

DMA 控制器直接访问 RAM,不经过 Cache。这意味着:

  • DMA 写入 RAM:CPU 的 Cache 中可能保留着旧数据,CPU 读到的还是旧值(Cache 未失效)。
  • DMA 读取 RAM:如果 CPU 刚写过数据但仍在 Cache 中(write-back),DMA 读到的可能是旧值(Cache 未写回)。

这就是数据不一致的根源。

2.3 为什么默认关闭?

STM32H7 复位后 L1 Cache 默认关闭,这保证了与旧代码的兼容性。但很多开发者为了性能主动使能,却忽略了与 DMA 的协同,导致上述问题。

三、修复方案:三种策略对比

方案一:直接关闭 D-Cache(简单粗暴)

适用于对性能要求不高的场景。

// 关闭 D-Cache(在 SystemInit 后调用)
SCB_DisableDCache();

方案二:配置 MPU 将 DMA 缓冲区设置为非缓存区域(推荐)

使用 MPU 将 DMA 使用的内存区域配置为 normal memory, non-cacheable,这样 CPU 访问该区域时直接读写 RAM,与 DMA 保持一致。

void MPU_Config(void)
{
    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 禁用 MPU 进行配置
    HAL_MPU_Disable();

    // 配置 DMA 缓冲区区域(例如 0x30000000,大小 64KB)
    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_NOT_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);

    // 使能 MPU
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

注意:MPU 配置必须在使能 Cache 之前完成,且要确保缓冲区地址对齐到区域大小。

方案三:软件维护 Cache(灵活但需谨慎)

在 DMA 操作前后手动维护 Cache。

  • DMA 写入 RAM 后,要使 CPU 的 Cache 失效,强制从 RAM 重新读取:
SCB_InvalidateDCache_by_Addr((uint32_t*)buffer, size);
  • DMA 读取 RAM 前,要将 CPU 的 Cache 写回,确保 DMA 看到最新数据:
SCB_CleanDCache_by_Addr((uint32_t*)buffer, size);

注意:地址必须 32 字节对齐,大小也需对齐。

四、完整代码示例(基于 HAL 库)

以下是一个典型的 SPI DMA 接收流程,采用方案二(MPU 非缓存区)进行修复。

// main.c 片段

// 定义 DMA 缓冲区(放在非缓存区域,例如 AXI SRAM 0x30000000)
#if defined(__ICCARM__)
#pragma location=0x30000000
uint8_t dma_rx_buf[1024];
#elif defined(__GNUC__)
uint8_t dma_rx_buf[1024] __attribute__((section(".non_cacheable")));
#endif

void SystemClock_Config(void);
void MPU_Config(void);

int main(void)
{
    HAL_Init();
    SystemClock_Config();

    // 先配置 MPU,再使能 Cache
    MPU_Config();
    SCB_EnableDCache();
    SCB_EnableICache();

    // SPI 和 DMA 初始化...
    MX_SPI1_Init();
    MX_DMA_Init();

    // 启动 DMA 接收
    HAL_SPI_Receive_DMA(&hspi1, dma_rx_buf, sizeof(dma_rx_buf));

    while (1)
    {
        // 主循环解析数据(此时 CPU 读取 dma_rx_buf 直接访问 RAM,与 DMA 一致)
    }
}

// 链接脚本中需定义 .non_cacheable 段,并映射到 0x30000000 区域

五、调试与验证技巧

  1. 使用断点观察:在 DMA 完成中断中设置断点,查看缓冲区内容。如果与预期不符,优先怀疑 Cache。
  2. 对比测试:临时关闭 Cache,看问题是否消失。
  3. 查看 MPU 配置:使用调试器查看 MPU->RBAR 和 MPU->RASR 寄存器,确认区域属性。
  4. 注意内存区域:STM32H7 的 RAM 分为 DTCM(0x20000000)、AXI SRAM(0x24000000)和 SRAM1-4(0x30000000 等)。DTCM 不支持 DMA,而 AXI SRAM 和 SRAM1-4 支持。建议将 DMA 缓冲区放在 AXI SRAM 或 SRAM1-4,并配置 MPU。

六、注意事项与总结

  • MPU 配置必须在使能 Cache 之前,否则不生效。
  • 缓冲区地址对齐:MPU 区域大小必须与缓冲区大小匹配,且基地址对齐到区域大小(例如 64KB 区域要求地址低 16 位为 0)。
  • 软件维护 Cache 时,地址和长度必须 32 字节对齐,否则会导致硬件错误。
  • 不要混合使用方案二和方案三,否则可能造成二次不一致。
  • 性能权衡:非缓存区域会降低 CPU 访问速度,但 DMA 缓冲区通常访问频率不高,影响可忽略。

总之,STM32H7 的 L1 Cache 是一把双刃剑。在涉及 DMA 的场景中,务必明确 Cache 策略。推荐使用 MPU 将 DMA 缓冲区配置为非缓存区域,既保证一致性,又保留 Cache 带来的性能提升。希望本文能帮你少踩一个经典的嵌入式大坑。