STM32 USART1环形队列接收:彻底解决串口丢数据问题
2026/9/13 12:28:25 网站建设 项目流程

简介:面向嵌入式开发者的环形队列串口通信方案,基于意法半导体STM32系列中的USART外设,演示了如何利用环形缓冲区解决接收数据丢失的问题。工程以F103与F102C8T6两款芯片的USART1接口为蓝本,采用中断接收方式,在中断服务程序中将新收到的字节压入队列尾部,主循环再从队列头部取出数据进行处理,通过头、尾指针的移动实现先进先出的异步缓存,并在队列写满时设置溢出标志。资源包含170个文件,主要构成有C源程序、头文件、存储配置文件、Keil工程文件,以及编译生成的hex烧录文件、axf可执行文件和map映射文件等,压缩包整体约4.24MB。目前已有592人学习下载。通过学习该工程,可掌握环形队列结构体与头尾指针的操作逻辑、队列初始化与判满判空函数、串口接收中断服务程序写法,以及溢出处理策略;同时也可参照其RCC时钟使能、GPIO复用配置和USART参数设置等底层代码。目录中一并提供标准外设库的多个驱动模块,便于了解完整工程依赖和编译链接关系。工程代码结构清晰,适合嵌入式入门者研究原理,也适合有经验开发者直接提取复用,可有效提升高数据速率下的串口通信稳定性。

1. 串口丢数据的根因与USART1环形队列的适用边界

很多工程师遇到串口丢字节,第一反应是调低波特率或者把主频拉高,结果问题照旧。真正的原因往往不在链路速率,而在中断服务程序把字节丢进全局变量后,主循环还没来得及取走,下一个字节的 RXNE 中断已经到了。这种 ISR 与任务上下文之间的节奏错配,靠寄存器级打补丁是补不完的。环形队列用一个预先分配的数组做 FIFO 缓冲,把收字节和处理字节彻底解耦,是嵌入式串口接收里最直接的解法。这份资源就是基于 STM32F103 / STM32F102C8T6 标准外设库的 USART1 环形队列实现,工程文件里包含了 rcc、gpio、usart、tim、adc 等标准外设库模块,核心逻辑在 stm32f10x_usart.c 与自建的队列模块中,适合正在写裸机串口通信,或者想把手上的串口工程改造成队列驱动模型的读者。

2. 环形队列的数据结构设计与FIFO判空判满逻辑

2.1 为什么用环形结构而不是线性数组

线性数组实现队列时,出队操作必须移动数据。假设缓冲区有 16 字节,队头出队一个字节后,后续 15 个字节全部要往前搬一格,复杂度 O(n)。在主循环里做倒还好,但如果这个操作出现在中断服务程序里,长搬移会显著拉长关中断时间,直接恶化实时性。环形队列让头指针和尾指针在数组下标上循环回绕,入队和出队都只做一次指针位移和一次取模运算,时间复杂度 O(1)。

这和链表队列的区别也要说清楚。链表节点需要动态分配内存,嵌入式裸机环境下 malloc 容易产生碎片,而且节点指针的开销在小 RAM 芯片上不可忽略。STM32F103 常规型号 RAM 只有 20KB 左右,一个 64 字节的静态数组足够缓存一帧完整指令,完全没有必要引入链表。数组环形队列的优势是内存静态分配、访问确定、无碎片,代价是容量固定,但串口接收缓冲区容量本来就是一个可以按协议帧上限估算的值。

2.2 队列结构体定义与头尾指针语义

队列结构体需要把缓冲区地址、容量、头指针、尾指针打包在一起。头指针指向下一个可读位置,尾指针指向下一个可写位置,两者相等时队列为空。

typedef struct { uint8_t *buffer; /* 缓冲区地址,由外部静态数组传入 */ uint16_t size; /* 缓冲区容量,必须是2的幂可优化取模 */ volatile uint16_t head; /* 读指针,主循环上下文修改 */ volatile uint16_t tail; /* 写指针,中断上下文修改 */ } RingQueue;

volatile 修饰很关键。head 和 tail 分别在中断服务程序和主循环两个上下文里被修改,编译器如果不知道这两个变量会被异步更新,很可能把读取操作优化成寄存器缓存值,导致判空判满失效。参数上 buffer 用 uint8_t 指针指向外部数组,这样队列模块不持有数据,便于多个串口各自创建独立队列实例。size 建议选择 2 的幂,比如 16、32、64,这样取模运算可以替换成位与操作,在 Cortex-M3 上能省几条指令。

