一、问题现象:DMA 数据为何“凭空消失”?

在 STM32H7 上,许多开发者会遇到这样的场景:

  • DMA 从外设搬运数据到内存,CPU 读取时却发现数据是旧的。
  • CPU 写入内存后启动 DMA 发送,接收端收到的却是乱码。
  • 调试时单步执行正常,全速运行却出错。

这些现象大多源于 D-Cache 与 DMA 的一致性冲突。STM32H7 的 Cortex-M7 内核带有 D-Cache,CPU 访问内存时会优先命中 Cache,而 DMA 直接访问物理内存,两者看到的数据可能不同步。

二、原理:为什么 Cache 会“欺骗”你?

  • CPU 视角:读写数据经过 D-Cache,若命中则直接操作 Cache,不立即写回内存(Write-Back 模式)。
  • DMA 视角:直接读写 SRAM,不经过 Cache。
  • 冲突:
    • CPU 写数据到 Cache 但未写回,DMA 读取的是旧内存。
    • DMA 写入新数据到内存,CPU 读取时却命中旧 Cache。

解决思路只有两个:让 CPU 绕过 Cache 或 在 DMA 传输前后维护 Cache。

三、MPU 配置:为 DMA 缓冲区划定“非缓存”区域

最彻底的方法是将 DMA 缓冲区配置为 Non-Cacheable,CPU 访问时直接读写内存。

3.1 配置步骤

  1. 使能 MPU:SCB_EnableMPU()。
  2. 定义区域基址和大小(需 32 字节对齐)。
  3. 配置属性:TEX=1, C=0, B=0, S=0 表示 Normal Non-Cacheable。
  4. 调用 HAL_MPU_ConfigRegion() 并启用。

3.2 代码示例

#include "stm32h7xx_hal.h"

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    /* 配置 SRAM1 的 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_LEVEL1;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.DisableExec      = MPU_INSTRUCTION_ACCESS_ENABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

注意:链接脚本需将 DMA 缓冲区定位到该区域,或使用 __attribute__((section(".dma_buffer")))。

四、Cache 维护:使用 SCB_CleanDCache 与 Invalidate

若无法使用 Non-Cacheable 区域,则必须在 DMA 传输前后维护 Cache。

4.1 关键函数

  • SCB_CleanDCache_by_Addr(addr, size):将 Cache 内容写回内存(CPU 写 → DMA 读)。
  • SCB_InvalidateDCache_by_Addr(addr, size):丢弃 Cache 内容,强制从内存重读(DMA 写 → CPU 读)。
  • SCB_CleanInvalidateDCache_by_Addr():先写回再无效化。

4.2 使用时机

  • CPU 写数据后启动 DMA 发送:调用 SCB_CleanDCache_by_Addr()。
  • DMA 接收数据完成后 CPU 读取:调用 SCB_InvalidateDCache_by_Addr()。
  • 双向传输:先 Clean 再 Invalidate。

4.3 代码示例

#define DMA_BUFFER_SIZE 1024
__attribute__((aligned(32))) uint8_t dma_buffer[DMA_BUFFER_SIZE];

void DMA_Send_Data(void)
{
    // 填充数据
    for (int i = 0; i < DMA_BUFFER_SIZE; i++) {
        dma_buffer[i] = i & 0xFF;
    }

    // 清理 D-Cache,确保数据写入内存
    SCB_CleanDCache_by_Addr((uint32_t*)dma_buffer, DMA_BUFFER_SIZE);

    // 启动 DMA 发送
    HAL_DMA_Start(&hdma_memtomem, (uint32_t)dma_buffer, (uint32_t)dest, DMA_BUFFER_SIZE);
}

void DMA_Receive_Complete_Callback(void)
{
    // DMA 接收完成,无效化 Cache,强制 CPU 从内存读取
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_buffer, DMA_BUFFER_SIZE);

    // 此时读取 dma_buffer 才是最新数据
    process_data(dma_buffer, DMA_BUFFER_SIZE);
}

五、完整实战:串口 DMA 收发一致性处理

以 UART 空闲中断 + DMA 接收为例:

#define RX_BUFFER_SIZE 256
__attribute__((aligned(32))) uint8_t rx_buffer[RX_BUFFER_SIZE];

void UART_DMA_Init(void)
{
    // 启动 DMA 接收
    HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);
}

void UART_IDLE_Callback(void)
{
    // 停止 DMA 以获取已接收长度
    HAL_UART_DMAStop(&huart1);
    uint16_t len = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx);

    // 无效化 Cache,确保读取到 DMA 写入的最新数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, len);

    // 处理数据
    parse_packet(rx_buffer, len);

    // 重新启动接收
    HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE);
}

六、注意事项与避坑指南

  • 地址与大小对齐:Cache 维护函数要求地址 32 字节对齐,大小建议为 32 的倍数。
  • 避免频繁维护:Cache 维护开销较大,尽量将 DMA 缓冲区集中管理。
  • 中断优先级:在 DMA 完成中断中调用 Cache 维护函数时,确保中断优先级不冲突。
  • 多缓冲区管理:若使用多个 DMA 缓冲区,每个缓冲区都需独立维护。
  • 调试陷阱:调试器可能自动执行 Cache 维护,导致问题隐藏,务必全速运行验证。
  • MPU 与 Cache 维护二选一:优先使用 MPU 配置 Non-Cacheable 区域,性能更好且代码简洁。

七、总结

STM32H7 的 D-Cache 与 DMA 一致性问题是嵌入式开发中的经典难题。通过 MPU 配置非缓存区域 或 合理使用 SCB_CleanDCache/InvalidateDCache,可以彻底解决。推荐优先使用 MPU 方案,若受限于内存布局,则务必在 DMA 传输前后正确维护 Cache。掌握这些技巧,你的 H7 应用将既高效又稳定。