
很多人第一次用STM32的HAL库写串口接触的第一个函数大概率是HAL_UART_Transmit阻塞发送一调一个准。但等到项目里要接GPS模块、要跟Wi-Fi模组对拷数据、要高频上报传感器状态的时候光会阻塞发送就不够用了。串口的收发在HAL库里其实被拆成了三条完全不同的路——阻塞、中断、DMA再加上一套贯穿始终的回调机制如果不把这些门道吃透写出来的代码轻则丢数据重则死机重启。这篇文章就把这几块分开讲清楚每一种模式解决什么问题、什么时候用、有什么坑一次性说明白。1. 串口通路的底层差异决定了模式选择1.1 从一次简单的发送说起不管是阻塞、中断还是DMA串口发送的物理动作其实都一样把数据逐字节从发送数据寄存器TDR送出去。但谁把数据放进寄存器这件事三种模式的答案完全不同。阻塞模式是CPU自己动手。调用HAL_UART_Transmit之后CPU进入一个循环每次往TDR写一个字节然后等发送完成标志位等到了再写下一个直到把整个缓冲区里的数据全部写完。这个过程里CPU被占住别的任务一律让路。如果开了RTOS这就是要让出CPU的。中断模式是CPU当监工。调用HAL_UART_Transmit_IT之后硬件一次只发送一个字节发完触发发送完成中断CPU在中断里把下一个字节写进TDR然后退出中断。发送大块数据时CPU会被频繁打断但每次打一断很短不像阻塞那样焊死在原地。DMA模式是CPU当甩手掌柜。调用HAL_UART_Transmit_DMA之后数据从内存到TDR的搬运完全由DMA控制器完成发送完成也没有中断除非你为DMA传输完成单独配置了中断。CPU只负责发起传输传完之后收到一个完成事件。接收方向的道理一样。阻塞接收就是死等中断接收是硬件收一个字节触发一次中断CPU把字节搬进缓冲区DMA接收则是硬件持续把收到的字节自动写进内存缓冲区收满指定长度或者产生空闲事件时才通知CPU。1.2 选模式之前先问两个问题第一个问题这次传输的数据量有多大几个字节的指令交互和几百字节的固件升级包处理方式完全不同。第二个问题数据来了CPU能不能容忍被占住如果系统里还有别的实时任务比如控制电机、读传感器、跑协议栈那阻塞模式在大多数情况下就是不可接受的。理解了这个底层差异之后再去看HAL库提供的API会发现套路其实很统一每种模式都有独立的发送函数和接收函数对应逻辑几乎一样差的就是名字结尾的_IT和_DMA。搞清楚底层干了什么API就只是换了个皮。2. 阻塞模式调试期好用到哭但别让它常驻生产2.1 阻塞收发API与超时参数的隐藏逻辑阻塞发送的接口长这样HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout);接收对应的是HAL_UART_Receive参数结构完全一致。这里的Timeout值得多说一句。很多人直接填HAL_MAX_DELAY意思是一直等下去这放在上电自检、打印启动日志的时候问题不大但在实时系统里埋了雷。如果对端设备没有回数据而这里又在死等整个系统的任务调度就卡死了。更好的做法是给一个合理超时比如200毫秒或者500毫秒超时后函数返回HAL_TIMEOUT程序自行决定是重试还是转错误处理。我自己调试硬件时习惯把超时设成1000毫秒这样既能肉眼看到现象又不会让程序假死太久。阻塞接收还有一个容易忽略的点HAL_UART_Receive必须收满Size个字节才会返回。如果对方只发来3个字节而你要求收20个这个函数就会一直等。所以在串口通信协议不太规整的场景阻塞接收往往不是好选择除非你能确定对端每次都会精确发出固定长度的包。2.2 阻塞模式的真实用武之地阻塞模式没有想象中那么差它最好用的场景是以下几个系统上电时的启动日志打印。此时外设刚初始化任务还没跑起来用阻塞方式打几十行日志完全无所谓。调试阶段的临时打点。想知道某一步执行了没有加一句HAL_UART_Transmit看完删掉顺手而且直观。通信双方协议非常简单且主从明显的场合比如MCU定期给某个执行器发送固定帧指令。但阻塞接收的情况要谨慎得多。除了一对一的调试对话生产环境里我一般不建议用阻塞接收。因为它天然和不确定什么时候来数据的场景相抵触和RTOS的配合也别扭——阻塞期间任务直接占住CPU调度器形同虚设。还有一个所有新手都会犯的错在中断回调或者定时器中断里调用阻塞发送。这相当于在某个更高优先级的中断上下文里死等一个串口标志位轻则阻塞其他中断重则直接卡死。如果非要在中断里发数据请往下看中断模式的正确用法。2.3 阻塞模式最容易翻车的两个细节第一个是数据竞态。如果你在一个低速任务里循环调用HAL_UART_Transmit发送日志同时又有一个串口中断在接收命令发送和接收共用同一个UART_HandleTypeDef结构体两边都在操作它的内部状态很容易出现状态错乱。解决方法是加一个互斥锁或者在架构上把发送和接收职责分开。第二个是超时单位。HAL库的Timeout不是毫秒而是内部基于滴答定时器换算的节拍数。在不同主频和不同时钟配置下同一个数值对应的真实等待时长可能不一样所以不要在代码里用魔法数尽量用宏定义统一管理或者直接依赖库函数只判断返回值。3. 中断模式异步收发的正确入门姿势3.1 中断收发怎么调用怎么完成中断模式的核心API是两个HAL_StatusTypeDef HAL_UART_Transmit_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size); HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);调用后函数会立即返回剩下的工作交给串口中断。发送完成时HAL库会调用HAL_UART_TxCpltCallback接收结束时会调用HAL_UART_RxCpltCallback。这就是中断模式的完整闭环。但有个大坑几乎所有初学者都踩过HAL_UART_Receive_IT不是开启中断接收后以后每来一个字节都自动存到缓冲区。它的真实语义是当前这一次启动接收收够Size个字节后停下来然后回调。回调结束之后接收通道就关了想继续收必须再调一次HAL_UART_Receive_IT。一个字节就收完一次、回调一次下一次不重新启动数据来多少丢多少。3.2 不定长数据接收的经典设计实际项目里串口数据大多不是固定长度尤其是接AT指令模组、蓝牙模块这类设备返回内容长度每次都不一样。中断模式怎么处理不定长思路有两个方向。方向一约定结束符。收到一个字节回调一次在回调里判断是不是结束符比如0x0D 0x0A收到才认为一帧结束。优点是实现简单缺点是如果一帧很短CPU被打断的频率也不算高但如果是高速高频数据流中断次数会非常密集。方向二结合空闲中断。STM32的空闲中断IDLE在总线上出现一个字节时间的空闲后触发配合HAL_UART_Receive_IT一字节一字节地收收到空闲中断时认为一帧结束了。这个方案现在已经很多库在用了主流写法是// 启动一字节接收 HAL_UART_Receive_IT(huart1, rx_byte_buf, 1);空闲中断触发后在HAL_UART_IDLECallback里做帧处理再重新启动接收。这个写法的好处是无论是3个字节还是300个字节的帧都能自动按帧分拣而且不需要关心内容里有没有特殊结束符。缺点是要额外处理中断标志位清除不同STM32系列库版本略有差异折腾过一次之后就很熟练了。3.3 中断优先级设置与重入问题串口中断的优先级不是随便设的。如果串口接收事关协议栈优先级一般要比普通外设高但不能高过系统节拍中断和某些关键实时中断。接收中断优先级设太高会导致其他中断被长时间挂起设太低波特率高一点就容易触发溢出错误Overrun丢字节。另外一个经典问题是重入在接收回调里再次调用HAL_UART_Receive_IT这个操作是在中断上下文中执行的逻辑上没问题但如果你在回调里做了大数据处理、延时和复杂打印中断占用的时间过长很可能打断同优先级中断。我见过一个项目波特率115200每秒几百帧数据接收回调里做JSON解析结果丢帧率居高不下问题就是中断占坑时间太长。中断模式适合的处理方式回调里只做标志位置位和数据搬运复杂的解析放到主循环或者任务里慢慢做。记住一句话回调越短系统越稳。4. DMA模式大流量数据和高频上报的最终方案4.1 DMA收发的API与内存搬运逻辑DMA模式的发送接收接口同样很统一HAL_StatusTypeDef HAL_UART_Transmit_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size); HAL_StatusTypeDef HAL_UART_Receive_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);发送方向DMA把pData指向的内存块按Size长度搬运到串口数据寄存器CPU全程不参与逐字节写入。接收方向串口每收到一个字节DMA自动把它写进pData指向的缓冲区收满Size个字节后触发传输完成回调HAL_UART_RxCpltCallback。这套机制最适合两类场景。一类是数据量大但结构固定比如通过串口持续刷日志、升级固件、传输图片或音频块另一类是要求CPU低介入的场合比如低功耗系统里CPU大多数时间睡大觉串口数据自己进内存攒够了再唤醒处理。DMA模式还分正常模式和循环模式。正常模式是一次性搬运完成即停。循环模式则是在缓冲区里循环写入写满后从头再来配合接收使用可以做到收多少都不怕溢出。但循环模式的代价是需要及时把数据处理掉否则新数据会覆盖旧数据。4.2 DMA模式不一定要配空闲中断但配上真香前面提到HAL_UART_Receive_DMA收满Size个字节才回调一次。如果协议帧长度不固定直接死在Size上肯定不行。业界成熟的方案是把Size设成一个比较大的上限值同时开启串口空闲中断当数据帧结束后的一小段空闲时间到来IDLE中断触发此时再从DMA缓冲区里读取实际收到多少字节。新版HAL库直接封装了一个组合接口HAL_StatusTypeDef HAL_UARTEx_ReceiveToIdle_DMA(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size);调用后如果收满Size字节就触发HAL_UARTEx_RxEventCallback如果提前产生空闲事件也会触发同一个回调回调参数里直接告诉你实际接收到的数据长度。这个接口基本就是串口不定长接收的最终答案省掉了自己去翻IDLE标志位和清中断的麻烦。4.3 用DMA缓冲区先解决对齐和覆盖这两个问题DMA对内存缓冲区有对齐要求官方文档一般建议缓冲区的首地址按DMA控制器的对齐要求处理有些平台要求4字节甚至16字节对齐。使用全局数组时给编译器加上对齐属性比如在STM32 GCC环境下可以写uint8_t rx_buf[512] __attribute__((aligned(4)));不要随手定义一个局部数组就丢给DMA栈上的地址是否对齐并不总能保证而且栈空间本身也有限。另一个常被忽略的问题是当你调用HAL_UART_Receive_DMA后旧数据还在缓冲区里新数据马上就会从地址0开始覆盖。如果应用层还在处理上一帧数据直接读缓冲区就会读到一帧一半新一半旧的东西。解决方式有两种一是开两个缓冲区交替使用乒乓缓冲二是在帧处理前先把数据搬走到应用层自己的内存区域。新手期我建议先做一个拷贝简单可靠后面性能不够了再上乒乓。5. 回调逻辑HAL库的事件通知机制不是让你在里面干活的5.1 回调从哪里来HAL库在stm32f1xx_hal_uart.c不同系列文件名略不同里定义了多个以__weak修饰的回调函数比如__weak void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) {} __weak void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) {} __weak void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) {}__weak的意思是如果你在自己的代码里定义了同名函数编译器就用你的不理会HAL库自带的那个空函数。所以回调不是你去注册出来的而是直接重写一个函数。这也是很多新手看别人代码时困惑的地方——也没看到注册回调的代码为什么中断触发后能跑到自己的逻辑里就是因为同名函数覆盖。需要特别注意的是如果你的工程里同时用了多个串口回调里一定要判断是哪个串口void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { // 处理串口1的数据 } else if (huart-Instance USART2) { // 处理串口2的数据 } }不判断实例而直接写死逻辑换到双串口甚至多串口项目时会非常难受。5.2 回调里能不能做耗时操作最好别中断模式下的回调本质是运行在串口中断上下文里。DMA模式下的回调虽然触发源不同最终也多半走UART中断或者由DMA中断引导。结论都一样回调里不适合做耗时操作。很多人看到HAL_UART_RxCpltCallback就忍不住在里面做数据处理比如解析协议、写Flash、执行一个长字符串格式化。一旦数据处理时间超过下一个字节到达的时间间隔串口缓冲区就会溢出轻则丢字节重则触发HAL_UART_ErrorCallback。我在开发中常用的一套组合拳是回调里只做两件事把数据标记为已就绪再设置一个帧计数变量。主循环里轮询标志位看到了就处理数据。如果是FreeRTOS环境更合理的做法是回调里把一个二值信号量置位让等待该信号量的任务去处理数据。这样既不会丢事件也不会拖累中断。5.3 错误回调的正确打开方式HAL_UART_ErrorCallback被很多人忽略但它往往是排查问题的第一手线索。函数参数huart的ErrorCode字段描述了错误类型常见的包括错误标志含义常见诱因HAL_UART_ERROR_OVERRUN接收溢出收到新字节时前一个字节还没被读走HAL_UART_ERROR_DMADMA传输故障DMA配置错误或内存访问异常HAL_UART_ERROR_PE奇偶校验错误线路干扰或波特率偏差HAL_UART_ERROR_NE噪声错误信号质量差线缆过长HAL_UART_ERROR_FE帧错误停止位异常常见于波特率不匹配调试串口问题的时候我习惯先写一个错误回调把ErrorCode打到调试屏幕或者转成LED闪烁节奏。很多莫名丢数据的问题最后都在这里现出原形——比如波特率两边都显示115200但实际因为晶振校准精度差异有一边的通信完全不可靠。6. 三种模式下怎么选型一张表和一个组合思路6.1 按数据特征选模式把三种模式和典型业务特征对应起来可以列一个比较务实的对照表维度阻塞模式中断模式DMA模式CPU占用传输全程占用每个字节打断一下全程基本不占用代码复杂度最低中等较高实时性差好好适合数据量小几十字节内中小几十到几百字节大几百字节以上不定长接收不适合配合空闲中断可用配合空闲中断最顺典型场景启动日志、调试打印指令交互、协议帧、AT指令固件升级、日志流、音视频块如果只是简单回显和调试选阻塞没有任何问题。如果是人机交互或者需要及时响应外部命令优先考虑中断。如果数据量大、或者要求CPU尽量少参与直接上DMA。6.2 组合使用才是常态实际项目中很少有人只用一种模式。最常见的组合是发送用DMA或中断接收用DMA加空闲中断这样发送大块日志不会卡任务接收又不用频繁被打断。还有一种典型组合是发送用阻塞接收用中断在上电阶段打日志没问题进入运行阶段后用中断接收外部指令。另一个更细的分工思路是把低频控制指令和高频业务数据分开走不同串口比如系统控制用串口1中断收发业务数据用串口2的DMA收发。两个串口互相独立各自的模式就不会互相挤兑。开发时我先打通低频通道保证系统的可命令性再上高频通道保证数据的吞吐性排查问题时有清晰的隔离边界。6.3 模式切换的注意点先停再换如果程序运行过程中需要从DMA模式切到中断模式或者反过来先别直接调用新API。正确做法是先停掉当前传输HAL_UART_Abort(huart1); // 同时中止收发 HAL_UART_AbortTransmit(huart1); // 只中止发送 HAL_UART_AbortReceive(huart1); // 只中止接收不停止旧传输就直接启动新传输DMA控制器和串口外设的状态可能还停留在上次配置里轻则传输位置错乱重则直接卡死。我见过有同事在循环里反复切换DMA接收和中断接收不加中止处理后来收到啥都乱套。养成习惯切换之前先调HAL_UART_Abort百利无一害。7. 回调置位之后怎么保证不丢数据一个可以复用的接收框架7.1 常用缓冲区模型把接收逻辑整理好比单独学会某个API更有价值。我一般在工程里固定用这样一套框架一个环形缓冲区负责DMA回调到应用层之间的数据转移。一个接收完成标志DMA的空闲接收回调里置位。主循环或者任务里查标志位处理完一帧之后清标志位。简化版可以先把环形丢一边直接用数组加长度变量#define RX_BUF_SIZE 512 uint8_t rx_dma_buf[RX_BUF_SIZE]; volatile uint16_t rx_frame_len; volatile uint8_t rx_frame_ready; void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { rx_frame_len Size; rx_frame_ready 1; } }然后在主循环里if (rx_frame_ready) { process_frame(rx_dma_buf, rx_frame_len); rx_frame_ready 0; HAL_UARTEx_ReceiveToIdle_DMA(huart1, rx_dma_buf, RX_BUF_SIZE); }这里再一次强调DMA接收和中断接收一样一次传输结束后要重新启动。HAL_UARTEx_ReceiveToIdle_DMA在触发回调后不会自动重新开启接收忘记重新调用就是第二次数据开始丢的根源。7.2 处理速度和数据到达速度的匹配接收框架的本质是把数据到达的节奏和应用层处理的节奏解耦。DMA负责按照硬件速度把数据收进内存回调只做标记应用层按照自己的速度取走数据。只要取数据的速度快于数据进来的平均速度系统就稳定。但如果一段时间内数据密集且应用层忙于其他任务缓冲区的数据就会积压一旦超过缓冲区容量就只能接受丢数据的现实。这种情况要么加大缓冲区要么缩短处理周期要么换更快的传输介质。先定位瓶颈再决定改哪里这个思路比东拼西凑打补丁管用得多。7.3 一个多次复用后觉得不错的小技巧在调试DMA接收的时候我习惯在缓冲区里故意填一个特殊字节等系统运行一段时间后用调试器查看环形缓冲区里的数据分布比如填一个0xAA然后在调试器里搜索第一个不是0xAA的位置可以直观看到DMA写到了哪个下标。这个方法在排查数据从哪里开始错位时非常高效比在代码里到处打断点管用多了推荐试试。8. 我第一次从阻塞迁移到中断DMA时的完整排错记录8.1 现象数据偶尔全对偶尔整体往后错一个字节那次是给一套数据采集模块做串口升级原来纯阻塞收发通信一卡就整个系统假死。后来改成DMA接收加重启标志位结果跑起来后出现一个诡异现象主控发过来一帧第一次解析全对第二次对第三次开始帧头位置偶尔错位数据包整体往后挪了一个字节。排查过程是这样的先怀疑接线干扰拿示波器看波形稳定。再怀疑波特率偏差换了两端都偏低一点的同一颗晶振测试问题还在。最后在错误回调里打印错误码发现偶发HAL_UART_ERROR_OVERRUN这才意识到是溢出了。8.2 根因DMA缓冲区启动时机太晚问题出在我在回调里做了比较多的数据处理处理期间DMA接收没有及时重启。虽然回调本身执行很快但处理一帧几百字节数据还是占用了不少时间如果下一秒数据已经到达而DMA接收通道还处于未启动状态硬件收到的字节无处可去只能触发溢出。解决方案也简单把数据处理全部挪出回调放进任务里回调里只把数据标记好、清掉错误标志、重启DMA接收。整个过程压缩到几个微秒内数据丢失和溢出就不再出现了。8.3 更正一个我早期误以为DMA不会丢数据的错觉DMA模式不是万能的。DMA只负责搬运但搬运的目的地是内存缓冲区。如果应用层消费速度跟不上生产速度缓冲区满了之后新数据照样会被覆盖或者丢弃只是丢弃的位置从串口硬件缓冲转移到了软件缓冲区。从总线上看数据是进来了从最终效果看该丢还是丢。所以不管哪种模式最终解决问题的都是整条数据处理链路的均衡设计。9. 最后再说一点我自己的使用体会我做了几个串口项目之后最深的感受是模式本身没有绝对的好坏只有合不合适的场景。上电调试用阻塞省事直观交互响应用中断实时可控大批量数据用DMA资源占用最少而回调则是贯穿所有模式的那根线——理解它写串口代码就顺畅很多。如果你刚开始接触这块建议按这个顺序练手先把阻塞跑通确信数据通路没问题再换中断感受一下异步的味道然后上DMA体验大块数据的爽快最后把空闲中断组合进来实现真正的不定长接收。每一步都踩过坑之后你就不会再为串口通信发愁了。