简介:面向使用STM32F103进行嵌入式开发、需要让USART1与USART2同时以中断方式收发数据的开发者,这份工程型资料提供双串口中断输入的可参考实现,重点对应多数据源通信与调试打印场景。压缩包共122个文件,约1.11MB,以.h头文件和.c源文件为主体,并配有.o、.d、.crf等编译中间文件,以及uvproj、uvopt工程配置和hex、axf产物,保留了较完整的Keil工程结构,便于按模块查阅与整体导入对照。目前已有1434人学习下载。工程内容围绕GPIO复用初始化、USART参数配置、NVIC中断使能、中断服务程序接收处理与发送接口展开,并包含printf及自定义USER_printf串口打印示例,可帮助读者理解双串口中断驱动收发的组织方式、优先级安排和错误处理思路,也便于移植到相近的F103项目。
1. 双串口同时收数:先别急着开两个中断
STM32 串口1+2 同时中断输入,这组词看起来像一句废话:两个 USART 都开接收中断不就行了?真上板后会发现,问题不在“开中断”,而在两路字节流同时到达时,回调重入、优先级抢占、环形缓冲区覆盖和帧边界判断会一起找上门。我见过最典型的翻车是 USART1 和 USART2 都用HAL_UART_Receive_IT收单字节,主循环里再各读各的,结果一路数据量大时另一路直接丢包,调试器一暂停更是满屏溢出。这套双串口中断接收骨架解决的就是两路不定长数据同时进来时,谁先入队、谁后解析、溢出怎么记、帧尾怎么认。适合手里已经有 STM32 最小系统板、要把两个传感器或两个上位机通道同时接进来的开发者。
2. 时钟、引脚与NVIC:USART1/USART2中断接收的初始化骨架
2.1 引脚与波特率:先确认物理层能跑满115200
双串口同时收数,第一步不是写中断服务函数,而是把物理层钉死。USART1 和 USART2 的 TX/RX 引脚在不同封装上会变,常见最小系统板会把 USART1 放在 PA9/PA10,USART2 放在 PA2/PA3,但换芯片、换封装就要以数据手册的复用表为准。TX 配复用推挽,RX 配输入上拉或复用上拉,RX 悬空时容易捡到噪声,表现就是没接设备也偶尔进中断、读出来一堆0x00或0xFF。
| 信号 | 常见引脚 | GPIO 模式 | 备注 |
|---|---|---|---|
| USART1_TX | PA9 | 复用推挽 | 输出端,接对方 RX |
| USART1_RX | PA10 | 输入上拉/复用上拉 | 接对方 TX,建议上拉 |
| USART2_TX | PA2 | 复用推挽 | 输出端,接对方 RX |
| USART2_RX | PA3 | 输入上拉/复用上拉 | 接对方 TX,建议上拉 |
波特率优先选 115200,8 数据位、1 停止位、无校验。内部 RC 时钟做 115200 不是不能用,但两路同时收、线材又长时,误差会以偶发乱码的形式出现。我一般会要求板上有外部晶振,至少把 USART 的时钟源挂到精度稳定的时钟上。过采样保持 16 倍,除非对方设备明确要求 8 倍过采样,否则不要随手改。
static void uart_gpio_init(void) { GPIO_InitTypeDef gpio = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); /* USART1_TX: PA9 复用推挽 */ gpio.Pin = GPIO_PIN_9; gpio.Mode = GPIO_MODE_AF_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); /* USART1_RX: PA10 输入上拉,F1 系列常见写法;F4 等系列按复用模式配置 */ gpio.Pin = GPIO_PIN_10; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &gpio); /* USART2_TX: PA2 复用推挽 */ gpio.Pin = GPIO_PIN_2; gpio.Mode = GPIO_MODE_AF_PP; gpio.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &gpio); /* USART2_RX: PA3 输入上拉 */ gpio.Pin = GPIO_PIN_3; gpio.Mode = GPIO_MODE_INPUT; gpio.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOA, &gpio); }这段代码只负责 GPIO,时钟使能、USART 时钟和中断向量还在后面。GPIO_MODE_AF_PP用于 TX,RX 在 F1 上常用GPIO_MODE_INPUT加GPIO_PULLUP,在 F4 等系列上 CubeMX 往往会生成复用模式,照生成代码改即可。参数里GPIO_SPEED_FREQ_HIGH对 115200 足够,不用上到最高档;上拉不是可选项,RX 线在设备未上电或热插拔时会悬空,上拉能减少误触发。
2.2 CubeMX生成代码与手写初始化对照
用 CubeMX 配双串口时,USART1 和 USART2 都选 Asynchronous,波特率 115200,字长 8,无校验,1 停止位,硬件流控 Disable。NVIC 里把 USART1 global interrupt 和 USART2 global interrupt 都勾上,优先级先都填 1。生成代码后不要直接往main.c的while(1)里塞解析逻辑,先把初始化顺序理清:HAL_Init()、系统时钟、MX_GPIO_Init()、MX_USART1_UART_Init()、MX_USART2_UART_Init(),最后再启动接收中断。
手写初始化时,两个UART_HandleTypeDef要分开定义,不要图省事共用一个句柄。句柄里的Instance、Init、hdmarx、hdmatx都是按串口独立的,共用会把回调里的huart->Instance判断搞乱。
UART_HandleTypeDef huart1; UART_HandleTypeDef huart2; void uart_dual_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; if (HAL_UART_Init(&huart1) != HAL_OK) { Error_Handler(); } huart2.Instance = USART2; huart2.Init = huart1.Init; /* 两路参数一致时可直接拷贝 Init 结构体 */ if (HAL_UART_Init(&huart2) != HAL_OK) { Error_Handler(); } /* 先分组,再设优先级;分组只设一次 */ HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); HAL_NVIC_SetPriority(USART1_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART1_IRQn); HAL_NVIC_SetPriority(USART2_IRQn, 1, 0); HAL_NVIC_EnableIRQ(USART2_IRQn); }huart2.Init = huart1.Init是结构体赋值,适合两路参数完全一致的情况;如果 USART1 跑 115200、USART2 跑 9600,就分开逐项赋值。NVIC_PRIORITYGROUP_4表示 4 位抢占优先级、0 位子优先级,数值越小优先级越高。两路都设 1,意味着它们不能互相抢占,先到先处理,后到的等前一个中断服务函数退出。这个选择在双串口同时收数时通常比“USART1 设 0、USART2 设 1”更稳,因为后者会让 USART1 频繁打断 USART2 的接收回调,回调里再一耗时,USART2 的硬件接收寄存器就溢出。
HAL_UART_Init内部会调用HAL_UART_MspInit,GPIO 和 USART 时钟一般放在MspInit里。如果不用 CubeMX,就要自己在HAL_UART_MspInit中使能__HAL_RCC_USART1_CLK_ENABLE()、__HAL_RCC_USART2_CLK_ENABLE()和对应 GPIO 时钟,否则HAL_UART_Init返回错误,串口一个字节都收不到。
2.3 优先级分组:两路USART怎么排抢占和子优先级
双串口同时中断输入,优先级不是越高越好,而是要让“中断里做的事”足够短。我的默认排法是:两路 USART 抢占优先级相同,都高于主循环里的软件定时器,低于 DMA 或看门狗这类硬实时中断。这样 USART1 不会因为自己数据多就把 USART2 按在地上摩擦,两路都按到达顺序排队。
如果两路业务实时性不同,比如 USART1 接的是控制指令、USART2 接的是慢速传感器,那 USART1 可以设 0,USART2 设 1。代价是 USART1 中断频繁时 USART2 的响应会被推迟,所以 USART1 的回调里只能做“入队”这一件事,不能做协议解析,更不能在里面打印日志。子优先级在NVIC_PRIORITYGROUP_4下不起作用,填 0 即可;如果项目里用了 RTOS,还要检查 USART 中断优先级数值不能高于 RTOS 允许调用 API 的最高优先级,否则在回调里调 RTOS 的队列或信号量会触发断言。
注意:
HAL_NVIC_SetPriorityGrouping要在所有HAL_NVIC_SetPriority之前调用,而且整个工程只调一次。中途改分组会把已经设好的优先级解释乱掉,表现是偶发进错中断或优先级反转。
3. 中断服务函数与环形缓冲区:两路字节流不打架的落点
3.1 HAL_UART_Receive_IT的重入边界
HAL_UART_Receive_IT的典型坑是“只在初始化时调用一次”。它启动的是一次指定长度的接收,收到指定字节数后进HAL_UART_RxCpltCallback,然后这次接收就结束了。如果回调里不重新调用,串口就只收第一包,后面再也不进回调。双串口场景下更隐蔽:USART1 回调里重新挂了,USART2 忘了挂,表现就是一路正常、一路只收第一个字节。
正确做法是在HAL_UART_RxCpltCallback里按huart->Instance区分端口,先入队,再无条件重新挂接收。入队失败也要重新挂,不能因为环形缓冲区满了就return,否则这一路接收直接停摆。HAL_UART_Receive_IT返回HAL_BUSY时不要死等,说明上一次接收还没结束,通常是在错误回调里重复挂接收导致的,检查是不是在HAL_UART_ErrorCallback和HAL_UART_RxCpltCallback里同时挂了同一路。
static uint8_t rx_byte1; static uint8_t rx_byte2; void uart_start_receive_it(void) { /* 两路都启动单字节中断接收,后续在回调里循环重挂 */ HAL_UART_Receive_IT(&huart1, &rx_byte1, 1); HAL_UART_Receive_IT(&huart2, &rx_byte2, 1); } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { (void)ring_put(&ring1, rx_byte1); HAL_UART_Receive_IT(&huart1, &rx_byte1, 1); } else if (huart->Instance == USART2) { (void)ring_put(&ring2, rx_byte2); HAL_UART_Receive_IT(&huart2, &rx_byte2, 1); } } void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(&huart1, &rx_byte1, 1); } else if (huart->Instance == USART2) { __HAL_UART_CLEAR_OREFLAG(huart); HAL_UART_Receive_IT(&huart2, &rx_byte2, 1); } }rx_byte1、rx_byte2必须是静态或全局变量,不能是回调里的局部变量,否则HAL_UART_Receive_IT保存的是栈地址,回调返回后数据被覆盖。HAL_UART_ErrorCallback里清ORE标志后重启接收,是双串口同时收数时最常见的后悔药;没有这个兜底,一旦某一路溢出,HAL 会把接收状态锁在错误态,后面怎么等都不进回调。
3.2 单生产者单消费者环形缓冲区
中断里只做“把字节放进缓冲区”,主循环再做解析,这是双串口不丢包的基本盘。环形缓冲区用单生产者单消费者模型:中断是生产者,只改head;主循环是消费者,只改tail。两个指针用volatile uint16_t,在 Cortex-M 上对齐的 16 位读写是原子的,不需要关中断。缓冲区大小取 2 的幂,入队出队用位掩码代替取模,省周期。
#define RX_RING_SIZE 256U typedef struct { uint8_t buf[RX_RING_SIZE]; volatile uint16_t head; /* 中断写 */ volatile uint16_t tail; /* 主循环读 */ volatile uint32_t overflow; /* 溢出计数,调试时看 */ } rx_ring_t; static rx_ring_t ring1; static rx_ring_t ring2; static inline bool ring_put(rx_ring_t *r, uint8_t b) { uint16_t next = (uint16_t)((r->head + 1U) & (RX_RING_SIZE - 1U)); if (next == r->tail) { r->overflow++; return false; } r->buf[r->head] = b; r->head = next; /* 先写数据,再更新 head */ return true; } static inline bool ring_get(rx_ring_t *r, uint8_t *b) { if (r->tail == r->head) { return false; } *b = r->buf[r->tail]; r->tail = (uint16_t)((r->tail + 1U) & (RX_RING_SIZE - 1U)); return true; }RX_RING_SIZE必须是 2 的幂,256 对应掩码255。head和tail相等表示空,next == tail表示满,会牺牲一个字节空间,这是常见做法。overflow不要省,双串口同时收数时它是判断“主循环是不是被别的任务拖住了”的第一手证据。如果溢出计数一直涨,先别怀疑串口硬件,去看主循环里有没有HAL_Delay、Flash 擦写、阻塞式打印。
缓冲区大小按波特率和主循环最大阻塞时间估算:115200 下每秒约 11520 字节,主循环如果 20 ms 才取一次,单路就要约 230 字节,两路各 256 只够刚好;主循环有 50 ms 的阻塞,单路就要 576 字节,直接上 1024。公式是buffer >= 波特率 / 10 * 最大阻塞时间 * 1.5,1.5 是留给突发和中断延迟的余量。
3.3 主循环取数与按端口分发
主循环里两路轮流取,不要一路while取到空再取另一路。如果 USART1 数据源源不断,先取空 USART1 会让 USART2 的缓冲区一直涨。每轮各取固定上限,比如每路最多 64 字节,然后去处理别的任务,下一轮再来。解析器按端口分开,USART1 一个状态机,USART2 一个状态机,不要共用一个全局状态变量。
void uart_poll(void) { uint8_t b; uint16_t n; /* 每路每轮最多取 64 字节,避免一路饿死另一路 */ n = 0; while (n < 64U && ring_get(&ring1, &b)) { parser_feed(0, b); n++; } n = 0; while (n < 64U && ring_get(&ring2, &b)) { parser_feed(1, b); n++; } }parser_feed的第一个参数是端口号,内部用两个独立的协议上下文。如果用了 RTOS,可以把ring_get换成任务通知或消息队列,但不要在中断里直接调用阻塞式队列发送;用xQueueSendFromISR也要先确认中断优先级数值符合 RTOS 配置。裸机下uart_poll放在主循环里,确保没有长阻塞,Flash 擦写、EEPROM 写入这类操作要么拆小,要么在操作前先看两路overflow有没有涨。
4. IDLE中断与帧边界:不定长数据收完整的两种做法
4.1 逐字节中断加IDLE标记
逐字节中断适合低速、偶发、数据量小的场景。它的好处是逻辑直观:每来一个字节进一次回调,主循环从环形缓冲区取。坏处是帧边界不清楚,一包数据什么时候结束,得靠协议里的帧头帧尾或超时判断。IDLE 中断可以补这个缺口:RX 线在一个字节时间内没有新数据,硬件置 IDLE 标志,进中断后标记“这一帧结束了”。
volatile uint8_t frame_end1; volatile uint8_t frame_end2; void uart_enable_idle_it(void) { __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); __HAL_UART_ENABLE_IT(&huart2, UART_IT_IDLE); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); frame_end1 = 1; } HAL_UART_IRQHandler(&huart1); } void USART2_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_IDLE) != RESET) { __HAL_UART_CLEAR_IDLEFLAG(&huart2); frame_end2 = 1; } HAL_UART_IRQHandler(&huart2); }__HAL_UART_ENABLE_IT要在启动接收后调用,__HAL_UART_CLEAR_IDLEFLAG负责读 SR 再读 DR 清标志,不要自己写寄存器顺序,不同系列宏的实现有差异。frame_end1只做标记,真正的帧数据还在环形缓冲区里。主循环看到标记后,把当前可读数据当成一帧取走。
本文还有配套的精品资源,点击获取