2.3 判空判满:预留空位法与计数法的取舍

环形队列最容易被坑的地方是判满。如果只用 head == tail 判断队列状态,空和满会冲突。工程上有两种解决思路:预留一个空位,或者单独维护一个计数器。

方案判定逻辑内存开销可用容量适合场景
预留空位法tail + 1 == head 即满无额外开销size - 1大多数裸机串口场景
计数法count == size 即满额外 1 字节计数size需要精确统计积压字节数时

推荐在串口接收场景用预留空位法。少存一个字节的代价微乎其微,但省掉了计数器的维护逻辑。计数法在 Push 和 Pop 时都需要更新 count,而且 count 同样要 volatile,读写路径变长,中断里多几条指令。

uint8_t RingQueue_IsEmpty(RingQueue *q) { return (q->head == q->tail); } uint8_t RingQueue_IsFull(RingQueue *q) { return (((q->tail + 1) % q->size) == q->head); }

2.4 Push 与 Pop 的实现要点

入队和出队都遵循先写数据、后移指针的顺序。这样即使指针更新瞬间被打断,另一侧读到旧指针也只会多等一个周期,不会读到半写状态的数据。

uint8_t RingQueue_Push(RingQueue *q, uint8_t data) { if (RingQueue_IsFull(q)) { return 0; /* 满则返回失败,由调用方决定是否丢弃 */ } q->buffer[q->tail] = data; q->tail = (q->tail + 1) % q->size; return 1; } uint8_t RingQueue_Pop(RingQueue *q, uint8_t *data) { if (RingQueue_IsEmpty(q)) { return 0; /* 空则返回失败,主循环继续等待 */ } *data = q->buffer[q->head]; q->head = (q->head + 1) % q->size; return 1; }

这套接口遵循单生产者单消费者模型:USART1 中断只调用 Push,主循环只调用 Pop。两侧不并发操作同一个指针,所以不需要关中断保护。如果你把 Pop 放到另一个中断里,就必须加临界区保护,否则会出现 head 被两边同时改的竞态。队列实例在 main 函数里初始化,把静态数组地址传进去,这一步在 USART 初始化之前完成。

3. 基于标准外设库的USART1初始化和中断接收

3.1 时钟使能与GPIO引脚配置

STM32F103 的 USART1 挂在 APB2 总线上,最高 72MHz。APB2 与 APB1 的差异容易踩坑:USART2 和 USART3 挂在 APB1 上,最高只有 36MHz,这在计算波特率分频系数时必须区分。USART1 的 TX 默认在 PA9,RX 在 PA10,部分板子会重映射到 PB6/PB7,资源包里没有做重映射,走默认引脚即可。

RCC_APB2PeriphClockCmd(RCC_APB2Periph_USART1 | RCC_APB2Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_9; /* TX */ GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; /* 复用推挽输出 */ GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_10; /* RX */ GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; /* 浮空输入 */ GPIO_Init(GPIOA, &GPIO_InitStructure);

TX 引脚配置成 GPIO_Mode_AF_PP,复用推挽输出,由 USART1 外设控制引脚电平。RX 引脚配置成浮空输入即可,对上位机 TX 的电平驱动能力要求不高。如果你的板子外部没有上下拉电阻,建议把 RX 改成 GPIO_Mode_IPU 上拉输入,避免空闲态电平不确定导致收到杂波。GPIO_Speed 在复用功能下影响翻转速率,串口波特率几 Mbps 级别用 50MHz 档位完全够。注意使能时钟时必须同时打开 GPIOA 和 USART1,漏掉任何一路外设都会导致寄存器写入无效。

3.2 USART_InitStructure各参数与波特率精度

标准外设库的初始化结构体把串口参数都集中在 USART_InitStructure 里,参数含义直接对应参考手册。波特率、字长、停止位、校验位、硬件流控和收发模式是一组完整配置。

USART_InitStructure.USART_BaudRate = 115200; USART_InitStructure.USART_WordLength = USART_WordLength_8b; USART_InitStructure.USART_StopBits = USART_StopBits_1; USART_InitStructure.USART_Parity = USART_Parity_No; USART_InitStructure.USART_HardwareFlowControl = USART_HardwareFlowControl_None; USART_InitStructure.USART_Mode = USART_Mode_RX | USART_Mode_TX; USART_Init(USART1, &USART_InitStructure);

115200 是串口调试最常用的波特率,在 72MHz 的 APB2 时钟下,USARTDIV 分频值为 39.0625,标准库取整后误差在 0.1% 以内。波特率越高,分频值越小,取整误差被放大。460800 以上建议优先用 16MHz 外部晶振并核对 BRR 寄存器值,而不是依赖内部 RC 振荡器。很多所谓的高速串口乱码,排查到最后是时钟源精度不够,不是代码逻辑问题。数据位和校验位有一个联动点:如果开启奇偶校验,数据位要选 USART_WordLength_9b,因为第 9 位是校验位,有效数据仍为 8 位。不用的硬件流控必须设为 None,否则 RTS/CTS 引脚状态会影响收发。

3.3 NVIC配置与中断优先级分组

串口接收中断的优先级设置直接关系到丢数据的概率。F1 内核的 NVIC 优先级分组通过 NVIC_PriorityGroupConfig 设定,这里使用 Group_2,也就是 2 位抢占优先级和 2 位子优先级。

NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure);

