news 2026/9/13 13:13:43

STM32 USART1环形队列接收:从原理到代码解决串口丢字节

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 USART1环形队列接收:从原理到代码解决串口丢字节

简介:面向STM32F103与STM32F102C8T6的USART1串口环形队列工程包,为嵌入式开发中需要处理串口高频收发、规避数据丢失场景的开发者提供了一套完整可参考的实现。工程以标准外设库为基础,包含USART1初始化配置、接收中断服务程序、环形队列结构体定义与入队出队操作等核心代码段,同时对队列溢出检测与处理策略给出注释式示例,并保留了编译过程的中间文件,便于对照学习、单步调试和二次移植。压缩包共170个文件,主要涵盖.c与.h源文件、Keil工程配置文件(.uvprojx/.uvoptx)、编译产物(.o/.d/.crf)以及可直接烧录的.hex与调试辅助的.axf/.map等,整体约4.24MB。已有592人学习浏览,对于想快速理解串口队列机制、减少接收数据丢失的开发者,这份资源具有直接的参考和使用价值。

1. 环形队列不是炫技:USART1 丢字节时你就知道它值了

调试一块 STM32F103C8T6 最小系统板,串口助手每隔几十毫秒发来一帧协议数据,你只在中断里把每个收到的字节存进一个uint8_t buf[8],然后用一个全局下标往后填。前 100 帧一切正常,第 101 帧开始,帧头对不上、校验和乱跳。查了半天发现:主循环里处理数据要花 3ms,这期间串口来了 12 个字节,数组只有 8 格,后面的字节覆盖了还没处理的帧头。USART1 硬件本身的接收移位寄存器只有 1 字节深,你不在中断里及时读走,DR 里的数据就会被下一个字节顶掉,硬件层面连「丢了多少」都数不清。环形队列在这类场景里不是代码风格问题,而是用一块连续内存配合两个指针,把「收到但还没处理」的数据先兜住,让中断只负责存、主循环只负责取,两边通过读写指针解耦。本文以 STM32F103 的标准外设库(V3.5 固件库)为例,从环形队列的结构设计讲到 USART1 的具体落地代码,最后附上调试手段和常见坑。

2. 在 STM32F103 上设计环形队列:结构、空满判断与缓冲区容量

2.1 环形队列的核心数据结构:head、tail 与容量计算

环形队列本质上是一个环形缓冲区,内存是普通的一维数组,逻辑上首尾相连。在 STM32F103 的实现里,我习惯用一个结构体把缓冲区、读写指针和容量绑在一起,而不是散落成全局变量。一个最小可用的定义长这样:

#define USART1_RX_BUF_SIZE 256 /* 缓冲区大小,必须是2的幂,后续判断可以优化 */ typedef struct { uint8_t buf[USART1_RX_BUF_SIZE]; /* 存储区 */ volatile uint16_t head; /* 写入位置,串口中断里更新 */ volatile uint16_t tail; /* 读取位置,主循环里更新 */ } usart1_ring_t; static usart1_ring_t g_rx = { .head = 0, .tail = 0 };

这里两个关键点。第一,headtail必须加volatile,因为这两个变量一个在中断上下文里被改写,另一个在主循环里被改写,编译器如果不知道它们会被异步修改,很可能会把读取操作优化成寄存器里的旧值,导致明明有数据却读不到。第二,缓冲区大小取 2 的幂不是玄学。环形队列的指针回绕通常写成head % size,而如果 size 是 2 的幂,取模可以编译成位与运算head & (size - 1),在 Cortex-M3 上能省掉一条除法指令。STM32F103 主频 72MHz,串口 115200 波特率下每个字节间隔约 87μs,循环里省几条指令无所谓,但在 1M 波特率下,中断频繁触发时这点差异会影响主循环的最大连续处理时间。

2.2 空判断与满判断:留一格还是加计数器

环形队列最容易写错的是空和满的区分。head == tail时队列一定是空的,但队列满时head == tail也成立,这就出现了歧义。常见做法有三种,工程上我推荐第三种。

