ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

模拟串口定时器扫描接收函数:环形队列与状态机完整实现

模拟串口定时器扫描接收函数:环形队列与状态机完整实现

模拟串口定时器扫描接收函数这个场景,做过单片机开发的朋友应该都不陌生:一根串口线连着上位机,你要在不依赖操作系统、不指望核心提供完整中断体系的前提下,把不定长的数据帧完整地收下来。很多人第一反应就是写一个RX中断回调,在回调里把字节塞进缓冲区,然后主循环里解析。这套思路本身没毛病,但在某些硬件资源紧张、串口数量多、或者工程里跑着RTOS且中断优先级分配非常棘手的情况下,你会发现自己被迫回到“定时器扫描接收”这条老路。

这篇文章就把我实际项目中验证过的一套“定时器扫描式串口接收”方案完整拆开讲。从定时器为什么会成为接收引擎、接收缓冲区怎么设计、扫描逻辑如何和帧协议配合,到参数怎么估算、坑在哪里,一条线理清楚。如果你手头正好资源紧张,或者想在模拟环境中先用定时器把串口接收逻辑跑通,后面的代码和思路可以拿过去直接改。

1. 定时器扫描接收的适用边界,以及它为什么重要

中断接收当然是理想方案。但谈能不能用,得先看手上有什么芯片、什么外设资源、以及整个系统的中断拓扑。我遇到过几种不得不放弃RX中断的情况:

  • 串口数量多,例如同时驱动三四路UART,外设中断向量和总线仲裁已经复杂,再给每路串口配独立RX中断,出bug之后排查链路很长;
  • 某些低端芯片的UART只支持查询模式,或者RXNE标志在特定低功耗状态下唤醒不及时;
  • 单片机主频不高,但数据速率也不太高,用定时器周期性查询足够覆盖数据到达的节奏;
  • 工程规模大,希望接收逻辑和数据解析严格分时隔离,中断里仅做标记,统一由定时器周期“扫描”。

定期器扫描的核心思路,就是把“我有新数据了”这个事件,从硬件中断里剥离出来,变成一个周期性的软件轮询动作。定时器负责提供稳定的时间节拍,每隔几百微秒到几毫秒去查看一次串口的接收缓冲区标志,然后把数据搬运到自己的环形队列里。

这听起来像是退化了,但在许多工业控制、传感器汇聚、协议网关场景中,它反而是工程上最稳的方案。因为接收速率低、帧长度短、时序实时性要求又不算苛刻,扫描节拍完全能追得上数据到达的速度。

1.1 定时器扫描的两个关键前提

不是所有串口场景都适合硬上扫描式接收。动手之前先确认这两件事:

第一,数据速率和帧长的乘积,要显著低于“扫描周期内能搬运的字节数”。比如波特率9600,每个字节约1.04ms,如果定时器扫描周期为1ms,一次扫描平均能取到接近一个字节,足够;但如果是115200波特率,一个字节约86.8us,扫描周期就必须压到几十微秒级别,否则缓冲区会溢出。

第二,缓冲区容量要足够容纳一个完整的数据帧。这个逻辑很朴素:既然扫描不是实时取走字节的,那硬件FIFO加软件队列就必须能装得下“从第一个字节到最后一个字节之间的所有数据”。如果缓冲区比最长帧还小,扫描稍慢就会丢帧尾。

1.2 模拟串口场景怎么理解

“模拟串口”这个词,我理解有两层意思。

一层是纯软件层面的模拟:在PC机上用虚拟串口对,或者用软件模拟单片机的UART外设寄存器,让接收逻辑跑在定时器扫描的模型下。这种场景常见于:

  • 项目早期硬件还没回来,先用上位机模拟串口设备,验证协议解析逻辑;
  • 自动化测试环境里,用定时器周期性向被测模块注入串口数据,模拟真实传感器上报。

另一层是指硬件串口不够用、或者执行链路不想中断化,于是通过外部引脚用定时器进行bit-banging式的接收扫描。不过bit-banging的时序和这里讨论的“定时器扫描接收函数”略有区别,本文侧重前者:硬件UART仍然在工作,只是我们用定时器周期性检查它的接收寄存器。

搞清楚这个边界之后,下面的缓冲区设计、扫描入口、协议解析,才能真正落地。

2. 接收模型拆解:硬件寄存器、软件缓冲、扫描节拍

定时器扫描式接收函数,本质上是一条“数据搬运链”:

