STM32H7串口DMA在RT-Thread上的适配与优化

STM32H7串口DMA在RT-Thread上的适配与优化 1. 为什么STM32H7的串口DMA在RT-Thread上会翻车STM32H7这颗芯片在嵌入式圈子里算是明星级别的存在480MHz的主频、双精度浮点、大容量RAM拿来做工业网关、音频处理、高速数据采集都很合适。但很多从F4、F7转过来的朋友第一次在H7上跑RT-Thread的串口DMA时大概率会遇到一个很尴尬的情况代码编译通过、串口能打印、但DMA就是不动或者发一次就卡死再或者接收数据永远进不了回调。我最早踩这个坑是在一个Modbus网关项目上用STM32H743配RT-Thread 4.1.x串口1挂DMA发送结果rt_device_write返回成功但示波器上量不到波形。查了两天才发现问题根本不在我的应用代码而是RT-Thread默认的STM32串口驱动对H7系列的DMA支持是不完整的——准确地说是H7的DMAMUX架构和F4/F7的DMA控制器差异太大默认驱动里那套基于DMA1_StreamX的写法在H7上根本对不上号。这篇文章就是把这个坑从头到尾讲清楚H7的DMA到底和以前有什么不一样、RT-Thread默认驱动为什么接不上、怎么改才能让串口DMA真正跑起来。内容适合已经会用RT-Thread、但被H7的DMA卡住的嵌入式工程师也适合正在选型阶段想提前了解H7开发难点的朋友。我会把寄存器层面的原因、驱动修改的具体位置、实测验证的方法都写出来代码可以直接抄。1.1 STM32H7的DMA架构到底变了什么先说清楚H7和F4/F7在DMA上的本质区别这是理解后面所有问题的前提。F4/F7时代的DMA叫DMA1、DMA2每个控制器有若干Stream流每个Stream有若干Channel通道外设请求通过Channel映射到Stream上。比如USART1_TX在F4上固定映射到DMA2_Stream7的Channel4这个映射关系是硬编码在芯片里的查参考手册的DMA请求映射表就能找到。H7完全换了一套架构。它用的是DMAMUXDMA request multiplexerDMA控制器本身叫DMA1、DMA2但每个控制器有8个Stream每个Stream不再直接绑定外设而是先连到DMAMUX由DMAMUX来决定这个Stream响应哪个外设的请求。DMAMUX相当于一个请求路由器每个Stream对应DMAMUX的一个通道通道里写一个请求编号Request ID就能把任意外设请求接到任意Stream上。这个变化带来的直接后果是H7上不存在USART1_TX固定用DMA2_Stream7这种说法了。你可以把USART1_TX接到DMA1_Stream0也可以接到DMA2_Stream5只要DMAMUX配置对就行。灵活性大大提高但代价是所有依赖固定映射的代码全部失效。还有一个坑是H7的DMA请求编号。比如USART1_RX的请求编号是DMA_REQUEST_USART1_RX这个宏在H7的HAL库头文件里定义值跟F4完全不同。如果你直接把F4的驱动移植过来请求编号写错了DMA会一直等一个永远不会来的请求表现就是配置成功但不动。1.2 RT-Thread默认驱动的实现假设RT-Thread的STM32串口驱动在bsp/stm32/libraries/HAL_Drivers/drv_usart.c里核心逻辑是打开DMA模式时根据串口实例去查一张表找到对应的DMA控制器和Stream然后调用HAL库的HAL_DMA_Init配置。问题就出在这张表上。默认驱动里的DMA配置表是按F4/F7的架构写的结构体大概是这样的struct stm32_uart_dma { DMA_Stream_TypeDef *Instance; rt_uint32_t channel; ... };注意那个channel字段。在F4上它是DMA的Channel编号在H7上这个概念已经不存在了H7需要的是DMAMUX的请求编号。默认驱动在H7上编译时要么因为找不到对应的宏而报错要么勉强编译通过但配置出来的DMA根本不工作。更麻烦的是很多BSP里的drv_usart.c是直接从F4的BSP复制过来的里面的DMA实例名、请求编号、中断向量全是F4的。你在H7的工程里用这套代码编译器可能不报错因为HAL库把一些宏做了兼容定义但运行时DMA就是不动。我实测过在RT-Thread Studio里新建一个H743的工程默认生成的drv_usart.c里DMA部分基本是空的或者注释掉的需要自己补。这就是为什么很多人说RT-Thread在H7上串口DMA用不了——不是用不了是默认没给你配好。2. 动手改造让串口DMA在H7上真正跑起来搞清楚原因之后改造思路就很清晰了把默认驱动里那套F4风格的DMA配置换成H7的DMAMUX风格。下面我按实际操作的顺序来讲每一步都说明为什么这么做。2.1 第一步确认你的H7型号和DMA请求编号不同H7型号H743、H750、H723、H7A3等的DMA请求编号表是不一样的这个必须查对应型号的参考手册。以H743为例USART1的请求编号在手册的DMAMUX1 request mapping表里能查到外设请求请求编号说明USART1_RX41DMAMUX1USART1_TX42DMAMUX1USART2_RX43DMAMUX1USART2_TX44DMAMUX1USART3_RX45DMAMUX1USART3_TX46DMAMUX1这些编号在HAL库里有对应的宏比如DMA_REQUEST_USART1_RX。你可以在stm32h7xx_hal_dma.h里搜一下确认。千万不要凭记忆写编号我见过有人把TX和RX的编号写反了结果发送时DMA去等接收请求自然一动不动。提示H7的DMAMUX1和DMAMUX2是两套DMA1/DMA2连的是DMAMUX1BDMA连的是DMAMUX2。串口一般用DMA1或DMA2所以查DMAMUX1的表。2.2 第二步重写DMA配置表默认驱动里那张表要整个换掉。我在项目里是这么改的在drv_usart.c里定义一个H7专用的配置结构struct stm32_h7_uart_dma { DMA_TypeDef *dma_ctrl; /* DMA1 或 DMA2 */ rt_uint32_t stream_index; /* 0~7 */ rt_uint32_t request; /* DMAMUX 请求编号 */ IRQn_Type irq; /* DMA 中断向量 */ }; static const struct stm32_h7_uart_dma uart1_tx_dma { .dma_ctrl DMA1, .stream_index 0, .request DMA_REQUEST_USART1_TX, .irq DMA1_Stream0_IRQn, }; static const struct stm32_h7_uart_dma uart1_rx_dma { .dma_ctrl DMA1, .stream_index 1, .request DMA_REQUEST_USART1_RX, .irq DMA1_Stream1_IRQn, };这里Stream的选择有讲究。H7的DMA1和DMA2各有8个Stream理论上任意Stream都能接任意请求但实际选的时候要考虑两点一是避开已经被其他外设占用的Stream二是尽量让TX和RX用不同的DMA控制器以分担总线压力。我上面把TX和RX都放在DMA1是因为这个项目里DMA2被SPI和ADC占了如果资源充足TX放DMA1、RX放DMA2是更优的选择。2.3 第三步初始化时正确配置DMAMUXHAL库的HAL_DMA_Init在H7上会自动处理DMAMUX配置但前提是你传进去的DMA_HandleTypeDef里Init.Request字段填对了。默认驱动往往漏了这一步。正确的初始化代码static rt_err_t h7_uart_dma_init(struct stm32_h7_uart_dma *cfg, DMA_HandleTypeDef *hdma) { hdma-Instance (DMA_Stream_TypeDef *)((rt_uint32_t)cfg-dma_ctrl 0x10 0x18 * cfg-stream_index); hdma-Init.Request cfg-request; hdma-Init.Direction DMA_MEMORY_TO_PERIPH; hdma-Init.PeriphInc DMA_PINC_DISABLE; hdma-Init.MemInc DMA_MINC_ENABLE; hdma-Init.PeriphDataAlignment DMA_PDATAALIGN_BYTE; hdma-Init.MemDataAlignment DMA_MDATAALIGN_BYTE; hdma-Init.Mode DMA_NORMAL; hdma-Init.Priority DMA_PRIORITY_HIGH; hdma-Init.FIFOMode DMA_FIFOMODE_DISABLE; if (HAL_DMA_Init(hdma) ! HAL_OK) { return -RT_ERROR; } return RT_EOK; }那个Instance的计算看着有点绕是因为H7的DMA Stream寄存器是连续排列的基地址加偏移就能算出每个Stream的地址。你也可以直接用DMA1_Stream0这种宏但用计算的方式在配置表里更灵活。Init.Request这一行是关键它告诉HAL库这个Stream要响应哪个外设请求HAL库内部会去写DMAMUX的CCR寄存器。如果这一行漏了或者填错DMA就是不动。2.4 第四步中断向量和回调的对接DMA中断向量在H7上也和F4不同。F4的DMA2_Stream7中断叫DMA2_Stream7_IRQHandlerH7虽然名字类似但向量号变了而且如果你用了DMAMUX的同步模式还可能有额外的中断。在RT-Thread里需要在stm32h7xx_it.c里把中断处理函数接到HAL库的回调上void DMA1_Stream0_IRQHandler(void) { HAL_DMA_IRQHandler(uart1_tx_dma_handle); }然后在驱动里注册发送完成回调通知RT-Thread的串口框架发送完成static void uart_dma_tx_cplt(DMA_HandleTypeDef *hdma) { struct stm32_uart *uart (struct stm32_uart *)((char *)hdma - offsetof(struct stm32_uart, dma_tx.handle)); rt_hw_serial_isr(uart-serial, RT_SERIAL_EVENT_TX_DONE); }这个offsetof的用法是从HAL句柄反推回RT-Thread的设备结构体是RT-Thread串口驱动的标准套路。如果你不接这个回调表现就是第一次发送成功第二次发送卡死——因为RT-Thread在等TX_DONE事件才会发下一包。3. 实测验证怎么确认DMA真的在工作改完代码不代表就成功了必须实测验证。我一般用三个层次来确认。3.1 寄存器层面看DMAMUX和DMA的使能位最直接的方法是在调试器里看寄存器。发送一包数据后暂停程序检查DMAMUX1_ChannelX_CCR应该等于你配置的请求编号DMA1_StreamX_CREN位应该被置1传输中或传输完成后被硬件清零DMA1_StreamX_NDTR剩余传输数量发送过程中应该递减如果CCR是0说明DMAMUX没配上回去检查Init.Request。如果CR的EN位一直是0说明HAL_DMA_Start没被调用或者调用失败。3.2 逻辑分析仪层面量TX引脚波形寄存器对了不代表波形对。我用逻辑分析仪抓USART1_TX引脚发送Hello五个字节应该看到清晰的串口波形。如果波形只有第一个字节然后停住多半是DMA传输完成中断没触发或者NDTR配置错了。这里有个H7特有的坑H7的DMA在传输完成后如果配置成循环模式NDTR会自动重载如果是普通模式NDTR归零后需要软件重新配置才能再发。RT-Thread默认用的是普通模式每次发送都要重新HAL_DMA_Start这个在驱动里要处理好。3.3 应用层面连续发送压力测试最后在应用层做压力测试连续发送1000包数据每包256字节看是否有丢包或卡死。我实测下来改好的驱动在H743上跑115200波特率连续发送CPU占用率不到3%比中断方式发送低了将近20个百分点这就是DMA的价值。测试项中断方式DMA方式改后115200连续发送CPU占用22%2.8%1000包丢包数00最大无阻塞波特率115200921600代码复杂度低中4. 常见问题速查与避坑经验这一节是我在实际项目中踩过的坑的汇总按问题现象来组织方便你对照排查。4.1 问题速查表现象可能原因排查方法编译报错找不到DMA_REQUEST_USART1_TXHAL库版本太老或型号选错确认stm32h7xx_hal_dma.h里有该宏配置成功但DMA不动DMAMUX请求编号错误查参考手册DMAMUX表核对CCR寄存器第一次发送成功第二次卡死TX_DONE回调没接检查rt_hw_serial_isr是否被调用接收数据进不了回调接收DMA没开循环模式或IDLE中断没配H7接收建议用DMA循环IDLE中断高速率下数据错位FIFO模式或对齐配置错误检查MemDataAlignment和PeriphDataAlignment中断进不去中断向量没接或优先级配置错误检查stm32h7xx_it.c和NVIC配置4.2 几个容易忽略的细节Cache一致性问题。H7有D-CacheDMA直接访问内存时如果Cache没处理好会出现内存里数据是对的但DMA发出去的是旧数据这种诡异现象。发送前要SCB_CleanDCache_by_Addr接收后要SCB_InvalidateDCache_by_Addr。这个问题在F4/F7上不突出在H7上是必踩的坑。我建议把DMA缓冲区放到非Cache区域或者用MPU配置成Write-Through模式省去手动维护Cache的麻烦。DMA传输完成中断和串口中断的优先级。如果DMA中断优先级比串口中断低可能出现串口中断里等DMA完成导致死锁。我一般把DMA中断优先级设得比串口中断高一级。RT-Thread的rt_device_write返回值。DMA模式下这个函数返回的是实际写入的字节数但如果DMA还在传输中新的写入会被阻塞或返回0。应用层要做好重试逻辑不要假设一次write就能全部发出去。注意H7的DMA1和DMA2不能同时访问同一个内存区域如果TX和RX缓冲区有重叠会出现数据竞争。建议TX和RX用独立缓冲区并且按Cache line对齐32字节。4.3 一个实用的调试技巧如果你不确定DMA到底有没有在工作可以在DMA传输完成回调里翻转一个GPIO用示波器看这个GPIO的翻转频率。这比看寄存器直观得多而且能直接反映出DMA的实际吞吐。我在调试阶段经常这么干一个LED或者一个空闲引脚就够了。另外RT-Thread Studio的调试器里可以实时查看变量把hdma-Instance-NDTR加到watch窗口发送时看它递减是最快的确认方法。5. 关于H7串口DMA的一些延伸思考改完这个驱动之后我对H7的DMA架构有了更深的理解。DMAMUX这套设计虽然初期学习成本高但用熟了之后灵活性确实强。比如你可以把多个外设的请求通过DMAMUX的同步模式做联动实现ADC转换完成自动触发DMA搬运这种硬件级流水线完全不占CPU。这在F4上是做不到的。还有一个值得关注的方向是H7的BDMABasic DMA。BDMA挂在DMAMUX2上专门用于低功耗域的外设比如LPUART。如果你做低功耗产品串口用LPUARTBDMA可以在STOP模式下保持接收功耗比用DMA1/DMA2低一个数量级。这个我还在验证中等有完整数据了再单独写一篇。最后说个实际体会RT-Thread的BSP质量参差不齐H7这种新芯片的BSP往往是从老芯片复制过来改的DMA这种架构变化大的模块最容易出问题。遇到驱动不工作先别怀疑自己的应用代码去翻BSP里的驱动实现大概率问题就在那。我现在的习惯是拿到新BSP先看drv_usart.c和drv_spi.c里的DMA部分确认是H7原生实现还是F4移植过来的这能省下大量调试时间。