☰
STM32串口中断收发实战:环形缓冲与帧解析解决丢包问题
2026/10/17 3:49:25 网站建设 项目流程

简介:这份资源是STM32 HAL库串口接收数据的参考代码工程,面向正在学习STM32串口通信、需要实现中断接收与ASCII指令解析的嵌入式开发者。工程基于STM32F405平台,采用HAL库编写,串口以ASCII码形式发送数据,并以回车符作为一帧数据的结束标志,适合用于调试终端交互、上位机指令下发等场景。压缩包共85个文件,约647KB,其中50个.h头文件与22个.c源文件构成核心代码,涵盖UART、IO、main及中断服务等模块,另含.ioc配置、.uvprojx工程、.hex固件与启动文件等,可直接用MDK-ARM打开编译。目前已有2130人学习下载。读者可从中获取完整的串口中断接收流程、回车结束符判定逻辑与工程目录组织方式,便于快速移植到自己的STM32项目中进行二次开发与排错验证。

1. 串口中断收发总丢包?先看这份 UARTRecIT 资源能解决什么

如果你调过 STM32 的串口,大概率遇到过这种场景:主循环里轮询接收,波特率一高就丢字节;换成中断接收,数据一多又出现半包、粘包,甚至 HardFault。更头疼的是,网上找的例程要么只发不收,要么收完就卡死,想改成自己的协议还得从头啃寄存器。这份名为 UARTRecIT.zip 的资源,核心就是一套基于 STM32 串口中断收发(UART Receive Interrupt)的完整工程,把「中断接收 + 环形缓冲 + 帧解析」这条链路串了起来。它适合正在用 HAL 库或标准库做串口通信的嵌入式开发者,尤其是需要稳定接收不定长数据的场景,比如上位机指令下发、传感器数据回传、多机通信。资源本身不依赖特定开发板,只要芯片带 USART 外设就能移植,对新手来说能直接跑通,对熟手来说可以当成中断收发的参考骨架。

2. 中断收发链路拆解:从 USART 寄存器到环形缓冲

2.1 为什么轮询和 DMA 之外还要看中断方案

STM32 的串口接收常见三种方式:轮询、中断、DMA。轮询最省事,但 CPU 被绑死在等待上,波特率 115200 以上时,一个字节间隔只有约 87 微秒,主循环稍微干点别的就错过。DMA 适合大批量定长数据,配置起来要算通道、对齐、传输长度,遇到不定长帧还得靠空闲中断配合,调试门槛不低。中断方案处在中间:每个字节触发一次 RXNE 中断,CPU 进中断取走数据,只要中断服务函数足够短,就能兼顾实时性和灵活性。UARTRecIT 这套资源选的就是中断路线,重点解决「中断里不能做耗时操作」和「数据来了怎么攒成一帧」这两个矛盾。

2.2 环形缓冲区的读写指针与满/空判定

中断接收最怕的就是数据覆盖。假设你开一个 128 字节数组,中断里往里面塞,主循环里取出来解析,如果主循环取慢了,写指针追上读指针,旧数据就被冲掉。环形缓冲用两个指针解决这个问题:写指针head只在中断里移动,读指针tail只在主循环里移动。判空条件是head == tail,判满条件是(head + 1) % size == tail。注意这里牺牲一个字节空间来区分满和空,是常见做法。下面这段代码是资源里环形缓冲的核心逻辑,我按自己的习惯加了注释:

// ring_buffer.h #define RING_BUF_SIZE 128 typedef struct { uint8_t buffer[RING_BUF_SIZE]; volatile uint16_t head; // 中断里写 volatile uint16_t tail; // 主循环里读 } ring_buf_t; // 中断里调用:写入一个字节,返回 0 成功,-1 表示满 int ring_buf_put(ring_buf_t *rb, uint8_t data) { uint16_t next = (rb->head + 1) % RING_BUF_SIZE; if (next == rb->tail) { return -1; // 缓冲区满,丢弃或置错误标志 } rb->buffer[rb->head] = data; rb->head = next; return 0; } // 主循环里调用:取出一个字节,返回 0 成功,-1 表示空 int ring_buf_get(ring_buf_t *rb, uint8_t *data) { if (rb->head == rb->tail) { return -1; // 空 } *data = rb->buffer[rb->tail]; rb->tail = (rb->tail + 1) % RING_BUF_SIZE; return 0; }

