一、为什么 DMA 和 D-Cache 会打架?
STM32H7 的 Cortex-M7 内核带有一级 D-Cache(通常 16KB)。当 CPU 访问内存时,数据会被缓存到 D-Cache 中,后续读写直接命中缓存,不再访问物理内存。而 DMA 是独立于 CPU 的总线主设备,它直接读写 SRAM 或外设 FIFO,完全绕过 D-Cache。
这就导致两个经典问题:
- CPU 写,DMA 读:CPU 写数据到缓存,但尚未回写到物理内存,DMA 读到的还是旧数据。
- DMA 写,CPU 读:DMA 把新数据写入物理内存,但 CPU 读的是缓存里的旧数据。
解决思路只有两条:要么让 DMA 缓冲区不经过 Cache(MPU 配置为 Write-Through/Non-Cacheable),要么在 DMA 传输前后手动维护 Cache 一致性(clean/invalidate)。
二、MPU 配置:给 DMA 缓冲区划一块“安全区”
最省心的做法是用 MPU 将 DMA 缓冲区所在的内存区域配置为 Non-Cacheable 或 Write-Through。这样 CPU 和 DMA 看到的内存始终一致,无需手动 clean/invalidate。
2.1 配置步骤
- 使能 MPU:
SCB_EnableMPU()前先SCB_DisableMPU()。 - 选择一个空闲的 MPU Region(0~15)。
- 设置基地址、大小、属性。
- 使能该 Region 和 MPU。
2.2 完整代码示例
#include "stm32h7xx.h"
// 假设 DMA 缓冲区放在 D2 SRAM 的 0x30000000 起始,大小 32KB
#define DMA_BUF_ADDR 0x30000000
#define DMA_BUF_SIZE 0x8000
void MPU_Config_DMA_Buffer(void)
{
HAL_MPU_Disable();
MPU_Region_InitTypeDef MPU_InitStruct = {0};
// 选择 Region 0
MPU_InitStruct.Enable = MPU_REGION_ENABLE;
MPU_InitStruct.Number = MPU_REGION_NUMBER0;
MPU_InitStruct.BaseAddress = DMA_BUF_ADDR;
MPU_InitStruct.Size = MPU_REGION_SIZE_32KB;
MPU_InitStruct.SubRegionDisable = 0x00;
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_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);
}
注意:
BaseAddress必须按 Region 大小对齐。32KB 区域基地址必须是 32KB 的整数倍。
三、手动维护一致性:clean 与 invalidate
如果 DMA 缓冲区必须放在可缓存区域(例如为了 CPU 高速访问),则需在传输前后调用 CMSIS 提供的函数。
3.1 两个核心函数
-
SCB_CleanDCache_by_Addr(addr, size):将 Cache 中已修改的数据写回物理内存。用于 CPU 写 → DMA 读 之前。 -
SCB_InvalidateDCache_by_Addr(addr, size):将 Cache 中对应行作废,强制下次读取从物理内存加载。用于 DMA 写 → CPU 读 之后。
3.2 正确使用顺序
// 场景:CPU 填充缓冲区,然后启动 DMA 发送
fill_buffer(tx_buf, len);
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, len); // 写回
HAL_DMA_Start(&hdma, (uint32_t)tx_buf, (uint32_t)&UART->TDR, len);
// 场景:DMA 接收完成,CPU 读取数据
// 在 DMA 完成中断中:
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, len); // 作废
process_data(rx_buf, len);
3.3 地址与长度对齐陷阱
Cache 操作以 32 字节 Cache Line 为单位。如果 addr 未对齐或 size 不是 32 的倍数,函数内部会向上取整,可能误伤相邻数据。
- 缓冲区首地址建议 32 字节对齐:
__attribute__((aligned(32)))。 - 长度建议补齐到 32 的倍数。
__attribute__((aligned(32))) uint8_t rx_buf[1024];
四、实战避坑清单
- 坑1:只 clean 不 invalidate。DMA 写回后 CPU 读旧值,必须 invalidate。
- 坑2:invalidate 在 DMA 传输完成前调用。DMA 还在写,invalidate 后 CPU 可能读到半新半旧数据。应在 DMA 完成中断中调用。
- 坑3:缓冲区跨 Cache Line 且与其他变量共享。invalidate 会丢弃相邻变量修改,务必对齐并隔离。
- 坑4:忘记配置 MPU 背景区域。默认背景区域是 Cacheable,若 DMA 缓冲区落在其中且未单独配置,问题依旧。
-
坑5:使用
SCB_InvalidateDCache()全局作废。这会清掉所有缓存,性能骤降,且可能影响其他任务,应使用by_Addr版本。 - 坑6:DMA 描述符本身在 Cache 中。若使用链表模式,描述符也要 clean/invalidate 或放在非缓存区。
五、总结
STM32H7 的 D-Cache 与 DMA 一致性没有银弹:简单场景用 MPU 划非缓存区,高性能场景用 clean/invalidate 精准维护。牢记“CPU 写后 clean,DMA 写后 invalidate”,并注意 32 字节对齐,就能避开绝大多数数据错乱问题。建议在项目初期就规划好 DMA 缓冲区的内存布局,后期调试会轻松很多。