简介:STM32F103双串口收发工程是一份面向嵌入式开发者、已调试通过的实例代码,解决在单个微控制器上同时使用两个串口收发数据的需求,适合设备控制、上位机通信、调试信息输出等场景。资源采用结构体版本的实现方式,可将串口号、波特率、收发引脚等统一配置,并包含USART初始化、发送、接收等核心函数,对时钟配置、BRR波特率计算、数据格式与中断使能有清晰说明,便于维护和扩展。压缩包共143个文件、约2.21MB,以C源码和H头文件为主,同时包含Keil MDK工程文件(uvprojx/uvoptx)、编译生成的axf/hex、map/lst及中间文件,可在MDK中直接打开、编译和烧录验证。已有2692人学习浏览。借助该工程,初学者能理解STM32标准外设库的双串口驱动程序结构,开发者也能快速复用稳定的收发流程,缩短开发周期。
1. 双串口收发到底在解决什么问题
我接手过不少 STM32F103 的小项目,很多同事一上来就只开一个串口,用来打印日志,结果真的要把数据传给下一级设备时,才发现串口不够用。标题里写“stm32f103双串口收发(调试通过)”,说白了就是让 USART1 和 USART2 同时工作,一个口负责和上位机或传感器通信,另一个口负责和调试终端或另一块板子通信,两个口互不干扰,收发都能跑通。这个需求在 IOT 网关、Modbus 转发、GPS 解析同时透传的场景里非常常见。本文会从硬件选型开始,给出可复现的初始化、中断接收、数据转发代码,再把我实际调试时踩过的乱码、丢字节、互锁、复位丢帧这些问题全部列出来,按“现象-原因-解决”的方式讲清楚。适合手上有板子、想直接跑双串口,或者已经跑通但总出怪问题的开发者。
2. 选型与接线:为什么是 USART1 + USART2,以及硬件上的坑
2.1 先定串口资源:USART1 和 USART2 的引脚分布
STM32F103 里串口不止两个,但双串口方案最常用的是 USART1 和 USART2,原因是引脚好接,和调试工具冲突少。USART1 的 TX 在 PA9,RX 在 PA10;USART2 的 TX 在 PA2,RX 在 PA3。这两组引脚在 LQFP48、LQFP64 封装上都有,即使是最小系统板也能直接引出。USART3 的 PB10/PB11 虽然也能用,但很多板子上这两个引脚被其它外设占用,干扰源多。我一般优先选 USART1 + USART2,一个用于与 PC 通信,一个用于与外部设备通信。
还需要注意 AFIO 时钟。使用 HAL 库时,如果只开启 USART 时钟而不开启 GPIO 时钟,引脚不会有输出;如果用的是重映射引脚,还要额外__HAL_RCC_AFIO_CLK_ENABLE()和__HAL_AFIO_REMAP_USARTx_ENABLE()。使用默认引脚时不需要重映射,但 GPIO 的复用推挽配置一定不能少。下面这段是常见做法:
__HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_USART1_CLK_ENABLE(); __HAL_RCC_USART2_CLK_ENABLE(); GPIO_InitTypeDef gpio = {0}; gpio.Pin = GPIO_PIN_9 | GPIO_PIN_10; gpio.Mode = GPIO_MODE_AF_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); gpio.Pin = GPIO_PIN_2 | GPIO_PIN_3; HAL_GPIO_Init(GPIOA, &gpio);这段代码的关键点是:PA9 和 PA10 复用为 USART1,PA2 和 PA3 复用为 USART2。如果只配置了 USART 外设,GPIO 没配成 AF_PP,发送会没有波形,接收也会一直读到高电平。
2.2 最小硬件连接:3.3V 电平与 TTL 转 USB 模块
双串口调试时,你至少需要两个 TTL 转 USB 模块,或者一个支持双串口的调试工具。STM32F103 的串口电平是 3.3V TTL,不能直接连 RS232 电平,也不能直接连 5V 的串口设备,否则长期工作可能烧引脚。常见做法是使用 CP2102 或 CH340 模块,模块的 TX 接 STM32 的 RX,模块的 RX 接 STM32 的 TX,地线必须共地。GND 不接会导致现象非常奇葩:能识别到串口,但发出去的数据完全乱码,或者只有第一次能收到。
还要注意,如果你用的是带 5V 电平输出的 USB 转 TTL 模块,比如某些国产板载模块,最好先确认模块方向。多数模块默认是 3.3V/5V 跳线可选,调试 STM32F103 时把跳线放在 3.3V 侧。两个 USB 模块同时插在电脑上,Windows 会枚举出两个 COM 口,最好在设备管理器里记住对应关系,或者用标签贴在模块上。否则你会在调试时反复猜自己到底在跟哪个口说话。
2.3 硬件接线最容易翻车的三个细节
第一,串口交叉接线。TX 对 RX、RX 对 TX,这个已经是常识,但实际用杜邦线跑双串口时,很容易把两根线插反。我建议每个串口用不同颜色的线,USART1 的 TX 用红色,RX 用黑色;USART2 的 TX 用黄色,RX 用绿色,这样一眼能看出当前接的是哪个口。
第二,模块供电问题。TTL 转 USB 模块一般从 USB 取电,如果模块还带着 3.3V 输出,而你用它给 STM32 板子供电,串口线又同时连接,容易形成回流。最稳的做法是 STM32 板子单独供电,USB 转 TTL 只接 TX、RX、GND,不接 VCC。如果必须从模块取电,确认模块的 LDO 能提供足够电流。
第三,串口地和外部设备地没连。双串口里有一个串口接的是外部传感器或单片机,如果外部设备和 STM32 各自独立供电,且没有共地,会出现接收数据偶尔正确、偶尔错位的现象。用示波器看波形时,看摆幅是否在 0~3.3V 之间,如果低电平抬高到 1.5V 左右,基本就是地电位不一致。
3. 串口驱动配置:从初始化到中断收发的完整代码
3.1 HAL 库串口初始化:参数怎么设,时钟怎么开
初始化串口时,除了 GPIO 复用,还要设置波特率、字长、停止位、校验位和硬件流控。对于常用调试场景,我一般设成 115200-8-N-1,也就是波特率 115200,字长 8 位,无校验,1 位停止位。如果外部设备只支持 9600,那另一个串口单独设成 9600,两个串口的波特率可以不同,各有各的huart句柄。
UART_HandleTypeDef huart1; UART_HandleTypeDef huart2; void MX_USART1_UART_Init(void) { huart1.Instance = USART1; huart1.Init.BaudRate = 115200; huart1.Init.WordLength = UART_WORDLENGTH_8B; huart1.Init.StopBits = UART_STOPBITS_1; huart1.Init.Parity = UART_PARITY_NONE; huart1.Init.Mode = UART_MODE_TX_RX; huart1.Init.HwFlowCtl = UART_HWCONTROL_NONE; huart1.Init.OverSampling = UART_OVERSAMPLING_16; HAL_UART_Init(&huart1); }OverSampling参数容易被忽略。默认 16 倍过采样,抗干扰能力更好;如果为了拉高波特率改成 8 倍,线缆稍长就容易出错。双串口同时收发时,我建议两个口都保留 16 倍过采样,波特率最高撑到 460800 足够常见场景使用,不要为了“看起来快”去牺牲稳定性。还有一点,HAL 库的HAL_UART_Init内部会调用HAL_UART_MspInit,如果你没有在MspInit里配置 GPIO 和中断使能,外设初始化会不完整。很多人把 GPIO 初始化写在用户代码里,然后发现干活不对,原因就在这。
3.2 中断接收的实现:单字节中断与环形缓冲
双串口同时收数据,如果不用中断,主循环轮询HAL_UART_Receive会阻塞,两个口互相拖累。正确做法是接收用中断,每收到一个字节就存进缓冲区,主循环或者发送中断时再把数据处理掉。常见做法是每个串口配一个环形缓冲区,我这里给一个简化版:
#define RX_BUF_SIZE 256 volatile uint8_t rx1_buf[RX_BUF_SIZE]; volatile uint8_t rx1_head = 0; volatile uint8_t rx1_tail = 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { uint8_t next_head; if (huart->Instance == USART1) { next_head = (rx1_head + 1) % RX_BUF_SIZE; if (next_head != rx1_tail) { rx1_buf[rx1_head] = rx1_last_byte; // 已经在调用前保存 rx1_head = next_head; } } }上面写法有个前提,rx1_last_byte需要在接收中断的另一个回调里保存。更干净的写法是直接用 HAL 库提供的中断接收函数:HAL_UART_Receive_IT(&huart1, &rx1_temp, 1),每次接收一个字节,接收完进入回调,然后在回调里再次调用HAL_UART_Receive_IT开启下一次接收。注意,这里有个嵌套调用:回调里不能等太久,否则下一个字节来了,而Receive_IT还没重新开启,数据就丢了。这个细节是双串口丢字节最经典的原因之一。
我建议的做法是在初始化时对每个串口调用一次HAL_UART_Receive_IT(&huart1, &rx1_temp, 1);,然后在各自的回调里再把rx1_temp存入自己的缓冲区,同时把接收函数重新准备好。两个串口的回调入口是同一个HAL_UART_RxCpltCallback,所以在这个函数里用huart->Instance区分是谁触发的,这是双串口跑起来的关键。
3.3 发送的三种方式:阻塞、中断、DMA 怎么选
发送方式决定了主循环能不能跑得快。HAL_UART_Transmit是阻塞发送,发送期间 CPU 会一直等待每个字节发完。如果波特率 115200,发送 10 个字节大约耗时 0.9ms,看起来不长,但如果主循环还有其他实时任务,比如 ADC 采样,就会感到明显卡顿。双串口转发场景里,我不会在中断回调里用阻塞发送,因为中断里等发送会让串口接收中断被长期占用,两个口互相阻塞。
中断发送HAL_UART_Transmit_IT是异步的,发送完成会有HAL_UART_TxCpltCallback回调。适合发送不定长数据,但需要保证发送缓冲区在发送完成前不被改写,不然会出现发到一半数据被覆盖的怪问题。DMA 发送适合大块数据,比如把一段 sensor 原始数据直接转发到另一串口,但需要开 DMA 时钟、配置 DMA 通道,两个串口占用不同的 DMA 通道,还要注意 DMA 中断优先级。我的建议是:双串口转发量不大时,优先用中断接收 + 中断发送;如果数据量大,接收端用 DMA + 空闲中断,发送端用 DMA。中间的“转发量不大”定义为每秒不超过几千字节,这类场景占大多数。
这里给一个中断发送的完整最小流程:
uint8_t tx_buf[64]; void send_uart1(uint8_t *data, uint16_t len) { memcpy(tx_buf, data, len); HAL_UART_Transmit_IT(&huart1, tx_buf, len); }注意,tx_buf是全局变量,send_uart1返回后HAL_UART_Transmit_IT还会慢慢发,期间不能再调用send_uart1,否则缓冲区被改。常见的处理是加一个uart1_busy标志,发送回调里清标志,下次要发新的数据时先检查标志,如果忙就排队。这个标志是实现双串口稳定转发的核心,也是很多新手容易忽略的地方。
4. 双串口之间的数据转发:状态机与消息分割
4.1 最简转发逻辑:收到什么发什么,有什么坑
把 USART1 收到的每个字节原样转发到 USART2,反过来也一样,这是很多人理解的“双串口透传”。但这看着简单,实际有一堆问题。最直接的坑是:如果在HAL_UART_RxCpltCallback里直接调用HAL_UART_Transmit_IT(&huart2, &rx_byte, 1),当数据连续到达时,上一次Transmit_IT还没完成,下一次Receive_IT又触发了,而第二次的数据没地方保存,并且Transmit_IT在忙时会返回HAL_BUSY,数据直接丢。所以收发必须各自有缓冲区或者队列,不能一字节一字节同步转发。
我见过很多翻车现场:用阻塞发送转发一字节,结果一帧 64 字节的数据转发完,接收缓冲区溢出,只前 30 字节正常,后面的全部丢失。原因就是转发速度慢于接收速度,而且没有流控。所以最简转发逻辑的正确形态是:接收中断只管把字节放进环形缓冲区,主循环里读取缓冲区,再通过一个发送队列把数据交给串口中断。主循环的执行频率决定了转发吞吐量,通常主循环跑 1ms 一次,115200 波特率下完全够用。
void main_loop(void) { uint8_t ch; while (ring_pop(&rx1_ring, &ch)) { uart2_send_queue_push(ch); } while (ring_pop(&rx2_ring, &ch)) { uart1_send_queue_push(ch); } }这段代码的含义是:两个收环缓冲各管各的,主循环把收到的字节交给对方的发送队列。发送队列再通过串口中断每次发送一个字节,发完自动取下一个。这样做虽然看起来土,但非常稳,不会出现一个串口忙时另一个串口被阻塞的情况。
4.2 用状态机拆帧:帧头帧尾与长度约定
如果只是简单转发,不需要拆帧。但通常情况下,你需要识别一帧完整的消息,比如从传感器串口收到的数据是AA 55 01 02 03 04 7E,你需要从流式数据里把AA 55当作帧头,7E当作帧尾,中间 4 个字节是有效数据。拆帧最稳的方法是用状态机,而不要用缓冲区里去找“帧头”然后截取。用状态机的好处是:无论数据是否对齐,状态机都能正确同步。
typedef enum { FRAME_WAIT_HEAD1, FRAME_WAIT_HEAD2, FRAME_DATA, FRAME_TAIL } frame_state_t; frame_state_t uart2_frame_state = FRAME_WAIT_HEAD1; uint8_t rx2_frame_buf[64]; uint16_t rx2_frame_len = 0; void uart2_frame_parse(uint8_t byte) { switch (uart2_frame_state) { case FRAME_WAIT_HEAD1: if (byte == 0xAA) uart2_frame_state = FRAME_WAIT_HEAD2; break; case FRAME_WAIT_HEAD2: if (byte == 0x55) uart2_frame_state = FRAME_DATA; else uart2_frame_state = FRAME_WAIT_HEAD1; break; case FRAME_DATA: if (byte == 0x7E) { uart2_frame_state = FRAME_WAIT_HEAD1; // 处理完整帧 } else if (rx2_frame_len < sizeof(rx2_frame_buf)) { rx2_frame_buf[rx2_frame_len++] = byte; } break; } }状态机的参数设置:帧头可以定义成两个字节,是为了降低误判率;如果只需要单字节帧头,状态可以合并为两个状态。rx2_frame_len在帧头识别成功后要清零,否则上一次的数据会混进来。帧尾以外的其他字节如果和帧头相同,比如数据里出现0xAA,需要判断是否紧跟0x55,如果是就当新帧处理。这个细节不处理,会出现数据错位。状态机的好处是处理到一半出错时,不需要清空整个缓冲区,只要回到等待帧头状态即可。
另一种常见方案是长度字段:在帧头后加一个字节表示有效数据长度,比如AA 55 04 01 02 03 04 7E,其中04表示后面有 4 个数据字节。这种方案状态机会多一个FRAME_LEN状态,记录收到指定字节数后直接跳到帧尾校验。这两种方案在双串口场景里都很好用,推荐首选帧头 + 长度 + 校验,比单纯帧头帧尾更不容易错判。
4.3 双串口共用中断优先级要注意的实时性问题
STM32F103 的 NVIC 支持抢占优先级和子优先级。USART1 的中断向量是USART1_IRQn,USART2 是USART2_IRQn。双串口同时工作时,如果优先级设置不当,其中一个串口可能长时间得不到响应。比如 USART1 抢占优先级设成 0,USART2 设成 1,当 USART2 正在接收时来了 USART1 中断,USART1 会打断 USART2 的接收处理,如果 USART2 的HAL_UART_Receive_IT还没处理完,数据就会丢。
我一般把两个串口的抢占优先级设成相同,子优先级不同,比如 USART1 抢占 1、子优先级 0,USART2 抢占 1、子优先级 1。这样两个串口不会互相打断。如果配有 DMA,还要把 DMA 中断优先级设置成不高于串口中断,否则会出现“接收中断已经把数据放入缓冲区,但 DMA 传输完成中断还没处理”的情况,逻辑上是没问题,但会增加延迟。在 HAL 库中,中断抢占优先级初始化要放在HAL_UART_Init之后,或者放在HAL_UART_MspInit中。错误地把 NVIC 设置在初始化之前,虽然大多时候不影响,但在极端时序下会导致第一个中断无法进入。
另外一个容易被忽略的点是:两个串口的HAL_UART_RxCpltCallback里都有可能重新调用HAL_UART_Receive_IT,这个调用必须在中断上下文中执行,不能延迟到主循环。很多人为了“减少中断占用时间”,在回调里只设一个标志,然后回主循环再开下一次接收。这样做在单串口时没问题,双串口时一旦主循环被另一串口的数据阻塞,接收就停了。所以必须坚持“处理一个字节后马上开下一次接收”的铁律,处理的部分可以非常轻量,比如只入队,不解析。
5. 调试记录与避坑清单(现象-原因-解决)
5.1 串口一直接收乱码
现象:两个串口互相发送,接收到的数据全是0xDF、0x0F之类无规律字节,偶尔能对几个。原因:波特率不匹配是最常见的,但双串口里还有另一种情况:USART1 的发送引脚 PA9 和 USART2 的接收引脚 PA3 都是复用推挽,如果配置 GPIO 时没有逐个初始化,PA2 或 PA3 被错误复用成 USART1 的信号,就会导致数据串扰。解决:检查 GPIO 复用配置,每个串口独立配置引脚;再检查波特率实际值和目标值是否一致,可以用回环测试:把 TX 和 RX 短接,自发自收。如果回环正常但对外乱码,那就是外部设备波特率或电平问题。
5.2 第二个串口完全收不到数据
现象:USART1 收发正常,USART2 打开串口助手后,无论发什么都收不到。原因:USART2 的时钟在某个初始化过程中被关掉了,或者 GPIO 被重新初始化成普通输入。有些板载的调试串口默认占用 USART2 的引脚,比如 PA2 和 PA3 连接了板上的电平转换芯片,跳线没有断开。解决:先量 PA2 和 PA3 的静态电平,如果都是 0V,检查板子原理图,确认跳线是否把这两个引脚连接到别的器件;然后检查HAL_UART_Init返回值,如果返回HAL_BUSY,说明外设没配置干净,需要先DeInit再重新初始化。
5.3 接收中断里丢字节
现象:每秒发送大量数据时,比如 1000 字节,接收端每帧少几个字节,而且丢的位置不确定。原因:在HAL_UART_RxCpltCallback里做了解析或转发,导致中断耗时太长,接收寄存器在新字节到达前还有数据没有取走。解决:回调里只做“存缓冲区 + 重新开启Receive_IT”两件事,解析和转发放到主循环。如果用了 DMA,检查 DMA 循环模式和空闲中断是否都开启。单独使用普通接收中断时,中断响应时间要优于一个字节时间,115200 波特率下单字节时间为 86.8 微秒,中断回调里超过这个时间就会丢。
5.4 发送阻塞导致整个程序卡死
现象:转发数据过程中,主循环偶尔卡住,之后外部指令完全没反应。原因:某个串口的发送队列满了,而发送中断发送速度或者服务方没有及时取走数据,导致队列阻塞。常见做法是使用阻塞发送HAL_UART_Transmit,发送 1 个字节也有一定耗时,一旦在中断里调用就会让中断挂起。解决:发送统一走队列机制,如果队列满了,丢弃最旧的数据而不是阻塞等待;中断发送的回调里继续发送下一字节,不要在主循环里清队列。对于实时性要求高的场景,可以适当提高串口中断优先级,缩短发送队列的刷新时间。
5.5 复位后第一次收到的第一帧数据总是丢
现象:上电后发送端先发一帧,接收端收不到,等发送端再发一帧,数据正常。原因:初始化完成后,HAL_UART_Receive_IT已经开启,但此时 gpio 刚完成配置,外部设备已经提前发送了一帧,帧头被接收中断取走后放入缓冲区,但程序主循环还没来得及处理,下一帧的数据已经在缓冲区里积累。另一个相关原因是使用 HAL 库时,HAL_UART_Receive_IT注册是在初始化里,但上电瞬间发送端和接收端的电平都在变化,可能触发一次假中断。解决:上电后先清空接收缓冲区,延迟 200ms 再让外部设备发数据。可以这样处理:初始化末尾调用一个clear_rx_buf()函数,把环形缓冲区所有指针清零,并且忽略这段时间产生的标志。
6. 收尾:一个让双串口更稳的小技巧
用串口空闲中断可以大幅降低 CPU 的解析负担。所谓空闲中断,是指串口在收到一字节后,总线出现一段空闲时间(超过一个字节时长),就断言当前一帧接收完成。在双串口转发场景中,很多数据帧不定长,但帧与帧之间存在间隔。使用空闲中断配合 DMA,可以让 DMA 自动收集一帧数据,然后由程序处理。
具体做法是配置 DMA 为循环模式,接收缓冲区一般为 256 字节或 512 字节,每次 DMA 传输后,通过HAL_UARTEx_ReceiveToIdle_DMA启动接收。收到数据后,寄存器里会有一个NDTR值,表示还有多少空间没填,用“缓冲区总大小 - NDTR”就知道这一次收到多少字节。两个串口同时使用 DMA 时,注意区分 DMA 通道:USART1 的 DMA 接收通道是 DMA1_Channel5,USART2 的 DMA 接收通道是 DMA1_Channel6。如果使用了HAL_UARTEx_ReceiveToIdle_DMA,接收中断回调里会有两个入口:一个是半传输回调,一个是空闲回调,实际处理数据应该在空闲回调里。
void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart->Instance == USART1) { memcpy(uart1_rx_frame, uart1_dma_buf, Size); // 处理完整帧 } }这个技巧让我在调试跑 921600 波特率的双串口转发时,再也没有出现过丢字节。原因很简单:普通中断每收到一个字节都要进一次中断,而空闲中断加 DMA 让整个帧只在完成时进一次中断。但是要注意,空闲中断在一直接收数据、不定时发数据的环境下会失效,因为总线始终不空闲,所以适合通信间隔明显的场景。
回忆起我最早调双串口时,就是因为忘了在接收回调里重新开启HAL_UART_Receive_IT,浪费了一整天。解决这个问题后,双串口收发变得非常简单。后来我每次写完代码,都先自查三件事:一是两个串口的 NVIC 优先级是否冲突;二是接收中断回调是否只做轻量操作;三是发送是否用了队列,而不是直接在中断里阻塞。做到这三点,双串口基本不会出怪问题。希望帮到你。
本文还有配套的精品资源,点击获取