简介这是一份面向STM32嵌入式开发者的双串口中断输入工程资料基于STM32F103系列解决串口1与串口2同时接收数据时的实时处理需求适合需要多串口数据采集、通信协议解析或复杂外设联动的场景。资源包共122个文件以标准库工程为主体涵盖.h头文件、.c源文件、Keil工程配置.uvproj/.uvopt、编译生成的.hex与.map文件以及系统启动相关文件压缩包仅1.11MB可直接解压查看。已有1434人学习下载。工程内提供USART1与USART2的完整初始化与中断服务程序包含PA9/PA10、PA2/PA3引脚复用配置、波特率等参数设置、中断优先级处理和接收数据逻辑并额外给出printf与USER_printf两种打印实现方便调试与日志输出。对照源码可快速搭建双串口中断收发框架理解HAL库或标准库的中断工作流程同时规避配置冲突与数据丢失问题对开发多串口设备的初学者和经验者均有实用价值。1. 双串口同时中断输入不是把单串口的初始化复制两份就能跑现实里经常遇到这种需求STM32 一边要从 GPS 模块收 NMEA 报文一边要从另一个传感器收测量数据两条串口几乎同时在往 MCU 灌数据。很多开发者第一次做串口1串口2 同时中断输入时习惯把单串口初始化函数复制一份改个串口号就烧进去结果发现只有串口1能收、串口2没反应或者收几帧后程序直接跑飞。这个 Demo 项目就是把两条串口的中断接收完整跑通的工程从 USART1 挂在 APB2、USART2 挂在 APB1 这两条时钟域的区别到 NVIC 抢占优先级分组、两路独立中断服务函数再到主循环对两路接收缓冲的分发处理每一步都有可直接照抄的代码。适合想在裸机工程里把单串口接收升级成双串口同时接收的开发者也适合从标准外设库往 HAL 库迁移时拿来做行为对照。2. 项目定位与硬件准备从串口数量翻倍到中断模型翻倍2.1 同时中断输入到底在解决什么先把这个题目的边界说清楚。项目标题里的“同时中断输入”不是指两个串口在同一微秒各自触发一次中断而是指两条串口的 RXNE 事件在时间轴上交错出现、互不知道对方存在MCU 要在任意时刻都能响应任意一条串口的数据到达。这种场景下最容易翻车的不是中断不会触发而是两条中断服务函数之间互相干扰或者初始化时漏掉了某条总线的时钟。单串口中断接收的流程是固定的收到一个字节 → 硬件置 RXNE → 进入对应的 IRQHandler → 读 DR 清标志 → 退出。这套流程本身不复杂复杂在于串口数量翻倍之后有三类资源需要显式区分第一是时钟USART1 挂在 APB2USART2 挂在 APB1两条总线要分别使能第二是中断向量USART1_IRQn 和 USART2_IRQn 是两个独立的 NVIC 通道优先级怎么配直接决定两个中断能不能互相抢占第三是数据缓冲两路数据不能共用同一个数组和同一个下标。为什么这个项目选中断而不是轮询或 DMA也是个值得展开的设计决策。轮询的问题是主循环必须高频地轮流查两个串口的 RXNE 标志波特率一高或者数据帧一长CPU 大部分时间都耗在空转上而且两个串口的响应延迟会互相拖累。DMA 接收则适合大量连续数据搬运但帧边界判断、环形缓冲管理、空闲检测都要额外写对只想先把双串口收数据跑通的人来说门槛偏高。中断接收是这三者里代码量最小、行为最直观的方案一个字节进中断存进缓冲就退出主循环按自己的节奏消费数据。这个 Demo 就是用这个模型把两条串口同时收数据的正确姿势固定下来。2.2 硬件准备与引脚分配硬件上只需要一块带 STM32F103 系列芯片的核心板、两个 USB 转 TTL 模块、若干杜邦线。USART1 和 USART2 的引脚都在 PA 口不需要额外复用配置之外的跳线接线关系如下表。功能STM32 引脚外部设备连接USART1_TXPA9USB转TTL模块A的 RXUSART1_RXPA10USB转TTL模块A的 TXUSART2_TXPA2USB转TTL模块B的 RXUSART2_RXPA3USB转TTL模块B的 TXGNDGND两个模块与核心板共地这里有一个硬件上最常见的低级错误就是只接 TX/RX 忘了共地。串口是异步通信收发双方必须以同一个 GND 为参考电平三个设备的地不连在一起收到的数据大概率是乱码或者完全没反应。另外要注意这些 USB 转 TTL 模块输出的是 3.3V TTL 电平千万别拿 RS232 电平的设备直接怼到 PA2/PA3 上轻则收不到数据重则烧引脚。2.3 工程目录结构与文件职责这套代码按标准外设库的裸机工程组织目录结构长这样STM32_UART12_IRQ/ ├─ USER/ │ ├─ main.c │ ├─ stm32f10x_it.c │ └─ system_stm32f10x.c ├─ CORE/ │ ├─ core_cm3.c │ └─ startup_stm32f10x_md.s ├─ HARDWARE/ │ ├─ uart1.c / uart1.h │ └─ uart2.c / uart2.h ├─ SYSTEM/ │ ├─ delay.c │ ├─ sys.c │ └─ usart.c └─ STM32F10x_FWLib/ ├─ inc/ └─ src/USER 目录放启动文件、中断向量集中的 stm32f10x_it.c 和主函数HARDWARE 目录是把两个串口的初始化分别封装成 uart1.c 和 uart2.c便于独立维护SYSTEM 目录里通常有一个已经初始化过 USART1 的 usart.c这个文件往往是后面一系列坑的源头我在第 4 章会专门解释。标准外设库的 src 和 inc 是整个工程的底层依赖一般不用改动。我一般会把 USART1_IRQHandler 从 stm32f10x_it.c 里挪到 uart1.c 中USART2_IRQHandler 挪到 uart2.c 中让中断服务函数和数据缓冲待在同一文件里。这样代码阅读者看一个文件就能理解一路串口的完整行为后续改成 HAL 库或者换芯片时迁移单位也更清晰。3. 核心实现拆解串口初始化到中断返回的完整链路3.1 时钟与 GPIO两条串口挂在两条总线上先看 USART1 的初始化这是整个项目的地基。STM32F103 里 USART1 挂在 APB2 总线最大 72MHz而 USART2 挂在 APB1 总线最大只有 36MHz。这个差异直接影响时钟使能和波特率计算。void UART1_Init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; /* USART1 在 APB2 上PA9/PA10 也在 APB2一条语句同时使能 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); /* PA9 作为 TX必须配置成复用推挽输出 */ gpio.GPIO_Pin GPIO_Pin_9; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); /* PA10 作为 RX配置成浮空输入 */ gpio.GPIO_Pin GPIO_Pin_10; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); usart.USART_BaudRate baud; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_HardwareFlowControl USART_HardwareFlowControl_None; usart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART1, usart); USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); nvic.NVIC_IRQChannel USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 0; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); USART_Cmd(USART1, ENABLE); }这段代码里最容易漏的是第一行的时钟使能。GPIOA 的时钟和 USART1 的时钟都挂在 APB2 上很多人只使能了 GPIOA串口外设没使能结果读寄存器全是 0中断自然进不来。参数方面GPIO_Mode_AF_PP 表示复用推挽输出TX 引脚必须这么配RX 引脚用浮空输入即可因为串口接收本来就依赖外部设备驱动电平不需要内部上拉下拉。USART_Mode 同时开了 Rx 和 Tx是为了后面主循环里做回显测试用如果只是纯接收开 USART_Mode_Rx 就够了。3.2 USART2 初始化APB1 时钟与中断向量都不同USART2 的初始化整体结构和 USART1 一致但有两个位置必须改时钟来源和中断向量号。USART2 挂在 APB1 上必须单独调用 RCC_APB1PeriphClockCmd而不是再去 APB2 那边使能。void UART2_Init(uint32_t baud) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; /* USART2 时钟在 APB1GPIOA 时钟在 APB2两条总线都要开 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); /* PA2 USART2_TXPA3 USART2_RX */ gpio.GPIO_Pin GPIO_Pin_2; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); gpio.GPIO_Pin GPIO_Pin_3; gpio.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, gpio); usart.USART_BaudRate baud; usart.USART_WordLength USART_WordLength_8b; usart.USART_StopBits USART_StopBits_1; usart.USART_Parity USART_Parity_No; usart.USART_HardwareFlowControl USART_HardwareFlowControl_None; usart.USART_Mode USART_Mode_Rx | USART_Mode_Tx; USART_Init(USART2, usart); USART_ITConfig(USART2, USART_IT_RXNE, ENABLE); nvic.NVIC_IRQChannel USART2_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 1; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); USART_Cmd(USART2, ENABLE); }注意 USART2 的 NVIC 通道是 USART2_IRQn不是 USART1_IRQn这一点写错的话编译不会报错但中断永远不会触发排查起来很费劲。波特率计算上标准外设库的 USART_Init 内部会通过 RCC_GetClocksFreq 拿到 PCLK1 和 PCLK2 的实际频率所以只要 SystemInit 里的 APB1 分频配置正确USART2 的波特率不会因为挂在 36MHz 总线上就跟 115200 对不上。真正会出问题的是你手动去改分频器或者移植代码时把时钟树配置搞乱这一条我在第 4 章展开。3.3 NVIC 优先级两个中断的抢占关系怎么定NVIC 配置是整个项目里最值得花时间理解的部分。main 函数一开始要调用一次 NVIC_PriorityGroupConfig这个函数设置的是整个芯片所有中断的优先级分组规则只能调用一次。我习惯用 NVIC_PriorityGroup_2也就是 2 位抢占优先级加 2 位子优先级。上面代码里 USART1 的抢占优先级配成 1、子优先级 0USART2 配成抢占 1、子优先级 1。这两个中断的抢占优先级相同意味着它们之间不能互相打断如果 USART1 的中断服务函数正在执行此时 USART2 有数据到达USART2 的中断会挂起等待直到 USART1 的 ISR 返回后才能进入。对于这个 Demo 来说两个 ISR 都只做读数据和写缓冲两件事执行时间在微秒级排队等待损失可以被接受。但如果你在中断里塞了重活比如 printf 或者大数组拷贝等待的那路串口就可能因为硬件接收寄存器被覆盖而丢数据。所以记住一个原则中断服务函数越短越好数据进缓冲就退出。如果你确认一路串口比另一路更重要可以把它的抢占优先级调成 0让高优先级中断可以在低优先级 ISR 执行中途直接插入代价是低优先级那路可能偶尔丢字节需要你自己权衡。3.4 两个中断服务函数与接收缓冲区中断服务函数放在各自的 c 文件里代码量很小但每一行都有讲究。以 USART1 为例static uint8_t rx1_buf[256]; static uint16_t rx1_len 0; void USART1_IRQHandler(void) { uint8_t ch; if (USART_GetITStatus(USART1, USART_IT_RXNE) ! RESET) { ch (uint8_t)USART_ReceiveData(USART1); if (rx1_len sizeof(rx1_buf)) { rx1_buf[rx1_len] ch; } } /* 溢出标志不清掉会阻塞后续RXNE中断读DR即可同时清ORE和RXNE */ if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) ! RESET) { (void)USART_ReceiveData(USART1); } }USART_GetITStatus 先判断这次中断是不是 RXNE 引起的这是中断服务函数的标配写法因为同一个串口还可能开了其他中断比如 IDLE、TC进来之后必须把中断源分清楚。USART_ReceiveData 返回的是 DR 寄存器的值同时读操作本身会把 RXNE 标志清掉所以不需要再额外调用 USART_ClearITPendingBit。如果处理完数据之后不去读 DRRXNE 一直为 1中断会反复触发表现为程序卡死在中断里这也是很多新手调串口时遇到的“收一个字节后整个系统不动了”的典型原因。第二个 if 处理 ORE 溢出标志。当上一字节还没被读走、新字节又到达时硬件会置 ORE。这个标志如果不处理在某些库版本里会一直占用状态寄存器导致后续 RXNE 中断不再正常触发。读一次 DR 就能同时把 ORE 和 RXNE 清掉所以我在每个 ISR 末尾都补了这么一段。USART2_IRQHandler 的结构完全一致只是把 USART1 全部换成 USART2数据缓冲换成 rx2_buf 和 rx2_len。3.5 主循环轮询两路缓冲与回显主循环的职责是消费两路缓冲里的数据。为了直观验证我在 Demo 里做了个简单的回显哪路收到了数据就把哪路收到的原样发回去同时在电脑端串口助手里能看到完整往返。int main(void) { uint16_t i; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); UART1_Init(115200); UART2_Init(115200); while (1) { if (rx1_len 0) { for (i 0; i rx1_len; i) { USART_SendData(USART1, rx1_buf[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) RESET); } rx1_len 0; } if (rx2_len 0) { for (i 0; i rx2_len; i) { USART_SendData(USART2, rx2_buf[i]); while (USART_GetFlagStatus(USART2, USART_FLAG_TXE) RESET); } rx2_len 0; } } }回显之前先等一下 TXE 标志确保发送数据寄存器为空再写下一字节避免连续发送时丢字节。这段代码的主循环里轮询的是缓冲长度而不是直接查硬件标志这是中断模型和轮询模型的本质区别硬件数据到达的实时性由中断保证主循环只负责把缓冲里的内容消费掉。后面的二次开发里把回显改成协议解析就是现成的框架。4. 避坑记录双串口中断最容易踩的 5 个坑4.1 只有串口1能收串口2毫无反应现象串口1 用电脑发数据能正常回显串口2 发什么都是静默打断点看进去发现 USART2_IRQHandler 根本不会被执行。原因90% 的情况是 USART2 的时钟没使能。USART2 挂在 APB1 上需要单独调用 RCC_APB1PeriphClockCmd(RCC_APB1Periph_USART2, ENABLE)。还有 10% 的情况是工程里已经存在一个同名的 USART2_IRQHandler比如某些模板工程的 SYSTEM/usart.c 或者 stm32f10x_it.c 里已经自带了这个函数你在 uart2.c 里又写了一份链接器直接报重复定义而你看到报错后把其中一个删掉删错了方向导致实际存在的那份是空实现。解决先查时钟使能有没有写全再在整个工程里搜索 USART2_IRQHandler 的出现位置确认只有一处定义。我从那以后每次新建串口文件都会先全工程搜一遍 IRQHandler 名字再开始写代码。4.2 两路同时发数据收几帧就进 HardFault现象单独测任何一路都正常两个串口同时用串口助手定时发送跑了几秒后程序跳进 HardFault_Handler或者主循环卡死。原因中断服务函数里调用了 printf 或者长延时。printf 的重定向最终走 USART_SendData 加等待 TXE 的循环发送一长串字符串消耗的时间远超另一路串口一个字节的到达间隔结果另一路 RXNE 中断挂起期间硬件缓冲被新数据覆盖引发溢出再往下就可能是数组越界、栈被踩最后 HardFault。解决中断里只做“读 DR 写缓冲”任何打印、协议解析、LED 翻转都挪到主循环。如果调试时必须看中断里收到了什么用一个全局计数器累加主循环里再把这个计数通过串口打印出来。4.3 串口2收到的全是乱码串口1完全正常现象串口1 回显数据干净完整串口2 回显出来是乱码或者波特率明显不对发“1”收到“0x80”之类的字节。原因乱码的根源几乎都是波特率不匹配而串口2 乱码往往是因为 APB1 时钟树配置和预期不一致。标准外设库的 USART_Init 会根据 RCC_GetClocksFreq 返回的 PCLK1 来计算波特率分频系数如果 SystemInit 里 PLL 倍频数改过或者 APB1 预分频配置被改动PCLK1 不是 36MHz而另一端电脑串口助手按 115200 收两边自然对不上。解决在 main 里用 RCC_GetClocksFreq 把 PCLK1、PCLK2 读出来通过串口打印或者仿真器 Watch 窗口确认 PCLK1 确实是 36MHz。如果发现 PCLK1 不对回到 SystemInit 的 RCC_CFGR 配置里查 APB1 分频位而不是在串口初始化代码里硬调波特率参数。4.4 调试器里打断点恢复运行后数据已经丢了现象在 USART1_IRQHandler 里打了一个断点单步执行完再点继续运行发现后面收到的数据缺了一段或者 buffer 里的序号不连续。原因断点挂起 CPU 期间串口外设的接收移位寄存器和数据寄存器仍在工作新到的字节没人读走一旦数据寄存器没来得及被取走下一个字节就会把 ORE 标志置上并丢弃新数据。这不是代码逻辑问题是调试方式的问题。中断服务函数里打断点等于人为制造了微秒级到秒级的数据饥饿窗口。解决不要直接在 ISR 里打断点看数据。想看中断是否触发就在 ISR 里对全局计数变量做自增断点打在 main 循环里检查计数。这样既能确认中断频率又不影响中断实时性。恢复运行后数据缺失的现象消失说明代码本身没有丢帧问题。4.5 两路缓冲的数据交叉错乱现象串口1 收到的内容里混进了串口2 的数据或者明明串口1 只发了 5 个字节主循环里读到的却是 10 个字节。原因一种可能是两个串口共用了一个接收数组和下标变量中断里互相覆盖另一种更隐蔽是 rx1_len、rx1_buf 这些在中断和主循环之间共享的变量没有加 volatile 修饰。编译器在开优化后主循环可能把 rx1_len 的值缓存到寄存器里导致中断更新后主循环读到的还是旧值出现“数据发了但好像没收到”的假象。解决每路串口的缓冲和长度变量独立定义并且在头文件里声明成 extern volatile。中断里只写、主循环只读这是共享变量的基本纪律。如果主循环需要在读取缓冲的同时禁止中断打断用 __disable_irq() 和 __enable_irq() 把拷贝动作包起来防止读一半时中断又往缓冲里写。5. 跑通与验证双串口同时收发的完整测试流程5.1 工程配置与烧录步骤拿到工程后第一步是把编译选项对齐芯片型号。如果是 STM32F103C8T6 这类中等容量芯片需要在 C/C 编译选项的 Preprocessor Symbols 里定义 STM32F10X_MD并且把启动文件选成 startup_stm32f10x_md.s。这个宏决定标准外设库编译哪些系列的驱动代码漏掉的话会报一堆函数未定义。烧录用 ST-Link 或者 J-Link 的 SWD 接口即可PA13/PA14 是 SWDIO/SWCLK不会和 USART1、USART2 用到的 PA2/PA3/PA9/PA10 冲突。下载完成后先别急着接外部设备把两个 USB 转 TTL 模块的 TX 和 RX 分别交叉接到 PA10/PA9、PA3/PA2注意模块的 TX 接 STM32 的 RX模块的 RX 接 STM32 的 TX同时把三者的 GND 连在一起。5.2 双串口助手联调两个窗口同时观察电脑上开两个串口助手窗口一个对应模块 A一个对应模块 B都设成 115200、8N1。先做单路测试只在助手 A 里发一串字符比如 “UART1-TEST-01”STM32 回显同样的内容然后只在助手 B 里发 “UART2-TEST-01”确认回显正常。单路通之后再做真正的“同时”测试。把两个助手的定时发送功能都打开助手 A 设 500ms、助手 B 设 300ms让它们按不同相位持续发送。观察两个窗口的回显内容正确结果是两边各自回显自己的内容不交叉、不缺失、不卡死。这里的技巧是让两路数据长度不一样比如 A 发短帧、B 发长帧这样如果存在缓冲互相覆盖的问题长帧回显时就能明显看到中间缺了一段。5.3 压测与误码检查常规收发没问题之后应该做一轮带校验的压测否则“能跑通”和“稳定”之间还隔着一段距离。我给这个 Demo 加了一个最简单的压测思路发送端按顺序发递增序号帧格式是 4 字节序号加固定校验字节接收端在回显的同时解析序号主循环里用一个变量记录最大连续序号一旦发现断号就在 LED 上闪烁体现。压测项发送配置判定标准单路长时间助手A每100ms发一帧跑10分钟回显序号连续无缺号双路同时A发50ms间隔B发80ms间隔跑10分钟两路都无缺号、无交叉错位突发数据手动一次性粘贴2000字节到A同时B持续发短帧A完整回显2000字节B无丢失压测跑完如果一切正常这个工程就可以作为后续开发的底座。我一般会把压测数据记在工程目录的 README 里包括波特率、发送间隔、压测时长、丢帧数等项目改动之后再跑同一套用例做回归对比。这个习惯帮我挡掉过不少“改了一行配置导致另一路串口偶发丢字节”的隐性回归问题。6. 二次开发思路把双串口中断收编成可复用接收框架这个 Demo 的直接形态是“两路缓冲加回显”再往产品方向走至少要完成三个改造。第一步是把数组加下标的存储模型换成真正的环形缓冲。中断里写头指针主循环读尾指针长度判断改成减法取模这样就不存在“缓冲满了必须丢帧”或者“主循环来不及清空导致覆盖”的问题。第二步是加帧边界判断。裸中断只保证字节不丢不保证帧完整实际项目的报文通常以 \n 或固定头字节结尾需要主循环里做状态机切帧或者用定时器做空闲超时判断超过 3 个字节时间没有新数据就认为一帧结束。第三步是给高吞吐那路串口换 DMA 接收中断只在空闲中断触发时搬运一整帧CPU 占用能降一个量级。改造点改动范围注意环形缓冲uart1.c/uart2.c 内部头尾指针要用 volatile取模用位与帧切分main.c 或新增协议解析文件状态机别放中断里DMA接收重写接收链路NVIC 优先级模型与裸中断项目是同一套这三步做完这个工程就不再是“两个串口的 Demo”而是“任意 N 路串口的通用接收框架”。我自己在某个模拟项目上就是按这个路径改的第一次改完 DMA 接收时忘了把空闲中断的优先级调高结果高波特率下偶发丢帧排查了整整一个下午才定位到是优先级嵌套问题。从那以后我每次给板子加新串口都会先确认时钟总线、中断向量、共享变量这三张清单再动手写初始化代码压力测试也强制跑一遍完整用例。这套流程虽然朴素但确实帮我避开了很多重复的坑。希望帮到你。本文还有配套的精品资源点击获取