一、问题背景:高性能 MCU 的 Cache 双刃剑

STM32H7 基于 Cortex-M7 内核,内置 L1 Cache(I-Cache 和 D-Cache),CPU 访问外部 RAM(如 SDRAM)或 Flash 时,Cache 能大幅降低延迟。然而,DMA 控制器直接访问物理内存,不经过 Cache。当 CPU 和 DMA 共享同一块内存区域时,就可能出现数据不一致:

  • CPU 写入数据,DMA 读到的却是旧值(Cache 未写回内存)
  • DMA 写入数据,CPU 读到的却是缓存中的旧值(Cache 未失效)

这种问题在音频采集、网络收发、图像处理等场景中尤为致命,轻则数据错乱,重则系统崩溃。

二、原理剖析:Cache 与 DMA 的冲突根源

1. 存储层次与一致性模型

Cortex-M7 的 D-Cache 采用写回(Write-back)策略,即 CPU 写数据时只更新 Cache 行,并标记为脏(Dirty),直到缓存行被替换或显式清理时才写回内存。DMA 则直接读写物理内存,两者之间没有任何硬件自动同步机制。

2. 一致性问题的两种场景

  • CPU→DMA:CPU 修改缓冲区后,脏数据仍在 Cache 中,DMA 从内存读取时得到旧数据。
  • DMA→CPU:DMA 将新数据写入内存,但 CPU 的 Cache 中仍保留旧副本,CPU 读操作命中 Cache,无法看到新数据。

3. 内存屏障与 Cache 操作

ARM 架构提供了内存屏障指令(DMB、DSB、ISB)和 Cache 维护操作(Clean、Invalidate、Clean&Invalidate)。在 STM32H7 上,我们通常使用 CMSIS 提供的函数:

  • SCB_CleanDCache():将脏 Cache 行写回内存
  • SCB_InvalidateDCache():使 Cache 行失效,下次读取强制从内存加载
  • SCB_CleanInvalidateDCache():先写回再失效

注意:这些函数操作的是整个 D-Cache,粒度较粗。对于性能敏感场景,可使用 SCB_CleanDCache_by_Addr() 等按地址操作函数。

三、实战案例:以太网 DMA 接收数据错乱

1. 问题描述

某项目使用 STM32H743 + LAN8720,通过 ETH DMA 接收网络数据包。初始化时分配了一个 4KB 的接收缓冲区,并开启了 D-Cache。运行后,接收到的数据偶尔出现前几个字节错误,但大部分数据正确。

2. 初步排查

  • 检查 ETH DMA 描述符配置,确认缓冲区地址正确。
  • 打印接收长度,发现长度正确,但数据内容有误。
  • 怀疑是 Cache 一致性问题,因为错误数据往往是旧数据。

3. 根因分析

ETH DMA 将数据写入内存后,CPU 通过轮询描述符得知数据到达。但 CPU 的 D-Cache 中可能缓存了该内存区域的旧数据,导致读取时命中 Cache,得到过时内容。

4. 解决方案

在 CPU 读取 DMA 数据之前,先执行 Cache 失效操作。修改代码如下:

// 接收缓冲区(需对齐到 32 字节,Cache 行大小)
__attribute__((aligned(32))) uint8_t rx_buffer[2048];

void ETH_RX_Handler(void) {
    // 检查 DMA 描述符,确认数据已接收
    if (rx_desc->status & ETH_DESC_RX_DA) {
        // 关键:使接收缓冲区对应的 Cache 行失效
        SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buffer, sizeof(rx_buffer));
        
        // 现在可以安全读取数据
        process_packet(rx_buffer, rx_desc->length);
        
        // 重新启用 DMA 接收
        rx_desc->status |= ETH_DESC_RX_OWN;
    }
}

同时,在发送数据时,需要先 Clean Cache,确保 CPU 写入的数据被写回内存:

