一、为什么 D-Cache 会成为 DMA 的“猪队友”?

STM32H7 搭载 Cortex-M7 内核,主频可达 480MHz,为了匹配高速内核与相对较慢的 Flash/SRAM,芯片内部集成了 L1 D-Cache(数据缓存)。CPU 访问数据时,若命中 Cache 则直接读写缓存,不会立即同步到物理内存。

而 DMA 是“老实人”,它直接读写物理内存(SRAM),完全不知道 Cache 的存在。这就导致两种经典错误:

  • CPU 写,DMA 读:CPU 把数据写入 Cache 但未回写内存,DMA 从内存读到旧数据。
  • DMA 写,CPU 读:DMA 把新数据写入内存,但 CPU 读的是 Cache 里的旧数据。

解决思路只有两个:clean(将 Cache 数据写回内存)和 invalidate(将 Cache 行标记为无效,强制下次从内存读取)。但用错时机或忽略地址对齐,就会引入更隐蔽的 bug。

二、地址对齐:32 字节是硬性门槛

Cortex-M7 的 D-Cache 行大小为 32 字节。clean 和 invalidate 操作的最小单位就是一行(32 字节),且起始地址必须 32 字节对齐。

如果缓冲区起始地址不是 32 字节对齐,调用 SCB_CleanDCache_by_Addr() 时,函数内部会向下对齐到行首,可能误伤相邻变量;invalidate 更危险,会把相邻数据也标记为无效,导致其他变量被意外丢弃。

正确做法:使用 __attribute__((aligned(32))) 强制对齐。

// 错误:地址可能不对齐
uint8_t adc_buf[1024];

// 正确:32 字节对齐
__attribute__((aligned(32))) uint8_t adc_buf[1024];

另外,缓冲区大小最好是 32 的整数倍,避免边界行处理不完整。

三、clean 与 invalidate 的正确使用时机

记住一个简单口诀:CPU 写、DMA 读 → clean;DMA 写、CPU 读 → invalidate。

  • 发送方向(CPU→DMA):CPU 准备好数据后,先 clean,再启动 DMA。
  • 接收方向(DMA→CPU):DMA 传输完成后,先 invalidate,再读数据。

注意:invalidate 会丢弃 Cache 中未回写的数据。如果 CPU 在 DMA 传输期间碰过同一缓冲区,必须先 clean 再 invalidate,否则数据丢失。

四、完整实战代码:ADC + DMA 采集

以 ADC1 连续扫描 + DMA2 传输到内存为例,展示完整配置与缓存维护。

#include "stm32h7xx.h"

#define ADC_BUF_LEN  256

// 32 字节对齐的 DMA 目标缓冲区
__attribute__((aligned(32))) static uint16_t adc_dma_buf[ADC_BUF_LEN];
static volatile uint8_t dma_done = 0;

// DMA 传输完成回调
void DMA2_Stream0_IRQHandler(void)
{
    if (DMA2->LISR & DMA_LISR_TCIF0) {
        DMA2->LIFCR = DMA_LIFCR_CTCIF0;  // 清除传输完成标志
        dma_done = 1;
    }
}

void adc_dma_init(void)
{
    // 1. 使能时钟
    RCC->AHB1ENR |= RCC_AHB1ENR_DMA2EN;
    RCC->APB2ENR |= RCC_APB2ENR_ADC1EN;

    // 2. 配置 DMA2 Stream0:外设到内存,循环模式
    DMA2_Stream0->CR &= ~DMA_SxCR_EN;
    while (DMA2_Stream0->CR & DMA_SxCR_EN);

    DMA2_Stream0->PAR  = (uint32_t)&ADC1->DR;
    DMA2_Stream0->M0AR = (uint32_t)adc_dma_buf;
    DMA2_Stream0->NDTR = ADC_BUF_LEN;
    DMA2_Stream0->CR   = (0 << DMA_SxCR_CHSEL_Pos) |
                         DMA_SxCR_PL_1 |          // 高优先级
                         DMA_SxCR_MSIZE_0 |       // 16 位内存
                         DMA_SxCR_PSIZE_0 |       // 16 位外设
                         DMA_SxCR_MINC |          // 内存递增
                         DMA_SxCR_CIRC |          // 循环模式
                         DMA_SxCR_TCIE;           // 传输完成中断

    DMA2_Stream0->FCR &= ~DMA_SxFCR_DMDIS;  // 使能直接模式
    DMA2_Stream0->CR |= DMA_SxCR_EN;

    // 3. 配置 ADC1 连续转换 + DMA
    ADC1->CFGR = ADC_CFGR_CONT | ADC_CFGR_DMAEN | ADC_CFGR_DMACFG;
    ADC1->SQR1 = (3 << ADC_SQR1_L_Pos);  // 4 个通道
    ADC1->SQR1 |= (1 << ADC_SQR1_SQ1_Pos);
    ADC1->SQR1 |= (2 << ADC_SQR1_SQ2_Pos);
    ADC1->SQR1 |= (3 << ADC_SQR1_SQ3_Pos);
    ADC1->SQR1 |= (4 << ADC_SQR1_SQ4_Pos);
    ADC1->CR |= ADC_CR_ADEN;
    while (!(ADC1->ISR & ADC_ISR_ADRDY));
    ADC1->CR |= ADC_CR_ADSTART;

    // 4. 使能中断
    NVIC_EnableIRQ(DMA2_Stream0_IRQn);
}

// 读取 ADC 数据(在 DMA 完成中断后调用)
void adc_read_buffer(uint16_t *out, uint32_t len)
{
    if (!dma_done) return;

    // 关键:DMA 写内存,CPU 读之前必须 invalidate
    SCB_InvalidateDCache_by_Addr((uint32_t *)adc_dma_buf,
                                 ADC_BUF_LEN * sizeof(uint16_t));

    for (uint32_t i = 0; i < len; i++) {
        out[i] = adc_dma_buf[i];
    }
    dma_done = 0;
}

五、五个必知的实战陷阱

  • 陷阱 1:忘记地址对齐。SCB_InvalidateDCache_by_Addr 传入非对齐地址时,会向下取整到行首,可能把相邻变量一起 invalidate,导致其他数据被“吃掉”。务必用 aligned(32)。
  • 陷阱 2:invalidate 前未 clean。如果 CPU 在 DMA 传输期间写过同一缓冲区,invalidate 会直接丢弃这些未回写的数据。正确顺序:先 clean,再 invalidate。
  • 陷阱 3:缓冲区跨 Cache 行。若缓冲区大小不是 32 的整数倍,最后一行可能包含其他变量。invalidate 会误伤相邻数据。建议缓冲区大小补齐到 32 的倍数。
  • 陷阱 4:在中断中频繁 clean/invalidate。这两个操作会阻塞流水线,影响实时性。尽量批量处理,或使用 MPU 将 DMA 缓冲区配置为 Write-Through/Non-Cacheable。
  • 陷阱 5:忽略 MPU 配置。对于频繁 DMA 的缓冲区,最优雅的方案是用 MPU 将其设为 Non-Cacheable,彻底避免一致性问题。但要注意 Non-Cacheable 会降低 CPU 访问速度,需权衡。

六、总结

D-Cache 与 DMA 的冲突是 STM32H7 开发中的高频痛点。核心原则只有三条:地址 32 字节对齐、方向决定 clean 还是 invalidate、先 clean 后 invalidate。掌握这些,你就能在享受 H7 高性能的同时,避开数据错乱的深坑。建议在项目初期就用 MPU 规划好 DMA 缓冲区属性,从架构上消除隐患。