PreemptionPriority 设为 1,比主循环中可能用到的滴答定时器优先级高,保证串口中断能及时抢占。如果你的工程里还有 DMA 中断或定时器捕获中断,需要按实时性要求重新排优先级。串口中断的抢占优先级不必设为 0,最高优先级应留给对时序最敏感的模块,比如电机编码器捕获。同一个抢占优先级下,子优先级决定响应顺序。只开一个串口中断时不需要分得那么细,但保留这个配置结构,后面扩展 USART2 时直接复制一份改通道号就行。

3.4 USART1_IRQHandler中的RXNE处理与ORE陷阱

中断服务程序的体量要尽量小。RXNE 置位表示接收数据寄存器非空,此时读取数据寄存器即可取到完整字节。标准外设库提供了判断标志位和读取数据的接口,但实际工程里直接操作寄存器更直观。

void USART1_IRQHandler(void) { uint8_t data; volatile uint32_t tmp; if (USART_GetFlagStatus(USART1, USART_FLAG_ORE) != RESET) { tmp = USART1->SR; /* 先读SR再读DR,清除溢出标志 */ tmp = USART1->DR; } if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { data = (uint8_t)(USART1->DR & 0x1FF); /* F1的DR为9位有效数据 */ RingQueue_Push(&rx_queue, data); } }

读 DR 寄存器本身就会清除 RXNE 标志,所以 USART_ClearITPendingBit 这一句在 F1 上对 RXNE 没有额外效果,可写可不写。但要特别留意 ORE 溢出错误。当 RXNE 仍为 1 而新字节已经到达时,硬件置位 ORE 并停止接收,如果不在中断里清除 ORE,后续所有字节都会被硬件丢弃。清除 ORE 的固定套路是先读 USART_SR,再读 USART_DR,顺序不能反。把 ORE 检查放在 RXNE 之前是必要的,否则一次持续的数据突发就能让串口永久卡死。

3.4.1 读DR清标志与ORE恢复

这里有个容易被忽略的细节:USART_GetITStatus 和 USART_GetFlagStatus 都是读状态寄存器,本身不清标志。ORE 的清除动作完全依赖对 SR 和 DR 的读操作顺序。中断服务程序里如果只做USART_ReceiveData(USART1)而不先读 SR,ORE 标志会一直挂在那里,接收中断虽然还在触发,但数据已经进不了 DR。很多串口程序跑着跑着就收不到数据,重启后又正常,多半是 ORE 堆积导致。资源里的中断实现没有丢这个分支,接入已有工程时不要因为看着多余就删掉。

3.5 主循环中的队列消费与解耦逻辑

接收侧只负责把字节塞进队列,协议解析放在主循环。这样中断服务程序的执行时间被压缩到微秒级,即使主循环里在跑 Flash 写入或者屏幕刷新这类耗时操作,串口数据也不会丢。

uint8_t byte; while (1) { if (RingQueue_Pop(&rx_queue, &byte)) { process_byte(byte); /* 协议解析或数据累积 */ } else { /* 队列为空,可以让出CPU或做低优先级任务 */ } }

主循环 Pop 的频率决定了数据从接收到被处理的延迟,但不会影响数据完整性。只要队列容量大于 ISR 两次触发之间主循环能消费的量,系统就是稳定的。如果主循环任务较重,可以把 Pop 放到一个 1ms 周期的定时器中断里,只是这样就要在 Pop 侧加临界区保护。延迟和吞吐量的平衡点在具体工程里不一样,但队列容量设为协议最大帧长度的两倍是一个比较稳的起点。