void ETH_TX_Send(uint8_t *data, uint32_t len) {
    memcpy(tx_buffer, data, len);
    // 写回 Cache,确保 DMA 能看到最新数据
    SCB_CleanDCache_by_Addr((uint32_t *)tx_buffer, len);
    
    // 启动 DMA 发送
    tx_desc->buffer = tx_buffer;
    tx_desc->length = len;
    tx_desc->status |= ETH_DESC_TX_OWN;
}

四、进阶技巧:内存屏障的使用

1. 何时需要内存屏障?

Cache 操作函数内部已经包含了必要的屏障指令,但当你直接操作外设寄存器或使用内联汇编时,可能需要显式添加屏障。例如,在修改 DMA 描述符后,需要确保写入对 DMA 可见:

// 使能 DMA 传输前,确保描述符写入完成
__DSB();  // 数据同步屏障,等待所有存储操作完成

2. 内存屏障的三种类型

  • DMB:数据内存屏障,确保屏障前的所有内存访问在屏障后的内存访问之前完成。
  • DSB:数据同步屏障,等待所有内存访问完成,更严格。
  • ISB:指令同步屏障,用于刷新流水线,通常在修改代码后使用。

在 STM32H7 中,CMSIS 提供了 __DMB()__DSB()__ISB() 宏。

五、完整代码示例:通用 Cache 安全 DMA 缓冲区

以下是一个可复用的缓冲区管理模块,适用于任何 DMA 外设:

#include "cmsis_compiler.h"

#define CACHE_LINE_SIZE 32

// 缓冲区结构体,确保对齐
typedef struct {
    uint8_t data[2048] __attribute__((aligned(CACHE_LINE_SIZE)));
    uint32_t len;
} dma_buffer_t;

// 初始化缓冲区(可选)
void dma_buffer_init(dma_buffer_t *buf) {
    memset(buf->data, 0, sizeof(buf->data));
    buf->len = 0;
}

// 在 DMA 写入前调用,使缓冲区 Cache 失效(防止读取旧数据)
void dma_buffer_prepare_rx(dma_buffer_t *buf) {
    SCB_InvalidateDCache_by_Addr((uint32_t *)buf->data, sizeof(buf->data));
}

// 在 DMA 读取前调用,将 CPU 写入的数据写回内存
void dma_buffer_prepare_tx(dma_buffer_t *buf, uint32_t len) {
    SCB_CleanDCache_by_Addr((uint32_t *)buf->data, len);
}

// 使用示例:UART DMA 接收
void UART_DMA_RX_IRQHandler(void) {
    dma_buffer_t *rx_buf = &uart_rx_buf;
    // 假设 DMA 已传输完成
    dma_buffer_prepare_rx(rx_buf);
    process_data(rx_buf->data, rx_buf->len);
}

六、注意事项与调试建议

  • 缓冲区对齐:Cache 操作按行(32 字节)进行,缓冲区首地址和长度最好对齐到 32 字节,否则可能操作不完整。
  • 性能权衡:频繁的 Clean/Invalidate 会降低性能,建议使用双缓冲或环形缓冲区,减少操作次数。
  • 调试技巧:若怀疑 Cache 问题,可临时关闭 D-Cache(SCB_DisableDCache())测试,若问题消失则确认是 Cache 一致性导致。
  • 使用 MPU 配置:可将 DMA 缓冲区所在内存区域配置为 Non-cacheable 或 Write-through,避免手动操作,但会牺牲部分性能。
  • RTOS 环境:在多线程中,需确保 Cache 操作与 DMA 操作的顺序正确,必要时使用互斥锁保护。

七、总结

STM32H7 的 L1 Cache 是一把双刃剑,正确使用能大幅提升性能,但忽视一致性会带来隐蔽的 Bug。通过理解 Cache 写回策略和 DMA 的直通特性,掌握 Clean/Invalidate 操作和内存屏障的使用时机,就能有效避免数据错乱。建议在项目初期就规划好 DMA 缓冲区的 Cache 策略,并封装通用接口,从根源上杜绝此类问题。