第一种是留一个格子不用:当(head + 1) % size == tail时认为队列满。代价是实际可用空间少 1 字节。第二种是加一个独立计数器count,入队时count++,出队时count--,满判断就是count == size。这样空间利用率 100%,但维护 counts 需要额外注意原子性,在单核 Cortex-M3 上,中断里对count的写操作和主循环的读操作天然不会交错,因为中断不会嵌套打断同一个 USART1 中断,所以这个方案反而很安全。

第三种是尾巴指针加数据类型自带的语义,比如用head ^ tail的最高位表示方向,常见于部分 RTOS 的队列实现。对于 STM32F103 串口应用,我通常选第二种,多一个变量多一份直观,排查问题的时候直接看三个数:head、tail、count,比盯两个数猜状态要快得多。

/* 入队:写入一个字节,返回0成功,-1失败 */ int ring_write(usart1_ring_t *q, uint8_t byte) { if (q->count >= USART1_RX_BUF_SIZE) { /* 队列已满 */ q->overflow++; return -1; } q->buf[q->head] = byte; q->head = (q->head + 1) & (USART1_RX_BUF_SIZE - 1); q->count++; return 0; } /* 出队:读取一个字节,返回0成功,-1失败 */ int ring_read(usart1_ring_t *q, uint8_t *byte) { if (q->count == 0) { /* 队列为空 */ return -1; } *byte = q->buf[q->tail]; q->tail = (q->tail + 1) & (USART1_RX_BUF_SIZE - 1); q->count--; return 0; }

我们再标出这个代码里容易抄错的三个细节。第一处,ring_readcount的递减放在tail更新之后,顺序不能反,因为如果先count--,紧接着来了一个高优先级中断往队列里写数据,可能在tail还没更新完成前就触发了空判断,读走旧数据。第二处,overflow计数变量用来记录丢失的字节数,这是后期排查硬件干扰或主循环阻塞时长的重要线索,很多人不统计它,等真出问题的时候只能盲猜。第三处,位与回绕能成立的前提是USART1_RX_BUF_SIZE是 2 的幂,如果你把容量改成 200 这种非 2 幂数,这里的& (size - 1)会随机跳指针,必须换回% size

2.3 USART1 与 USART2/3 的差异:为什么选 USART1

STM32F103 上 USART1 挂载在 APB2 总线上,时钟来自 PLL 输出经 APB2 预分频后的 72MHz;而 USART2 和 USART3 挂在 APB1 总线上,最高 36MHz。两边的外设时钟频率不同,直接导致波特率发生器的分频误差不一样。比如跑 115200 波特率时,USART1 用 72MHz 做分频基准,误差在 0.01% 量级;USART2/3 用 36MHz,部分波特率下误差会到 0.1% 以上。多数 UART 设备容忍 ±2% 的波特率偏差,但如果你做的是 Modbus 轮询或者多机通信,收发双方都用 F103 且时钟配置不同,波特率误差会叠加,最容易在长帧尾部出现字节错位。

另一个差异在中断向量和 DMA 请求上。USART1 的 DMA 请求映射在 DMA1 的 Channel4,USART2 在 Channel6,USART3 在 Channel2。如果后续想从「中断收字节进队列」改造成「DMA 收一批进内存、空闲中断判帧结束」,中断通道的优先级分组也不同。在标准外设库里,USART1_IRQn的优先级默认可以调到比USART2_IRQn更高,而 NVIC 的抢占优先级分组一旦设置,后续改动优先级需要整体重新规划。我的建议是:主串口如果跑的关键协议,就用 USART1,把它的抢占优先级设为最高,让其他串口中断排队等它。

3. 直接用标准外设库写一组可抄的 USART1 环形队列代码

3.1 USART1 初始化:GPIO、NVIC 和中断配置一起写

标准外设库要求先把 GPIO 的复用功能打开,再配置 USART 本身,最后开中断。这一步顺序错了,常见现象是串口完全没输出,或者收到的全是 0xFF。我按 V3.5 库的接口给出可用的初始化函数:

