ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32U575 GPDMA双缓冲实战:原理、配置与避坑指南

2026/8/29 12:20:30 拓冰建站 浏览量
STM32U575 GPDMA双缓冲实战:原理、配置与避坑指南 做嵌入式这几年我调过F1、F4、L4几代产品的DMA一直觉得DMA无非就是“搬数据中断”没什么好琢磨的。直到最近在STM32U575上做项目发现这一代控制器把DMA的玩法拉高了一个档次——GPDMA配合Linked-list、Repeated Block和Double Buffer这些机制一开始还真有点不适应。尤其是做UART高速接收和ADC连续采样这类场景双缓冲用得好不好直接决定系统的流畅度。这篇文章我按自己的项目实践来写结合STM32U575的GPDMA外设把双缓冲的实现思路、寄存器配置、代码流程和踩坑经验一次讲清楚。内容更适合正在用STM32U5系列做开发、或者刚接触GPDMA想搞明白双缓冲怎么用的朋友。1. GPDMA与传统DMA到底差在哪1.1 不只是改名整个架构都换了很多从STM32F系列迁移过来的人第一反应是“DMA换个名字而已”。实际用起来才发现GPDMA是重新设计过的DMA控制器。F系列的DMA有固定的通道映射关系DMA1的通道4给UART1_TXDMA2的通道3给I2C1_RX这些映射是写死的配置错一个位就搬不到数据。而STM32U5上引入了DMAMUXDMA请求多路复用器外设。它的作用是把外设的DMA请求信号做成一个可配置的矩阵任意一个GPDMA通道都可以通过配置DMAMUX的Request ID来接收任意外设的请求。这个变化在工程上非常实用以前通道不够用、为抢通道头疼的问题少多了。GPDMA的核心能力还体现在传输模式上除了传统的单次块传输Single Block还支持Repeated Block重复块传输和Linked-list链表传输。链表模式允许你把多段传输描述符串起来让DMA自己跑去加载下一段配置不需要CPU干预。这个能力是双缓冲的硬件基础。1.2 双缓冲到底解决了什么问题单缓冲的工作方式很直观DMA往一块缓冲区写数据写满了产生中断CPU进中断处理数据处理完DMA再开始下一轮。问题是CPU处理数据的这段时间DMA是停着的。如果数据一直在来要么丢数据要么DMA被强制等待。双缓冲的思路是准备两块缓冲区DMA写A缓冲区的时候CPU处理B缓冲区当A写满后DMA自动切到BCPU去处理A。用流水线思维看就是把“填满缓冲区”和“处理缓冲区”这两件事解耦了各自并行跑起来中间不存在谁等谁。打个比方一个人倒酒一个人喝只要酒杯够多酒水就能一直供应不会出现倒酒的人要等喝酒的人喝完才能继续倒的尴尬。2. GPDMA双缓冲的原理与关键寄存器2.1 DBM位与两个缓冲区地址的切换在STM32U575的GPDMA寄存器手册里双缓冲模式由GPDMA_CTR2寄存器中的DBMDouble Buffer Mode位控制。当DBM位置1后DMA在完成一个块的传输时会自动加载链表中的下一个节点配置并切换源/目的地址到另一块缓冲区。与双缓冲强相关的寄存器有这几个GPDMA_CBR1块大小寄存器里面的BNDTBlock Number of Data Transfer字段表示一次块传输的数据量。双缓冲模式下每填满BNDT个数据就产生一次块完成事件。GPDMA_CSAR / GPDMA_CDAR源地址和目的地址。在链表切换过程中这些地址会被硬件自动更新为下一个节点的地址。GPDMA_CLAR链表地址寄存器存放下一个传输节点的配置地址。双缓冲的节点串接就是靠它完成的。GPDMA_CTR2的DBM位双缓冲模式总开关。从原理上讲双缓冲并不算一个独立的DMA模式而是链表循环模式的典型应用——你构建一个包含两个节点的循环链表每个节点各指向一块缓冲区DMA每传输完一个块就沿着链表跳到另一个节点于是两块缓冲区就被交替使用起来。2.2 传输模式怎么选择和搭配GPDMA提供了好几种传输模式搞混了很容易出问题。我项目里用到的组合是Block Mode Linked-list循环这也是官方做双缓冲最推荐的搭配。Block Mode单块传输模式DMA只搬一块数据就停下。适合一次性的、固定长度的传输场景。Repeated Block Mode重复块传输同一配置被反复执行。很多做PWM/ADC采集的朋友喜欢用这个。Linked-list Mode链表模式传输配置以节点形式存放在内存中DMA通过CLAR寄存器逐个加载。在双缓冲场景里如果只用Repeated Block Mode每次传输的缓冲区地址都是同一个切不到第二块缓冲区。必须用链表的两个节点分别放两个缓冲区地址节点之间形成循环才能实现乒乓切换。另外要注意GPDMA的触发事件配置TCEM字段。块传输完成事件Block Transfer Complete在中断处理里是关键信号双缓冲的场景要把TCEM配置为Block模式这样每个缓冲区填满时都会触发一次中断CPU就知道该处理哪块了。3. 手把手配置STM32U575 GPDMA双缓冲3.1 CubeMX里的基础配置我习惯先在STM32CubeMX里把GPDMA的框架搭起来省去写时钟和引脚配置的麻烦。以UART1接收为例选择GPDMA1添加一个Channel请求源选择UART1_RX方向选Peripheral to Memory优先级按需设置。在Parameter Settings里把源地址增量设为Fixed因为UART数据寄存器地址固定目的地址增量设为Incremented。数据宽度这一栏UART数据寄存器是8位的所以源和目的都选Byte。传输模式这里在CubeMX里往往不会直接给一个“Double Buffer”选项而是通过Linked-list相关配置或者代码里手动构建两个节点来完成这一点一开始会有点困惑但搞清楚原理就好了。生成工程后CubeMX会帮你把GPDMA1的时钟和基础句柄初始化好剩下的链表节点和双缓冲逻辑需要在代码里自己补。3.2 基于HAL/LL的链表节点代码实现下面是我在项目中实际用到的关键代码。为了方便说明我把用到的GPDMA通道定义为GPDMA1_Channel0。/* 双缓冲区定义建议4字节对齐 */ #define BUF_SIZE 256 __ALIGN_END(4) uint8_t dma_buf0[BUF_SIZE]; __ALIGN_END(4) uint8_t dma_buf1[BUF_SIZE]; GPDMA_HandleTypeDef hgpdma; /* DMA句柄 */ GPDMA_NodeConfTypeDef node0, node1; /* 两个链表节点 */ volatile uint8_t active_buf; /* 当前被DMA写入的缓冲区号 */初始化函数如下先配置节点属性再初始化通道最后把链表加载到通道中void MX_GPDMA1_Init(void) { /* 节点0指向缓冲区0 */ node0.NodeMode GPDMA_NODE_CIRCULAR; /* 循环模式节点执行完可重新装载 */ node0.RegMode GPDMA_REG_RELOAD; /* 每次重载配置寄存器 */ node0.DestAddr (uint32_t)dma_buf0; /* 目的地址 缓冲区0 */ node0.BlkSize BUF_SIZE; /* 块大小 */ node0.TCEM GPDMA_TCEM_BLOCK; /* 块传输完成事件 */ /* 节点1指向缓冲区1 */ node1.NodeMode GPDMA_NODE_CIRCULAR; node1.RegMode GPDMA_REG_RELOAD; node1.DestAddr (uint32_t)dma_buf1; node1.BlkSize BUF_SIZE; node1.TCEM GPDMA_TCEM_BLOCK; /* 初始化通道0 */ hgpdma.Instance GPDMA1_Channel0; hgpdma.Init.Request GPDMA_REQUEST_UART1_RX; hgpdma.Init.Direction GPDMA_PERIPH_TO_MEMORY; hgpdma.Init.SrcInc GPDMA_SINC_FIXED; /* 源地址固定 */ hgpdma.Init.DestInc GPDMA_DINC_INCREMENTED; /* 目的地址递增 */ hgpdma.Init.SrcDataWidth GPDMA_SRC_DATA_BYTE; hgpdma.Init.DestDataWidth GPDMA_DEST_DATA_BYTE; hgpdma.Init.Mode GPDMA_BLOCK_MODE; HAL_GPDMA_Init(hgpdma); /* 添加两个节点形成循环链表 */ HAL_GPDMA_LinkedListAddNode(hgpdma, node0); HAL_GPDMA_LinkedListAddNode(hgpdma, node1); /* 将链表地址装载到通道 */ HAL_GPDMA_LinkedListConfig(hgpdma, node0); }注意如果你用的固件包版本不同GPDMA_NodeConfTypeDef里的字段名和HAL_GPDMA_LinkedListAddNode这类API可能有细微差异以当前SDK头文件里的定义为准。整体思路是一致的构建两个节点、节点串成循环链表、把链表首地址给DMA。3.3 中断处理与缓冲区消费逻辑双缓冲的好处不只是DMA自动切换更重要的是中断处理非常简单清晰。每当一个缓冲区填满GPDMA就产生一次块传输完成中断我们只需要在回调里切换当前处理的缓冲区即可。void HAL_GPDMA_CpltCallBack(GPDMA_HandleTypeDef *hgpdma) { /* 当前DMA正在写的缓冲区号 */ uint8_t processing_buf; if (active_buf 0) { /* DMA接下来会切到缓冲区1那我们该处理的是缓冲区0 */ processing_buf 0; } else { processing_buf 1; } /* 处理已填满的缓冲区 */ ProcessBuffer(processing_buf); /* 翻转当前缓冲区标志表示下一次中断要处理另一块 */ active_buf active_buf ^ 1; }这里的关键是理解中断发生的时机DMA刚填满缓冲区A产生中断时它还没有开始写缓冲区B或者说硬件已经自动切换到B了所以你要处理的缓冲区是刚写完的那块也就是active_buf当前值指向的块。处理完后再翻转一次为下一次中断做好准备。我一开始在这个回调里写错过把处理的对象搞成了即将写入的缓冲区结果数据全部错位。后来捋清楚中断发生在块边界、DMA已经切换、CPU处理的是上一个块这个时序整个逻辑就通顺了。4. 高频踩坑与排查思路大全4.1 缓冲区切换丢数据的根因我实际调试中遇到的一个典型现象是双缓冲配置好后接收256字节的帧最终只拿到128字节正确数据另一半是乱码或空数据。后来定位到原因是缓冲区地址没有正确对齐或者链表节点的BNDT设置小于外设单次突发请求的数据量。GPDMA对缓冲区地址的对齐要求比较严格尤其在开了缓存或内存保护的时候。建议所有DMA缓冲区都用__ALIGN_END(4)甚至__ALIGN_END(8)声明让起始地址满足对齐要求。另外BNDT的值必须能被总线的突发长度整除否则最后一次突发传输可能被截断导致数据不完整。排查这个问题的思路也很简单先关掉双缓冲用单缓冲验证数据通路完全正常再打开双缓冲如果单缓冲正常双缓冲丢数基本就是地址对齐、链表配置或事件模式的问题。4.2 缓存一致性问题不能忽视STM32U575的Cortex-M33内核带有ICache和DCache。GPDMA是外设它访问内存不经过CPU的cache直接走总线。如果DMA往内存里写数据而CPU读取时读到的是cache里的旧缓存就会拿到陈旧数据。我遇到过最隐蔽的问题双缓冲在第一次运行完全正常第二次开始数据就乱了。为什么会这样因为第一次运行时cache还没有被填充CPU直接从内存读取运行一段时间后缓冲区内容被缓存了但DMA又更新了内存数据CPU再读缓存就拿到了旧值。解决方案有两类。一是用MPU把DMA缓冲区区域配置为non-cacheable这是最干净利落的做法二是在CPU读取DMA数据前手动执行SCB_InvalidateDCache_by_Addr或在DMA启动前执行SCB_CleanDCache_by_Addr确保数据在cache和内存之间同步。用MPU配non-cacheable区域代码大致是void MPU_Config(void) { MPU_Region_InitTypeDef MPU_InitStruct; HAL_MPU_Disable(); MPU_InitStruct.Enable MPU_REGION_ENABLE; MPU_InitStruct.BaseAddress (uint32_t)dma_buf0; MPU_InitStruct.Size MPU_REGION_SIZE_4KB; MPU_InitStruct.SubRegionDisable 0; MPU_InitStruct.TypeExtField MPU_TEX_LEVEL1; MPU_InitStruct.AccessPermission MPU_REGION_FULL_ACCESS; MPU_InitStruct.DisableExec MPU_INSTRUCTION_ACCESS_DISABLE; MPU_InitStruct.IsShareable MPU_ACCESS_NOT_SHAREABLE; MPU_InitStruct.IsCacheable MPU_ACCESS_NOT_CACHEABLE; MPU_InitStruct.IsBufferable MPU_ACCESS_BUFFERABLE; HAL_MPU_ConfigRegion(MPU_InitStruct); HAL_MPU_Enable(MPU_PRIVILEGED_DEFAULT); }注意缓冲区大小要按实际情况调整配置的区域必须覆盖两块缓冲区所在的地址范围。4.3 低功耗模式下GPDMA容易踩的坑STM32U575是超低功耗产品线很多人会做低功耗唤醒、待机间断电这类设计。GPDMA在低功耗模式下的表现和F系列DMA不太一样它依赖的是不同的电源域和时钟树。我碰到过一次系统进入STOP模式后再唤醒GPDMA通道就再也启不动了中断能产生但数据完全不搬。查手册发现进入低功耗前GPDMA如果还处于工作状态唤醒后需要重新做完整的通道初始化而不能只靠HAL_GPDMA_Start_IT恢复。稳妥的方案是在进入低功耗之前显式停止并复位GPDMA通道唤醒后把GPDMA重新初始化一遍。如果数据采集不能断那要考虑用低功耗模式下仍然工作的外设来配合DMA这部分就要根据具体的低功耗模式单独设计没有统一解得看你的工作模式和数据流。5. 性能实测与优化扩展5.1 双缓冲模式下实际能提升多少我在一个参考项目里做了BSP测试UART1波特率设为2Mbps数据包长度256字节分别用单缓冲和双缓冲收数据观察CPU占用和丢包率。单缓冲模式DMA每收满256字节就触发一次中断CPU在中断里处理完数据后DMA才能继续接收。由于CPU处理需要几十微秒在2Mbps速率下数据间隔很紧偶尔出现下一帧数据到来但DMA还没来得及重新启动的情况实测丢包率约千分之一。双缓冲模式DMA写缓冲区0时CPU处理缓冲区1当缓冲区1被填满时缓冲区0已经空闲出来了。CPU的中断处理量和单缓冲一样但因为DMA不需要等待CPU处理完把“停等”时间完全消除了。实测连续测试几万帧零丢包。中断服务函数的耗时反而可以更长只要在下一个缓冲区被填满之前处理完就行。这个时间窗口从整数帧间隔变成了一帧间隔宽容度大了很多。5.2 双缓冲思路还能用到哪些场景双缓冲不仅限于UART接收。ADC连续采样、SPI读传感器、甚至DAC波形输出都可以用同样的思路。比如我要做ADC多通道连续采样时DMA在两块缓冲区之间切换CPU在后台处理上一块数据可以实现无缝采集。SPI刷屏场景下用双缓冲可以避免屏幕刷新过程中的撕裂现象——DMA正在往LCD控制器送数据时CPU已经在准备下一帧画面。更进一步如果你有多段不同地址、不同长度的数据传输需求GPDMA的Linked-list还能做多节点的传输表最多可以挂很多个节点节点之间可以跳转、循环。这种能力在音频流处理、数据记录、电机控制等场景都非常实用。我在实际使用中还有一个体会GPDMA的调试要比传统DMA更依赖逻辑分析仪和寄存器读取。遇到奇怪问题不要急着改代码先读一下GPDMA当前的控制寄存器、链表地址寄存器看看硬件到底执行到哪个节点、地址有没有被正确加载。这种底层确认往往比猜代码快很多。另外就是建议把频繁改动的参数都做成宏定义比如缓冲区大小、块大小、数据宽度。调试的时候改起来方便后期维护也省心。我第一次调双缓冲时就因为临时用魔法数填了个块大小排查了半天才发现是BNDT和突发长度不匹配导致的。这些东西在代码注释里写清楚能帮后面的自己省不少时间。