STM32H563串口UART:接收DMA+发送中断实战 搞嵌入式开发的十有八九都跟串口打过交道。平时写个日志、调个参数、和传感器模块通信串口永远是出场率最高的外设之一。但真要把它用得既省心又不拖累CPU还是有不少门道。这篇文章我就拿STM32H563来聊一个非常经典的组合UART接收方向用DMA发送方向用中断IT。这套方案在实际项目中非常常见既能解决接收数据“不知道什么时候来、来多少”的问题又能让主循环腾出大量时间跑业务逻辑适合正在做数据采集、运动控制、工业设备通信或者自定义协议栈的朋友参考。文章里我不会只贴代码还会把为什么这么配、CubeMX里怎么点、HAL库里哪些回调会被调到、以及我实际调试中踩过的坑都讲清楚。不管你是刚接触H5系列还是已经从F系列迁移过来照着走一遍基本都能跑通。1. 为什么接收用DMA、发送用中断1.1 三种收发方式的区别串口收发数据抛开自己写寄存器操作应用层基本就三条路轮询、中断、DMA。我把它们放在一起对比如下方式CPU占用实时性实现复杂度适合场景轮询查询高一直在等或反复查标志响应缓慢取数不及时低简单调试、单帧交互中断收发中每来一个字节进一次中断很高逐字节响应中数据量不大、对延迟敏感的短帧DMA收发低硬件搬运批量完成后才通知较高配合空闲中断可做到帧级响应中高不定长帧、大数据量、高波特率接收方向用DMA最重要的原因就是接收是“被动”的。你根本不知道上位机或者传感器什么时候发数据过来如果每来一个字节都要打断CPU那在115200波特率下大概每87微秒就进一次中断。任务多的时候CPU的高优先级时间全耗在处理串口中断上了。用DMA就不一样数据从RX引脚进来到内存是DMA控制器自动搬运的CPU完全不需要逐字节参与只有一整帧数据收完或者发生空闲事件时才通过中断通知应用层。发送方向不一样发送是“主动”的数据什么时候发、怎么发完全由我们自己控制。用中断发送启动一次发送后每个字节发送完会触发发送中断但这个频率仍然可控而且在发送过程中只要保证TDR寄存器有数据写进去就行CPU完全可以抽空去处理其他任务。对大多数应用来说单帧报文长度也就几十到几百字节中断发送带来的开销并不大但代码结构比DMA发送简单不少也更容易管理发送缓冲区的生命周期。1.2 为什么不干脆收和发都用DMA可能有人会问既然接收用了DMA发送干脆也上DMA这样CPU占用不是更低吗理论上是的很多大型通信场景也确实是这么做的。但回到这里我建议先考虑一个问题你的DMA通道够不够用STM32H563上UART实例不少但DMA通道是有限的资源。接收用DMA发送再用DMA一个串口就占去两个通道如果板子上接了多个串口或者ADC采样、SPI外设也需要DMA通道很快就吃紧了。而发送方向用中断在很多情况下效率已经足够完全没有必要为了省那一点点CPU而浪费一个DMA通道。另外DMA发送在很多细节上比接收要敏感。比如发送完成标志TC的处理、DMA传输完成后是否立即把TE标志拉高、发送缓冲区什么时候能释放这些都要考虑清楚。一旦你在发送过程中还在写缓冲区DMA很可能就传了一堆乱数据出去。中断发送就直观得多HAL_UART_Transmit_IT()启动后发送完成会回调HAL_UART_TxCpltCallback()在回调里再处理缓冲区释放或状态机转移逻辑非常清晰。所以这个组合最典型的价值是接收方向用DMA把“不确定”变成“可控”发送方向用中断把“逻辑”保持“简单”。这也是我在实际项目里最愿意先采用的方案。2. 先盘一盘STM32H563的UART和DMA资源2.1 UART实例怎么选STM32H563属于STM32H5系列Cortex-M33内核最高主频可以跑到250MHz外设资源比之前的F1/F4丰富不少。串口实例数量根据具体型号有差异一般都有多路USART和UART比如USART1、USART2、USART3、UART4、UART5、USART6、UART7、UART8等。规划产品时要注意不一定要死磕USART1而是先看引脚冲突。我在实际选型时有几个习惯调试日志串口选一个引脚不紧张、方便飞线或者板载USB转串口直连的实例比如USART1或USART2。通信业务串口尽量和调试串口分开避免调试输出干扰业务数据。需要多路串口同时工作的时候提前查一下这些实例对应的引脚在封装里是否都被引出来有些引脚会和ADC、定时器、JTAG复用。H5系列的UART配置比老型号更灵活支持的波特率范围也广。但有个容易忽略的点串口的根时钟通常来自PCLK而PCLK的数值会影响波特率发生器分频后的精度。CubeMX里配置时钟树时会自动算出误差一般建议选择整数分频能对齐的波特率比如115200、230400这类常见值。如果某些特殊波特率算出来误差超过2%通信就容易出现偶发乱码。2.2 DMA控制器与通道映射STM32H563上的DMA结构和F1/F4很不一样它是通过DMAMUX来把外设的DMA请求路由到DMA通道上的。简单说老型号上串口的DMA请求是“固定绑定”在某个通道上的比如USART1_RX只能用DMA1的某个通道你没法随意换。而H5系列不同配置时需要先在CubeMX的DMA设置里为UART选择一条合适的DMA请求线然后再绑定到具体的DMA通道。这对开发者来说实际上是件好事。假如你规划DMA通道时发现某个通道被高频外设占用可以通过DMAMUX把UART的请求转到另一个空闲通道上灵活性高很多。但反过来也要求你理解“DMA通道”和“外设DMA请求”是两个层面的概念调试的时候如果发现接收不到数据先检查CubeMX里是否真的把UART的DMA请求配置到已使能的通道上了。另外H5的DMA是支持循环模式Circular Mode的这点对串口接收非常重要。普通模式Normal下DMA搬运完指定长度就会停止如果还想继续接收必须重新配置。循环模式则会在缓冲区尾部自动回卷到头部继续搬运适合做环形缓冲。后面会详细讲。2.3 TrustZone对DMA/UART的影响STM32H563有带TrustZone的型号这颗料默认可能处于TrustZone开启状态。很多人刚上手时写的代码跑在非安全侧结果发现怎么也配置不了UART和DMA或者HAL初始化一直返回错误大概率就是安全状态没对上。我的建议是如果在做常规产品开发不打算使用TrustZone的安全隔离功能直接在CubeMX的工程选项里把TrustZone支持关掉或者用烧录工具把选项字节里TZEN位关闭。这样UART、DMA等所有外设都统一归非安全侧管理整个开发和调式流程和传统STM32基本一致。如果项目确实需要TrustZone那就要把UART和DMA初始化放在安全侧并且在非安全侧调用时确保中断处理函数也能正常路由。这个坑比较隐性初学阶段不推荐一边开TrustZone一边调串口很容易被各种“为什么中断不进来”的问题搞崩溃。3. CubeMX图形化配置实操3.1 基本UART配置用STM32CubeMX生成STM32H563工程时第一步肯定是在Pinout视图里把UART引脚选出来。比如用USART1在芯片图上把PA9配置为USART1_TX、PA10配置为USART1_RX或者用板载ST-Link虚拟串口对应的引脚具体看你的板子原理图。然后在外设配置界面里把模式改成Asynchronous异步模式波特率设成115200数据位8位无校验1个停止位。这组参数是绝大多数串口通信的默认值如果上下位机协议里不是这个格式再改。这里有个细节CubeMX默认生成的代码里MX_USART1_UART_Init()函数会调用HAL_UART_Init()如果你还想用更高级的FIFO或者硬件流控功能可以在高级参数里勾选。但一般调试和业务通信用不到保持默认即可。3.2 添加RX方向的DMA配置在USART1的DMA Settings标签页里点击Add然后选择USART1_RX请求。这时候系统会帮你分配一个DMA请求线和DMA通道你不需要手动去记通道号。但有几个参数建议手动确认一下Direction必须确认是Peripheral To Memory这是接收方向。如果选反了数据根本不会往内存里搬。Mode根据业务需求选Normal还是Circular。我在接收不定长数据时习惯用Normal因为每次空闲中断后我会主动重新启动接收这样一帧数据对应一次DMA搬运逻辑清晰。如果希望串口数据持续往一个大缓冲区里塞把数据边界完全交给协议层去解析那可以选Circular。Peripheral Increment Address外设地址固定保持Disabled。Memory Increment Address内存地址自动递增打开Enabled。如果关闭了DMA会把数据一直覆盖写入同一个地址后面的数据全丢了。Data Width串口数据是字节的外设和内存的宽度都选Byte。配置完成后如果后续要打开DMA中断记得记一下这个DMA通道对应的中断名称后面在NVIC里要用。3.3 NVIC中断配置打开Project Manager里的NVIC Settings或者直接在Pinout视图里查看会看到USART1相关的几个中断USART1 global interrupt这个必须打开。发送中断IT、接收错误中断、以及空闲中断都依赖它。DMA1_Channelx global interrupt接收DMA通道对应的中断建议打开。这样当DMA接收完成填满缓冲区时还会触发DMA传输完成中断。优先级分配上我一般把USART全局中断的抢占优先级设得比普通业务中断高一点比如抢占优先级1子优先级0。DMA中断可以比它稍低比如抢占优先级2。这样做的原因是空闲中断和接收相关的事件是实时的如果被其他业务中断长时间堵住数据容易丢。而DMA中断主要是通知“一帧数据收完了”稍微晚一点处理数据还在缓冲区里不会丢所以优先级可以不那么激进。需要注意不要在低优先级中断里做耗时操作尤其不要在串口接收回调里塞大量浮点运算或者打印日志。优先级高的中断虽然能抢占但长时间占用照样会让低优先级任务饿死。4. 代码实现从零搭一套可用的收发框架4.1 缓冲区与全局变量定义CubeMX生成的工程一般会在main.c里放外设初始化代码但更推荐把收发逻辑放到一个独立的模块里比如uart_comm.c这样业务代码不会越写越乱。先定义接收缓冲区。接收缓冲区的大小要根据一帧最长数据来定比如协议里最大报文长度是128字节那缓冲区至少定义到256留出余量。最好用__ALIGN_BEGIN和__ALIGN_END做4字节对齐因为DMA在做一些对齐访问时会更快也可以避免后续如果改成半字/字传输引起的对齐问题。#define RX_BUF_SIZE 256 __ALIGN_BEGIN static uint8_t g_rx_buf[RX_BUF_SIZE] __ALIGN_END; static volatile uint8_t g_rx_frame_ready 0; static volatile uint16_t g_rx_frame_len 0; static volatile uint8_t g_tx_busy 0;这里g_rx_frame_ready是给主循环轮询用的标志收到新一帧后置1主循环处理完再清0。g_tx_busy是发送忙标志防止在上一帧还没发完时再次启动发送导致HAL返回HAL_BUSY。4.2 初始化里启动接收在main()函数里调用完MX_USART1_UART_Init()之后紧接着就要启动DMA接收。最推荐的方式是用HAL的扩展接口HAL_UARTEx_ReceiveToIdle_DMA(huart1, g_rx_buf, RX_BUF_SIZE);这个函数的意思是启动DMA接收同时开启串口空闲IDLE中断。当两种条件之一发生时回调会被触发接收长度达到了RX_BUF_SIZE即缓冲区满了接收过程中检测到串口空闲线也就是发完一帧数据后总线上空了一段时间。对于不定长帧的协议这个函数是“神器”。以前在老库上需要手动开启IDLE中断然后在中断服务函数里判断标志位现在一个API全部搞定。如果你的项目用的HAL版本比较旧没有这个接口也可以手动实现逻辑是这样的HAL_UART_Receive_DMA(huart1, g_rx_buf, RX_BUF_SIZE); __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);两种思路都能工作只是新接口更省事我这里默认你用的新版本HAL库。4.3 空闲中断接收一帧数据回调函数写在用户代码区比如main.c或uart_comm.c里void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { g_rx_frame_len Size; g_rx_frame_ready 1; /* 立即重新启动接收保证下一帧到来时不丢数据 */ HAL_UARTEx_ReceiveToIdle_DMA(huart1, g_rx_buf, RX_BUF_SIZE); } }这个回调里的Size就是当前DMA已经收到的字节数。这里有一个非常重要的点回调执行时HAL库内部已经把接收DMA停掉了你拿到的是安全的数据快照接下来有两种处理策略快速处理直接在回调里解析数据用完就走。适合帧内容少、处理逻辑快、不阻塞其他中断的场景。标记后处理把数据拷贝到业务缓冲区或者标记g_rx_frame_ready让主循环在处理。适合需要对数据做复杂解析的场景。上面示例用的是第二种思路但要注意一个问题我在回调里立刻重新启动了接收而数据还留在g_rx_buf里如果主循环还没来得及处理下一帧数据到了就会覆盖旧数据。所以更稳妥的做法是准备一个业务缓冲区在回调里用memcpy把g_rx_buf里的数据搬走再重新启动接收。void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { memcpy(g_rx_frame_buf, g_rx_buf, Size); g_rx_frame_len Size; g_rx_frame_ready 1; HAL_UARTEx_ReceiveToIdle_DMA(huart1, g_rx_buf, RX_BUF_SIZE); } }如果项目里接收数据非常频繁且数据量大还可以考虑双缓冲机制定义两个缓冲区一个给DMA写一个给应用层读收到空闲中断后交换。这个方案能显著降低丢包率但代码复杂度也会上去建议在业务出现覆盖问题后再升级。4.4 发送封装IT发送的完整流程发送用HAL_UART_Transmit_IT()配合一个发送忙标志即可。这里要特别注意因为发送是异步的pData指向的内存必须在发送完成前一直有效。如果你传入的是一个局部数组函数一返回局部变量生命周期就结束了DMA虽然没参与发送但中断发送照样会从那个地址读数据读出来就是垃圾数据。所以要么用静态缓冲区要么把全局发送缓冲区作为参数传进来。uint8_t uart1_send_frame(uint8_t *data, uint16_t len) { if (g_tx_busy) { return 0; /* 上一帧还没发完可以先丢弃或者排队 */ } if (HAL_UART_Transmit_IT(huart1, data, len) HAL_OK) { g_tx_busy 1; return 1; } return 0; } void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { g_tx_busy 0; } }实际业务里发送需求往往是有队列的也就是多发几帧时不能丢帧。最简单的方式是把uart1_send_frame里的策略改成“如果忙就缓存到业务发送队列”。很多项目用一个环形队列就能解决这里不展开但至少在单帧发送的场景下上面的代码已经能稳定工作。4.5 错误处理串口通信并不是永远顺风顺水。对方半路断电、波特率配错、干扰导致数据超限这些都可能触发错误中断。HAL库处理错误时会回调HAL_UART_ErrorCallback如果不处理接收状态会停在错误状态之后再也不会收数据这是最容易被忽略的问题。void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { /* 根据错误类型打印/记录推荐用 __HAL_UART_GET_FLAG 查看具体原因 */ __HAL_UART_CLEAR_FLAG(huart1, UART_FLAG_ORE | UART_FLAG_FE | UART_FLAG_NE); g_tx_busy 0; HAL_UARTEx_ReceiveToIdle_DMA(huart1, g_rx_buf, RX_BUF_SIZE); } }这里建议只处理UART相关的错误标志不要在这个回调里做太多业务逻辑。因为像DMA传输错误这种问题可能和内存访问、总线状态更相关把状态清掉重新拉起来让系统先恢复运行再说。5. 常见问题与排查记录5.1 空闲中断不触发接收不到数据最常见的排查思路是先用示波器或逻辑分析仪确认RX引脚上到底有没有波形。很多“接收不到数据”并不是代码问题而是TX/RX接反了。这里再啰嗦一句A设备的TX接B设备的RXB设备的TX接A设备的RX别把两边TX连到一起那是什么也收不到的。如果波形正常再检查CubeMX配置USART的全局中断是否在NVIC里打开DMA的USART1_RX请求是否真的配置了方向是否Peripheral To Memory初始化后是否调用了HAL_UARTEx_ReceiveToIdle_DMA()。这三个条件缺一不可。我自己就经常在工程里加了接收函数后忘了打开NVIC中断结果空闲中断永远不触发DMA数据也不搬后来才发现是全局中断没使能。5.2 一帧被拆成两段或者多帧粘在一起空闲中断判定的是“总线空闲超过一个字节时间”它并不会理解你的协议帧边界。如果上位机发完一帧后中间因为处理逻辑停顿了一小会儿就可能拆成两次空闲中断你收到的是不完整的两半。反过来如果协议帧之间间隔很短小于判断空闲的时间窗口两帧数据会粘在一起一次回调里就收到两帧内容。解决这个问题的思路有三个协议层做帧头、帧尾或长度字段解析不要依赖空闲中断作为唯一的分帧依据回调里拿到数据后把一帧内容放入解析状态机按协议规定的长度字段分包处理如果接收的是传感器连续流数据本身没有帧边界那就用Circular模式加环形缓冲应用层自己按头尾做切片。老实说对付不定长协议空闲中断只是“帮助你减少工作量”真正的可靠性还是要靠协议设计兜底。5.3 发送卡死程序一直停在发送函数里如果你在代码里用了HAL_UART_Transmit()它默认是阻塞的数据没发完之前函数不会返回这在很多场景下就是卡死。改成中断方式之后HAL_UART_Transmit_IT()是立即返回的不会卡在那边。但如果你之前代码是在中断回调里调用了阻塞发送那就要小心了发送期间中断一直被占用新的串口中断进不来整个系统很容易卡住。所以我的经验是业务代码里的串口请求一律走异步接口发送完成放到回调里处理不要在有实时性要求的地方用阻塞发送。5.4 DMA接收方向配反导致数据错乱CubeMX里自动生成的DMA配置大概率不会错但如果是手动添加DMA请求很容易把方向选成Memory To Peripheral。方向一旦选反接收数据的寄存器就不会被读取RX中断虽然来了读取的数据却一直是错误的。只要在调试时发现收到的数据“看起来不对但引脚波形正常”第一件事就是去DMA配置里看方向这是最脏也是最容易被忽略的问题。5.5 中断优先级配置不当导致系统卡死中断优先级不是随便分配的。如果两个中断的抢占优先级一样它们之间不能互相打断如果其中一个里面调用了另一个中断才能完成的事件就会出现活锁。比如在UART接收回调里等一个DMA发送完成标志而这个DMA中断优先级和UART一样那就永远等不到。所以优先级分配上我一般遵循“接收与错误处理优先发送次之业务最末”的原则。另外如果用了FreeRTOS还要考虑中断优先级和FreeRTOS配置的LIBRARY_MAX_SYSRQ_INTERRUPT_PRIORITY关系优先级数不能超过FreeRTOS允许的范围否则中断里调用的HAL函数可能触发断言。这个在调试时很容易被忽略但只要跑起来就立刻崩溃排查方向要早一点考虑到。6. 一组实测数据与调参体会我在一块STM32H563的板子上实际测试过这套方案。主频设为250MHzUSART1的波特率115200接收侧用一个USB转串口模块以每5毫秒发送一帧64字节的报文的节奏持续发送。主循环里做简单的计数器累加同时喂一个LED闪烁。在接收DMA发送中断的组合下主循环的空闲比例非常高几乎看不出串口接收对主循环时间线的影响。我还对比过全中断方式同样是64字节报文每5毫秒一帧如果接收用逐字节中断大约每一个字节触发一次USART中断一帧数据就会打断主循环64次。虽然单次开销很小但累积起来如果同时还在跑PID控制、LCD刷新等任务调度抖动就会变得很明显。这还不算万一波特率再高一档比如1Mbps中断频率会直线上升影响会更加严重。这个对比并不是说中断方式一定不行而是说在系统里有多个任务、要求稳定时间线的情况下把串口这种“外设内部搬运”的工作交给DMA能让CPU把精力集中在真正需要判断和计算的逻辑上。调参上我最终把USART1全局中断优先级设为抢占优先级2DMA接收中断设为3业务中其他用不到的中断全部关掉了跑了好几天没出现一次丢帧或卡死。我在实际使用中还发现一个挺有用的小技巧在接收回调里不要急着解析数据先把g_rx_frame_ready置位然后在主循环里统一定时轮询处理。这样即使哪次一帧数据处理了比较久也不会长时间霸占中断上下文系统的整体稳定性会好很多。当然如果你的协议对响应时间要求极高那就只能用双缓冲加快速解析的思路了。这套“接收DMA、发送中断”的组合对我来说已经快变成标准模板了。它不像双DMA方案那样对资源要求高也不像全中断方案那样让CPU一直被打扰在大多数产品级应用里都够用。如果你后面要扩展多路串口思路也是一样的每个串口实例配好自己独立的缓冲区、发送忙标志和回调判断即可。先跑通一路再复制扩展就不会乱了。