为什么 DMA 和 D-Cache 会打架
STM32H7 使用 Cortex-M7 内核,带 16KB 的 D-Cache。CPU 访问内存时优先命中 Cache,而 DMA 是独立于内核的总线主设备,它直接读写 SRAM,完全绕过 Cache。于是同一个地址出现两份数据:Cache 里一份、SRAM 里一份。
- CPU 写数据:只更新了 Cache,SRAM 还是旧值,DMA 发出去的是旧数据。
- DMA 收数据:只更新了 SRAM,Cache 里还是旧值,CPU 读到的是旧数据。
这就是数据不一致的根源。解决办法不是关 Cache(H7 关掉 D-Cache 性能损失巨大),而是在正确的时机做 Clean 和 Invalidate。
Clean 与 Invalidate 的语义边界
先明确两个操作的准确含义,这是最容易搞混的地方:
- Clean(清理):把 Cache 中“脏”数据写回 SRAM。方向是 Cache → 内存。用于 CPU 写、DMA 读 的场景。
- Invalidate(无效化):把 Cache 行标记为无效,下次读时从 SRAM 重新加载。方向是内存 → Cache。用于 DMA 写、CPU 读 的场景。
关键边界:
- 发送前(CPU 填好缓冲区,交给 DMA 发):必须 Clean,让 SRAM 拿到最新数据。
- 接收后(DMA 填好缓冲区,CPU 要读):必须 Invalidate,丢弃 Cache 里的旧副本。
- 接收前:如果缓冲区之前被 CPU 读过(Cache 里有副本),也要先 Invalidate,否则 DMA 写入 SRAM 后,CPU 读到的仍是 Cache 旧值。
一句话记忆:谁写谁负责同步,读之前先失效。
配置步骤
1. 使能 D-Cache
void Cache_Enable(void)
{
SCB_EnableICache();
SCB_EnableDCache();
}
2. MPU 配置 DMA 缓冲区为 Non-Cacheable(推荐方案)
对于频繁 DMA 的缓冲区,最省心的做法是用 MPU 把该区域设为 Non-Cacheable,彻底绕开一致性问题:
void MPU_Config(void)
{
HAL_MPU_Disable();
MPU_Region_InitTypeDef init = {0};
init.Enable = MPU_REGION_ENABLE;
init.BaseAddress = 0x30000000; /* D2 SRAM,DMA 常用区 */
init.Size = MPU_REGION_SIZE_64KB;
init.AccessPermission = MPU_REGION_FULL_ACCESS;
init.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
init.IsCacheable = MPU_ACCESS_NOT_CACHEABLE; /* 关键 */
init.IsShareable = MPU_ACCESS_SHAREABLE;
init.Number = MPU_REGION_NUMBER0;
init.TypeExtField = MPU_TEX_LEVEL1;
init.SubRegionDisable = 0x00;
init.DisableExec = MPU_INSTRUCTION_ACCESS_DISABLE;
HAL_MPU_ConfigRegion(&init);
HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}
3. 若缓冲区仍可缓存,则手动维护
/* 发送前:把 CPU 写的数据刷到 SRAM */
SCB_CleanDCache_by_Addr((uint32_t *)buf, len);
/* 接收后:丢弃 Cache 旧副本,强制从 SRAM 读 */
SCB_InvalidateDCache_by_Addr((uint32_t *)buf, len);
完整代码示例:UART DMA 收发
#include "stm32h7xx_hal.h"
#define BUF_SIZE 128
/* 必须 32 字节对齐,且长度按 32 字节向上取整 */
aligned_32 uint8_t tx_buf[BUF_SIZE];
aligned_32 uint8_t rx_buf[BUF_SIZE];
/* 发送:CPU 填数据 -> Clean -> 启动 DMA */
void Uart_Send_DMA(UART_HandleTypeDef *huart, uint8_t *data, uint16_t len)
{
memcpy(tx_buf, data, len);
SCB_CleanDCache_by_Addr((uint32_t *)tx_buf, len);
HAL_UART_Transmit_DMA(huart, tx_buf, len);
}
/* 接收前:先 Invalidate,避免读到 Cache 旧值 */
void Uart_Start_Receive(UART_HandleTypeDef *huart, uint16_t len)
{
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, len);
HAL_UART_Receive_DMA(huart, rx_buf, len);
}
/* 接收完成回调:DMA 已写入 SRAM,CPU 读前再 Invalidate */
void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart)
{
SCB_InvalidateDCache_by_Addr((uint32_t *)rx_buf, BUF_SIZE);
/* 此时读 rx_buf 才是 DMA 收到的真实数据 */
ProcessData(rx_buf, BUF_SIZE);
}
踩坑复盘
坑一:Invalidate 把未回写的数据丢了
某项目在接收回调里对整块 128 字节缓冲区 Invalidate,但缓冲区前 32 字节是 CPU 刚写的配置头。Invalidate 直接丢弃了 Cache 中未 Clean 的脏数据,配置头变成随机值。
教训:Invalidate 前若该区域有 CPU 写入且未 Clean,必须先 Clean,否则数据丢失。
坑二:地址或长度未按 32 字节对齐
SCB_InvalidateDCache_by_Addr 内部按 Cache 行(32 字节)操作。若地址非 32 字节对齐,或长度不是 32 的倍数,会误伤相邻数据。曾出现缓冲区首字节被清、尾部数据被破坏的诡异现象。
教训:缓冲区用 __attribute__((aligned(32))) 对齐,长度向上取整到 32 的倍数。
坑三:DMA 描述符与数据在同一 Cache 行
DMA 链表描述符紧挨着数据缓冲区,位于同一 32 字节 Cache 行。Clean 数据时把描述符也刷了,Invalidate 时又把描述符改了,导致 DMA 传输错乱。
教训:描述符与数据缓冲区之间留出至少 32 字节间隔,或分别放到 Non-Cacheable 区域。
注意事项
- 优先用 MPU 把 DMA 缓冲区设为 Non-Cacheable,比手动维护更可靠。
- 手动维护时,Clean 用于“写后发”,Invalidate 用于“收后读”。
- 地址与长度务必 32 字节对齐,长度向上取整。
- Invalidate 会丢弃未回写的脏数据,操作前确认无待写数据。
- 回调函数中操作 Cache 前,确保 DMA 传输已真正结束(用传输完成中断而非轮询标志)。
- 调试时若怀疑 Cache 问题,可临时关闭 D-Cache 验证,但正式产品不要长期关闭。
理清 Clean/Invalidate 的方向和时机,配合 MPU 与对齐规范,STM32H7 的 DMA 数据一致性问题就能从“玄学”变成可控的工程问题。