一、为什么 Cache 会让 DMA 数据“错乱”?
STM32H7 搭载 Cortex-M7,主频高达 480MHz,为了匹配高速内核,芯片内部集成了 L1 Cache(分为 D-Cache 和 I-Cache)。D-Cache 默认关闭,但一旦开启,CPU 访问 SRAM 或外部 SDRAM 时,数据会先被缓存到 Cache 中。
DMA 控制器则直接访问物理内存,不经过 Cache。这就导致两个经典问题:
- CPU 写,DMA 读:CPU 写入数据后,数据可能还在 Cache 中(Write-Back 模式),未同步到物理内存。DMA 启动后从物理内存读到的仍是旧数据。
- DMA 写,CPU 读:DMA 将新数据写入物理内存,但 CPU 读取时命中了 Cache 中的旧数据,导致读到“过期”内容。
解决思路有两种:硬件层面用 MPU 将 DMA 缓冲区配置为 Write-Through 或 Non-Cacheable;软件层面在 DMA 传输前后手动执行 Clean/Invalidate 操作。两者需结合使用,才能既保证性能又确保数据一致。
二、MPU 配置:为 DMA 缓冲区划定“安全区”
MPU(Memory Protection Unit)可以将特定内存区域设置为不同的缓存策略。对于 DMA 缓冲区,推荐两种方案:
- Non-Cacheable:完全绕过 Cache,CPU 和 DMA 都直接访问物理内存。简单可靠,但 CPU 访问速度会下降。
- Write-Through:CPU 写操作同时更新 Cache 和物理内存,读操作仍可缓存。比 Non-Cacheable 性能好,但写操作会占用总线带宽。
下面以将 0x30000000 开始的 32KB 区域配置为 Write-Through 为例,给出 MPU 配置代码。
#include "stm32h7xx.h"
void MPU_Config(void)
{
HAL_MPU_Disable();
MPU_Region_InitTypeDef MPU_InitStruct = {0};
/* 配置 D1 SRAM 区域为 Write-Through,允许 DMA 访问 */
MPU_InitStruct.Enable = MPU_REGION_ENABLE;
MPU_InitStruct.BaseAddress = 0x30000000;
MPU_InitStruct.Size = MPU_REGION_SIZE_32KB;
MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
MPU_InitStruct.IsBufferable = MPU_ACCESS_BUFFERABLE; // 允许缓冲写
MPU_InitStruct.IsCacheable = MPU_ACCESS_CACHEABLE; // 允许缓存
MPU_InitStruct.IsShareable = MPU_ACCESS_NOT_SHAREABLE;
MPU_InitStruct.Number = MPU_REGION_NUMBER0;
MPU_InitStruct.TypeExtField = MPU_TEX_LEVEL0;
MPU_InitStruct.SubRegionDisable = 0x00;
MPU_InitStruct.DisableExec = MPU_INSTRUCTION_ACCESS_ENABLE;
HAL_MPU_ConfigRegion(&MPU_InitStruct);
/* 若需 Non-Cacheable,改为:
IsCacheable = MPU_ACCESS_NOT_CACHEABLE;
IsBufferable = MPU_ACCESS_NOT_BUFFERABLE; */
HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}
关键点:MPU 配置必须在使能 Cache 之前完成,且区域不能与其它外设映射冲突。
三、Clean 与 Invalidate:何时用,怎么用?
即使配置了 MPU,若区域仍为 Cacheable(如 Write-Back),仍需手动维护一致性。Cortex-M7 提供以下操作:
- 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);
注意:地址必须 32 字节对齐,长度建议为 32 的整数倍,否则可能误操作相邻数据。
四、完整实战代码:DMA 串口发送与接收
以 UART DMA 发送和接收为例,展示正确的一致性维护流程。
#define DMA_BUF_SIZE 128
__attribute__((aligned(32))) uint8_t tx_buf[DMA_BUF_SIZE];
__attribute__((aligned(32))) uint8_t rx_buf[DMA_BUF_SIZE];
/* 发送前:Clean 确保数据写入物理内存 */
void UART_DMA_Send(uint8_t *data, uint16_t len)
{
memcpy(tx_buf, data, len);
SCB_CleanDCache_by_Addr((uint32_t*)tx_buf, len);
HAL_UART_Transmit_DMA(&huart1, tx_buf, len);
}
/* 接收完成回调:Invalidate 丢弃旧缓存,读取新数据 */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
if (huart->Instance == USART1) {
SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, DMA_BUF_SIZE);
/* 此时 rx_buf 中为 DMA 写入的最新数据 */
process_data(rx_buf, DMA_BUF_SIZE);
HAL_UART_Receive_DMA(&huart1, rx_buf, DMA_BUF_SIZE);
}
}
避坑:在 HAL_UART_RxCpltCallback 中,必须先 Invalidate 再读取数据,且 Invalidate 的地址范围要覆盖整个 DMA 缓冲区,不能只覆盖部分。
五、常见陷阱与注意事项
-
地址对齐:Clean/Invalidate 操作要求 32 字节对齐,缓冲区定义时使用
__attribute__((aligned(32)))。 - 长度对齐:长度最好是 32 的倍数,否则可能影响相邻变量。
- 中断中操作:在 DMA 完成中断中执行 Invalidate 是安全的,但注意不要嵌套调用导致性能下降。
- 多缓冲区:若使用双缓冲,每个缓冲区都要独立维护一致性。
- MPU 与手动维护二选一? 建议对频繁小数据用 Non-Cacheable,大数据用 Write-Back + 手动维护。
- 调试时关闭 Cache:若问题诡异,可先关闭 D-Cache 验证是否为一致性问题。
- 不要忘记 I-Cache:如果 DMA 搬运的是代码(如外部 Flash 加载),需同时处理 I-Cache。
六、总结
STM32H7 的 Cache 与 DMA 一致性问题是嵌入式开发中的“高级坑”,但只要掌握 MPU 配置和 Clean/Invalidate 的适用场景,就能游刃有余。核心原则:CPU 写后 Clean,DMA 写后 Invalidate,地址长度要对齐,MPU 配置要先行。希望本文能帮你少走弯路,让 H7 的性能与稳定性兼得。