这里head和tail都加了volatile,防止编译器优化导致主循环读到寄存器里的旧值。RING_BUF_SIZE取 128 是权衡:太小容易满,太大占 RAM,一般串口帧不会超过 64 字节,留一倍余量够用。如果你要收 1KB 以上的数据包,建议改用 DMA + 空闲中断,中断方案不适合这种量级。

2.3 中断服务函数里到底该写几行

HAL 库把中断处理拆成了HAL_UART_IRQHandler和回调HAL_UART_RxCpltCallback。很多人直接在回调里做协议解析,结果主循环卡顿、串口溢出。正确的做法是:回调里只做一件事——把收到的字节塞进环形缓冲,然后立刻重新开启接收中断。资源里的写法是这样的:

// 全局变量 ring_buf_t uart_rb; uint8_t rx_byte; // 单字节接收缓存 // 初始化时开启第一次接收中断 void uart_recv_start(void) { HAL_UART_Receive_IT(&huart1, &rx_byte, 1); } // 接收完成回调(每收到 1 字节进一次) void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { ring_buf_put(&uart_rb, rx_byte); // 只入缓冲 HAL_UART_Receive_IT(&huart1, &rx_byte, 1); // 重新武装 } }

注意HAL_UART_Receive_IT必须在回调里重新调用,否则收完一个字节就停了。这是新手最容易翻车的地方——串口只收到第一个字节,后面全没反应。另外回调里不要加printf或延时,中断上下文里做这些会拖垮系统。

2.4 主循环里怎么把缓冲数据拼成帧

数据进了环形缓冲,接下来要在主循环里解析。常见协议有两种:定长帧和不定长帧。定长帧简单,攒够 N 个字节就处理;不定长帧一般靠帧头、帧尾或长度字段。资源里给了一个状态机解析的示例,假设协议是0xAA 0x55 + 长度 + 数据 + 校验:

