简介:这份资源面向STM32嵌入式开发初学者与需要多串口通信方案的工程师,提供STM32F103双串口收发功能的完整工程实现,且已调试通过可直接运行。工程采用结构体方式组织串口配置,将USART外设、波特率、收发引脚等参数封装管理,便于扩展与维护,适合设备控制、调试监控等需同时进行串口交互的场景。压缩包共143个文件,约2.21MB,包含34个h头文件、31个c源文件,以及Keil工程相关的uvprojx、uvoptx、sct、hex、axf等编译与烧录文件,另有若干备份与中间产物,覆盖从源码到可执行固件的完整链路。目前已有2692人学习下载,读者可参考其串口初始化流程、收发函数封装与主循环验证逻辑,快速搭建双串口通信框架,并在此基础上结合HAL或LL库做进一步抽象与移植。
1. 双串口到底难在哪:从一块调试通过的工程说起
很多人第一次在 STM32F103 上跑双串口,都会卡在一个反直觉的地方:代码编译通过、下载进去、单串口收发也正常,可一旦把 USART2 也打开,要么第二个串口死活没反应,要么第一个串口开始丢数据,甚至整块板子像死机一样。问题往往不在“串口本身”,而在时钟、中断优先级和收发缓冲这三件事上。这份 stm32f103 双串口收发工程是调试通过的完整实现,核心价值就是给出一套能同时跑通 USART1 和 USART2 的稳定框架,适合正在做多路通信、调试口加业务口、或者上位机加模块透传的嵌入式开发者。它解决的不是“怎么初始化串口”这种入门问题,而是“两个串口同时工作时怎么不互相拖死”的工程问题。下面我按实际拆工程的顺序,把配置、收发、避坑和验证一层层讲清楚。
2. 时钟与引脚配置:为什么你的第二个串口总是没反应
2.1 先确认时钟树,再谈串口初始化
STM32F103 的串口挂在不同总线上,USART1 在 APB2,USART2 在 APB1。很多人写代码时只开了 GPIO 和 USART 的时钟,却忘了 AFIO 或者把 APB1 的时钟使能写错位置,结果就是 USART2 的寄存器写进去没反应。常见做法是先把 RCC 相关使能集中写在一个函数里,别散落在各个初始化函数中,否则后期加串口很容易漏。
// 串口时钟与GPIO时钟统一使能,避免漏开 void UART_Clock_Init(void) { // USART1 在 APB2,USART2 在 APB1 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // 如果用到重映射或者外部中断,AFIO时钟也要开 RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); }这段代码的逻辑很直接:USART1 和 GPIOA 走 APB2,USART2 走 APB1,AFIO 也挂在 APB2。参数上唯一要注意的是,如果你用的是 STM32F103C8T6 这类小容量芯片,USART2 的引脚是 PA2/PA3,不需要重映射;但如果你用的是其他封装,PA2/PA3 可能被其他功能占用,这时候就要查数据手册确认是否要重映射。我一般会在时钟初始化后加一句读回 RCC 寄存器的操作,确认使能位真的置上了,这招在排查“配置写了但没生效”时特别管用。
2.2 GPIO 模式别抄错:复用推挽和浮空输入的区别
串口引脚配置翻车最多的地方,是把 TX 配成复用推挽、RX 配成浮空输入或上拉输入,这个没错,但有人把 TX 也配成浮空输入,结果就是发不出去。还有人在 USART2 上直接复制 USART1 的 GPIO 配置,忘了改引脚号。下面这段是双串口 GPIO 的典型写法。
void UART_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // USART1 TX - PA9 复用推挽 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART1 RX - PA10 浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART2 TX - PA2 复用推挽 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_Init(GPIOA, &GPIO_InitStructure); // USART2 RX - PA3 浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); }逻辑说明:TX 必须配成复用推挽,因为串口发送是外设控制引脚;RX 配成浮空输入或上拉输入都可以,浮空更常见。参数上 GPIO_Speed 选 50MHz 是习惯做法,其实串口速率不高,2MHz 也够,但统一写 50MHz 不容易出玄学问题。这里有个细节:如果你在 USART2 上外接了一个需要上拉的模块,RX 最好配上拉输入,否则空闲时引脚可能漂移,导致误触发接收中断。我一般会在原理图确认外部有没有上拉,没有的话就在代码里开内部上拉。
2.3 波特率计算:别让误差把你坑了
STM32F103 的串口波特率是靠 APB 时钟分频得到的,USART1 用 PCLK2,USART2 用 PCLK1。很多人只改波特率数值,却没注意两个串口挂在不同总线上,导致实际波特率偏差很大。比如系统时钟 72MHz 时,PCLK2 是 72MHz,PCLK1 是 36MHz,同样设 115200,USART1 和 USART2 的分频值完全不同。标准库的 USART_Init 会自动算,但如果你用的是寄存器版或者 HAL 库,就要确认时钟配置对不对。
void UART_Config(void) { USART_InitTypeDef USART_InitStructure; // USART1 配置 USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, &USART_InitStructure); // USART2 配置,参数相同但挂载总线不同 USART_Init(USART2, &USART_InitStructure); USART_Cmd(USART1, ENABLE); USART_Cmd(USART2, ENABLE); }这段代码里两个串口共用同一个初始化结构体,参数完全一致,但底层分频会自动按各自总线计算。参数上唯一要留意的是硬件流控制,一般不用就写 None,如果你外接了 RTS/CTS 设备,就要改成对应的模式并额外配置引脚。我一般会在初始化后用一个简单的回环测试确认波特率误差:发一个 0x55,看接收端能不能正确收到,如果收到乱码,先查时钟树,再查波特率误差是否超过 3%。
3. 中断与收发缓冲:两个串口同时来数据怎么不丢
3.1 中断优先级分组:别让两个串口互相打断
双串口最容易翻车的地方就是中断优先级。如果 USART1 和 USART2 的中断优先级设成一样,或者抢占优先级没分好,就会出现一个串口在接收数据时被另一个串口打断,导致数据丢失。常见做法是把两个串口的抢占优先级设成不同,或者用同一个优先级但确保中断服务函数里处理足够快。我一般会把调试口设成高抢占优先级,业务口设成低抢占优先级,这样调试信息不会因为业务数据量大而被延迟。
void UART_NVIC_Init(void) { NVIC_InitTypeDef NVIC_InitStructure; // 优先级分组:2位抢占,2位子优先级 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); // USART1 抢占优先级 1 NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); // USART2 抢占优先级 2 NVIC_InitStructure.NVIC_IRQChannel = USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_Init(&NVIC_InitStructure); }逻辑说明:NVIC_PriorityGroup_2 表示 2 位抢占优先级和 2 位子优先级,这样 USART1 可以打断 USART2,反过来不行。参数上抢占优先级数值越小优先级越高,所以 USART1 设 1,USART2 设 2。如果你把两个都设成 0,那就没有抢占关系,一个中断服务函数执行时另一个只能等着,数据量一大就丢。这里有个血泪经验:中断服务函数里只做收数据和置标志,别在里面做解析和打印,否则再高的优先级也救不了。
3.2 接收中断里只做一件事:把数据搬进缓冲区
很多人写接收中断,喜欢在中断里直接处理协议,结果就是中断执行时间太长,另一个串口的数据来了没法及时响应。正确的做法是中断里只把 DR 寄存器的数据读出来放进环形缓冲区,主循环再去解析。下面是一个双串口环形缓冲区的简化实现。
#define UART1_BUF_SIZE 256 #define UART2_BUF_SIZE 256 typedef struct { uint8_t buffer[UART1_BUF_SIZE]; volatile uint16_t head; volatile uint16_t tail; } RingBuffer; RingBuffer uart1_rx; RingBuffer uart2_rx; // 写入一个字节到环形缓冲区 void RingBuffer_Write(RingBuffer *rb, uint8_t data) { uint16_t next = (rb->head + 1) % UART1_BUF_SIZE; if (next != rb->tail) { // 缓冲区未满 rb->buffer[rb->head] = data; rb->head = next; } // 满了就丢弃,实际项目可以加溢出计数 } // USART1 中断服务函数 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); RingBuffer_Write(&uart1_rx, data); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } } // USART2 中断服务函数 void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART2); RingBuffer_Write(&uart2_rx, data); USART_ClearITPendingBit(USART2, USART_IT_RXNE); } }逻辑说明:环形缓冲区用 head 和 tail 两个指针,写入时移动 head,读取时移动 tail,不需要搬移数据。参数上缓冲区大小根据你的最大帧长来定,一般 256 够用,如果协议帧超过 256 就要加大。注意这里两个串口用了同一个 RingBuffer 类型,但缓冲区大小宏是分开的,实际项目中最好把大小作为参数传进去,避免写死。我一般会在主循环里定期检查 head 和 tail 的差值,如果持续增长说明消费太慢,需要优化解析逻辑。
3.3 发送别用轮询:双串口下轮询会互相阻塞
单串口时用轮询发送问题不大,但双串口同时发送时,轮询会阻塞 CPU,导致另一个串口的接收中断响应变慢。常见做法是发送也用中断或者 DMA,至少要把发送做成非阻塞。下面是一个简单的发送中断实现,用发送完成中断来驱动下一个字节。
typedef struct { uint8_t *data; uint16_t len; volatile uint16_t index; volatile uint8_t busy; } UartTx; UartTx uart1_tx; UartTx uart2_tx; // 启动发送 void UART_Send_Start(USART_TypeDef *USARTx, UartTx *tx, uint8_t *data, uint16_t len) { if (tx->busy) return; // 上一次还没发完 tx->data = data; tx->len = len; tx->index = 0; tx->busy = 1; USART_ITConfig(USARTx, USART_IT_TXE, ENABLE); // 开TXE中断 } // USART1 中断里处理发送 void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t data = USART_ReceiveData(USART1); RingBuffer_Write(&uart1_rx, data); USART_ClearITPendingBit(USART1, USART_IT_RXNE); } if (USART_GetITStatus(USART1, USART_IT_TXE) != RESET) { if (uart1_tx.index < uart1_tx.len) { USART_SendData(USART1, uart1_tx.data[uart1_tx.index++]); } else { USART_ITConfig(USART1, USART_IT_TXE, DISABLE); uart1_tx.busy = 0; } USART_ClearITPendingBit(USART1, USART_IT_TXE); } }逻辑说明:发送中断用 TXE 标志,每发一个字节触发一次,发完最后一个字节后关掉 TXE 中断并清 busy 标志。参数上 data 指针要指向全局或静态缓冲区,别指向局部变量,否则发送还没完局部变量就失效了。这里只写了 USART1 的中断服务函数,USART2 同理,但要注意两个中断服务函数里都要处理接收和发送,别只处理一个。我一般会在发送启动函数里加一个忙判断,如果上一次没发完就直接返回,避免数据被覆盖。
4. 避坑与排查:双串口调试中最容易翻车的五个点
4.1 现象:USART2 能发不能收,或者收几个字节就卡死
原因:最常见的是 GPIO 配置错了,PA3 没配成浮空输入,或者配成了复用推挽。另一个可能是 USART2 的中断没使能,或者 NVIC 里 USART2_IRQn 写成了 USART1_IRQn。还有一种情况是接收中断里忘了清标志,导致中断反复触发,程序卡在中断里出不来。
解决:先确认 PA3 的 GPIO_Mode 是 GPIO_Mode_IN_FLOATING 或 GPIO_Mode_IPU,再检查 USART_ITConfig(USART2, USART_IT_RXNE, ENABLE) 有没有调用,最后确认中断服务函数里调用了 USART_ClearITPendingBit。我一般会在中断入口翻转一个 IO 口,用示波器看有没有进中断,这招比打印快。
4.2 现象:两个串口同时收发时,数据随机丢失
原因:中断优先级没分好,或者中断服务函数执行时间太长。如果两个串口抢占优先级相同,一个中断执行时另一个只能等,等的时间超过一个字节的接收时间就会丢数据。另外,如果在中断里调用了 printf 或者做了复杂运算,也会导致丢数据。
解决:把两个串口的抢占优先级设成不同,高优先级的给数据量大的那个。中断服务函数里只做搬数据,解析放到主循环。如果数据量实在大,考虑用 DMA 接收,但 DMA 和中断的配合要小心,别在 DMA 传输完成中断里做太多事。
4.3 现象:波特率设成 115200 后,接收到的全是乱码
原因:时钟树配置不对,比如系统时钟没跑到 72MHz,或者 APB1 分频不是 2。STM32F103 的 USART2 挂 APB1,如果 APB1 分频设成 1,PCLK1 就是 72MHz,但标准库默认 APB1 是 2 分频,所以 PCLK1 是 36MHz。如果你手动改了分频系数,波特率就会偏。
解决:用 RCC_GetClocksFreq 读一下实际时钟,确认 PCLK1 和 PCLK2 的值。然后算一下波特率误差,公式是误差 = (实际波特率 - 目标波特率) / 目标波特率,超过 3% 就容易乱码。我一般会在初始化后发一个固定字节,用逻辑分析仪抓一下波形,量一下位宽,比算公式直观。
4.4 现象:串口发送正常,但接收不到任何数据
原因:RX 引脚没配置,或者配置成了输出模式。还有一种可能是外部设备的地线没共地,或者电平不匹配。STM32F103 是 3.3V 电平,如果外接 5V 设备,RX 引脚可能被拉高烧掉,或者根本识别不了。
解决:先确认 RX 引脚配置为输入模式,再用万用表量一下空闲时的电平,应该是 3.3V 左右。如果外接 5V 设备,加一个电平转换电路,或者至少串一个电阻。我一般会在 RX 引脚上并一个 LED 或者用示波器看有没有波形,没有波形就是硬件问题,别死磕代码。
4.5 现象:程序下载后第一次运行正常,复位后就不行了
原因:初始化顺序有问题,比如先使能了串口再配置 GPIO,或者中断使能太早,导致一上电就进中断。还有一种可能是全局变量没初始化,环形缓冲区的 head 和 tail 是随机值。
解决:把初始化顺序固定为时钟、GPIO、串口参数、中断、最后使能串口。全局变量要显式初始化为 0,别依赖编译器。我一般会在 main 函数开头加一个延时,等电源稳定后再初始化外设,这招在电池供电或者电源纹波大的场合特别有用。
5. 验证与进阶:用回环和压力测试确认双串口真的稳
5.1 先做单串口回环,再做双串口交叉验证
调试通过的工程不代表在你的板子上也能跑通,所以拿到代码后第一步是验证。最省事的办法是把 USART1 的 TX 和 RX 短接,发什么收什么,确认波特率和中断都正常。然后 USART2 也做同样的回环。两个都通过后,再做交叉验证:USART1 发数据,USART2 收,或者反过来。交叉验证能暴露中断优先级和缓冲区的问题。
// 简单的回环测试:USART1发,USART1收 void UART1_Loopback_Test(void) { uint8_t test_data[] = "UART1 Loopback Test\r\n"; UART_Send_Start(USART1, &uart1_tx, test_data, sizeof(test_data) - 1); // 等待发送完成 while (uart1_tx.busy); // 检查接收缓冲区 while (uart1_rx.tail != uart1_rx.head) { uint8_t data = uart1_rx.buffer[uart1_rx.tail]; uart1_rx.tail = (uart1_rx.tail + 1) % UART1_BUF_SIZE; // 这里可以把收到的数据再发回去或者打印 } }逻辑说明:这个测试函数先发一串数据,然后等发送完成,再从接收缓冲区里读数据。参数上 test_data 是局部数组,但发送是中断驱动的,所以函数返回前必须等 busy 清零,否则局部数组失效。实际项目中我一般会把测试数据放到全局数组里,避免这种等待。交叉验证时,把 USART1 的 TX 接到 USART2 的 RX,USART2 的 TX 接到 USART1 的 RX,然后两边同时发数据,看能不能都收到。
5.2 压力测试:连续发大包看丢不丢
回环通过后,下一步是压力测试。用上位机或者另一块板子,以最高波特率连续发送 1KB 以上的数据包,同时另一个串口也在收发。观察接收缓冲区的溢出计数,如果溢出计数在涨,说明消费太慢或者中断优先级有问题。我一般会加一个溢出计数变量,在 RingBuffer_Write 里缓冲区满时递增,这样一眼就能看出有没有丢数据。
// 带溢出计数的环形缓冲区写入 volatile uint32_t uart1_overflow = 0; void RingBuffer_Write_With_Count(RingBuffer *rb, uint8_t data) { uint16_t next = (rb->head + 1) % UART1_BUF_SIZE; if (next != rb->tail) { rb->buffer[rb->head] = data; rb->head = next; } else { uart1_overflow++; // 缓冲区满,丢弃并计数 } }压力测试时,如果溢出计数不涨,说明缓冲区够大、消费够快;如果涨了,先加大缓冲区,再优化主循环的解析速度。注意压力测试要跑够长时间,至少几分钟,因为有些丢数据是偶发的,跑几秒看不出来。我一般会跑 10 分钟,同时用串口助手记录发送和接收的字节数,对比是否一致。
5.3 用 DMA 解放 CPU:双串口高负载下的进阶选择
如果你的双串口数据量很大,比如两个串口都在跑 115200 且持续收发,中断方式可能还是会让 CPU 占用偏高。这时候可以考虑 DMA。STM32F103 的 USART1 有独立的 DMA 通道,USART2 也有,但要注意 DMA 通道不能冲突。常见做法是接收用 DMA 循环模式,发送用 DMA 正常模式,配合中断处理半满和完成事件。
// USART1 RX 使用 DMA1 通道5 void UART1_DMA_Init(void) { DMA_InitTypeDef DMA_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel5); DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)uart1_dma_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = UART1_DMA_BUF_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_InitStructure.DMA_Priority = DMA_Priority_High; DMA_InitStructure.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel5, &DMA_InitStructure); DMA_Cmd(DMA1_Channel5, ENABLE); USART_DMACmd(USART1, USART_DMAReq_Rx, ENABLE); }逻辑说明:DMA 接收用循环模式,缓冲区满了自动从头覆盖,所以主循环要及时读走数据。参数上 DMA_BufferSize 是传输数量,不是字节数,这里配成 UART1_DMA_BUF_SIZE 个字节。注意 DMA 和中断不能同时用同一个串口的接收,否则会冲突。我一般会在 DMA 接收上再加一个空闲中断,一帧数据收完后触发空闲中断,在中断里置标志,主循环处理,这样比轮询效率高很多。
从那以后我每次调双串口,都会先把时钟树和中断优先级确认一遍,再跑回环和压力测试,最后才上业务逻辑。这套流程帮我省了很多后悔药。希望帮到你。
本文还有配套的精品资源,点击获取