硬件UART收到字节 → 放入硬件RDR寄存器 → 定时器触发扫描 → 软件读取寄存器并写入环形队列 → 上层协议解析取走数据

这中间每一环都要保证不阻塞、不覆盖、不丢失。因此,先把数据结构定清楚是整个方案的地基。

2.1 环形队列:扫描接收的标配数据结构

串口接收天然就是一个生产者、消费者模型。硬件是生产者,协议解析是消费者,定时器扫描只是搬运工。生产者的写入节奏不完全可控(取决于上位机什么时候发数据),消费者的读取节奏也不固定(取决于主循环什么时候调度解析任务)。环形队列正好解决两边节奏不一致的问题。

环形队列的基本实现并不复杂,但在多场景下有两个细节要特别注意:

  • 队头队尾指针的更新要用volatile修饰,防止编译器优化掉关键读写;
  • 队列为空的判断和队列为满的判断要能区分,常用方法是“留一个槽位不用”,用(tail+1)%max == head表示满。
#define RX_RING_SIZE 64 typedef struct { volatile uint8_t buf[RX_RING_SIZE]; volatile uint16_t head; volatile uint16_t tail; } ring_queue_t;

写入端比较简单,把字节放进队尾:

static inline int ring_write(ring_queue_t *q, uint8_t byte) { uint16_t next = (q->tail + 1) % RX_RING_SIZE; if (next == q->head) { return -1; /* 队列满 */ } q->buf[q->tail] = byte; q->tail = next; return 0; }

2.2 扫描函数要做什么、不做什么

经验不足的时候,最容易犯的错就是把扫描函数写成“又搬运又解析”,也就是在定时器中断里直接去判断帧头、丢帧尾、查校验。这样做表面上省了一次函数调用,实际上把不定长的协议解析逻辑拖进了中断上下文。协议一旦复杂,解析时间抖动会直接影响下一次扫描节拍,最终反而丢数据。

正确的分工方式是这样的:

  • 扫描函数只负责“把硬件接收寄存器里有数据搬到环形队列”;
  • 帧解析统一放在主循环或独立任务里;
  • 定时器扫描不关心协议内容,未知字节一视同仁地搬进队列。

这样处理的好处是,数据搬运的耗时变得固定。不管来的是1字节还是10字节,扫描一次的执行时间都是若干个内存拷贝操作加指针更新,时间耗尽全可控,不会因为协议分支出现极端耗时。

2.3 扫描入口的代码骨架

我把最常用的定时器扫描入口写成这样。这里以裸机环境为例,定时器中断里调用scan_uart_rx():

static ring_queue_t uart1_rx_q; static UART_Type *active_uart = /* 指向当前要扫描的串口 */; void timer_irq_handler(void) { scan_uart_rx(active_uart, &uart1_rx_q); } void scan_uart_rx(UART_Type *uart, ring_queue_t *q) { uint8_t byte; /* 尽量把当前RDR里的数据一次取完,而不是只取一个 */ while (uart->SR & UART_SR_RXNE) { byte = uart->RDR; if (ring_write(q, byte) != 0) { /* 队列满:记录溢出标志,视项目需要丢弃或置错误位 */ q_overflow_flag = 1; break; } } }

这里必须说明一点:扫描函数里用了while (uart->SR & UART_SR_RXNE)循环读取,而不是只读一次。原因很简单,虽然定时器周期固定,但上位机可能在两个扫描周期之间一个字节都不发,也可能连续发完一整帧。如果扫描周期是1ms,而数据帧是连续10个字节,那么在某一次扫描中会同时发现RXNE置位,如果不把它读完,下一次扫描就可能撞上缓冲区溢出。

很多从中断接收转过来的开发者,习惯一次中断只读一字节,到了定时器扫描场景就会漏数据。务必记住:在扫描模式下,一次扫描要“能读多少读多少”。

2.4 多串口扩展

当一个工程里有多个串口时,定时器扫描方案的优势就体现出来了。不需要每个串口都申请一个独立中断号,只需要在一份定时器中断里依次扫描各串口:

void timer_irq_handler(void) { scan_uart_rx(UART1, &uart1_rx_q); scan_uart_rx(UART2, &uart2_rx_q); scan_uart_rx(UART3, &uart3_rx_q); }

当然,前提是这几个串口的接收寄存器都不是“边读边清”的类型,或者你显式处理了状态标志。有些UART的RXNE标志在读取RDR后自动清除,有些需要手动写0。具体芯片具体分析,但扫描逻辑的骨架不变。

