STM32H7 的 D-Cache 与 DMA 数据一致性排查:地址对齐、Clean/Invalidate 时序与常见踩坑

1. 为什么 D-Cache 和 DMA 会打架?

STM32H7 基于 Cortex-M7 内核,主频高达 480MHz,为了弥补 CPU 与内存之间的速度差距,引入了 D-Cache(数据缓存)。CPU 访问数据时,会先查 Cache,命中则直接读写,不会立即同步到物理内存(SRAM/SDRAM)。

而 DMA(直接存储器访问)是独立于 CPU 的硬件单元,它直接读写物理内存,完全绕过 Cache。这就导致了经典的一致性问题:

  • CPU 写,DMA 读:CPU 写入的数据可能还在 Cache 中(写回模式),DMA 读到的却是内存中的旧数据。
  • DMA 写,CPU 读:DMA 将新数据写入内存,但 CPU 读到的却是 Cache 中的旧数据。

解决思路只有两个:Clean(清理)Invalidate(无效化)

  • Clean:将 Cache 中已修改的数据写回物理内存。
  • Invalidate:将 Cache 中的对应行标记为无效,下次读取时强制从内存加载。

2. 地址对齐:Cache Line 的隐形陷阱

Cortex-M7 的 D-Cache 采用 32 字节 Cache Line。所有 Clean/Invalidate 操作都是以 Cache Line 为单位进行的。如果你的 DMA 缓冲区地址或长度没有按 32 字节对齐,就会误伤相邻数据。

错误示例

uint8_t dma_buf[100]; // 起始地址可能不是 32 字节对齐

假设 dma_buf 起始地址为 0x20000004,长度为 100 字节。当你对 dma_buf 执行 Invalidate 时,实际会影响到 0x20000000~0x2000007F 共 128 字节的范围,其中 0x20000000~0x20000003 这 4 个字节可能存放着其他重要变量,被意外无效化,导致数据丢失。

正确做法

  • 使用 __attribute__((aligned(32))) 强制 32 字节对齐。
  • 缓冲区大小建议为 32 的整数倍。
__attribute__((aligned(32))) uint8_t dma_buf[128];

3. Clean/Invalidate 的时序:方向决定顺序

这是最容易出错的地方。根据数据传输方向,操作顺序截然不同:

3.1 CPU 写 → DMA 读(发送数据)

  1. CPU 填充缓冲区。
  2. Clean 缓冲区(确保数据写回内存)。
  3. 启动 DMA 发送。
memcpy(dma_buf, src, len);
SCB_CleanDCache_by_Addr((uint32_t *)dma_buf, len);
HAL_UART_Transmit_DMA(&huart1, dma_buf, len);

3.2 DMA 写 → CPU 读(接收数据)

  1. 启动 DMA 接收。
  2. 等待 DMA 完成(或半传输中断)。
  3. Invalidate 缓冲区(丢弃 Cache 旧值)。
  4. CPU 读取数据。
HAL_UART_Receive_DMA(&huart1, dma_buf, len);
// 等待完成...
SCB_InvalidateDCache_by_Addr((uint32_t *)dma_buf, len);
process_data(dma_buf, len);

关键点:Invalidate 必须在 DMA 完成之后、CPU 读取之前执行。如果在 DMA 传输过程中 Invalidate,可能把 DMA 还未写入的数据行无效化,导致数据错乱。

4. 完整代码示例:UART DMA 回环测试

以下代码基于 STM32H743,使用 UART DMA 接收不定长数据,并正确处理 Cache。

#include "stm32h7xx_hal.h"

#define BUF_SIZE 128
__attribute__((aligned(32))) uint8_t rx_buf[BUF_SIZE];
volatile uint8_t rx_done = 0;

UART_HandleTypeDef huart1;
DMA_HandleTypeDef hdma_usart1_rx;

void UART_DMA_Init(void) {
    // 省略 GPIO、UART 初始化,重点配置 DMA
    hdma_usart1_rx.Instance = DMA1_Stream0;
    hdma_usart1_rx.Init.Request = DMA_REQUEST_USART1_RX;
    hdma_usart1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_usart1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_usart1_rx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_usart1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_usart1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_usart1_rx.Init.Mode = DMA_NORMAL;
    hdma_usart1_rx.Init.Priority = DMA_PRIORITY_HIGH;
    HAL_DMA_Init(&hdma_usart1_rx);
    __HAL_LINKDMA(&huart1, hdmarx, hdma_usart1_rx);
}

void Start_Receive(void) {
    // 启动前先 Invalidate,确保 Cache 中没有旧数据
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
}

// DMA 接收完成回调
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    if (huart->Instance == USART1) {
        // 传输完成后 Invalidate,丢弃 Cache 旧值
        SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
        rx_done = 1;
    }
}

int main(void) {
    HAL_Init();
    SystemClock_Config();
    // 使能 D-Cache(通常已在 SystemInit 中完成)
    SCB_EnableDCache();
    UART_DMA_Init();
    Start_Receive();

    while (1) {
        if (rx_done) {
            rx_done = 0;
            // 此时 rx_buf 中的数据是有效的
            // 处理数据...
            Start_Receive(); // 重新启动接收
        }
    }
}

5. 常见踩坑与排查方法

  • 坑1:忘记 Clean 导致 DMA 发送旧数据。现象:串口发送的数据总是上一次的。排查:在启动 DMA 前加 Clean。
  • 坑2:Invalidate 时机错误。现象:接收数据随机错乱。排查:确保 Invalidate 在 DMA 完成中断中执行,而非之前。
  • 坑3:缓冲区未对齐。现象:相邻变量被意外修改。排查:用 __attribute__((aligned(32))) 并检查链接脚本。
  • 坑4:多缓冲区或链表操作。现象:DMA 描述符被 Cache 缓存。排查:对描述符所在内存也进行 Clean/Invalidate,或将其放在非缓存区域(MPU 配置)。
  • 坑5:中断中调用 Cache 维护函数。注意:SCB_CleanDCache_by_Addr 等函数执行时间较长,在高速中断中可能影响实时性,建议评估或使用 MPU 将 DMA 缓冲区配置为 Write-Through 模式。

调试技巧

  • 使用 SCB_InvalidateDCache() 全局无效化(简单粗暴,但影响性能)。
  • 通过 MPU 将 DMA 缓冲区所在区域配置为 Non-Cacheable,一劳永逸,但会降低 CPU 访问速度。
  • 利用 ITM/SWO 打印 Cache 状态,或使用调试器查看内存与 Cache 内容差异。

6. 总结

STM32H7 的 D-Cache 与 DMA 共存需要开发者手动维护一致性。核心原则:CPU 写后 Clean,DMA 写后 Invalidate,地址对齐 32 字节,时序严格遵循方向。掌握这些要点,就能避开大多数数据错乱的坑,充分发挥 H7 的高性能。