STM32H7 的 D-Cache 与 DMA 数据一致性:地址对齐、Clean/Invalidate 时机与实测踩坑记录

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

STM32H7 基于 Cortex-M7,主频高达 480MHz,为了匹配 CPU 速度,芯片内部集成了 D-Cache(数据缓存)。CPU 访问数据时,优先从 Cache 读写,而不是直接访问 SRAM 或外设。

DMA 则是一个“老实人”,它直接读写物理内存(SRAM),完全不知道 Cache 的存在。于是问题来了:

  • CPU 写数据:数据可能只停留在 Cache 里,还没写回 SRAM。此时启动 DMA 发送,DMA 读到的是旧数据。
  • DMA 写数据:DMA 把新数据写入 SRAM,但 CPU 读的时候,Cache 里可能还是旧数据,导致 CPU 读到错误值。

这就是 数据一致性 问题。解决手段就是手动维护 Cache:Clean(写回)和 Invalidate(无效化)。

2. 地址对齐:必须遵守的硬性规则

Cortex-M7 的 Cache 按 Cache Line 管理,STM32H7 的 D-Cache Line 大小是 32 字节。Clean 和 Invalidate 操作都是以 Cache Line 为单位进行的。

关键规则:DMA 缓冲区地址必须 32 字节对齐,且大小最好是 32 字节的整数倍。

如果缓冲区没有对齐,Invalidate 时可能会把相邻的有用数据也无效掉,导致程序跑飞。例如:

// 错误示范:未对齐的缓冲区
uint8_t rx_buf[100];  // 起始地址可能不是 32 字节对齐

// 正确做法:使用对齐属性
__attribute__((aligned(32))) uint8_t rx_buf[128];

在 Keil 或 IAR 中,也可以使用 __align(32)。建议缓冲区大小也取 32 的倍数,避免边界问题。

3. Clean 与 Invalidate 的时机

3.1 CPU 写 → DMA 读(发送)

CPU 准备好数据后,必须 Clean 对应的 Cache Line,把数据写回 SRAM,再启动 DMA 发送。

// 准备发送数据
memcpy(tx_buf, source, len);
// 清理 D-Cache,确保数据写入 SRAM
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);
// 启动 DMA 发送
HAL_UART_Transmit_DMA(&huart1, tx_buf, len);

3.2 DMA 写 → CPU 读(接收)

DMA 接收完成前,CPU 不应访问缓冲区。DMA 完成后,必须 Invalidate 对应的 Cache Line,丢弃 Cache 中的旧数据,强制 CPU 从 SRAM 重新读取。

// DMA 接收完成回调中
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 无效化 D-Cache,丢弃旧缓存
    SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len);
    // 现在可以安全处理 rx_buf 中的数据
    process_data(rx_buf, len);
}

3.3 双向传输(如 SPI 全双工)

发送前 Clean TX 缓冲区,接收后 Invalidate RX 缓冲区。注意:如果 TX 和 RX 是同一块内存,操作要格外小心。

4. 完整代码示例:UART DMA 不定长接收

以下代码基于 STM32H7 HAL 库,使用 DMA 空闲中断实现不定长接收,并正确处理 Cache。

#include "stm32h7xx_hal.h"

#define RX_BUF_SIZE 128
__attribute__((aligned(32))) uint8_t rx_buf[RX_BUF_SIZE];
volatile uint8_t rx_len = 0;
volatile uint8_t rx_flag = 0;

void UART_DMA_Init(void)
{
    // 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    // 使能空闲中断
    __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);
}

// 空闲中断处理(在 stm32h7xx_it.c 中调用)
void UART_IDLE_Handler(void)
{
    if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE))
    {
        __HAL_UART_CLEAR_IDLEFLAG(&huart1);
        // 停止 DMA 以获取当前接收长度
        HAL_UART_DMAStop(&huart1);
        rx_len = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);
        // 无效化 D-Cache,确保 CPU 读到 DMA 写入的新数据
        SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, rx_len);
        rx_flag = 1;
        // 重新启动 DMA 接收
        HAL_UART_Receive_DMA(&huart1, rx_buf, RX_BUF_SIZE);
    }
}

// 主循环中处理数据
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    // 使能 D-Cache(默认已使能,此处确认)
    SCB_EnableDCache();
    UART_DMA_Init();
    while (1)
    {
        if (rx_flag)
        {
            rx_flag = 0;
            // 安全处理 rx_buf 中的数据
            process_data(rx_buf, rx_len);
        }
    }
}

5. 实测踩坑记录

坑 1:忘记 Clean,DMA 发送旧数据

现象:用 DMA 发送一个递增的数组,串口助手收到的数据始终不变。 原因:CPU 修改了数组,但数据还在 Cache 里,DMA 从 SRAM 读到的还是旧值。 解决:发送前调用 SCB_CleanDCache_by_Addr

坑 2:Invalidate 导致数据错乱

现象:DMA 接收后,CPU 读取的数据偶尔错位,甚至程序 HardFault。 原因:缓冲区未对齐,Invalidate 时把相邻变量所在的 Cache Line 也无效了,而那个变量刚被 CPU 修改过,导致数据丢失。 解决:所有 DMA 缓冲区强制 32 字节对齐,且大小取 32 的倍数。

坑 3:在 DMA 传输过程中访问缓冲区

现象:DMA 还在接收,CPU 就去读缓冲区,结果读到一半新一半旧。 原因:DMA 和 CPU 同时访问同一块内存,且 Cache 未同步。 解决:使用双缓冲区或等待 DMA 完成中断后再访问。

坑 4:Cache 维护操作耗时

现象:高速 DMA 传输时,频繁 Clean/Invalidate 导致 CPU 负载过高。 原因:每次操作都遍历整个缓冲区,Cache Line 操作本身也有开销。 解决:尽量使用 32 字节对齐的缓冲区,减少操作次数;或使用 MPU 将 DMA 缓冲区配置为 Write-Through 模式,避免 Clean 操作。

6. 注意事项总结

  • 地址对齐:DMA 缓冲区必须 32 字节对齐,大小建议为 32 的倍数。
  • Clean 时机:CPU 写数据后、启动 DMA 发送前。
  • Invalidate 时机:DMA 接收完成后、CPU 读取数据前。
  • 避免重复 Invalidate:如果 CPU 在 DMA 完成后已经读取过数据,再次 Invalidate 可能丢失 CPU 的修改。
  • MPU 配置:可将 DMA 缓冲区所在区域配置为 Non-Cacheable 或 Write-Through,简化 Cache 维护,但会牺牲部分性能。
  • 调试技巧:在调试器中查看内存时,注意 Cache 可能未写回,可手动执行 Clean 操作。

掌握以上要点,就能在 STM32H7 上驯服 D-Cache 与 DMA,既享受高性能,又保证数据可靠。