资讯动态

STM32双串口同时收发完整指南:从CubeMX配置到HAL库代码实现

发布时间:2026/10/3 7:41:48 来源:尧图企业网站定制
做嵌入式开发的人迟早会遇到一个场景一个串口接调试、一个串口接设备或者一个串口发日志、一个串口跑协议。尤其是STM32这种性价比极高的芯片双串口甚至多串口几乎是项目标配。但很多刚接触的朋友在配置第二个串口时会发现照着别人的教程做要么编译报错、要么只有一个串口能通折腾半天找不出原因。这篇文章我从硬件规划、CubeMX配置、代码实现到问题排查把双串口同时工作的完整路径讲清楚代码是HAL库的可直接参考文末也解释了为什么这么写。适合三类人看一是想给现有项目加一个串口的二是刚接触STM32、对串口重映射和中断机制还比较模糊的三是准备做毕业设计或小项目、需要同时对接GPS和上位机的。读完你至少可以做到两个串口各自收发互不干扰日志和业务数据分流出问题知道去哪里查。1. 双串口的核心痛点与整体设计思路1.1 为什么需要双串口而不是用同一个串口轮询很多人在做项目时习惯一个USART打天下需要接多个外设就手动切换引脚或者用IO模拟串口。这种方案在低速率、短时间测试时勉强能用但一旦涉及正式项目就非常难受。举个例子我在一个定位终端项目里需要同时对接GPS模块和4G模块。GPS模块用9600波特率持续输出NMEA报文4G模块用115200波特率和服务器保持长连接。如果只有一个串口就得在两个模块之间反复切换引脚或软件模拟不仅响应延迟高还容易丢数据。更关键的是调试信息无处安放——总不能让GPS的数据和printf的日志混在一个串口里吧。双串口的意义在于物理隔离。USART1走业务数据USART2走调试日志互不抢占总线。这还只是最基本的用法进阶一点可以做协议隔离一个串口跑Modbus一个串口跑自定义帧协议两边独立收发逻辑上也清爽得多。1.2 串口重映射到底是什么怎么选引脚STM32的每一个USART都有默认引脚比如USART1默认是PA9(TX)/PA10(RX)USART2默认是PA2(TX)/PA3(RX)。但实际画板时这些引脚经常被其他外设占用比如PA9/PA10可能接了USB或CAN这时候就需要用到引脚重映射。重映射的关键是AFIO寄存器在HAL库中通过GPIO的Alternate配置来实现。以USART2为例它不仅可以在PA2/PA3上工作还可以重映射到PD5/PD6上。如果你用CubeMX配置时直接在芯片视图上点击PD5/PD6然后选择USART2_TX/USART2_RXCubeMX会自动设置好AFIO和复用功能。关于引脚选择我建议遵循三个原则第一确认目标引脚没有被其他外设占用比如I2C的SCL/SDA、SPI的MOSI/MISO这些引脚尽量避开第二优先选择同一组引脚的连续编号方便布线第三注意核对原理图特别是你自己画的板子PA2/PA3这种容易被其他插座占用。1.3 芯片选型先看有几个串口再动手STM32家族非常庞大不同型号的串口资源差很多。F1系列的C8T6只有3个USART而F103ZET6有5个。F4系列的F407有6个串口F429甚至更多。我的建议是如果你预估项目中可能需要超过2个串口直接用中间以上型号别在C8T6上死磕。另外需要注意USART和UART的区别。USART是通用同步/异步收发器除了支持异步串口通信外还支持同步模式和智能卡、LIN等UART是通用异步收发器只支持异步模式。串口通信里我们一般都跑异步模式所以两者用起来没什么区别但如果你后面要用到同步模式就必须选择USART。刚才提到104个引脚的F103ZET6和48个引脚的C8T6串口数量差异很大这就是为什么要先看串口资源再选型。市面上大部分最小系统板用的是C8T6如果毕业设计或比赛需要3个以上串口建议用ZET6的板子价格也没贵多少但引脚资源和串口数量都宽裕得多。2. 硬件层面必须处理好的三个细节2.1 电平匹配TTL、RS232、RS485不能直接互接这是我在帮别人排查问题时遇到最多的情况。STM32的串口引脚输出的是TTL电平0~3.3V而很多工控设备、老式模块用的是RS232电平±12V或者RS485差分电平直接连接轻则通信失败重则烧毁芯片引脚。如果你接的是GPS模块、蓝牙模块、ESP8266等常见模组它们基本都是TTL电平可以和STM32直连注意共地就行。但如果你对接的是PLC、传感器变送器、工业仪表就需要在中间加一个RS232转TTL模块或者RS485转TTL模块。淘宝上几块钱的模块就能解决关键是别省这一步。另外有个容易被忽略的细节如果是5V单片机系统和3.3V的STM32通信建议串一个1K欧姆电阻做降压保护或者在RX引脚上加一个限流电阻。我见过不少把引脚烧坏的情况都是因为两个系统的电平不完全兼容。2.2 供电和共地布线时就要想好的事双串口意味着你要同时接两个外部设备这时候供电和共地就要特别注意。比如GPS模块和4G模块如果都由板子的3.3V供电总电流可能超出稳压芯片的输出能力导致电压跌落串口通信就会不稳定。一个更隐蔽的问题是共地。STM32板子的地和外部设备的地在电气上必须是同一个参考点否则串口RX/TX的信号电平没有一个共同的基准就会出现乱码或完全不通的情况。很多人在用两个USB转TTL模块同时调试时遇到这个问题两个模块各自从电脑取电地之间形成电位差串口就不正常。解决办法是保证所有设备共用一个地或者用外部电源集中供电。2.3 上拉电阻和引脚悬空的问题STM32的USART引脚内部有弱上拉但外部环境复杂时建议在RX引脚上加上拉电阻防止悬空时引入干扰。这在长线传输、电机驱动等电磁干扰较大的场景下特别有效。另外如果你在初始化串口之前外部设备就已经在上电发送数据那么这些数据会被丢弃。这在特定场景下会引发大问题——比如GPS模块一上电就输出开机报文而STM32串口还没完成初始化这些数据就丢了。我在卫星同步项目中就踩过这个坑GPS的GGA报文丢失后程序等了很久才拿到有效定位数据。解决办法有两个一是硬件上加延迟上电让STM32先完成初始化再给模块供电可以在模块的VCC引脚上串一个MOS管做电源开关二是在GPS模块上配置冷启动不自动输出等收到查询命令再回数据。这两种方法我都试过简单可靠的是第二种前提是模块支持这种配置。3. 软件配置CubeMX里把两个串口同时跑起来3.1 CubeMX配置双串口的具体步骤我用CubeMX配了无数次串口流程已经固定了这里按步骤梳理一遍。以STM32F103C8T6为例配置USART1和USART2。第一步在PinoutConfiguration界面中找到USART1点击Mode选择Asynchronous然后系统会自动分配PA9为USART1_TX、PA10为USART1_RX。USART2同理分配到PA2和PA3。如果你手动调整了引脚要确认芯片视图上对应的引脚颜色变为绿色。第二步在Parameter Settings中设置波特率。USART1我这里设为115200USART2设为9600。数据位8位、无校验、1位停止位这是最常用的配置。如果对时间精度要求高可以勾选Overrun Detection连续接收的错误检测默认开启即可。第三步打开中断。在NVIC Settings里把USART1 global interrupt和USART2 global interrupt都勾上优先级默认即可。这里我一般会调整一下如果USART2是持续收数据的GPS而USART1只是偶尔打日志就把USART2的抢占优先级调高一点避免被高频数据打断时丢失字节。第四步配置时钟。在Clock Configuration里确认APB2总线给USART1的时钟是72MHzAPB1总线给USART2的时钟是36MHz。这两个频率直接影响波特率发生器分频值的上限一般默认是对的不用改。3.2 生成工程的几个小细节CubeMX生成工程前建议在Project Manager里设置好Toolchain为MDK-ARM或STM32CubeIDE生成后就不要在CubeMX和IDE之间来回切换了容易覆盖你手动修改的代码。还有一个细节很多人不知道生成的usart.c文件里HAL_UART_MspInit函数是在HAL_UART_Init之前被调用的专门用来做GPIO、时钟和中断的配置。如果你手动修改了引脚重新生成工程时这个函数可能会被覆盖需要注意备份。生成工程后两个串口的初始化代码已经在了。MX_USART1_UART_Init()和MX_USART2_UART_Init()会分别在main函数里被调用。现在跑起来基本能完成一个串口发送数据但接收还需要我们自己去写逻辑这就是下一步的内容。3.3 标准库和HAL库的差异在哪里现在网上的资料参差不齐有标准库的老教程也有HAL库的新教程。标准库的写法是直接操作寄存器USART_SendData、USART_ReceiveData、USART_ITConfig这类函数看起来非常直观。HAL库则是封装了一层更多是配置结构体和句柄。我给的建议是如果是新学的人直接学HAL库。原因很简单HAL库是ST现在的官方方向CubeMX生成代码效率高而且芯片升级时迁移成本低。标准库虽然没有完全淘汰但ST早已不更新了新芯片型号根本不支持。但这不代表标准库的知识没用调试时能看懂寄存器层面的位定义可以帮助你快速定位问题。比如HAL库huart-Instance-SR直接操作状态寄存器这在排查中断标志位问题时非常有效。4. 双串口同时收发的完整代码示例4.1 中断接收和DMA接收的区别在动手写代码之前先把接收方案定下来。串口接收数据有三种主流方式轮询、中断、DMA。轮询就是程序一直在循环里检查接收标志位简单但不高效一个串口占住了CPU另一个串口就没法及时响应。中断是每收到一个字节进入一次中断函数处理完后退出适合中低速率通信。DMA是数据到了自动搬进内存不占用CPU适合大批量高速接收比如串口波特率跑到460800以上时。对于双串口场景我的建议是两个串口都使用中断接收简单、易调试速率不太高时完全够用。如果其中一个串口的数据量比较大比如每秒几百KB那这个串口考虑用DMA另一个串口保持中断。4.2 完整代码初始化、接收回调、发送函数我直接给出一份可以直接抄的工程框架以STM32F103C8T6 HAL库为例。代码逻辑USART1负责与上位机交互USART2负责接收GPS数据两个串口均通过中断方式接收。先看main.c的核心部分#include main.h #include usart.h #include gpio.h // 接收缓冲区每个串口独立避免数据交叉 uint8_t rx1_buf[128]; uint8_t rx2_buf[128]; volatile uint8_t rx1_len 0; volatile uint8_t rx2_len 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_USART2_UART_Init(); // 启动两个串口的中断接收每次接收一个字节 HAL_UART_Receive_IT(huart1, rx1_buf[0], 1); HAL_UART_Receive_IT(huart2, rx2_buf[0], 1); while (1) { // 主循环里处理收到的数据逻辑与串口接收分离 if (rx1_len 0) { // 处理来自USART1的数据 process_uart1_data(rx1_buf, rx1_len); rx1_len 0; HAL_UART_Receive_IT(huart1, rx1_buf[0], 1); } if (rx2_len 0) { // 处理来自USART2的数据 process_uart2_data(rx2_buf, rx2_len); rx2_len 0; HAL_UART_Receive_IT(huart2, rx2_buf[0], 1); } } }然后是中断回调函数这个函数是HAL库固定的弱函数我们在外部重写它// 在stm32f1xx_it.c或者单独的文件中重写 void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { rx1_len 1; } else if (huart-Instance USART2) { rx2_len 1; } }这里有一个关键点收到一字节后rx1_len被置1主循环里处理完这个字节后马上再次调用HAL_UART_Receive_IT开始接收下一字节。如果不再次调用中断接收只会触发一次这是新手最容易踩的坑。不过上面的写法只适用于单字节接收一次进中断处理一个字节效率有点低。更优雅的方案是用环形缓冲区我下面细讲。4.3 中断接收的精髓环形缓冲区写法单字节接收的代码虽然能跑但有一个隐患如果主循环处理数据花了太长时间而串口数据一直在来就会丢字节。因为每次接收一个字节调用HAL_UART_Receive_IT到下一次中断接收之间是有时间窗口的。解决办法就是环形缓冲区。它把接收到的数据先暂存到一个数组里由中断往缓冲区写入数据主循环从缓冲区读取数据。这样即使主循环忙了一段时间数据也不会丢最多就是缓冲区满了覆盖旧数据。下面是一个简化版的环形缓冲区实现适用于两个串口#define RX_BUF_SIZE 256 typedef struct { uint8_t buffer[RX_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_buffer_t; ring_buffer_t uart1_rb; ring_buffer_t uart2_rb; // 写入一个字节到缓冲区在中断里调用 void ring_buf_write(ring_buffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % RX_BUF_SIZE; if (next ! rb-tail) // 判断缓冲区是否已满 { rb-buffer[rb-head] data; rb-head next; } } // 从缓冲区读取一个字节在主循环里调用 uint8_t ring_buf_read(ring_buffer_t *rb) { uint8_t data 0; if (rb-tail ! rb-head) // 判断缓冲区是否为空 { data rb-buffer[rb-tail]; rb-tail (rb-tail 1) % RX_BUF_SIZE; } return data; } // 判断缓冲区是否有数据 uint8_t ring_buf_has_data(ring_buffer_t *rb) { return rb-tail ! rb-head; }中断回调函数改成这样void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { ring_buf_write(uart1_rb, rx1_byte); HAL_UART_Receive_IT(huart1, rx1_byte, 1); } else if (huart-Instance USART2) { ring_buf_write(uart2_rb, rx2_byte); HAL_UART_Receive_IT(huart2, rx2_byte, 1); } }这里rx1_byte和rx2_byte是两个独立的单字节变量。主循环里只需要检查ring_buf_has_data(uart1_rb)就能知道有没有新数据。我在实际项目中使用这种结构配合115200波特率即使主循环里在做耗时的Flash擦写操作串口数据也不会丢失。如果你的设备发送频率更快、数据量更大可以把RX_BUF_SIZE调到512或1024。4.4 printf重定向到指定串口而不是默认串口很多人在工程里用printf输出调试信息很简单重写一下fputc函数就行。但双串口场景下你得决定printf到底往哪个串口输出。默认的工程里fputc会被重定向到USART1或USART2取决于你重写时用的句柄。我的做法是用printf输出到调试串口USART2业务串口USART1则通过HAL_UART_Transmit手动发送。这样代码稍加改造一下#include stdio.h int fputc(int ch, FILE *f) { HAL_UART_Transmit(huart2, (uint8_t *)ch, 1, 0xFFFF); return ch; }然后在Keil工程选项里勾选Use MicroLIB或者在STM32CubeIDE里配置--specsnano.specsprintf就能正常工作了。这样上位机收业务数据串口助手收调试日志两边互不干扰。4.5 发送端也要注意阻塞发送和中断发送的区别接收说完再说发送。HAL库提供了两个发送函数HAL_UART_Transmit是阻塞发送HAL_UART_Transmit_IT是中断发送。阻塞发送会一直占用CPU直到数据全部发完。如果数据量不大、发送频率不高用阻塞发送完全没问题。但如果你的程序里有精确时延要求或者需要一边发数据一边处理其他任务就需要用中断发送它不会卡住主循环。双串口同时发送的场景比较少见通常是一个串口被动接收、另一个串口主动上报数据。因为两个串口是独立的硬件外设它们同时发送互不干扰所以这里不用做互斥锁之类的处理。但如果两个串口共用了一根数据线或同一个DMA通道那才需要额外关注。5. 常见问题与排查技巧实录5.1 为什么改了引脚后串口还是不通这是最典型的双串口陷阱。你用CubeMX把USART1从PA9/PA10改到了PB6/PB7代码也重新生成了但通电后就是不通。排查思路按下面顺序来先看芯片视图上PB6、PB7有没有变绿。如果没有变绿说明引脚复用没有配成功重新检查CubeMX配置。再看代码里HAL_UART_MspInit函数确认GPIO初始化用的是GPIO_AF1_USART1如果这里填的是GPIO_AF1_USART2之类的错值引脚配置就是错的。另外ST-Link或下载器占用串口也是坑。比如你在Standard Library时代可能用过SWD的引脚作为串口如果复用了SWDIO引脚做串口下载器就没法连上了。解决方法是换其他引脚或者在代码里禁用JTAG功能两种方法选一种就行。5.2 一个串口乱码另一个完全正常如果USART1收到数据正常USART2却是乱码问题基本出在波特率上。记住一个原则波特率误差超过2%就可能产生通信错误。常见模块的波特率是9600、115200误差来源主要是时钟源。STM32的USART1挂载在APB2上72MHzUSART2挂载在APB1上36MHz。如果你的系统时钟不是标准的72MHz比如外部晶振用了8MHz以外的频率CubeMX会自动计算分频这时要检查计算出来的实际波特率跟目标波特率差多少。还有一种诡异的情况板子上存在第二路USB转串口它的RX和TX接到了你正在调试的串口引脚上形成回流干扰。在我的调试经历里这种问题最多表现为拔掉一个USB转串口后另一个串口就正常了。5.3 中断回调只进一次之后再也不进去了这个问题出现过很多次原因基本都是忘了在回调里再次调用HAL_UART_Receive_IT。HAL库的使用机制是调用了接收中断函数才开启接收接收完成进入回调函数如果要继续接收必须再调用一次。这一点官方文档里有注释但看文档的人不多。另外一个原因是中断没有使能。CubeMX工程里MX_USART1_UART_Init只是配置了串口参数不会自动打开接收中断。接收中断的开关是通过HAL_UART_Receive_IT来控制的。所以就算在CubeMX里勾选了NVIC不调用HAL_UART_Receive_IT中断接收依然不会工作。5.4 主循环里的延时导致数据丢失怎么办如果你在主循环里用了HAL_Delay(500)两个串口同时有高频数据进来中断接收会把数据放入环形缓冲区但如果主循环迟迟不去取缓冲区满了之后新数据就会覆盖旧数据。解决思路很直接一是把缓冲区加大给主循环更多时间去消费数据二是减少阻塞延时把延时逻辑改为非阻塞的时间片调度三是用DMA接收配合空闲中断直接把一大包数据搬进内存这样对CPU的依赖更小。在这几种方案里我项目中最常用到的是第三种。DMA接收适合数据量大的场景但代码复杂度也更高新手可以先从加大缓冲区和优化主循环做起。5.5 双串口常见问题速查表现象可能原因排查顺序两个串口都不通波特率配置错误 / 时钟异常检查CubeMX时钟树确认HSE、PLL倍频USART1通USART2不通引脚复用配置错 / 对应时钟没开检查HAL_UART_MspInit里GPIO和时钟乱码波特率误差大 / 共地问题用示波器或逻辑分析仪看波形实际波特率中断只进一次没有重调HAL_UART_Receive_IT在回调里再次调用数据丢字节主循环处理慢 / 缓冲区太小加大缓冲区或改用DMA收到多余字节RX引脚悬空或受干扰加上拉电阻检查接线是否牢固5.6 我调试双串口积累的几个小习惯第一调试串口先用固定数据做回环测试。接好硬件后把TX和RX短接用串口助手发什么收到什么确认串口本身没问题再接外部设备这样能快速隔离问题源。第二使用逻辑分析仪排查通信波形。虽然示波器效果更好但逻辑分析仪便宜且能自动解析串口数据很适合在外面调试时快速看问题。第三给每个串口的调试输出加一个前缀。例如USART1的打印信息统一加[U1]USART2的加[U2]这样在处理双串口交互时能一眼看出消息来自哪个串口。双串口本身并不复杂核心就三件事引脚分配要提前规划中断接收逻辑要正确缓冲区要足够。只要把这三件事想清楚了剩下的都是常规配置流程。我在实际项目中的习惯是先把串口打通再写应用层逻辑这样出问题时能快速定位是底层通信的问题还是上层处理的问题。如果你在配PA9/PA10或PA2/PA3之外遇到了其他引脚选择上的问题不妨先回头看看我之前提到的重映射原则大多数困扰其实都出在临时改动上。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