STM32U5 GPDMA实战指南:从架构到链表传输

发布时间:2026/8/29 4:49:05
STM32U5 GPDMA实战指南:从架构到链表传输 1. 先搞清楚一件事U5 的 GPDMA 不是老 DMA 的改名版1.1 为什么 STM32U5 要换 DMA 架构STM32U575/585 这颗芯片核心是 Cortex-M33主频 160MHz带 TrustZone 安全架构主打低功耗和富集成度。它的外设数量和总线复杂度摆在那里如果沿用老的 DMA 控制器你会发现几个很别扭的问题第一老 DMA 的触发源映射是固定的。串口 2 的接收请求只能接到固定的几个通道上ADC 的转换完成请求又只能接另外几个通道外设一多通道规划就像在抢车位。第二老 DMA 一次只能处理一块连续区域。你要搬三块数据就得进三次中断、重新配三次寄存器CPU 被频繁打扰这跟低功耗的定位完全是矛盾的。第三老 DMA 没有硬件链表复杂传输完全依赖 CPU 编排。GPDMAGeneral Purpose DMA就是冲着这些痛点来的。它在 U5 系列上承担了传统 DMA 加 DMAMUX 的全部职责但架构完全不同通道内置请求复用器、支持硬件链表、支持二维块传输还附带 CRC 计算等能力。对于做低功耗采集、复杂协议处理的应用来说GPDMA 不是升级版的老 DMA而是换了一套设计思路。1.2 GPDMA 和传统 DMA 的本质差异对比项传统 DMAF4/L4 系列GPDMAU575/585通道数8 至 16视型号而定16 个Channel 0 ~ 15请求源映射固定映射或依赖 DMAMUX每个通道内置请求复用器映射灵活链表支持无硬件链表可串联多个传输节点地址模式线性为主线性 二维块传输Block Transfer数据宽度8/16/32 位8/16/32 位支持打包/解包附加能力很少CRC 计算、LFSR 数据生成、数据交换等低功耗联动有限支持 LPBAM可在低功耗模式下自主搬运数据LPBAM 这个点特别值得提一下。U5 系列在设计时就考虑了低功耗场景下的外设自主运行GPDMA 在低功耗模式下依然可以配合外设搬运数据CPU 可以一直睡在低功耗状态。这在做电池供电的传感节点时价值非常大也是老 DMA 做不到的。从使用层面感受最明显的是老 DMA 配一次搬一次GPDMA 配一次可以搬一串。如果把老 DMA 比作单次搬运工GPDMA 就是流水线管理员。2. 配 GPDMA 前必须理解的四个核心概念2.1 通道编号与优先级GPDMA 的 16 个通道里通道编号越大优先级越高。也就是说 Channel 15 的优先级最高Channel 0 最低。这一点和老 DMA 的习惯编号越小越高恰好相反从老平台迁过来的人容易在这里犯迷糊。多通道同时竞争总线时高优先级通道的请求会先被响应。但优先级高不等于中断优先级高通道优先级只影响 GPDMA 内部的仲裁和 NVIC 中断优先级是两码事。实际项目中我会把时效性要求高、数据量小的外设比如定时器触发采样放在高编号通道把大数据量的块搬运放在低编号通道。2.2 请求复用器外设和通道是怎么绑定的GPDMA 每个通道内部都集成了一个请求复用器作用就是把外设的 DMA 请求信号连接到指定通道。串口 1 的接收请求、ADC1 的转换完成信号、定时器的触发事件都可以通过配置通道的请求 ID 来绑定。这个机制把传统方案里独立的 DMAMUX 外设收编进了 GPDMA 内部。配置时你只需要在初始化结构体里指定Request字段hdma_gpdma1_channel0.Request GPDMA_REQUEST_USART1_RX;这些GPDMA_REQUEST_xxx宏在 STM32U5 的 HAL 头文件里都有定义。不同外设的请求 ID 映射表在参考手册 RM0456 里有一张专门的大表格。用 CubeMX 配置时它会自动帮你选好手写代码时就要自己去翻手册这一步容易出错。2.3 链表描述符GPDMA 的灵魂链表是 GPDMA 和老 DMA 拉开差距的核心机制。链表模式下GPDMA 会从内存中读取一个描述符Descriptor描述符里记录了这次传输的全部参数源地址、目的地址、传输长度、控制配置以及下一个描述符的地址。当前传输完成后硬件自动加载下一个描述符继续跑直到链表结束。描述符的内存布局大致包含下一个链表节点的地址、源地址SAR、目的地址DAR、一组传输控制寄存器TR1/TR2/TR3、块寄存器BR1/BR2。每个节点可以配置完全不同的源地址、长度和传输方向这就让一段连续传输可以跨多个不连续的缓冲区。描述符必须放在可写的内存里一般放 SRAM不能放 Flash。我习惯单独开一块描述符专用缓冲用对齐宏做好地址对齐避免和业务数据混在一起。2.4 单次传输、循环传输与触发方式GPDMA 支持一次性传输和循环传输。循环模式常用于 ADC 连续采样、串口持续接收这类永远不停的外设场景。和传统 DMA 的循环模式相比GPDMA 的循环模式可以通过链表实现更复杂的循环序列比如一组节点轮流接收、轮流处理而不是只能循环单一缓冲区。触发方式分软件触发和硬件触发。内存到内存传输用软件触发即可外设相关传输由外设的请求信号触发。注意不是配好通道就立即开始传输必须等到触发条件满足。这也是调试时经常被忽略的点。3. 从 CubeMX 到代码跑通第一个内存到内存传输3.1 CubeMX 图形化配置要点实际开发里我几乎不纯手写 GPDMA 寄存器初始化都是先用 CubeMX 生成工程框架。在 CubeMX 里找到 GPDMA1添加一个 Channel然后配置请求源、方向、地址递增模式和数据宽度。内存到内存传输的配置要点请求源选MEMORY_TO_MEMORY方向选 Memory to Memory源地址递增和目的地址递增都要设为递增模式数据宽度按你的数据类型选8 位就选 Byte32 位就选 Word突发长度Burst选 1 或 16 都可以首次调试建议先选小值CubeMX 生成代码后会在main.c里初始化 GPDMA 句柄命名类似hdma_gpdma1_channel0后续所有 HAL 调用都用这个句柄。3.2 HAL_GPDMA_Start 轮询方式实现搬运最简单的内存拷贝示例#define BUFFER_SIZE 256 uint32_t srcBuffer[BUFFER_SIZE]; uint32_t dstBuffer[BUFFER_SIZE]; volatile uint32_t transfer_done 0; // 填充源数据 for (uint32_t i 0; i BUFFER_SIZE; i) { srcBuffer[i] i * 3; } // 启动 GPDMA 传输 HAL_StatusTypeDef status HAL_GPDMA_Start(hdma_gpdma1_channel0, (uint32_t)srcBuffer, (uint32_t)dstBuffer, BUFFER_SIZE * sizeof(uint32_t)); if (status ! HAL_OK) { Error_Handler(); } // 轮询等待传输完成 status HAL_GPDMA_PollForTransfer(hdma_gpdma1_channel0, 500); if (status HAL_OK) { transfer_done 1; }这里有三个细节必须说清楚第一个是长度单位。HAL_GPDMA_Start的第四个参数是字节数不是元素个数。如果缓冲区是uint32_t数组必须乘以sizeof(uint32_t)。我见过不少新手在这里少乘一个 4结果只搬了四分之一的数据然后怀疑 GPDMA 坏了。第二个是地址强转。源地址和目的地址要强转成uint32_t这是 HAL 接口的固定要求。第三个是轮询延时。HAL_GPDMA_PollForTransfer的第二个参数是等待超时时间按系统 tick 计算。给 0 可能导致某些库版本直接返回超时给一个合理值比如 500够用就好。3.3 中断方式 HAL_GPDMA_Start_IT 与回调机制产品代码里几乎不会用轮询DMA 的意义就在于让 CPU 干别的。中断方式HAL_GPDMA_Start_IT(hdma_gpdma1_channel0, (uint32_t)srcBuffer, (uint32_t)dstBuffer, BUFFER_SIZE * sizeof(uint32_t));传输完成后的回调函数名是HAL_GPDMA_XferCpltCallback注意是 GPDMA不要写成传统 DMA 的HAL_DMA_XferCpltCallback这两个函数在同一个工程里可能会同时存在名字很容易搞混。void HAL_GPDMA_XferCpltCallback(GPDMA_HandleTypeDef *hdma) { if (hdma-Instance GPDMA1_Channel0) { transfer_done 1; } }回调函数里不要做耗时操作最好只置标志位让主循环或 RTOS 任务去处理数据。这里涉及中断优先级的问题后面第 5 节会详细说。4. 串口不定长接收GPDMA 最典型的落地方案4.1 空闲中断 GPDMA 接收的整体设计低功耗物联网设备里串口接收不定长数据是最常见的需求。传统做法是串口逐字节中断收一个字节进一次中断160MHz 的 CPU 被 115200 波特率的串口拖得体无完肤。换 GPDMA 后数据自动进缓冲区CPU 只需要在帧结束时处理一次。我的方案是空闲中断加 GPDMA 接收空闲中断负责判断一帧数据结束GPDMA 负责把数据搬进缓冲。数据量小时用单次传输就够了数据量大或协议复杂时用链表串联多个缓冲区效果更明显。4.2 初始化与外设到内存的配置细节CubeMX 里将 USART1 配置为异步模式然后在 DMA Settings 里添加 USART1_RX 到 GPDMA 通道方向选 Peripheral to Memory数据宽度选 Byte。生成代码后初始化结构体大致如下hdma_gpdma1_channel0.Request GPDMA_REQUEST_USART1_RX; hdma_gpdma1_channel0.Init.SrcInc GPDMA_SINC_FIXED; // 外设地址固定 hdma_gpdma1_channel0.Init.DstInc GPDMA_DINC_INCREMENTED; // 内存地址递增 hdma_gpdma1_channel0.Init.SrcDataWidth GPDMA_DWIDTH_BYTE; hdma_gpdma1_channel0.Init.DstDataWidth GPDMA_DWIDTH_BYTE;串口接收的关键在于源地址是串口的数据寄存器每次读取都会清掉接收标志所以源地址必须固定SINC_FIXED目的地址是内存缓冲区要递增DINC_INCREMENTED。方向和递增模式搞反是最常见的错误现象是数据只写在一个固定地址里要么覆盖要么缓冲区永远是同一个值。启动接收的代码#define UART_RX_BUF_SIZE 128 uint8_t uart_rx_buf[UART_RX_BUF_SIZE]; volatile uint8_t uart_rx_frame_ready 0; HAL_GPDMA_Start_IT(hdma_gpdma1_channel0, (uint32_t)huart1.Instance-RDR, (uint32_t)uart_rx_buf, UART_RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);串口中断里处理空闲事件void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); if (__HAL_UART_GET_IT_SOURCE(huart1, UART_IT_IDLE)) { __HAL_UART_CLEAR_IT(huart1, UART_IT_IDLE); uart_rx_frame_ready 1; } }4.3 实际接收流程与重新装载逻辑一帧数据到达时GPDMA 把字节逐个搬进缓冲区直到线路空闲触发空闲中断。主循环检测到uart_rx_frame_ready置位后需要知道这帧数据到底有多长。GPDMA 不会像传统 DMA 那样暴露一个明显的NDTR计数器但可以通过读取通道的块长度寄存器来算剩余量。不同 HAL 版本提供的取计数接口名可能不同我们项目里是直接读CxBR1的相关位段来获取剩余传输量再用配置长度减去剩余量得到实际接收长度uint32_t remain READ_REG(GPDMA1_Channel0-CxBR1) GPDMA_BR1_BNDT; uint32_t actual_len UART_RX_BUF_SIZE - remain;拿到实际长度后处理这帧数据然后重新启动一次 GPDMA 接收让链路回到待命状态。整个流程 CPU 只介入两次一次在空闲中断一次在主循环处理数据。如果要用链表方式做更精细的协议解析比如第一段接收固定 4 字节协议头第二段根据协议头里的长度字段接收剩余数据就需要在收到协议头后动态修改链表节点的块长度参数。这个玩法调试周期会长一些我建议先把单段传输跑稳再上。5. 实战中容易翻车的四个坑含完整排查链路5.1 缓存一致性问题搬运完了数据却是旧的U5 的 Cortex-M33 有 I-Cache 和 D-Cache。CPU 写数据时数据可能留在 Cache 里没有回写物理内存而 GPDMA 直接访问物理内存搬走的是旧数据。反过来GPDMA 往内存写新数据后CPU 读时命中的是 Cache 里的旧数据。典型现象传输完成标志置位目的缓冲区内容却还是初始值或者读到的是上一次的数据。这个问题排查起来最迷惑人因为配置看起来全对。解决方法有两种。第一种是把 DMA 缓冲区所在的存储区域配置为不可缓存通过 MPU 或者选择默认非缓存的 SRAM 区域实现。第二种是手动维护缓存一致性// 启动 DMA 前把源缓冲区的脏数据回写内存 SCB_CleanDCache_by_Addr((uint32_t *)srcBuffer, BUFFER_SIZE * sizeof(uint32_t)); // DMA 完成后使目的缓冲区对应 Cache 行失效 SCB_InvalidateDCache_by_Addr((uint32_t *)dstBuffer, BUFFER_SIZE * sizeof(uint32_t));这两个 API 在 CMSIS 的core_cm33.h里。注意地址和长度都要做 32 字节对齐处理否则行为未定义。5.2 链表描述符对齐不对齐就进 HardFaultGPDMA 的链表描述符有对齐要求32 位描述符需 4 字节对齐64 位描述符需 8 字节对齐。实际工程里我统一按 32 字节对齐省心也保险。定义描述符缓冲时#if defined(__ICCARM__) #pragma data_alignment32 uint32_t desc_buffer[8]; #pragma data_alignment #elif defined(__GNUC__) || defined(__CC_ARM) __ALIGNED(32) uint32_t desc_buffer[8]; #endif不对齐的典型现象调用HAL_GPDMA_Start_IT后通道状态卡住传输不开始甚至直接进入 HardFault。排查时先看描述符地址的低 5 位是否全零这个检查 10 秒钟就能做能排除一大半链表问题。5.3 请求 ID 和外设使能传输不启动的排查链路遇到GPDMA 配置好了但就是不传输我建议按下面的顺序排查第一步确认通道被使能。读 GPDMA 的状态寄存器看对应通道位是否置位没置位说明初始化就没成功。第二步确认请求 ID。回看 CubeMX 里通道选择的请求源是否和外设匹配。比如串口 1 接收必须选GPDMA_REQUEST_USART1_RX选成 USART2 的 ID数据永远来不了。我之前帮同事排查过一个两天没解决的怪问题最后发现就是请求 ID 在代码里被某个宏覆盖了。第三步确认外设的 DMA 请求真的打开了。以串口为例如果用的是裸 HAL_UART_Receive_DMA 函数它会内部处理使能如果是自己手动绑定 GPDMA 通道要确认串口控制寄存器的 DMA 接收使能位已经置位。第四步确认触发方式。内存到内存用软件触发外设到内存必须等外设请求信号。如果外设一直没产生请求比如串口根本没收到数据传输自然不会开始。5.4 中断回调里的隐形炸弹GPDMA 的中断回调里绝对不要调用依赖 SysTick 的延时函数比如 HAL_Delay。如果 GPDMA 中断优先级比 SysTick 高SysTick 被抢占无法更新HAL_Delay 会永远等下去形成死锁。这个问题的表现形式是程序在某个回调里卡死调试器暂停一看停在 HAL_Delay 内部。我的原则是中断回调里只做两件事置标志位和记录状态。真正的数据搬运逻辑放到主循环或任务里执行。另外如果开启了多个 GPDMA 通道每个通道的完成回调要判断hdma-Instance避免 A 通道的完成事件被 B 通道的回调处理。6. 实测数据与从传统 DMA 迁移的思维转换6.1 内存到内存的实际带宽测试我在 U575 开发板上做了一组内存到内存的传输测试源和目的缓冲区各 4KB位于 SRAM1数据宽度 32 位突发长度 16内核跑到 160MHz从HAL_GPDMA_Start_IT调用到传输完成中断触发整个过程耗时在微秒量级CPU 开销几乎可以忽略。对比同条件下用memcpy逐字拷贝虽然 memcpy 在 Cache 命中时也不慢但 DMA 的核心优势在于搬运期间 CPU 可以并行处理协议解析、外设控制、低功耗调度等其他任务。在 CPU 利用率接近饱和的系统中这个差别会被放大得非常明显。6.2 调试时值得留意的寄存器用调试器观察 GPDMA 状态我一般盯这几个位置目标寄存器关注内容传输配置CxTR1/CxTR2/CxTR3SINC/DINC、数据宽度、突发长度剩余传输量CxBR1/CxBR2当前块剩余字节源/目的地址CxSAR/CxDAR地址是否按预期递增或固定全局状态GSTATUS 相关位通道使能与错误状态错误标识各通道错误状态位总线错误、触发冲突等传输计数不动时先看源/目的地址和长度再看请求源有没有真正触发最后才怀疑硬件。绝大多数问题是配置或缓存不是 GPDMA 本身。6.3 用链表思维重新设计多路采集从传统 DMA 迁到 GPDMA最大的转变是思维方式。传统 DMA 是一次性配置搬运GPDMA 是配置一条搬运流水线。我之前在 F4 上做多通道 ADC 采样需要开多个 DMA 通道每个通道采完进一次中断几十个通道轮询下来CPU 中断频率高得吓人。换到 U5 的 GPDMA 后我把多段采样任务拼成一个链表每段采样对应一个链表节点源地址指向 ADC 数据寄存器目的地址指向各通道的独立缓冲区长度按各通道需要配置。GPDMA 自动把整条链表跑完最后只产生一次完成中断。中断次数从每秒几十次降到每秒几次CPU 占用率和系统功耗都有明显改善。这种优化在数据采集类应用里收益非常大值得花时间把链表机制吃透。最后分享一个具体的上手建议第一次接触 GPDMA不要直接上完整应用。先在 CubeMX 里生成一个 Memory to Memory 的最小工程用轮询跑通再改成中断然后再上链表最后才接外设。每跑通一步在调试器里对比一次寄存器值。这个递进过程能帮你把 GPDMA 的每个配置项都理解透彻后面接串口、接 ADC、接定时器就是水到渠成的事。实际项目里我还会在 GPDMA 的完成回调里加一个计数器通过观察计数器的递增频率来验证传输是否按预期发生。这个方法在没有逻辑分析仪的场合特别管用简单粗暴但很可靠。