void usart1_init(uint32_t baudrate) { GPIO_InitTypeDef gpio; USART_InitTypeDef usart; NVIC_InitTypeDef nvic; /* 1. 打开 USART1 和 GPIOA 的时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); /* 2. 配置 TXD=PA9 为复用推挽输出,RXD=PA10 为浮空输入 */ gpio.GPIO_Pin = GPIO_Pin_9; gpio.GPIO_Speed = GPIO_Speed_50MHz; gpio.GPIO_Mode = GPIO_Mode_AF_PP; /* 复用推挽 */ GPIO_Init(GPIOA, &gpio); gpio.GPIO_Pin = GPIO_Pin_10; gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING; /* 浮空输入 */ GPIO_Init(GPIOA, &gpio); /* 3. USART 参数 */ usart.USART_BaudRate = baudrate; 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); /* 4. 只开接收中断,发送用轮询 */ USART_ITConfig(USART1, USART_IT_RXNE, ENABLE); /* 5. NVIC:抢占优先级 1,子优先级 1 */ nvic.NVIC_IRQChannel = USART1_IRQn; nvic.NVIC_IRQChannelPreemptionPriority = 1; nvic.NVIC_IRQChannelSubPriority = 1; nvic.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&nvic); USART_Cmd(USART1, ENABLE); }

这里 GPIO 速度 50MHz 是配置内部驱动能力,不是串口速率;如果你用 2MHz 也能工作,但边沿会变缓,长走线时更容易受噪声干扰。PA10 用浮空输入是因为标准库例程多这么写,但强烈建议改用GPIO_Mode_IPU上拉输入:当外部设备未连接或处于高阻态时,浮空输入会把 RX 线拉到不确定电平,一旦噪声触发起始位,USART 会收到 0x00 或 0xFF 的假数据,而环形队列会把它当成真数据存起来。

3.2 中断回调:让 RXNE 标志驱动队列入队

USART1 的中断处理函数里,要区分多种中断源。RXNE(接收数据非空)是我们要处理的,空闲中断帧 IDLE 在纯中断模式下通常不开。一个稳妥的写法如下:

void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t byte = (uint8_t)USART_ReceiveData(USART1); ring_write(&g_rx, byte); /* 入队失败时 g_rx.overflow 会自增 */ } }

读取USART_ReceiveData(USART1)这个动作本身就会清除 RXNE 标志位。如果这里改写成先读USART1->SR再用USART1->DR操作寄存器位,注意读 SR 再读 DR 的先后顺序不能变,这是 STM32 手册里明确写的流程。中断里不做任何业务逻辑,不解析帧、不剥帧头,只负责把字节塞进队列。每个字节平均占用中断时间大约 1μs 到 2μs,在 115200 波特率下,中断频率约 11.5kHz,占用主频时间占比低于 2%,完全可以接受。

3.3 主循环里怎样消费队列

while (1) { uint8_t byte; while (ring_read(&g_rx, &byte) == 0) { /* 在这里做帧解析、协议处理,或者把数据搬运到别的队列 */ process_byte(byte); } /* 没有数据时执行其他任务,比如 LED 翻转、按键扫描 */ }

这段代码容易踩的坑是process_byte如果执行时间太长,USART1 中断里后续字节会把队列写满,然后触发 overflow。所以如果协议处理比较复杂,比如要解析 Modbus CRC 或转存到 Flash,建议主循环一次性从队列里读出整帧数据再处理,不要在process_byte里做耗时操作。一个经验值:队列大小至少是最大协议帧长度的两倍,给中断留出「主循环还没来取」时的缓冲空间。115200 波特率下,256 字节的队列能扛住约 22ms 的主循环阻塞,这个时间足够跑完多数轻量级协议解析。

4. 用环形队列收不定长帧:超时判帧、DMA 方案对比与混用技巧

4.1 主循环里的超时判帧逻辑

串口接收最实际的需求是「收到一帧完整的数据,帧长度可变」。环形队列本身不感知帧边界,需要我们自己定义:当收到一个字节后,如果超过若干毫秒没有后续字节,就认为一帧结束。这个超时机制放在主循环比放在中断里好,因为主循环可以阻塞,中断里不能做delay_ms

