
简介串口空闲中断收发是STM32F103处理不定长UART数据的常用方案特别适合无固定结束标志的数据流场景。这套资源面向嵌入式开发学习者提供基于标准外设库的完整工程模板涵盖UART初始化、空闲中断开启、中断服务函数处理、数据接收与解析等关键环节可帮助读者快速理解并移植该机制。压缩包共180个文件约2.58MB以C源码34个.c、头文件35个.h和启动汇编33个.s为主同时包含Keil工程配置文件.uvprojx、编译产物.axf/.hex及调试相关文件.map/.lst等目录结构完整适合直接打开工程对照学习或二次开发。目前已有5303人学习下载。借助示例中的代码框架与中断时序处理思路读者可以规避常见的溢出覆盖、标志位清除遗漏等问题提升串口通信稳定性。1. 串口空闲中断收发一条不定长串口帧的完整落地接到一个串口数据解析需求时我最想直接拿走的就是一套能在 STM32F103 上稳定跑完的串口空闲中断收发代码。上一位开发者留下的写法是定长接收上位机命令长度一变接收就错位数据全废。改成空闲中断后串口总线只要停够一个字节时间硬件就自动告诉你帧边界不定长数据不再需要猜长度。这份 stm32f103串口空闲中断收发工程把 USART1 的收发拆成中断收、环形缓冲、主循环解析三段源码里还带了一个回显协议适合做传感器采集、上位机联调的同学直接当驱动层抄。2. 为什么选“空闲中断 环形缓冲区”技术选型和工程边界2.1 固定长度接收为什么翻车很多 F103 驱动新手一开始都会这么写定义一个大数组uint8_t rx_buf[64]然后调HAL_UART_Receive_IT(huart1, rx_buf, 64)。这个函数的本意是“一直收到 64 个字节满了再通知你”。只要上位机一帧实际没有发满 64 字节你的回调可能永远不触发发多了数组被写爆后续的数据全乱。再往下挖一排还有第二种做法用定时器每 10ms 扫描一次 RXNE 标志把HAL_UART_Receive(huart1, byte, 1, 0)拿到的字节拼进缓冲区。短帧没毛病但主频 72MHz 的 F103 一旦同时要刷屏、写 Flash、跑状态机定时器抖动一大115200 波特率下就开始丢字节。丢一帧、错一帧查半天最后怀疑到定时器优先级上这属于典型的串口“玄学调试”。空闲中断解决的是帧边界问题它不管你这一帧有多长只要总线上出现一个完整字节时间的空闲硬件就认为“这一帧发完了”。于是接收代码从“固定长度才知道完成”变成“一空闲就立刻知道完成”不定长协议自然变得好写。2.2 设计边界中断里只做搬运业务全放主循环确定了用空闲中断之后我给自己定的第一个工程边界是中断回调里绝不解析协议绝不调用printf绝不执行超过几十个周期的逻辑。ISR 只做三件事把收到的字节转存到环形缓冲区记录这一帧的长度置一个frame_ready标志位让主循环知道有新帧。环形缓冲区在这里不是装饰品。它把“中断产生数据”和“主循环消费数据”解耦哪怕主循环正在处理上一帧、做一段比较重的解析UART 中断也能继续把后续字节存进 FIFO不会因为对方多发了几个字节就立刻 ORE 溢出。2.3 源码结构与串口参数这份工程的目录结构我拆成了三层拿到压缩包后先顺着这个结构找代码stm32f103-uart-idle-demo/ ├── app/ │ ├── main.c # 主循环消费 FIFO发送回显 │ └── protocol/ │ ├── frame.c # 帧解析帧头、长度、校验 │ └── frame.h ├── bsp/ │ ├── uart/ │ │ ├── uart.c # 串口初始化、空闲接收启动函数 │ │ └── uart.h │ ├── fifo/ │ │ ├── fifo.c # 环形缓冲区 │ │ └── fifo.h │ └── led/ ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ └── MDK-ARM/app层管业务bsp层管硬件驱动层是 HAL 库自动生成的。以后要换串口、换 MCU 平台只动bsp/uart和bsp/fifo协议那边基本不用改。USART1 这里我按最常见的板子配置参数取值说明MCUSTM32F103C8T672MHz 主频64KB Flash串口USART1PA9 发送PA10 接收波特率1152008 位数据位、1 位停止位、无校验接收方式空闲中断 环形缓冲中断里搬运主循环解析FIFO 大小256 字节一次可缓存多个不定长帧主频、波特率、FIFO 大小都在头文件里用宏集中定义改一个数字就能适配下一块板子不用满工程翻魔数。3. 核心代码拆解从空闲中断触发到业务取包3.1 先搭好 FIFO中断和生产消费之间的蓄水池环形缓冲区是实现“中断收、主循环取”的最小数据结构。单生产者UART 中断、单消费者主循环的场景不需要加锁只要保证 head 和 tail 不会被中断打乱就行。/* fifo.h */ #define UART_RX_FIFO_SIZE 256U /* 必须是 2 的幂方便优化 */ typedef struct { uint8_t buffer[UART_RX_FIFO_SIZE]; volatile uint16_t head; /* 写入位置只有中断写 */ volatile uint16_t tail; /* 读取位置只有主循环读 */ } uart_fifo_t; /* fifo.c */ int uart_fifo_push(uart_fifo_t *f, uint8_t byte) { uint16_t next (uint16_t)((f-head 1U) % UART_RX_FIFO_SIZE); if (next f-tail) { /* 队满直接丢弃 */ return -1; } f-buffer[f-head] byte; /* 先写数据再更新 head */ f-head next; return 0; } int uart_fifo_pop(uart_fifo_t *f, uint8_t *byte) { if (f-head f-tail) { /* 队空 */ return -1; } *byte f-buffer[f-tail]; f-tail (uint16_t)((f-tail 1U) % UART_RX_FIFO_SIZE); return 0; }这里有两个关键点。第一head和tail都加了volatile因为head会被中断读取、tail会被主循环读取编译器必须承认它们在异步修改不能优化掉。第二push里先写buffer再更新head保证主循环看到head变化时已经写到数组里的数据是完整可读的。FIFO 满时我选择直接丢新字节而不是覆盖旧数据原因很朴素协议解析宁可丢最后一帧也不要让前面一帧被半个新帧挤脏。真正高可靠的场景是靠上位机超时重发来兜底的。3.2 开启空闲接收HAL_UARTEx_ReceiveToIdle_IT 的正确姿势新版 HAL 直接在 UART 扩展接口里提供了“接收一段不定长数据遇到空闲自动回调”的封装F103 上也能用/* uart.c */ #define UART_IDLE_FRAME_MAX_SIZE 128U static uint8_t uart_frame_buf[UART_IDLE_FRAME_MAX_SIZE]; static volatile uint8_t uart_frame_ready; static volatile uint16_t uart_frame_len; static uart_fifo_t uart_rx_fifo; void uart_start_idle_receive(UART_HandleTypeDef *huart) { /* * 收满 UART_IDLE_FRAME_MAX_SIZE 字节或者检测到总线空闲 * 都会触发 HAL_UARTEx_RxEventCallback(huart, Size)。 * Size 就是本次实际收到的字节数。 */ HAL_UARTEx_ReceiveToIdle_IT(huart, uart_frame_buf, UART_IDLE_FRAME_MAX_SIZE); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { for (uint16_t i 0; i Size; i) { /* 中断里只做 FIFO 写入不做帧解析 */ uart_fifo_push(uart_rx_fifo, uart_frame_buf[i]); } uart_frame_len Size; uart_frame_ready 1U; /* 主循环看到 1 就知道有一帧可处理 */ /* 立刻挂下一次空闲接收否则下一帧进来不会触发中断 */ uart_start_idle_receive(huart); } }参数说明第一个参数huart是串口句柄第二个参数是存放“当前这一帧”的缓冲区第三个参数是缓冲区最大长度。也就是说只要一帧不超过 128 字节数据就不会丢超过 128 字节HAL 也会用同一个缓冲继续接收但这个场景需要配合 DMA 做自动回绕后面二次开发里单独讲。这里最容易被忽略的是最后一行HAL_UARTEx_ReceiveToIdle_IT只初始化了一次接收流程空闲事件发生后HAL 不会自动帮你重启下一次接收。如果你不在这里重新调用串口 RXNE 中断会慢慢“哑火”表现为收完第一帧之后后续数据全部静默。3.3 中断入口和发送回调里不能用阻塞发送CubeMX 生成的stm32f1xx_it.c里USART1 全局中断入口长这样void USART1_IRQHandler(void) { HAL_UART_IRQHandler(huart1); }HAL 会在内部判断 RXNE、IDLE、ORE 等事件并调用对应的回调函数。所以你要写的回调就是上面的HAL_UARTEx_RxEventCallback不用自己去读USART1-SR清标志这是新版接口最省心的地方。发送我选择在主循环里用最普通的轮询函数void uart_send_ack(UART_HandleTypeDef *huart, const uint8_t *ack, uint16_t len) { /* * 主循环里轮询发送最稳200ms 超时防止串口异常时死等。 * 注意不能在中断回调里调用这个函数会阻塞中断上下文。 */ HAL_UART_Transmit(huart, (uint8_t *)ack, len, 200); }如果发送的数据量很大比如一次要回传 1KB 日志这个轮询发送会占主循环时间。工程里另外留好了HAL_UART_Transmit_IT的位子把tx_buf改成 volatile 并加一个tx_busy标志即可。3.4 主循环取包消费一帧并回显长度回调负责收主循环负责消费。为了让串口助手一眼看出“这一帧收到几个字节”我在main.c里放了一个最简单的回显上位机发什么板子回0xAA 帧长度 0x00。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); uart_start_idle_receive(huart1); /* 上电先挂上第一个空闲接收 */ while (1) { if (uart_frame_ready) { uart_frame_ready 0; /* 回显格式0xAA 长度 0x00 */ uint8_t ack[3] { 0xAA, (uint8_t)uart_frame_len, 0x00 }; uart_send_ack(huart1, ack, 3); /* 从 FIFO 取字节喂给协议层 */ uint8_t byte; while (uart_fifo_pop(uart_rx_fifo, byte) 0) { frame_parse_byte(g_frame, byte); } } } }回显 ACK 放在主循环而不是放在中断回调里就是为了避免“中断里发送半天外面数据进不来”的经典车祸现场。串口调试的时候你在串口助手里随便发一串字符板子会立刻回三个字节只要能看到长度和你发的字符数一致这条收发包链路就基本通了。4. 工程落地从 CubeMX 建工程到串口通电验证4.1 CubeMX 里要勾掉的坑用 CubeMX 生成 F103 工程时USART1 的配置有几步容易出错MCU 选STM32F103C8T6芯片型号不对后面引脚全部对不上左侧Connectivity - USART1Mode 选Asynchronous不要选 Single Wire检查 PA9 和 PA10 是否自动变为USART1_TX和USART1_RXNVIC 设置里勾上USART1 global interrupt这一步必须做否则中断回调永远是死的时钟树确认 APB2 时钟是 72MHzUSART1 挂在 APB2 上时钟错了波特率也跟着错。生成代码后CubeMX 会自动生成MX_USART1_UART_Init和HAL_UART_MspInit。串口引脚和中断 priority 都在 MspInit 里我一般把 USART1 的中断优先级设为10比 SysTick 高比定时器中断低既不饿死接收也不至于频繁抢占导致主循环被挤成“PPT”。4.2 驱动文件合入工程压缩包里的bsp目录可以直接拖到你的项目里。如果用 MDK记得把bsp/uart和bsp/fifo加进工程分组然后在 Options - C/C - Include Paths 里把bsp根目录加进去。合入之后main.c只需要调用三个函数MX_USART1_UART_Init()、uart_start_idle_receive(huart1)然后就是主循环里的消费逻辑。uart.c里已经包含了#include fifo.h不需要再重复声明。如果你用的是 GCC-Makefile 工程编译命令里加上bsp/uart/uart.c bsp/fifo/fifo.c即可头文件路径同理。4.3 用串口助手验证发随机长度需要写个小脚本串口助手手动点“发送”只能测固定长度测不出不定长帧的边界问题。我习惯用 Python 跑一轮随机长度回环import serial import random import time ser serial.Serial(COM4, 115200, timeout0.2) for index in range(200): payload_len random.randint(1, 120) payload bytes([random.randint(0, 0x7F) for _ in range(payload_len)]) ser.write(payload) time.sleep(0.05) ack ser.read(3) if len(ack) ! 3: print(f第 {index} 帧丢包发送长度 {payload_len}) break elif ack[1] ! payload_len: print(f第 {index} 帧长度错期望 {payload_len}收到 {ack[1]}) break ser.close() print(验证结束)这个脚本的逻辑很简单随机生成 1 到 120 字节的帧发送读 3 字节 ACK检查第二字节是否等于发送长度。正常跑完 200 帧说明空闲中断收发和 FIFO 都稳了。如果第一次就丢先检查上位机每次write之间有没有sleep因为超过一个帧时间的停顿会被串口识别成“两帧”。5. 避坑串口空闲中断的 5 个经典踩坑记录5.1 数据总差一字节清 IDLE 清晚了现象串口助手发出12345板子收到的却是2345或1234长度永远差 1。原因手工清空闲中断的标准动作是先读SR再读DR。旧版本工程里有人直接在USART1_IRQHandler开头写__HAL_UART_CLEAR_IT(huart1, UART_IT_IDLE)但这条宏在部分 HAL 版本里对 IDLE 标志并不总是生效结果最后字节还没收到IDLE 标志就被当成“已处理”清掉了。解决不要自己动手碰标志直接走HAL_UARTEx_ReceiveToIdle_IT HAL_UART_IRQHandler的组合。如果是老库没有这个 API就按标准外设库的写法先读USART1-SR再读USART1-DR并且保证你的清标志代码跑在读取数据之后。5.2 一帧被拆成两包问题出在上位机节奏现象协议规定上位机连续发 16 字节板子偶尔收到8 8两帧。原因IDLE 检测的是“超过一个字节时间的总线空闲”。如果上位机每发 8 字节就sleep一下哪怕只是 2ms信号在 TX 线上已经是一片空闲高电平F103 自然认为这一帧结束了于是拆帧。解决先把上位机发送端的延时全部去掉改成一次性write一整帧。如果业务实在做不到连续发送那就在协议里加入帧头帧尾并在主循环里做一个“超时合并”逻辑收到半包后等 20ms 没新数据才判为完整帧。5.3 中断回调里做解析波特率一高就掉帧现象115200 下收发正常改成 921600 后串口偶尔丢字节严重时整个程序卡住。原因我在回调里干过“读 FIFO 解析 length 查表 回包”全套操作中断被占了几十微秒。921600 时一个字节约 10usISR 超过这个时间下一个字节 RXNE 还没处理SR 的 ORE 位就置起来了之后所有数据都会被硬件丢弃。解决中断回调严格只做 FIFO 搬运和置标志所有解析、应答全部丢到主循环。宁可主循环处理慢一点也不能让 ISR 变成全工程的瓶颈。5.4 ORE 溢出标志不处理跑一会儿彻底“装死”现象低速小数据量完全正常快速连续通信 30 秒后板子对任何串口输入都没有反应串口助手发数据也不回。原因接收频繁时只要有一次 ISR 处理不及时USART_SR_ORE就会被置位。如果代码里没有清这个标志后续 RXNE 中断可能一直进不来串口看起来就是死机。解决不管用不用 HAL 自带的错误回调都要做好 ORE 兜底。最简单的方式是在主循环周期检查if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_ORE) ! RESET) { (void)huart1.Instance-SR; (void)huart1.Instance-DR; }读 SR 再读 DR 就能清掉 ORE之后串口不需要重启就能继续接收。加了这个兜底上面的“装死”问题基本不会出现在现场。5.5 波特率看似一样高速帧时好时坏现象短帧没问题长帧偶尔错一个字节查了所有代码都找不到原因。原因F103 系统时钟不是标准 72MHz或者外部晶振负载电容不对导致实际波特率偏移。短帧时偏移被容差吸收长帧末尾累积到采样错误就表现为偶发单字节错误。解决先用逻辑分析仪抓 TX 引脚数一下单个 bit 的实际时间是 8.68us 还是明显偏了。如果偏了回时钟树看 HSE/PLL 配置不要盲目调串口分频器。记住一句话时钟对了波特率才会对。6. 二次开发把收发核心改造成 DMA、多串口和自定义协议6.1 从纯中断接收升级到空闲中断 DMA纯中断接收在 115200 下已经够用但如果你想跑 460800 以上或者一帧接近几百字节就要把“ISR 逐字搬运”改成 DMA 搬运。改动很小#define UART_DMA_BUF_SIZE 512U static uint8_t dma_rx_buf[UART_DMA_BUF_SIZE]; void uart_start_idle_dma(UART_HandleTypeDef *huart) { HAL_UARTEx_ReceiveToIdle_DMA(huart, dma_rx_buf, UART_DMA_BUF_SIZE); } void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart-Instance USART1) { /* Size 来自 DMA 接收到的实际字节数 */ for (uint16_t i 0; i Size; i) { uart_fifo_push(uart_rx_fifo, dma_rx_buf[i]); } uart_frame_len Size; uart_frame_ready 1U; uart_start_idle_dma(huart); /* 重启 DMA 接收 */ } }DMA 模式下要注意缓冲区是“一整块”一帧超过UART_DMA_BUF_SIZE时会被截断或发生 DMA 回绕异常。我的习惯是把 DMA 缓冲设为协议允许的最大帧长度并在回调开头做一次长度保护超过 512 字节直接丢帧并计数报警。6.2 多串口工程把 FIFO 装进结构体而不是写死全局变量如果设备要同时跑 USART1 和 USART2不要复制粘贴两份回调。把每个串口封装成一个上下文typedef struct { UART_HandleTypeDef *huart; uart_fifo_t rx_fifo; uint8_t *rx_buf; uint16_t rx_buf_size; volatile uint8_t frame_ready; volatile uint16_t frame_len; } uart_port_t;回调里用huart-Instance区分串口再对应操作各自的uart_port_t。这样协议解析、日志输出、Modbus 主从都可以共用同一套 FIFO 逻辑。从那以后我每拿到一个新的串口项目第一件事都是先把“空闲中断 FIFO”这层驱动写到位确认回显长度对了再往上叠协议和业务。先把边界打稳后面的解析、重发、状态机才不会天天给你找不痛快。希望帮到你。本文还有配套的精品资源点击获取