STM32 DMA 双缓冲传输的缓冲区对齐陷阱与 MPU 配置策略

一、为什么双缓冲模式需要对齐?

DMA 双缓冲(Double Buffer)模式允许外设(如 ADC、UART、SPI)在后台交替使用两个内存缓冲区,当一个缓冲区被 DMA 填充时,CPU 可处理另一个缓冲区,从而避免数据丢失。然而,STM32 的 DMA 控制器(尤其是 DMA1/DMA2)在双缓冲模式下,要求每个缓冲区的起始地址必须按数据传输宽度对齐(如半字 2 字节、字 4 字节),且两个缓冲区大小必须一致。若未对齐,DMA 可能产生总线错误或数据错位,尤其在开启 Cache 的 Cortex-M7 内核(如 STM32H7)上,还会引发 Cache 与 DMA 之间的数据不一致性问题。

二、对齐陷阱的根源:Cache 与 MPU

Cortex-M7 内核默认启用指令 Cache 和数据 Cache,而 DMA 直接访问内存(绕过 CPU Cache)。当 CPU 写入缓冲区后,数据可能仍停留在 Cache 中,尚未写回 RAM;DMA 读取时可能拿到旧数据。反之,DMA 写入缓冲区后,Cache 中的副本可能过期,CPU 读取时得到脏数据。解决思路有两种:

  • 禁用 Cache(简单但性能损失大)
  • 配置 MPU 将缓冲区内存区域设置为非 Cacheable(或 Write-Through),并确保缓冲区地址对齐到 MPU 区域大小(通常 32 字节或 64 字节)。

三、MPU 配置策略详解

1. 内存区域划分

将双缓冲的两个缓冲区放置在一个独立的内存段(如内部 SRAM 的某个区域),并配置 MPU 为该区域设置属性:

  • Normal memory, Non-cacheable:适用于 DMA 频繁访问的缓冲区。
  • 或者使用 Write-Through 模式,但 Non-cacheable 更简单可靠。

2. MPU 寄存器配置步骤

以 STM32H743 为例,使用 HAL 库配置 MPU:

// 定义缓冲区地址和大小(必须对齐到 32 字节)
#define BUF_SIZE 1024
__attribute__((aligned(32))) uint8_t buf1[BUF_SIZE];
__attribute__((aligned(32))) uint8_t buf2[BUF_SIZE];

void MPU_Config(void)
{
    MPU_Region_InitTypeDef MPU_InitStruct = {0};

    // 禁用 MPU 进行配置
    HAL_MPU_Disable();

    // 配置区域:覆盖 buf1 和 buf2 的连续地址空间
    MPU_InitStruct.Enable = MPU_REGION_ENABLE;
    MPU_InitStruct.BaseAddress = (uint32_t)buf1;
    MPU_InitStruct.Size = MPU_REGION_SIZE_4KB; // 根据实际大小调整
    MPU_InitStruct.AccessPermission = MPU_REGION_FULL_ACCESS;
    MPU_InitStruct.IsBufferable = MPU_ACCESS_NOT_BUFFERABLE;
    MPU_InitStruct.IsCacheable = MPU_ACCESS_NOT_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_DISABLE;

    HAL_MPU_ConfigRegion(&MPU_InitStruct);

    // 使能 MPU
    HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT);
}

注意:MPU 区域大小必须是 2 的幂,且起始地址必须对齐到区域大小。若两个缓冲区不连续,可配置两个 MPU 区域,或将其放入一个更大的连续数组。

四、DMA 双缓冲配置示例

以 SPI 接收为例,使用 DMA 双缓冲模式:

// 初始化 DMA 句柄
DMA_HandleTypeDef hdma_spi_rx;

void DMA_Init(void)
{
    __HAL_RCC_DMA1_CLK_ENABLE();

    hdma_spi_rx.Instance = DMA1_Stream0;
    hdma_spi_rx.Init.Request = DMA_REQUEST_SPI1_RX;
    hdma_spi_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_spi_rx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi_rx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_spi_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_spi_rx.Init.Mode = DMA_CIRCULAR; // 必须使用循环模式以支持双缓冲
    hdma_spi_rx.Init.Priority = DMA_PRIORITY_HIGH;
    hdma_spi_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
    HAL_DMA_Init(&hdma_spi_rx);

    // 关联 DMA 到 SPI
    __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi_rx);

    // 配置双缓冲:交替使用 buf1 和 buf2
    HAL_DMAEx_MultiBufferStart(&hdma_spi_rx, (uint32_t)&hspi1.Instance->RXDR, (uint32_t)buf1, (uint32_t)buf2, BUF_SIZE);
}

// DMA 传输完成回调(每个缓冲区填充完成时触发)
void HAL_DMAEx_MultiBufferHalfCpltCallback(DMA_HandleTypeDef *hdma)
{
    // 处理 buf1(当前 DMA 正在填充 buf2)
    ProcessBuffer(buf1);
}

void HAL_DMAEx_MultiBufferCpltCallback(DMA_HandleTypeDef *hdma)
{
    // 处理 buf2(当前 DMA 正在填充 buf1)
    ProcessBuffer(buf2);
}

关键点

  • 必须使用 HAL_DMAEx_MultiBufferStart 而非普通 HAL_DMA_Start
  • 缓冲区大小必须一致,且地址对齐到 DMA 传输宽度(此处为字节,但建议至少 4 字节对齐)。
  • 回调函数中处理数据时,由于 MPU 已配置为非 Cacheable,无需手动清 Cache。

五、常见陷阱与注意事项

  • 陷阱 1:缓冲区未对齐。若使用 uint8_t 数组且未指定对齐属性,编译器可能将其分配到任意地址。务必使用 __attribute__((aligned(32)))alignas(32)
  • 陷阱 2:MPU 区域覆盖不足。若缓冲区跨越两个 MPU 区域,需分别配置,否则部分区域仍为 Cacheable,导致不一致。
  • 陷阱 3:DMA 模式错误。双缓冲必须使用循环模式(DMA_CIRCULAR),否则传输一次后停止。
  • 陷阱 4:中断优先级。双缓冲回调中处理数据需快速,避免 DMA 覆盖未处理缓冲区。建议使用临界区或任务通知。
  • 陷阱 5:MPU 配置时机。必须在 DMA 启动前配置 MPU,且配置后不可随意更改区域属性。

六、总结

DMA 双缓冲是提升嵌入式系统实时性的利器,但前提是正确处理内存对齐和 Cache 一致性。通过合理配置 MPU 将缓冲区设为非 Cacheable,并严格对齐地址,可彻底避免数据错乱。实际项目中,建议结合调试器观察 DMA 和 CPU 访问的地址,验证配置正确性。掌握这一策略,你将能从容应对 STM32H7 等高性能 MCU 的复杂数据流场景。