static uint32_t last_rx_tick; /* 记录最后一个字节入队的时间 */ void process_byte(uint8_t byte) { frame_buf[frame_len++] = byte; /* 先存进帧缓冲区 */ last_rx_tick = HAL_GetTick(); /* 标准外设库可改成读 SysTick 计数值 */ } uint8_t try_get_frame(uint8_t *out, uint8_t *len) { uint8_t byte; while (ring_read(&g_rx, &byte) == 0) { process_byte(byte); } if (frame_len > 0 && (get_tick() - last_rx_tick) > 5) { /* 超过 5ms 没有新字节,认为一帧结束 */ memcpy(out, frame_buf, frame_len); *len = frame_len; frame_len = 0; return 1; } return 0; }

这里几个参数要按实际业务调。第一是超时时间:9600 波特率下,1 个字节的传输时间约 1.04ms,5ms 超时意味着最多允许 4 个字节间隙;如果是 Modbus RTU,标准要求帧间隔 3.5 个字符时间,9600 波特率下约 4ms,超时可以取 5ms 覆盖。115200 波特率下 1 字节约 87μs,5ms 相当于留了 57 字节的间隙,明显太长,这种速率下我一般取 2ms。第二是frame_len的上限,你要在process_byte里加保护,超过帧缓冲区大小就丢弃并重新开始,否则一帧超长数据会把帧缓冲区写穿,覆盖其他全局变量。

4.2 DMA 接收 + 空闲中断 vs 队列方案

串口接收还有一条路线:DMA 把数据搬运到指定缓冲区,配合空闲中断判断一帧结束。两种方案各有适用场景,我用一张表对常用维度做个对比:

比较项环形队列 + 逐字节中断DMA + 空闲中断
CPU 开销每字节进一次中断,约 1~2μs每帧进一次中断,DMA 搬运无 CPU 参与
帧长度约束只需帧缓冲区够大受 DMA 传输长度寄存器限制,一般最长 65535
空闲判帧主循环计时间差,简单灵活依赖 USART 空闲中断,需要额外处理
实现复杂度低,代码直观高,要处理 DMA 半传输/完成/空闲三类中断
适用场景帧长可变、帧率低、控制器高频定长数据流、音频或传感器连续采样

如果丢包率高到无法接受,很多人会直接改 DMA 方案。但有个折中路:保持队列作为整个串口数据链路的第一层缓冲,中断照常入队,另开一个 DMA 缓冲做整块搬运,队列变成 DMA 缓冲和协议解析之间的中间层。这种混用在 Modbus 多站轮询里很实用:DMA 攒够一帧,空闲中断把整帧标记为有效,主循环再把整块搬进队列做解析,既有 DMA 的低 CPU 开销,又保留队列对数据量大小的不敏感特性。不要在这个场景里用 DMA 循环模式,因为循环模式写入的缓冲区永远在覆盖,你无法自然地感知帧边界。

4.3 中断里追加一个字节计数:定位丢包和帧分裂

排查串口问题最缺的是「数据到底丢在哪一环」的证据。在中断回调里加一个只增不减的计数变量,是成本最低的手段:

volatile uint32_t g_rx_isr_count; /* 中断里收到的总字节数 */ volatile uint32_t g_rx_overflow; /* 队列溢出次数 */ void USART1_IRQHandler(void) { if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { uint8_t byte = (uint8_t)USART_ReceiveData(USART1); g_rx_isr_count++; if (ring_write(&g_rx, byte) != 0) { g_rx_overflow++; } } }

如果g_rx_isr_count和设备实际发送字节数一致,而队列消费出来的字节数不足,说明问题出在主循环消费侧或判断逻辑,和中断无关。如果两边一致但协议解析不对,那问题在帧边界判断。这三个计数都能通过调试器实时查看,比逐帧抓逻辑分析仪快得多。

5. 队列之外的三个细节:USB 转串口芯片的背压、SysTick 时钟基准和工程移植

5.1 USB 转串口芯片的背压效应

很多人把环形队列做好了,却发现还是偶尔丢字节,尤其是用 CH340、CP2102 这类 USB 转串口芯片连接 PC 的时候。根因不在 STM32 侧,而在 USB 芯片内部:PC 端的串口助手如果读得不及时,USB 芯片内部的 FIFO 满了,会给发送端返回流控信号。如果接线没有接硬件流控的 CTS/RTS,芯片只能默默把发不出去的数据扔掉。表现为主机明明发送了 100 个字节,USART1 中断只进了 90 次。这块的责任不在队列,但排查时先确认一句:用跳线短接板子的 TX 和 RX,让单片机自发自收,如果不再丢字节,说明链路中 USB 芯片的背压是主因