typedef enum { STATE_IDLE, STATE_HEAD1, STATE_HEAD2, STATE_LEN, STATE_DATA, STATE_CHECK } parse_state_t; void uart_parse_task(void) { static parse_state_t state = STATE_IDLE; static uint8_t len = 0, index = 0; static uint8_t payload[64]; uint8_t byte; while (ring_buf_get(&uart_rb, &byte) == 0) { switch (state) { case STATE_IDLE: if (byte == 0xAA) state = STATE_HEAD1; break; case STATE_HEAD1: state = (byte == 0x55) ? STATE_HEAD2 : STATE_IDLE; break; case STATE_LEN: len = byte; index = 0; state = (len > 0 && len <= sizeof(payload)) ? STATE_DATA : STATE_IDLE; break; case STATE_DATA: payload[index++] = byte; if (index >= len) state = STATE_CHECK; break; case STATE_CHECK: // 这里做校验,比如累加和 if (byte == calc_checksum(payload, len)) { handle_frame(payload, len); // 业务处理 } state = STATE_IDLE; break; default: state = STATE_IDLE; break; } } }

这个状态机每进一次主循环就把缓冲里的数据全部消费掉,不会阻塞。payload大小根据你的最大帧长调整,别开太小导致长帧被丢。handle_frame里再做具体业务,比如控制 GPIO、回传数据。整个链路下来,中断只负责搬运,解析在主循环,分工明确。

3. 移植到自己的工程:时钟、引脚、中断优先级怎么配

3.1 从 CubeMX 生成代码到手动补全中断逻辑

如果你用 STM32CubeMX,配置 USART1 为异步模式,波特率 115200,8 数据位,无校验,1 停止位。NVIC 里勾选USART1 global interrupt,优先级别设太高,一般比 SysTick 低一点,避免打断系统 tick。生成代码后,CubeMX 只会帮你初始化外设,不会自动加环形缓冲和回调逻辑,需要手动把上面几段代码填进去。具体步骤:

  1. 在main.c的/* USER CODE BEGIN PV */区域定义ring_buf_t uart_rb;和uint8_t rx_byte;
  2. 在/* USER CODE BEGIN 2 */里调用uart_recv_start();
  3. 把HAL_UART_RxCpltCallback写到/* USER CODE BEGIN 4 */区域
  4. 在while(1)里调用uart_parse_task();

注意HAL_UART_Receive_IT的第三个参数是接收长度,这里写 1 表示每收一个字节进一次中断。如果你写 10,就是收满 10 个才进回调,适合定长帧,但中间出错不好恢复。

3.2 中断优先级与波特率的匹配关系

中断优先级配置有个经验值:USART 中断的抢占优先级不要高于 SysTick,否则HAL_Delay可能被反复打断导致延时不准。但也不能太低,如果系统里有其他高频中断(比如定时器 1kHz),USART 优先级要高于它,否则串口数据来不及取。以 115200 波特率为例,一个字节 87 微秒,中断服务函数执行时间要控制在 20 微秒以内,否则连续收数据时会丢。用 HAL 库时,HAL_UART_IRQHandler本身有几十行判断,实际测下来 72MHz 的 F103 进一次中断约 5 到 8 微秒,加上环形缓冲写入,总共 10 微秒左右,余量够。如果你超频到 168MHz 的 F4,更宽裕。

3.3 用示波器和逻辑分析仪验证收发时序

调串口不能只看代码,得看波形。拿逻辑分析仪接 TX 和 RX,发一串0xAA 0x55 0x01 0x02 0x03,看波形里起始位、数据位、停止位是否干净。如果发现 RX 线上有毛刺,检查波特率误差:HAL 库算出来的 BRR 寄存器值和实际波特率可能有偏差,误差超过 3% 就容易误码。用示波器测一个位宽,比如 115200 下一位是 8.68 微秒,实测如果偏差大,换晶振或调整HAL_UART_Init里的OverSampling参数。资源里默认是 16 倍过采样,一般不用改。

4. 避坑与排查:串口中断收发的五个血泪教训

4.1 现象:只收到第一个字节,后面全无反应

原因:HAL_UART_RxCpltCallback里忘了重新调用HAL_UART_Receive_IT。HAL 库的接收中断是一次性的,收完指定长度就关闭 RXNE 中断。解决:在回调末尾重新武装,或者改用HAL_UART_Receive_IT的循环模式(但 HAL 不直接支持,得手动重调)。

4.2 现象:数据偶尔错位,帧头对不上

原因:环形缓冲的head和tail没加volatile,编译器优化后主循环读到的head是缓存值,导致读出的数据顺序错乱。解决:两个指针都加volatile,并且读写时不要用位域或结构体打包,避免编译器重排。

4.3 现象:进中断后程序卡死,看门狗复位

原因:在中断回调里调用了HAL_Delay或printf。HAL_Delay依赖 SysTick 中断,而 SysTick 优先级低于 USART 时,进 USART 中断后 SysTick 被屏蔽,HAL_Delay永远等不到 tick,死循环。解决:中断里只做数据搬运,需要延时或打印的放到主循环。

4.4 现象:波特率 9600 正常,调到 115200 就丢包

原因:中断服务函数执行时间过长,或者主循环里uart_parse_task被其他任务阻塞太久,环形缓冲溢出。解决:用 GPIO 翻转测中断执行时间,优化代码;或者把环形缓冲加大到 256 字节;再不行就降波特率或换 DMA。

4.5 现象:移植到另一块板子后收不到数据

原因:引脚复用没配置对。比如 USART1 默认是 PA9/PA10,但有些板子用的是 PB6/PB7,CubeMX 里要改引脚映射,同时检查 GPIO 模式是否设为Alternate Function Push-Pull,上拉电阻是否使能。解决:对照芯片数据手册的复用表,用GPIO_InitStruct.Mode = GPIO_MODE_AF_PP,Pull = GPIO_PULLUP。

5. 进阶:把中断收发改成协议栈,再加一层超时重传

5.1 用空闲中断 + 中断接收做不定长帧的混合方案

纯中断方案每字节进一次中断,115200 下每秒 11520 次中断,CPU 占用约 5% 到 10%。如果数据量再大,可以结合空闲中断(IDLE):平时用中断收,检测到总线空闲后触发一次 IDLE 中断,在 IDLE 回调里把整帧数据取走。这样中断次数不变,但解析时机更明确。配置方法:在HAL_UART_Receive_IT基础上,使能__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE),然后在USART1_IRQHandler里判断 IDLE 标志,清标志后调用解析。注意 HAL 库没有直接提供 IDLE 回调,需要自己写 IRQHandler 或者用HAL_UARTEx_ReceiveToIdle_IT(F4/H7 支持)。

5.2 加超时重传:让上位机知道下位机还活着

串口通信没有握手,发出去的数据对方收没收到不知道。可以在协议里加一个 ACK 机制:上位机发指令,下位机解析后回一个 ACK 帧,如果上位机 100ms 内没收到 ACK,就重传,最多重传 3 次。实现上用一个软件定时器,每次发送后启动,收到 ACK 后停止。下面是一个简单的重传逻辑:

typedef struct { uint8_t send_buf[64]; uint8_t send_len; uint8_t retry_count; uint32_t last_send_tick; uint8_t waiting_ack; } uart_send_ctx_t; void uart_send_with_retry(uart_send_ctx_t *ctx, uint8_t *data, uint8_t len) { memcpy(ctx->send_buf, data, len); ctx->send_len = len; ctx->retry_count = 0; ctx->waiting_ack = 1; ctx->last_send_tick = HAL_GetTick(); HAL_UART_Transmit_IT(&huart1, ctx->send_buf, ctx->send_len); } // 主循环里轮询 void uart_retry_task(uart_send_ctx_t *ctx) { if (ctx->waiting_ack && (HAL_GetTick() - ctx->last_send_tick > 100)) { if (ctx->retry_count < 3) { ctx->retry_count++; ctx->last_send_tick = HAL_GetTick(); HAL_UART_Transmit_IT(&huart1, ctx->send_buf, ctx->send_len); } else { ctx->waiting_ack = 0; // 放弃,上报错误 } } }

收到 ACK 时把waiting_ack置 0 即可。这个机制在工业现场很实用,但注意重传次数别太多,否则总线拥塞。

5.3 验证方法:用 Python 脚本做压力测试

调完代码,最好用上位机脚本跑一遍压力测试。用 Python 的pyserial库,每 10ms 发一帧 64 字节的数据,连续发 10000 帧,看下位机回传的丢包率。脚本大概这样:

import serial, time, struct ser = serial.Serial('COM3', 115200, timeout=0.1) frame = b'\xAA\x55' + bytes([64]) + bytes(range(64)) + b'\x00' lost = 0 for i in range(10000): ser.write(frame) resp = ser.read(70) if len(resp) != 70: lost += 1 time.sleep(0.01) print(f"丢包率: {lost/10000*100:.2f}%")

如果丢包率超过 1%,回头查环形缓冲大小和中断优先级。我一般会把RING_BUF_SIZE设成最大帧长的 4 倍,留足余量。从那以后我每次调串口中断,都强制先跑一遍这个压力测试,确认不丢包再往下做业务。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询