2026/10/21 3:26:42

STM32双串口中断接收实战:USART1与USART2同时收数不丢字节

STM32双串口中断接收实战:USART1与USART2同时收数不丢字节 简介这份资源面向STM32嵌入式开发者聚焦串口1与串口2同时中断输入的实现方案适合已掌握GPIO与USART基础、需要处理多数据源通信的初中级工程师。内容围绕STM32F103展开涵盖USART1PA9/PA10与USART2PA2/PA3的GPIO复用初始化、USART参数配置、接收中断使能、中断服务程序编写及NVIC优先级设置等关键环节并给出printf与自定义USER_printf两种串口打印思路便于调试与性能优化。压缩包共122个文件以h头文件、c源文件为主辅以o、d、crf等编译中间文件及uvproj、uvopt工程文件整体约1.11MB工程结构完整可直接参考。目前已有1434人学习下载。读者可据此快速搭建双串口中断收发框架理解中断驱动接收相较轮询的优势并借鉴中断优先级与错误处理策略减少重复调试成本。1. 双串口同时收数一个被低估的嵌入式刚需场景做过 STM32 项目的人多半遇到过这种局面主控要同时跟两个外设对话一个串口接上位机发指令另一个串口接传感器或无线模块回数据两边都是不定长、随时可能来包。你要是把两个串口都塞进主循环轮询稍微来点并发就丢字节你要是只给其中一个开中断另一个就得干等。STM32 串口12 同时中断输入这个需求本质上是让 USART1 和 USART2 各自独立触发接收中断互不阻塞CPU 在任意时刻都能响应任意一路的数据到达。这份资源围绕的就是这个场景它给出了一套可运行的双串口中断接收工程覆盖 GPIO 复用配置、NVIC 优先级分配、中断服务函数里的收数逻辑以及主循环怎么把两路数据取走处理。适合正在做多串口通信、又不想上 DMA 或 RTOS 的开发者也适合刚学完串口中断、想找个完整例子跑通的新手。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序把这份工程拆开讲。2. 工程结构与中断配置两个 USART 怎么各走各的路2.1 目录结构与关键文件拿到一份 STM32 标准库或 HAL 库工程先别急着编译把目录扫一遍能省很多事。这类双串口中断工程常见结构大致如下Project/ ├── CMSIS/ # 内核相关一般不动 ├── FWlib/ │ ├── inc/ │ │ ├── stm32f10x_usart.h │ │ ├── stm32f10x_gpio.h │ │ └── stm32f10x_rcc.h │ └── src/ │ ├── stm32f10x_usart.c │ ├── stm32f10x_gpio.c │ └── stm32f10x_rcc.c ├── User/ │ ├── main.c # 主循环处理两路数据 │ ├── stm32f10x_it.c # 中断服务函数所在地 │ ├── usart1.h / usart1.c # 串口1初始化与收发封装 │ └── usart2.h / usart2.c # 串口2初始化与收发封装 └── Project.uvprojx # Keil 工程文件关键点在于每个串口单独一对 .c/.h初始化、发送、接收缓冲都各自封装互不干扰。中断服务函数统一放在stm32f10x_it.c里这是标准库工程的惯例HAL 库则通常放在stm32f1xx_it.c。你如果用的是 CubeMX 生成的工程结构会变成Core/Src/usart.c集中管理但逻辑是一样的。2.2 时钟与 GPIO 复用配置串口要工作第一步是把时钟和引脚打开。USART1 挂在 APB2 上USART2 挂在 APB1 上这个区别很多人第一次配会搞混导致串口死活没输出。// 使能时钟USART1 在 APB2USART2 在 APB1 RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); // USART1: TXPA9(复用推挽), RXPA10(浮空输入) GPIO_InitStructure.GPIO_Pin GPIO_Pin_9; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_10; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // USART2: TXPA2(复用推挽), RXPA3(浮空输入) GPIO_InitStructure.GPIO_Pin GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure);逻辑说明TX 引脚配成复用推挽输出因为发送是 MCU 主动驱动RX 引脚配成浮空输入因为接收时引脚由外部设备驱动MCU 只负责读电平。参数上GPIO_Mode_AF_PP里的 AF 就是 Alternate Function复用功能的意思。如果你把 RX 也配成推挽输出两个设备同时驱动同一根线轻则收不到数据重则引脚发热这是新手最常见的翻车点之一。2.3 USART 参数与中断使能GPIO 配好之后接着初始化 USART 本身。两个串口的参数可以不同比如串口1跑 115200串口2跑 9600各接各的外设。// USART1 初始化115200-8-N-1 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); // 开启接收中断数据到达时触发 RXNE 中断 USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); USART_Cmd(USART1, ENABLE); // USART2 初始化9600-8-N-1参数独立 USART_InitStructure.USART_BaudRate 9600; USART_Init(USART2, USART_InitStructure); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); USART_Cmd(USART2, ENABLE);这里的关键是USART_ITConfig(..., USART_IT_RXNE, ENABLE)RXNE 是 Receive Data Register Not Empty 的缩写每收到一个字节就置位触发中断。注意两个串口的波特率可以完全独立因为它们是两个独立的硬件外设各自有自己的波特率发生器。我一般会把波特率写成宏定义放在头文件里改的时候不用翻 .c 文件。2.4 NVIC 优先级分配两个串口都要中断就得在 NVIC 里分别使能并且分配优先级。STM32 的中断优先级分抢占优先级和响应优先级抢占优先级高的可以打断低的。NVIC_InitTypeDef NVIC_InitStructure; // USART1 抢占优先级 1 NVIC_InitStructure.NVIC_IRQChannel USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); // USART2 抢占优先级 2低于 USART1 NVIC_InitStructure.NVIC_IRQChannel USART2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 2; NVIC_Init(NVIC_InitStructure);参数说明PreemptionPriority是抢占优先级数值越小优先级越高。这里给 USART1 设 1、USART2 设 2意味着串口1的中断可以打断串口2的中断服务函数。如果你两路数据都很关键、谁也不能被打断那就设成相同的抢占优先级让它们互不抢占按到达顺序排队处理。优先级分组记得在main开头用NVIC_PriorityGroupConfig设好否则抢占和响应优先级的位数分配跟你预期不一致这是另一个玄学来源。3. 中断服务函数与数据缓冲收数不丢字节的写法3.1 中断服务函数的标准骨架中断服务函数是整个工程的核心。每收到一个字节硬件置位 RXNECPU 跳进对应的 ISR你要做的就是在里面把数据读走、存进缓冲区然后清标志。// stm32f10x_it.c void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { uint8_t ch USART_ReceiveData(USART1); // 读走数据硬件自动清 RXNE Usart1_RxBuffer[Usart1_RxIndex] ch; // 存入环形/线性缓冲 if (Usart1_RxIndex USART1_BUF_SIZE) Usart1_RxIndex 0; // 简单回绕防溢出 } } void USART2_IRQHandler(void) { if (USART_GetITStatus(USART2, USART_IT_RXNE) ! RESET) { uint8_t ch USART_ReceiveData(USART2); Usart2_RxBuffer[Usart2_RxIndex] ch; if (Usart2_RxIndex USART2_BUF_SIZE) Usart2_RxIndex 0; } }逻辑说明USART_ReceiveData读 DR 寄存器时RXNE 标志会被硬件自动清除所以不需要手动USART_ClearITPendingBit。但如果你还开了其他中断源比如 ORE 溢出中断那就得单独判断和清除。参数上USART1_BUF_SIZE和USART2_BUF_SIZE建议设成 2 的幂方便后面做环形缓冲的取模运算。缓冲区大小要按你最大的数据包长度来定一般留 2 倍余量。3.2 环形缓冲 vs 线性缓冲上面用的是最简单的线性缓冲加回绕适合数据量小、处理及时的场合。但如果你的主循环偶尔会忙一阵线性缓冲的索引回绕会覆盖还没处理的数据。更稳妥的做法是环形缓冲用头尾指针判断空满。typedef struct { uint8_t buf[USART1_BUF_SIZE]; volatile uint16_t head; // 中断里写 volatile uint16_t tail; // 主循环里读 } RingBuffer_t; // 中断里写入 void RingBuffer_Push(RingBuffer_t *rb, uint8_t data) { uint16_t next (rb-head 1) % USART1_BUF_SIZE; if (next ! rb-tail) { // 未满才写满了丢弃 rb-buf[rb-head] data; rb-head next; } } // 主循环里读取 uint8_t RingBuffer_Pop(RingBuffer_t *rb, uint8_t *data) { if (rb-head rb-tail) return 0; // 空 *data rb-buf[rb-tail]; rb-tail (rb-tail 1) % USART1_BUF_SIZE; return 1; }关键点head和tail必须加volatile因为它们在中断和主循环两个上下文里被访问编译器不能把它们优化到寄存器里。head只在中断里改tail只在主循环里改这样单生产者单消费者模型下不需要关中断保护这是环形缓冲最优雅的地方。我一般会把两个串口各建一个 RingBuffer_t 实例互不干扰。3.3 主循环怎么取走两路数据中断只负责收处理逻辑放主循环这是中断编程的基本原则——ISR 越短越好。主循环里轮询两个环形缓冲有数据就取出来解析。int main(void) { NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); Usart1_Init(115200); Usart2_Init(9600); while (1) { uint8_t ch; // 处理串口1数据 while (RingBuffer_Pop(rb1, ch)) { Usart1_ProcessByte(ch); // 组包、解析协议 } // 处理串口2数据 while (RingBuffer_Pop(rb2, ch)) { Usart2_ProcessByte(ch); } // 其他任务... } }逻辑说明while (RingBuffer_Pop(...))会一直取到缓冲为空为止保证一次主循环把积压的数据都处理完。Usart1_ProcessByte里通常做状态机解析比如判断帧头、收长度、校验、执行命令。两路处理逻辑完全独立串口1的协议不影响串口2。如果你主循环里还有其他耗时任务建议把这两个 while 放在任务开头避免数据积压。4. 避坑与排查双串口中断最容易翻车的几个点4.1 只收到一路数据另一路没反应现象串口1能正常收发串口2发数据进来毫无反应中断也不进。原因最常见的是时钟没使能对。USART2 挂在 APB1 上如果你习惯性地用RCC_APB2PeriphClockCmd去开 USART2时钟根本没打开外设不工作。另一个可能是 GPIO 复用没配对PA2/PA3 没设成 AF 模式。解决检查RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE)是否调用再确认 GPIO 模式和引脚号。用万用表量一下 PA3 的电压空闲时应该是高电平如果一直是低说明引脚配置有问题。4.2 数据偶尔丢字节现象大部分数据能收到但偶尔少一两个字节尤其是波特率较高或主循环较忙时。原因中断里处理太慢或者缓冲区太小被覆盖。如果 ISR 里做了浮点运算、打印调试信息那基本必丢。另一个原因是没开 RXNE 中断只开了 IDLE 中断或者中断优先级被其他高优先级中断长时间阻塞。解决ISR 里只做读数据和存缓冲两件事其他全部挪到主循环。缓冲区至少设成最大包长的 2 倍。检查有没有其他中断比如 SysTick抢占太频繁。我一般会在 ISR 里翻转一个 GPIO用示波器看中断响应时间超过几微秒就得优化。4.3 两个串口互相干扰收着收着就死机现象单独跑一路正常两路同时跑一段时间后程序卡死或数据错乱。原因两个 ISR 访问了共享变量但没做保护或者环形缓冲的头尾指针类型不对比如用了uint8_t但缓冲区大于 256。还有一种情况是 NVIC 优先级分组没设导致抢占行为跟预期不符低优先级 ISR 被无限期延迟。解决确保每个串口有独立的缓冲区和索引变量不共享。索引类型用uint16_t或uint32_t别用uint8_t。在main开头调用NVIC_PriorityGroupConfig明确分组。如果确实有共享资源在访问前后关中断__disable_irq()/__enable_irq()但临界区要尽可能短。4.4 波特率对不上收到乱码现象数据能收到但全是乱码或者偶尔对偶尔错。原因波特率计算误差太大。STM32 的波特率寄存器是整数加小数分频某些波特率在特定主频下误差超过 3% 就会乱码。比如 72MHz 主频下跑 9600 没问题但跑某些非标准波特率就可能出问题。另外两个串口如果用了不同的时钟源一个 APB1 一个 APB2分频系数不一样计算时要分别确认。解决用标准波特率9600、115200 等这些在常见主频下误差都很小。如果必须用非标准波特率用 STM32CubeMX 或官方工具算一下实际误差超过 2% 就换主频或换波特率。检查RCC_GetClocksFreq确认 APB1 和 APB2 的实际频率别想当然。4.5 中断进了但读出来全是 0现象断点打在 ISR 里能进但USART_ReceiveData读出来一直是 0。原因读的时机不对或者 RXNE 标志判断有误。有些人在 ISR 里先USART_ClearITPendingBit再读数据结果标志清了但数据还没读读出来就是空的。还有人用USART_GetFlagStatus判断 RXNE但中断模式下应该用USART_GetITStatus两者行为有差异。解决先读数据硬件自动清 RXNE不要手动清。判断用USART_GetITStatus(USARTx, USART_IT_RXNE) ! RESET。如果还不行检查是不是开了 DMA 但没关中断两者同时开会有冲突。5. 二次开发从能跑到好用几个改造方向把工程跑通只是第一步真正用到项目里还得按需求改。下面几个方向是我实际改过的按优先级排。第一把接收改成 IDLE 中断加 DMA。现在的中断收数方式每个字节进一次中断115200 波特率下每秒最多 11520 次中断CPU 开销不小。如果数据包是不定长的用 IDLE 中断配合 DMA 是更优解DMA 负责搬数据IDLE 中断在总线空闲时触发一次性把整包取走。改造时保留环形缓冲的思路但写入端从 ISR 换成 DMA 完成回调。这个改动能把 CPU 占用降一个数量级代价是配置复杂一点得处理 DMA 传输完成和 IDLE 的配合。第二加协议解析层。现在的Usart1_ProcessByte只是个占位实际项目里得根据你的协议做状态机。常见做法是定义帧头、长度、命令字、数据、校验几个字段用一个状态机逐字节推进。我一般会写一个通用的解析器结构体把状态、索引、校验和都放进去两个串口各实例化一个代码复用率高。校验推荐用 CRC16 或累加和别用奇偶校验那个只能查单比特错误。第三加超时和错误恢复。串口通信最怕对方死机或线松了数据收一半没了。给每个串口加一个超时计数器在 SysTick 里递减收到字节就重置。超时后清空缓冲、重置状态机必要时发个错误上报。这个机制能避免程序卡在等一个永远不来的字节上。第四把缓冲和解析做成可配置的。用宏定义控制每个串口的缓冲区大小、是否启用 DMA、是否启用超时这样同一套代码能适配不同项目。我习惯在头文件里放一个配置区所有可调参数集中在一起改的时候不用翻 .c 文件。验证改造是否成功我一般用两个手段一是用串口助手同时给两个串口发不同频率的数据跑半小时看有没有丢包或死机二是在 ISR 入口和出口翻转 GPIO用逻辑分析仪看中断频率和持续时间。这两个手段能覆盖大部分问题。从那以后我每次配双串口都强制先确认 APB1/APB2 时钟分别使能、NVIC 分组设好、缓冲区用 volatile 修饰这三步走完再写业务逻辑省了太多回头查玄学的时间。希望帮到你。本文还有配套的精品资源点击获取