4. 队列溢出策略、不定长帧解析与DMA选型对比

4.1 队列满时的三种处理策略

环形队列容量有限,数据涌入速度持续高于消费速度时,队列必然填满。满队列不是错误状态,而是系统过载的信号。工程上有三种处理策略:丢弃新数据、覆盖最旧数据、暂停接收。

策略实现方式数据特征适用场景
丢弃新数据Push 返回失败即放弃新旧数据都重要指令协议,丢包可请求重发
覆盖最旧数据满时 head 跟随 tail 前进新数据优先级高连续采样数据,旧值可丢
暂停接收中断里禁止 RXNE 中断数据必须按序处理背压式流控,配合上位机暂停发送

丢弃新数据是最常用的策略。Push 返回 0 时,中断直接放弃当前字节,并置一个溢出标志供主循环查询。覆盖最旧数据适合传感器数据流场景,处理器跟不上,与其处理迟到的旧值,不如直接读当前最新值。暂停接收策略在裸机裸串口里用得少,因为它会让上位机那边产生超时,反而更容易乱。

4.2 溢出标志通知上层的实现

溢出标志是中断上下文向主循环传递过载信息的通道。主循环定期检查该标志,可以把事件上报到调试串口或记录到日志缓冲区。

volatile uint8_t rx_overrun_flag; void USART1_IRQHandler(void) { uint8_t data; if (USART_GetITStatus(USART1, USART_IT_RXNE) != RESET) { data = (uint8_t)(USART1->DR & 0x1FF); if (!RingQueue_Push(&rx_queue, data)) { rx_overrun_flag = 1; } } }

主循环发现 rx_overrun_flag 后,先记录溢出次数,再清标志,不需要立即处理队列里已有的数据。这种机制把过载检测和数据处理分离,避免在中断里做复杂的状态处理。溢出标志本身用 volatile 修饰,因为它在中断里写,在主循环里读。如果你同时使用了多个串口,每个队列实例配一个溢出标志,状态互相隔离。

4.3 用环形队列接不定长协议帧

环形队列的核心价值体现在接收不定长帧。固定长度帧可以直接按字节数截取,不定长帧则需要额外的帧边界判断手段。常用的协议帧格式是头部 + 长度 + 数据 + 校验,主循环从队列里逐字节读取状态机,碰到帧头进入累加模式,长度字段决定帧尾位置,最后校验通过就交给应用层。

队列在这个过程里的作用是把字节缓存和帧解析解耦。中断只负责字节进队列,主循环的解析状态机从队列取字节时,即使帧被拆成两段到达,状态机也能跨次 Pop 正确组帧。这个模型不要求一帧数据在物理层连续到达,是它比固定长度 DMA 接收更灵活的地方。

4.3.1 空闲定时器判定帧结束

另一种不定长帧解法是空闲超时判定。收到一个字节后启动定时器,比如 TIM3,定时 3 个字符时间;若定时器溢出前又有新字节到达,则重置定时器。超时说明总线空闲,认为一帧结束。

void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); frame_ready = 1; /* 主循环发现该标志后处理完整帧 */ TIM_Cmd(TIM3, DISABLE); } }

空闲定时器适合 Modbus RTU 这类没有显式帧头、靠时间间隙分帧的协议。定时时长通常取 3.5 个字符时间,115200 波特率下约 304 微秒。这个方案结合环形队列后,数据缓存由队列管理,定时器只负责产生帧结束事件,结构非常干净。STM32 的标准外设库定时器模块在资源工程里已经包含,这块逻辑可以直接基于 stm32f10x_tim.c 实现。

4.4 中断逐字节接收与DMA接收的选型对比

高波特率下逐字节中断的 CPU 占用会显著上升。115200 波特率每秒约 11520 字节,每个字节触发一次中断,Cortex-M3 的中断进出开销加上入队操作约 50 个周期,每秒占用不足 1% CPU,完全可接受。但到 921600 波特率时,中断频率翻 8 倍,CPU 占用明显增加,而且每次中断都可能打断主循环里的关键代码段。

DMA 接收可以批量搬运,让外设直接把数据写入内存缓冲区,只在 DMA 传输完成或总线空闲时产生一次中断。