3. 定时器节拍的选型原则:频率、波特率、负荷之间的博弈

定时器扫描接收最容易被低估的就是节拍选型。节拍太慢,缓冲区溢出丢数据;节拍太快,CPU全部耗在查询上,主循环几乎没机会跑业务。

3.1 节拍由什么决定

首先要承认一个现实:定时器扫描接收的上限不会太高。它不是一个能支撑高波特率大流量的方案。一般实践中,9600波特率、38400波特率用扫描式完全没问题;115200波特率如果数据是稀疏的、短帧的,也能用;再往上,比如1M波特率甚至更高,扫描式就会非常吃力。

一个可估算的公式是:

设扫描周期为T_s,波特率对应字节时间为T_b(= 10 / 波特率,含起始位停止位),每个扫描周期平均到达的字节数为 T_s / T_b。为确保不丢数据,必须满足 RX环形队列长度 > 2 × T_s / T_b + 最大突发帧长。

之所以乘2,是为了给“扫描瞬间刚好来一批数据”留出缓冲余量。

举个例子:波特率9600,T_b约1.04ms,取扫描周期1ms,则每个周期平均到达约0.96字节。如果最大一帧是20字节,那么环形队列至少要能装下大约20 + 2 = 22字节,保险起见定到32或64。

再看115200,T_b约86.8us。取扫描周期100us,则每个周期平均到达1.15字节。如果帧长32字节,队列至少要35字节以上,而且CPU每100us就要进一次定时器中断做一轮扫描,负荷已经不小了。

3.2 实际选型时的优先顺序

我的做法是“先定协议,再定节拍,最后定队列长度”。具体步骤是:

  1. 确认最大单帧字节数;
  2. 确认波特率和字节时间;
  3. 选择一个让“每周期到达字节数”不超过1.5的扫描周期;
  4. 按上述公式计算队列长度,并向上取整到2的幂,方便队列模运算优化;
  5. 实测满载发包100000字节,统计溢出标志是否被置位。

如果你手上的芯片支持可调分频的定时器,尽量选能输出“接近整数微秒”的周期,方便用逻辑分析仪验证。

3.3 节拍选择的实测现象

我在某次测试中把扫描周期分别设为1ms、500us、200us,观察同一波特率下接收是否丢帧。观察结果是:

扫描周期9600波特率效果115200波特率效果
1ms稳定,无丢帧帧较长时偶尔溢出
500us稳定稳定,但CPU占用升高
200us稳定稳定,CPU占用明显

这说明问题的关键在于“扫描周期是否远小于字节间隔”,而不是扫描本身有多快。9600波特率下1ms扫描能稳定工作,是因为1ms接近1.04ms的字节时间,平均而言每次扫描只会有一次RXNE;115200波特率下1ms就太慢了,必须压缩到200~500us才保险。

4. 帧解析层:如何基于扫描式接收实现稳定的状态机

数据搬进环形队列只是第一步。真正让“模拟串口定时器扫描接收函数”可用的,是上层帧解析如何从队列里准确、鲁棒地切出每一帧数据。

4.1 不定长帧与定时器扫描的适配

常见的串口帧协议,往往是“帧头+长度字节+数据区+校验”。比如:

0xAA 0x55 LEN DATA[LEN] CHECKSUM

这类协议在中断接收模式下,习惯用逐字节状态机处理:每进一次中断就推进一次状态。到了扫描接收模式,处理方式略有不同,但状态机依然是最稳的选择。

区别在于:中断模式里,每个字节都有可能触发一次状态判断;扫描模式里,我们应该一次性把队列里的所有字节都丢进状态机,直到队列为空。这个“尽量把队列取空”的处理思路和扫描函数尽量读空硬件寄存器是同一逻辑,都是为了在定时器到达的瞬间把积压数据消费掉,给下一次扫描留出充足余量。

void rx_frame_process(ring_queue_t *q) { uint8_t byte; while (ring_read(q, &byte) == 0) { rx_state_machine(byte); } }

4.2 一个可复用的通用状态机模板

用状态机解析串口帧的好处是,它天然能应对“帧跨多个扫描周期”的情况。扫描只负责搬运数据,解析状态在两次扫描之间会保持,因此哪怕上位机把一个帧分成两段发送,也不影响解析结果。

我常用的模板是三态+一长度计数器:

