一、问题现象:DMA 数据为何“时好时坏”?

在 STM32H7 上使用 DMA 搬运 ADC 采样数据或串口收发时,常遇到以下现象:

  • 数据偶尔错位、丢失或出现旧值。
  • 调试器查看内存正确,但程序读取错误。
  • 关闭 D-Cache 后一切正常。

根本原因:Cortex-M7 的 D-Cache 与 DMA 控制器访问内存的路径不同。CPU 通过 Cache 访问,DMA 直接访问物理内存。若 Cache 中有未回写的数据,DMA 读到的是旧数据;若 DMA 更新了内存,而 Cache 中仍有旧副本,CPU 读到的也是旧数据。

二、原理:Cache 一致性与维护操作

Cortex-M7 的 D-Cache 采用**写回(Write-Back)**策略,数据修改后不会立即写入物理内存,而是标记为“脏”。DMA 传输前后必须进行 Cache 维护:

  • Clean(清理):将 Cache 中的脏数据写回物理内存。用于 CPU 写、DMA 读 的场景(如发送缓冲区)。
  • Invalidate(无效化):丢弃 Cache 中的内容,强制下次读取从物理内存加载。用于 DMA 写、CPU 读 的场景(如接收缓冲区)。
  • Clean & Invalidate:两者结合,用于双向传输。

CMSIS 提供了对应函数:

void SCB_CleanDCache_by_Addr(uint32_t *addr, int32_t dsize);
void SCB_InvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize);
void SCB_CleanInvalidateDCache_by_Addr(uint32_t *addr, int32_t dsize);

注意:addr 必须 32 字节对齐,dsize 为字节数。

三、配置步骤:手动维护 Cache 的完整流程

以 UART DMA 接收为例,假设缓冲区 uint8_t rx_buf[64] 位于 0x24000000(AXI SRAM)。

步骤 1:初始化 DMA 和 UART

// 使能 DMA1 时钟和 UART4 时钟
__HAL_RCC_DMA1_CLK_ENABLE();
__HAL_RCC_UART4_CLK_ENABLE();

// 配置 DMA 为循环模式,目标地址为 rx_buf
hdma_uart_rx.Instance = DMA1_Stream0;
hdma_uart_rx.Init.Request = DMA_REQUEST_UART4_RX;
hdma_uart_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
hdma_uart_rx.Init.PeriphInc = DMA_PINC_DISABLE;
hdma_uart_rx.Init.MemInc = DMA_MINC_ENABLE;
hdma_uart_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
hdma_uart_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
hdma_uart_rx.Init.Mode = DMA_CIRCULAR;
hdma_uart_rx.Init.Priority = DMA_PRIORITY_HIGH;
HAL_DMA_Init(&hdma_uart_rx);

// 关联 DMA 到 UART
__HAL_LINKDMA(&huart4, hdmarx, hdma_uart_rx);

步骤 2:启动 DMA 前无效化 Cache

// 确保 CPU 不会从 Cache 读取旧数据
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, sizeof(rx_buf));
HAL_UART_Receive_DMA(&huart4, rx_buf, sizeof(rx_buf));

步骤 3:DMA 完成中断中再次无效化

void DMA1_Stream0_IRQHandler(void)
{
    HAL_DMA_IRQHandler(&hdma_uart_rx);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
    // 数据已由 DMA 写入物理内存,但 Cache 可能仍有旧数据
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, sizeof(rx_buf));
    // 现在可以安全读取 rx_buf
    process_data(rx_buf, sizeof(rx_buf));
}

步骤 4:发送场景(CPU 写,DMA 读)

// 填充发送缓冲区
memcpy(tx_buf, data, len);
// 清理 Cache,确保 DMA 读到最新数据
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, len);
HAL_UART_Transmit_DMA(&huart4, tx_buf, len);

四、进阶:使用 MPU 配置内存属性

手动维护 Cache 容易遗漏,且频繁 Clean/Invalidate 影响性能。更优雅的方案是利用 MPU(内存保护单元) 将 DMA 缓冲区所在内存区域配置为 Non-CacheableWrite-Through

配置步骤:

  1. 在链接脚本中划分一块专用内存区域(如 0x30000000 的 SRAM4)。
  2. main() 初始化时配置 MPU。
void MPU_Config(void)
{
    HAL_MPU_Disable();
    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 配置 SRAM4 区域为 Non-Cacheable
    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_SHAREABLE;
    MPU_InitStruct.Number = MPU_REGION_NUMBER0;
    MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
    MPU_InitStruct.SubRegionDisable = 0x00;
    MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE;

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

之后将 DMA 缓冲区定义到该区域:

__attribute__((section(".sram4"))) uint8_t dma_buffer[256];

这样 CPU 和 DMA 访问该内存时都不经过 Cache,无需手动维护。

五、注意事项与避坑指南

  • 对齐问题SCB_*DCache_by_Addr 要求地址 32 字节对齐,长度建议为 32 的倍数。否则可能误伤相邻数据。
  • 中断中调用:Cache 维护函数执行时间较长,避免在高速中断中频繁调用。
  • 多缓冲区:若使用多个 DMA 缓冲区,确保每个缓冲区独立维护,不要跨区域操作。
  • DMA 描述符:若使用链表模式,描述符本身也需注意 Cache 一致性。
  • 调试影响:调试器可能绕过 Cache 直接访问内存,导致“调试正常、运行异常”。
  • 性能权衡:Non-Cacheable 区域会降低 CPU 访问速度,仅建议用于 DMA 缓冲区,不要全局关闭 Cache。

六、总结

STM32H7 的 D-Cache 与 DMA 冲突本质是数据一致性问题。手动维护 Cache 适合小规模、低频传输;MPU 配置 Non-Cacheable 区域则一劳永逸,适合高频、多缓冲场景。理解 SCB_CleanDCacheSCB_InvalidateDCache 的语义,结合 MPU 灵活运用,才能充分发挥 H7 的性能优势。