DMA_InitStructure.DMA_PeripheralBaseAddr = (uint32_t)&USART1->DR; DMA_InitStructure.DMA_MemoryBaseAddr = (uint32_t)rx_dma_buffer; DMA_InitStructure.DMA_DIR = DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize = RX_DMA_BUFFER_SIZE; DMA_InitStructure.DMA_PeripheralInc = DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc = DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize = DMA_PeripheralDataSize_Byte; DMA_InitStructure.DMA_MemoryDataSize = DMA_MemoryDataSize_Byte; DMA_InitStructure.DMA_Mode = DMA_Mode_Circular; DMA_Init(DMA1_Channel5, &DMA_InitStructure);

F1 的 USART1_RX 对应 DMA1_Channel5,开启循环模式后,DMA 自动回绕写入,配合 USART 的空闲中断即可实现不定长帧的零拷贝接收。DMA 方案的代价是链路管理变复杂:环形 DMA 缓冲区的读位置需要根据 DMA 当前剩余计数算出,而且要处理半传输中断。两种方案的分界线大致在 460800 波特率。低于这个速率、不需要极限吞吐的场景,中断逐字节接收加环形队列的代码量更少,可读性更好,推荐优先落地。高于这个速率再切 DMA,收益才值得额外复杂度。

5. 环形队列的验证方法与调试验证技巧

5.1 Keil Watch窗口观察head/tail

编译工程生成 USART.axf 后,用 ST-LINK 进入 Keil 调试模式,把 rx_queue.head 和 rx_queue.tail 添加到 Watch 窗口。上位机发送一帧数据后单步暂停,观察两者差值,差多少就是队列里有几字节未被消费。如果差值大于 0 但主循环没有处理,说明 Pop 逻辑没跑到;如果差值长期为 0 且数据也丢了,说明中断根本没触发。这个手段能快速把问题定位在接收侧还是处理侧,比瞎猜串口参数高效得多。

5.2 故障注入测试溢出

验证队列容量是否合理,可以人为制造过载条件。主循环里屏蔽 Pop 调用,让队列只进不出,再用串口助手一次性发送超过队列容量的数据。观察 rx_overrun_flag 置位时的发送字节数,再对比队列容量计算值,能确认溢出保护逻辑是否生效。恢复 Pop 后,队列会按 FIFO 顺序吐出缓冲数据,这时候检查数据序列的连续性能发现队列管理是否有错位。测试时建议用 0x00 到 0xFF 的递增序列,错一个字节都能立即看出来。

5.3 GPIO电平法量化ISR占用

想知道环形队列方案在中断上到底花了多少时间,用逻辑分析仪或示波器量最直接。在 USART1_IRQHandler 入口把某个空闲 GPIO 拉高,出口拉低:

void USART1_IRQHandler(void) { GPIO_SetBits(GPIOC, GPIO_Pin_6); /* 标记进入中断 */ /* ... 原有接收处理 ... */ GPIO_ResetBits(GPIOC, GPIO_Pin_6); /* 标记退出中断 */ }

示波器上测高电平宽度,再乘以每秒中断次数,就是 ISR 占用的 CPU 比例。115200 波特率下,这个值通常在 1% 以内。如果测出来超过 5%,说明中断里混进了耗时操作,要检查是否在中断里做了协议解析或者打印。这个方法也可以在切换到 DMA 方案后做对比,用数据说服自己方案切换的必要性。

5.4 一个改善:按需批量Flush

队列积压场景下,逐字节 Pop 加逐字节处理会产生大量函数调用开销。批量 Flush 的思路是一次性把队里现有的数据全部复制到局部数组,再统一处理。这里给出一个完整的批量出队实现:

uint16_t RingQueue_Flush(RingQueue *q, uint8_t *out, uint16_t max_len) { uint16_t count = 0; while (count < max_len && !RingQueue_IsEmpty(q)) { out[count++] = q->buffer[q->head]; q->head = (q->head + 1) % q->size; } return count; }

调用侧先探测队列状态,再决定是否整批处理。批量处理减少了状态机切换损耗,也能让协议解析函数的输入变成连续内存块,配合 memcpy 直接搬运到应用缓冲区更高效。这个接口和逐字节 Pop 可以共存,处理短指令用 Pop,处理大数据帧用 Flush。实测在 460800 波特率下批量 Flush 能比逐字节处理多挤出约 30% 的 CPU 裕量,对数据密集型的串口应用是一笔很划算的优化。

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

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

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

立即咨询