enum { ST_IDLE, ST_HDR2, ST_LEN, ST_DATA, ST_CHK } rx_state; uint8_t expect_len; uint8_t data_count; uint8_t checksum; uint8_t frame_buf[64]; void rx_state_machine(uint8_t byte) { switch (rx_state) { case ST_IDLE: if (byte == 0xAA) { rx_state = ST_HDR2; } break; case ST_HDR2: if (byte == 0x55) { rx_state = ST_LEN; } else { rx_state = ST_IDLE; /* 回到寻找帧头状态 */ } break; case ST_LEN: expect_len = byte; data_count = 0; checksum = 0; rx_state = ST_DATA; if (expect_len == 0) { rx_state = ST_CHK; } break; case ST_DATA: frame_buf[data_count++] = byte; checksum ^= byte; if (data_count >= expect_len) { rx_state = ST_CHK; } break; case ST_CHK: if (byte == checksum) { /* 帧校验通过,可以取走frame_buf */ handle_valid_frame(frame_buf, expect_len); } rx_state = ST_IDLE; break; default: rx_state = ST_IDLE; break; } }

这段代码里,checksum用的是异或校验,实际项目按你的协议换成CRC或累加和即可。重点在于状态机的推进完全由“当前字节”和“当前状态”决定,而不管这个字节是哪一个扫描周期带来的。

4.3 为什么状态机解法优于“查帧头+内存拷贝”

有些开发者追求简单,直接在环形队列里查找帧头,找到就把整段数据拷贝出来解析。这个思路在低负载、单帧环境是可以跑的,但一旦数据流中出现误码或干扰字节,查找法会产生“伪帧头”误判,而且伪帧头之后的所有数据全被污染。

状态机的好处是,任何时候只要校验不过,状态立刻回到IDLE,重新等待帧头,最多只丢失这一帧。对工业现场偶发干扰的处理能力明显更强。

4.4 扫描式接收下的超时判断

扫描接收还有一个中断模式少有的问题:怎么判断一帧数据接收完成?

中断模式下,人们常用“空闲中断”或者“接收到最后一个字节后启动软件超时计时”。扫描模式下反而简单了:既然我们自己是定时器驱动的,那么天然可以在定时器扫描里顺便数一下“距离上一次收到新数据已经过了多少个扫描周期”。如果连续几个扫描周期都没有读到新字节,并且状态机不是在IDLE态,就认为这一帧“传输超时”。

void timer_irq_handler(void) { if (scan_uart_rx(active_uart, &uart1_rx_q) > 0) { rx_timeout_cnt = 0; } else if (rx_timeout_cnt < 0xFFFF) { rx_timeout_cnt++; } if (rx_timeout_cnt >= 3) { on_rx_timeout(); rx_timeout_cnt = 0; } }

这里的“3个扫描周期”只是个经验值。具体要选几个周期,取决于协议允许的最大帧内字节间隔。如果上位机发完帧头后因为某种原因停顿了很长时间,状态机就能在超时后安全复位,避免半个残帧一直卡在状态机里不出来。

5. 仿真与测试:让函数在真实数据流里站稳脚跟

这部分是我最想强调的。定时器扫描接收这套逻辑,停留在纸面上时感觉很简单,真正接入系统后,你会发现bug往往不在扫描函数本身,而在于周边环境的干涉。

5.1 用PC端虚拟串口模拟真实设备的注入测试

如果手头硬件还没到位,可以先在PC上模拟测试。创建一对虚拟串口,比如COM3和COM4,被测程序监听COM3,测试脚本向COM4周期性写入模拟帧数据,观察被测程序是否能完整解析出每一帧。

测试脚本最核心的写法是:模拟真实设备的“不规则发包”。很多人在仿真测试时习惯固定间隔、固定长度地发数据,测出来的结果过于理想。真实环境中,上位机通常会受到操作系统调度、USB转串口缓冲、应用层负载的影响,发送时间间隔并不均匀。因此,至少要构造以下三种模式来压测:

测试模式具体做法考察点
均匀间隔每100ms发一帧,帧长固定基本功能正确性
突发连续一次性发50帧,间隔仅1~2ms环形队列吞吐、是否溢出
随机抖动间隔在5ms~50ms间随机变化扫描节拍与解析状态机的鲁棒性

这三种模式跑完,基本能还原出真实环境中最棘手的场景。突发连续模式最容易暴露“环形队列太小”的问题,随机抖动模式最容易暴露“超时判断太敏感”的问题。

5.2 常见bug复盘

以下四类bug是我在实际开发中真实遇到过的,每条都能让你排查好几个小时:

