做嵌入式开发的人,迟早会遇到一个场景:一个串口接调试、一个串口接设备,或者一个串口发日志、一个串口跑协议。尤其是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_RX,CubeMX会自动设置好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。
第一步,在Pinout&Configuration界面中,找到USART1,点击Mode选择Asynchronous,然后系统会自动分配PA9为USART1_TX、PA10为USART1_RX。USART2同理,分配到PA2和PA3。如果你手动调整了引脚,要确认芯片视图上对应的引脚颜色变为绿色。
第二步,在Parameter Settings中设置波特率。USART1我这里设为115200,USART2设为9600。数据位8位、无校验、1位停止位,这是最常用的配置。如果对时间精度要求高,可以勾选Overrun Detection连续接收的错误检测,默认开启即可。
第三步,打开中断。在NVIC Settings里,把USART1 global interrupt和USART2 global interrupt都勾上,优先级默认即可。这里我一般会调整一下:如果USART2是持续收数据的GPS,而USART1只是偶尔打日志,就把USART2的抢占优先级调高一点,避免被高频数据打断时丢失字节。
第四步,配置时钟。在Clock Configuration里确认APB2总线给USART1的时钟是72MHz,APB1总线给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里配置--specs=nano.specs,printf就能正常工作了。这样上位机收业务数据,串口助手收调试日志,两边互不干扰。
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上(72MHz),USART2挂载在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之外遇到了其他引脚选择上的问题,不妨先回头看看我之前提到的重映射原则,大多数困扰其实都出在临时改动上。