一、为什么 D-Cache 和 DMA 会打架?

STM32H7 主频高达 480MHz,为了匹配 CPU 速度,Cortex-M7 内核配备了 L1 D-Cache。CPU 访问数据时,如果命中 Cache,就直接从 Cache 读写,不会立即同步到 SRAM。而 DMA 是“老实人”,它直接读写物理内存,完全不知道 Cache 的存在。

这就导致两个经典问题:

  • DMA 读取(外设→内存):DMA 把新数据写入 SRAM,但 CPU 可能还在读 Cache 里的旧数据。
  • DMA 发送(内存→外设):CPU 刚修改了数据,但只更新了 Cache,SRAM 里还是旧值,DMA 发出去的就是旧数据。

解决思路有两种:硬件层面用 MPU 将 DMA 缓冲区配置为 Write-Through 或 Non-Cacheable软件层面在 DMA 传输前后手动 Clean/Invalidate Cache。下面逐一拆解。

二、MPU 配置:一劳永逸的硬件方案

MPU(Memory Protection Unit)可以把某段内存区域设置为不同的内存属性。对于 DMA 缓冲区,推荐两种属性:

  • Non-Cacheable:完全绕过 Cache,CPU 和 DMA 都直接访问 SRAM,最简单但性能有损失。
  • Write-Through:CPU 写操作同时更新 Cache 和 SRAM,读操作仍可缓存。适合读多写少的场景。

2.1 配置步骤

  1. 使能 MPU:SCB->SHCSR |= SCB_SHCSR_MEMFAULTENA_Msk; 然后 MPU->CTRL = MPU_CTRL_ENABLE_Msk | MPU_CTRL_PRIVDEFENA_Msk;
  2. 定义一个区域(Region),设置基地址、大小、属性。
  3. 使能该区域。

2.2 完整代码示例

#include "stm32h7xx.h"

// 假设 DMA 缓冲区起始地址 0x30000000,大小 32KB
#define DMA_BUFFER_ADDR  0x30000000
#define DMA_BUFFER_SIZE  (32 * 1024)

void MPU_Config(void)
{
    HAL_MPU_Disable();

    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 配置 Region 0 为 Non-Cacheable, Non-Shared
    MPU_InitStruct.Enable           = MPU_REGION_ENABLE;
    MPU_InitStruct.Number           = MPU_REGION_NUMBER0;
    MPU_InitStruct.BaseAddress      = DMA_BUFFER_ADDR;
    MPU_InitStruct.Size             = MPU_REGION_SIZE_32KB;
    MPU_InitStruct.SubRegionDisable = 0x0;
    MPU_InitStruct.TypeExtField     = MPU_TEX_LEVEL0;
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.DisableExec      = MPU_INSTRUCTION_ACCESS_DISABLE;
    MPU_InitStruct.IsShareable      = MPU_ACCESS_NOT_SHAREABLE;
    MPU_InitStruct.IsCacheable      = MPU_ACCESS_NOT_CACHEABLE;
    MPU_InitStruct.IsBufferable     = MPU_ACCESS_NOT_BUFFERABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

注意:MPU 配置必须在使能 Cache 之前完成,否则可能触发 MemManage 异常。

三、软件方案:Clean 与 Invalidate 的正确用法

如果不想动 MPU,或者缓冲区是动态分配的,就得手动维护 Cache。关键函数:

  • SCB_CleanDCache_by_Addr(addr, size):把 Cache 里的脏数据写回 SRAM。
  • SCB_InvalidateDCache_by_Addr(addr, size):丢弃 Cache 内容,强制下次从 SRAM 读。
  • SCB_CleanInvalidateDCache_by_Addr(addr, size):先 Clean 再 Invalidate。

3.1 使用场景

| 传输方向 | 操作 | |----------|------| | 内存 → 外设 (TX) | 发送前 Clean | | 外设 → 内存 (RX) | 接收前 Invalidate,接收后 Invalidate |

3.2 代码示例

// DMA 发送前
SCB_CleanDCache_by_Addr((uint32_t*)tx_buffer, tx_len);
HAL_DMA_Start(&hdma, (uint32_t)tx_buffer, (uint32_t)&UART->TDR, tx_len);

// DMA 接收前
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, rx_len);
HAL_DMA_Start(&hdma, (uint32_t)&UART->RDR, (uint32_t)rx_buffer, rx_len);

// 等待 DMA 完成后再 Invalidate 一次(确保 CPU 读到最新数据)
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buffer, rx_len);

四、踩坑记录:那些年我们踩过的雷

坑 1:地址未对齐导致 HardFault

SCB_CleanDCache_by_Addr 要求地址 32 字节对齐,长度也建议 32 字节对齐。如果缓冲区是 uint8_t 数组且未对齐,直接调用会触发 HardFault。

解决:使用 __attribute__((aligned(32))) 定义缓冲区,或手动对齐地址和长度。

__attribute__((aligned(32))) uint8_t rx_buffer[1024];

坑 2:Invalidate 丢失未写回的数据

如果 CPU 刚写了数据但还没 Clean,此时直接 Invalidate,Cache 里的新数据会被丢弃,SRAM 里还是旧值。

解决:对于 RX 缓冲区,如果 CPU 之前写过,必须先 Clean 再 Invalidate。或者干脆用 Non-Cacheable 区域。

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

DMA 正在传输时,CPU 如果访问同一缓冲区,Cache 可能预取到不完整的数据。

解决:传输期间避免 CPU 访问,或使用双缓冲区(Ping-Pong)机制。

坑 4:MPU 配置与链接脚本冲突

如果 MPU 区域覆盖了栈或堆,可能导致程序跑飞。务必确保 MPU 区域只覆盖 DMA 缓冲区,不要重叠其他关键区域。

五、最佳实践总结

  • 优先用 MPU:将 DMA 缓冲区固定为 Non-Cacheable 或 Write-Through,一劳永逸。
  • 软件维护要成对:TX 前 Clean,RX 前 Invalidate,RX 后再次 Invalidate。
  • 地址对齐:所有 Cache 操作地址和长度按 32 字节对齐。
  • 避免传输中访问:DMA 工作时 CPU 不要碰缓冲区。
  • 调试手段:开启 MemManage 异常,用 SCB->CFSR 定位问题。

掌握这些,STM32H7 的 D-Cache 与 DMA 就能和谐共处,性能与稳定兼得。