STM32H7 的 DCache 与 DMA 数据一致性:地址对齐、Clean/Invalidate 时机与实测踩坑

一、为什么 DCache 会让 DMA 数据“出错”?

STM32H7 的 Cortex-M7 内核带有一级数据缓存(DCache),默认写回(Write-Back)、写分配(Write-Allocate)。CPU 访问内存时,数据可能只停留在 Cache 中,并未写入实际 RAM。而 DMA 控制器直接访问物理内存,不经过 Cache。这就导致:

  • CPU 写,DMA 读:CPU 写入的数据还在 Cache 里,DMA 从 RAM 读到的却是旧数据。
  • DMA 写,CPU 读:DMA 把新数据写入 RAM,但 CPU 读到的却是 Cache 中的旧数据。

解决思路只有两个:Clean(将 Cache 写回 RAM)和 Invalidate(丢弃 Cache 内容,强制从 RAM 重读)。但操作时机和地址对齐有严格要求。

二、地址对齐:32 字节是硬性要求

Cortex-M7 的 Cache 行大小为 32 字节。SCB_CleanDCache_by_Addr 和 SCB_InvalidateDCache_by_Addr 要求地址按 32 字节对齐,长度也建议为 32 的整数倍。否则可能误伤相邻数据,导致其他变量被意外清除或写回。

正确做法:DMA 缓冲区使用 __attribute__((aligned(32))) 强制对齐,且长度向上取整到 32 的倍数。

#define DMA_BUF_SIZE  256
__attribute__((aligned(32))) uint8_t dma_tx_buf[DMA_BUF_SIZE];
__attribute__((aligned(32))) uint8_t dma_rx_buf[DMA_BUF_SIZE];

如果缓冲区是动态分配的,需手动对齐地址和长度:

uint32_t aligned_addr = (uint32_t)buf & ~0x1F;          // 向下对齐到 32 字节
uint32_t aligned_size = ((uint32_t)buf + len + 31) & ~0x1F; // 向上取整

三、Clean 与 Invalidate 的正确时机

3.1 CPU 发送数据给 DMA(TX)

  1. CPU 填充缓冲区。
  2. Clean 缓冲区,确保数据写入 RAM。
  3. 启动 DMA 发送。
  4. DMA 传输完成中断中,无需额外操作(但若缓冲区会被复用,建议再次 Clean 或等待)。
void dma_send(uint8_t *data, uint16_t len) {
    memcpy(dma_tx_buf, data, len);
    SCB_CleanDCache_by_Addr((uint32_t*)dma_tx_buf, len);
    HAL_DMA_Start(&hdma_memtomem, (uint32_t)dma_tx_buf, (uint32_t)dest, len);
}

3.2 DMA 接收数据给 CPU(RX)

  1. 启动 DMA 接收前,Invalidate 缓冲区,丢弃可能存在的旧 Cache 行。
  2. 启动 DMA。
  3. DMA 完成中断中,再次 Invalidate 缓冲区,确保 CPU 读到最新数据。
void dma_receive_start(void) {
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buf, DMA_BUF_SIZE);
    HAL_DMA_Start_IT(&hdma, (uint32_t)src, (uint32_t)dma_rx_buf, DMA_BUF_SIZE);
}

void HAL_DMA_ConvCpltCallback(DMA_HandleTypeDef *hdma) {
    SCB_InvalidateDCache_by_Addr((uint32_t*)dma_rx_buf, DMA_BUF_SIZE);
    // 此时 dma_rx_buf 中的数据是有效的
}

关键点:Invalidate 必须放在 DMA 启动前和完成后各一次。启动前是为了防止 Cache 中的脏数据在 DMA 写入后被意外写回覆盖新数据;完成后是为了让 CPU 读取到 DMA 写入的新数据。

四、实测踩坑记录

坑 1:忘记 Clean,DMA 发送旧数据

现象:串口 DMA 发送字符串,第一次正常,修改缓冲区后再次发送,收到的还是旧内容。 原因:CPU 修改的数据在 Cache 中,DMA 从 RAM 读旧值。 解决:发送前调用 SCB_CleanDCache_by_Addr。

坑 2:Invalidate 时机错误,数据被覆盖

现象:DMA 接收完成后立即 Invalidate,但偶尔数据错乱。 原因:在 DMA 传输过程中,CPU 可能访问了缓冲区,导致 Cache 中产生脏行。DMA 完成后 Invalidate 会丢弃这些脏行,但若脏行在 DMA 写入之后才写回,就会覆盖 DMA 数据。 解决:确保 DMA 传输期间 CPU 不访问该缓冲区,或在启动 DMA 前先 Clean 再 Invalidate。

坑 3:地址未对齐,相邻变量被清除

现象:Invalidate 后,一个无关的全局变量值变为 0。 原因:缓冲区未按 32 字节对齐,Invalidate 操作覆盖了相邻变量所在的 Cache 行。 解决:强制 32 字节对齐,并确保长度是 32 的倍数。

坑 4:MPU 配置不当,Cache 策略冲突

现象:即使正确 Clean/Invalidate,数据仍偶尔出错。 原因:MPU 将 DMA 缓冲区配置为 Write-Back 但未正确设置共享属性。 解决:将 DMA 缓冲区所在内存区域配置为 Non-Cacheable 或 Write-Through,可彻底避免一致性问题(但会牺牲性能)。

五、最佳实践总结

  • 对齐:DMA 缓冲区必须 32 字节对齐,长度取整到 32 的倍数。
  • TX:CPU 写完后 Clean,再启动 DMA。
  • RX:启动 DMA 前 Invalidate,DMA 完成后再次 Invalidate。
  • 避免 CPU 访问:DMA 传输期间不要读写缓冲区。
  • MPU 辅助:对性能要求不高的缓冲区,直接配置为 Non-Cacheable 最省心。
  • 使用 CMSIS 函数:SCB_CleanDCache_by_Addr、SCB_InvalidateDCache_by_Addr 是标准做法。

六、完整示例:串口 DMA 回环测试

#include "stm32h7xx.h"

#define BUF_SIZE 128
__attribute__((aligned(32))) uint8_t rx_buf[BUF_SIZE];
__attribute__((aligned(32))) uint8_t tx_buf[BUF_SIZE];

void uart_dma_init(void) {
    // 初始化 UART 和 DMA(略)
}

void uart_send_dma(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);
}

void uart_receive_dma(void) {
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, BUF_SIZE);
    HAL_UART_Receive_DMA(&huart1, rx_buf, BUF_SIZE);
}

void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {
    SCB_InvalidateDCache_by_Addr((uint32_t*)rx_buf, BUF_SIZE);
    // 处理 rx_buf 数据
    uart_send_dma(rx_buf, BUF_SIZE); // 回环发送
    uart_receive_dma(); // 重新启动接收
}

七、结语

DCache 与 DMA 的一致性问题看似复杂,但只要掌握 对齐、Clean/Invalidate 时机、避免并发访问 三原则,就能稳定运行。建议在项目初期就规划好 DMA 缓冲区的内存布局,必要时用 MPU 将缓冲区设为 Non-Cacheable,可大幅降低调试成本。