第一类,扫描函数里一次只读一字节导致漏数据。表面现象是偶发性丢字节,逻辑分析仪上能看到RXNE拉高但程序就是没读完。这需要把扫描函数里的while循环加回去。

第二类,环形队列的读写指针跨优化级别后跑飞。编译器在高优化等级下可能把head和tail的读操作缓存到寄存器里,导致写端认为队列满、读端认为队列空。解决方法是给共享变量加volatile,必要时使用内存屏障。

第三类,定时器中断和协议解析函数操作同一个队列。扫描中断写队尾,主循环解析读队头,表面上一个写一个读没有竞态,但如果读操作改动了tail的缓存副本,或者主循环在判断队列空时被中断打断,就可能出现指针错乱。可靠做法是:保证环形队列的单次读取操作在关中断保护下完成,也就是:

uint8_t ring_read(ring_queue_t *q, uint8_t *byte) { uint8_t r = 0; __disable_irq(); if (q->head != q->tail) { *byte = q->buf[q->head]; q->head = (q->head + 1) % RX_RING_SIZE; r = 1; } __enable_irq(); return r; }

第四类,波特率误差累积导致边缘采样错位。扫描式接收虽然不直接参与位采样,但它的扫描节拍本身存在误差,如果定时器精度不够,或者分频系数选择不合理,导致扫描的实际周期比预期长,缓冲区就可能在最长帧场景下溢出。这种问题在9200波特率以上更容易被观察到。

5.3 调试时的观测手段

推荐一个非常有效的调试组合:逻辑分析仪测UART RX线、定时器中断GPIO翻转、队列溢出标志输出。

操作方法如下:在定时器中断入口处翻转一个空闲GPIO,在环形队列溢出时置位另一个GPIO。用逻辑分析仪同时采集这三个信号。当出现丢帧时,你能直接在波形上看到:

  • 串口RX上明明有连续字节到达;
  • 定时器扫描GPIO有规律地翻转;
  • 溢出GPIO在某一刻突然拉高。

这一个动作就能把问题从“到底哪里丢了”快速定位到“是扫描不够快还是队列不够深”,非常管用。

5.4 对Ring队列长度的一次实际调参记录

某次项目里,我用115200波特率接收一台上位机的数据帧,最长帧为80字节。起初我将环形队列设为96字节,500us扫描周期,满载测试时发现偶发溢出。那时候我直接用队列长度向上涨,改成128字节,问题仍然存在。后来用逻辑分析仪看波形才发现,上位机每秒会发一次突发包,一次突发里连发三帧,也就是240字节会在极短时间窗口内涌进来。500us扫描周期完全扛不住这么密集的数据,把扫描周期降到100us之后,128字节队列就稳定了。

这个案例说明了一个很关键的原则:发现丢数据时,先看“数据到达的突发特性”,再调队列长度或扫描周期。盲目加大队列只是在掩盖扫描周期不够短的根本问题。

6. 定时器扫描接收背后的工程取舍思路

文章最后,我想聊一点比代码更重要的东西。定时器扫描式串口接收,在技术层面确实比中断式接收“低端”一些,但在工程层面,它代表了一种非常典型的取舍方式:用CPU时间片换取中断复杂度。

实际项目中,我见过很多开发者在串口接收这件事上过度设计。明明协议帧很短、数据量很小,非要配一堆DMA、空闲中断、双缓冲区,结果调试成本比功能本身还高。而定时器扫描方案,在合适的场景里,往往半小时就能调通并稳定运行。

不过也要清醒地认识到:扫描方案不是免费的。它的代价是CPU负荷增加、实时性响应延迟变大、数据吞吐上限受限。如果未来某一天项目需要把波特率提高到460800以上,或者需要同时处理大量串口数据流,那你还是得回到中断加DMA的路线上来。

从工程演进的角度看,定时器扫描接收更像是一把“瑞士军刀”:它不华丽,但绝大多数低中速业务场景都够用,而且足够简单可靠。我个人的习惯是,优先使用这套方案把业务跑通,如果后期压力测试显示瓶颈确实在串口接收上,再针对性升级成中断或DMA方式。这样既能快速交付,又不至于在一开始就陷入底层外设配置的泥潭里。

这套思路同样适用于那些在PC上做模拟串口和通信协议仿真的项目——先让接收逻辑稳定、健壮、可观察,再考虑高性能传输的事。希望这篇拆解能帮你在自己的项目里少踩几个坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进