5.2 用 SysTick 做统一的时间基准

超时判帧、帧间隔统计都要用到一个毫秒计数。标准外设库的SysTick默认配置成 72MHz 时钟、重装载值 72000,每 1ms 产生一次中断。我自己习惯在syscalls.c里重写_getchar_write,让printf重定向到 USART1 发送,同时把系统时基函数统一成get_tick(),避免在中断和主循环里各维护一个millis变量。具体做法是在SysTick_Handler里把一个全局volatile uint32_t g_tick加一,所有需要记录时间的地方都读它,保证时间戳全局唯一。注意不要在printf的实现里直接操作 USART1 寄存器,如果 USART1 正在中断里入队,发送和接收共用同一外设时会有竞争,发送用轮询等 TXE 标志,接收中断只动数据寄存器,工程上一般不会冲突。

5.3 把这份代码移植到 STM32F102 或更大容量的 F103 时看哪里

标题里出现STM32F102C8T6,这类芯片和 F103 在 USART 外设上完全一致,区别主要在 Flash 容量、主频上限和一些模拟外设,直接复制代码即可。移植到STM32F103ZET6大容量芯片时注意两件事:一是时钟树 APB2 频率可能不同,如果外部晶振是 8MHz 且启用 PLL 倍频到 72MHz,配置一致;但如果你用 12MHz 晶振或改过倍频系数,波特率误差会变,建议用USART_ClockInit检查一下实际波特率。二是中断向量表,大容量芯片的启动文件用startup_stm32f10x_hd.s,中容量用md.s,启动文件选错会出现进不了中断的诡异现象,而队列代码本身没有差别。

验证队列是否健壮,我建议做一遍这样的测试:主循环只做HAL_Delay(20)什么都不取,串口助手以最高速率发 2000 字节,对比g_rx_isr_count和发送总数,如果g_rx_overflow为 0 且中断计数对上,说明队列容量足够容纳主循环阻塞期间的全部数据。再用调试器改小容量到 32 字节,观察溢出计数如何随延迟增长,这会直观建立「缓冲区大小、主循环阻塞时间、波特率」三者关系的量感。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/13 13:13:28

PostgreSQL连接失败?一文彻底搞懂pg_hba.conf配置与排查方法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/13 13:12:25

京东电商大数据分析:Hadoop与Echarts实战

1. 项目背景与核心价值 京东作为国内头部电商平台,每天产生数以亿计的消费行为数据。这些数据中隐藏着用户偏好、消费趋势、商品关联等宝贵信息。传统的数据分析方式已无法有效处理如此庞大的数据量,这正是大数据技术发挥价值的场景。 这个毕业设计项目…

作者头像 李华
网站建设 2026/9/13 13:11:32

分布式坐席KVM系统:从原理到落地的完整指南

项目标题里的这几个字——“主机多、工位散、距离远”——几乎把我这些年做过的调度中心、机房管控项目全概括了。分布式坐席系统KVM听起来是个挺硬核的词,但说白了就一件事:把不同房间、不同楼甚至不同园区里的服务器信号,通过IP网络拉到你手…

作者头像 李华
网站建设 2026/9/13 13:07:09

基于STM32与机智云的智慧厨房环境监测与报警系统设计

简介:基于STM32F103C8T6的智慧厨房系统完整工程,面向嵌入式初学者、毕业设计、课程设计及工程实训人群,也可作为初期项目立项参考。系统采集烟雾、火焰、一氧化碳、煤气等多路气体传感器数据,经ESP8266模块上传至机智云平台&#…

作者头像 李华
网站建设 2026/9/13 13:04:59

异步导出方案设计与实现:从原理到实践

1. 异步导出方案设计背景在数据处理领域,导出操作是最常见也最耗时的任务之一。传统同步导出方式存在三个致命缺陷:首先,当数据量达到百万级时,导出过程可能耗时数分钟甚至更久,导致用户界面长时间无响应;其…

作者头像 李华