STM32H7 的 D-Cache 与 DMA 数据一致性:Clean/Invalidate 的误用场景与实测验证

STM32H7 系列凭借 Cortex-M7 内核和高达 480MHz 的主频,成为高性能嵌入式应用的首选。然而,启用 D-Cache 后,CPU 访问的是缓存,而 DMA 直接访问物理内存,两者视角不一致,极易导致数据错乱。本文从原理出发,分析常见误用,并给出实测验证与正确代码。

一、为什么 D-Cache 会与 DMA 冲突?

Cortex-M7 的 D-Cache 是写回(Write-Back)、写分配(Write-Allocate)的。CPU 写数据时,若缓存行存在则只写缓存,不立即写回内存;CPU 读数据时,若缓存未命中,则从内存加载到缓存。而 DMA 绕过缓存直接读写内存。因此:

  • CPU 写 → DMA 读:数据可能还在缓存中未写回内存,DMA 读到旧数据。
  • DMA 写 → CPU 读:内存已更新,但缓存中仍是旧数据,CPU 读到旧值。

解决手段是 Clean(将缓存写回内存)和 Invalidate(将缓存标记为无效,强制下次读取从内存加载)。但若使用不当,反而会引入更隐蔽的错误。

二、常见误用场景与实测验证

误用 1:DMA 发送前只 Invalidate 不 Clean

// 错误示例:发送前只 Invalidate
SCB_InvalidateDCache_by_Addr((uint32_t*)tx_buf, sizeof(tx_buf));
HAL_DMA_Start(&hdma, (uint32_t)tx_buf, (uint32_t)&uart->DR, sizeof(tx_buf));

现象:DMA 发送的数据部分为旧值。因为 Invalidate 丢弃了 CPU 刚写入缓存的新数据,内存中仍是旧数据。

实测:用逻辑分析仪抓 UART 波形,发现前几个字节正确,后续字节为上次发送的残留值。

正确做法:发送前应 Clean,确保缓存写入内存。

误用 2:DMA 接收后只 Clean 不 Invalidate

// 错误示例:接收后只 Clean
HAL_DMA_Start(&hdma, (uint32_t)&uart->DR, (uint32_t)rx_buf, sizeof(rx_buf));
// ... 等待传输完成
SCB_CleanDCache_by_Addr((uint32_t*)rx_buf, sizeof(rx_buf));

现象:CPU 读取 rx_buf 时得到旧数据。因为 Clean 只是把缓存写回内存,但缓存中可能仍是旧值(若之前 CPU 读过该区域),Invalidate 才能强制从内存重新加载。

实测:在调试器中观察 rx_buf 内存与缓存内容,发现内存已更新,但 CPU 读取的变量值不变。

正确做法:接收后应 Invalidate,且必须在 DMA 传输完成后、CPU 读取之前执行。

误用 3:Invalidate 范围未按缓存行对齐

Cortex-M7 的 D-Cache 行大小为 32 字节。若 Invalidate 的地址范围不是 32 字节对齐,可能误伤相邻数据。

// 危险:未对齐的 Invalidate
SCB_InvalidateDCache_by_Addr((uint32_t*)(rx_buf + 1), 10);

后果:与 rx_buf 相邻的变量缓存被无效,若该变量刚被 CPU 修改且未写回,将丢失更新。

正确做法:缓冲区地址和大小均按 32 字节对齐,或使用 SCB_InvalidateDCache_by_Addr 前手动对齐。

三、正确配置步骤与完整代码

步骤 1:将 DMA 缓冲区放在非缓存区域或正确维护缓存

推荐将 DMA 缓冲区放在 0x30000000(DTCM)或使用 MPU 配置为 Write-Through 模式。但更通用的方法是手动维护缓存。

步骤 2:发送流程

// 发送前:Clean 缓存,确保数据写入内存
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, TX_SIZE);
HAL_DMA_Start(&hdma_tx, (uint32_t)tx_buf, (uint32_t)&huart->Instance->TDR, TX_SIZE);

步骤 3:接收流程

// 接收前:Invalidate 缓存,避免 CPU 读到旧数据
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, RX_SIZE);
HAL_DMA_Start(&hdma_rx, (uint32_t)&huart->Instance->RDR, (uint32_t)rx_buf, RX_SIZE);
// 等待 DMA 完成(中断或轮询)
// 接收后:再次 Invalidate,确保 CPU 读取最新数据
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, RX_SIZE);
// 此时 CPU 可安全读取 rx_buf

完整示例:UART DMA 收发

#include "stm32h7xx_hal.h"

#define TX_SIZE 64
#define RX_SIZE 64

__attribute__((aligned(32))) uint8_t tx_buf[TX_SIZE];
__attribute__((aligned(32))) uint8_t rx_buf[RX_SIZE];

void uart_dma_send(void) {
    // 填充发送数据
    for (int i = 0; i < TX_SIZE; i++) tx_buf[i] = i;
    // Clean 缓存
    SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, TX_SIZE);
    // 启动 DMA 发送
    HAL_DMA_Start(&hdma_usart1_tx, (uint32_t)tx_buf, (uint32_t)&USART1->TDR, TX_SIZE);
}

void uart_dma_receive(void) {
    // Invalidate 缓存
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, RX_SIZE);
    // 启动 DMA 接收
    HAL_DMA_Start(&hdma_usart1_rx, (uint32_t)&USART1->RDR, (uint32_t)rx_buf, RX_SIZE);
}

// DMA 接收完成中断回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    // Invalidate 缓存,确保 CPU 读取最新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, RX_SIZE);
    // 处理数据...
}

四、注意事项

  • 对齐:所有 DMA 缓冲区必须 32 字节对齐,使用 __attribute__((aligned(32)))。
  • 顺序:发送前 Clean,接收前 Invalidate,接收后再次 Invalidate。
  • 范围:Clean/Invalidate 的地址范围必须覆盖整个缓冲区,且按缓存行对齐。
  • 性能:频繁 Clean/Invalidate 会降低性能,可考虑将缓冲区放在非缓存区域(如 DTCM)。
  • MPU 配置:若使用 MPU,可将 DMA 缓冲区配置为 Write-Through 或 Non-Cacheable,简化维护。
  • 调试:在调试器中观察内存与缓存内容,可快速定位一致性问题。

五、总结

D-Cache 与 DMA 的数据一致性是 STM32H7 开发中的经典陷阱。理解 Clean 与 Invalidate 的语义,避免“发送只 Invalidate、接收只 Clean”等误用,并严格按对齐和顺序操作,才能确保系统稳定。实测表明,正确维护缓存后,DMA 传输误码率降为 0。建议在项目初期就规划好 DMA 缓冲区的内存布局,必要时借助 MPU 简化设计。