串口中断里每收到一个字节就马上扔给解析函数,主循环稍微卡顿几十毫秒,数据就哗啦啦丢一片。这个场景我相信搞嵌入式的都遇到过。项目里加上日志、传感器、按键扫描之后,主循环越来越长,丢帧成了家常便饭。回头去看,问题往往不是解析逻辑慢,而是串口的“字节节奏”和业务的“帧节奏”中间缺了一层缓冲。给串口加上环形队列,正是给这两种节奏之间加了一台缓存器。这就是我这次要分享的核心内容。
这篇内容不是把环形队列的原理抄一遍,而是结合我在实际设备上的改动和验证,把为什么用、怎么写、怎么定队列长度、怎么做协议解析、遇到丢数据怎么排查,一步步讲清楚。适合刚接触 MCU 串口通信的新手,也适合那些已经在中断里解析协议解析到吐的老哥。看完之后,你可以直接把它移植到自己的工程里。
1. 为什么一提到串口数据,就先想到环形队列
1.1 串口数据接收的经典困境
先看最常见的三种处理方式,都踩过坑。
第一种:中断里每收到一个字节就做业务解析。问题是串口中断优先级通常不低,ISR 里做字节解析、判断状态机、复制 buffer,一套流程跑下来可能几十微秒甚至更多。处理器在这段时间里被占用,如果系统里还有定时器、ADC 或其他中断,很容易造成时序抖动。更糟的是,解析逻辑一旦依赖某个全局变量或用了printf,可能直接死锁。
第二种:主循环轮询接收标志位,有字节就读、就解析。这个问题更直白——主循环里总有其他任务,比如刷新屏幕、处理按键、读写 Flash。某一次长时间阻塞后,串口 FIFO 本来就那么深,硬件 FIFO 一旦溢出,多出来的字节直接丢掉。位带操作再好,也救不了来不及读的数据。
第三种:用一个变量缓存“上一个字节”。这只能对付一次只来一个字节的情况。实际串口通讯通常是连续来一串数据,长度还不固定。比如 GPS 模块输出的一句 NMEA 报文,可能有几十到上百字节,中间还有不同间隔。
这些场景说明一件事:串口的接收是“生产”过程,业务的解析是“消费”过程。两个过程速度不匹配,如果不加缓冲,生产方一旦快过消费方,数据就被牺牲。环形队列就是用来匹配这一对速度矛盾的。
1.2 环形队列解决的核心矛盾
环形队列本质上是一块固定内存,逻辑上首尾相接。它有两个指针:写指针(head)和读指针(tail)。生产者在 head 处写入数据并推进 head,消费者在 tail 处读取数据并推进 tail。当指针到达内存末尾,会回到开头继续用,因此叫“环形”。
你可以把它想象成食堂打饭的排队窗口:窗口后厨不停出菜(生产),打饭的人不停端走(消费)。如果出菜速度快而打饭的人还没到,菜就先放在取餐台(队列)上。取餐台长度就是队列容量,限制在于放不下时新菜只能倒掉(数据溢出)。
相比普通数组、链表,环形队列有几个非常实在的优点:
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 普通数组+头尾指针 | 实现简单,内存固定 | 数据搬移麻烦,越界需小心 | 简单固定长度缓冲 |
| 链表 | 长度可以动态扩展 | 内存碎片,指针开销,实时性难保证 | RAM 很大、无硬实时要求 |
| 环形队列 | 固定内存,读写 O(1),无需搬移 | 长度有限,满时需定义策略 | 串口字节流缓冲,协议解析 |
每次读写都只操作指针,不做数据搬移,这在中断里很关键。中断处理函数必须短小,往环形队列里塞一个字节只需要几条语句,开销极小。主循环什么时候有空,什么时候再把队列里的字节取出来慢慢解析,谁也不会拖累谁。
1.3 什么时候不该用环形队列
环形队列不是银弹。如果协议本身就非常短且固定,比如一个字节就代表一个命令,硬件 FIFO 都足够,那没必要额外加一层。如果用了 DMA 接收并且每帧都有空闲中断,数据一帧一帧完整到达,可以直接在空闲中断里搬到业务缓冲区,也不一定需要 ring buffer。但这种情况依然常见——因为后面往往还要做协议头识别、拆包、校验,那时一个更大的缓冲仍然很顺手。
另一个需要留意的点是:如果系统响应速度极差,主循环可能几十毫秒都没机会消费队列,那队列再大也会被填满。环形队列只是给你一个“喘息空间”,不能替代合理的任务调度。设计时一定要知道自己的系统最差阻塞时间是多少。
2. 一种适合单片机的环形队列实现,附完整代码
2.1 结构体和空满判断
先给一个最常用的实现。为了方便索引,一般把队列大小定义为 2 的 N 次幂,然后用位与代替取模。如果大小是 256,那么head & 0xFF就等于head % 256,性能更好,也避免除法。
#define RBUF_SIZE 256 // 必须是 2 的幂 typedef struct { unsigned char buf[RBUF_SIZE]; volatile unsigned int head; // 写指针,中断里更新 volatile unsigned int tail; // 读指针,主循环更新 volatile unsigned int count; // 当前积压的字节数 } ring_buf_t;这里的volatile不要漏。因为 head、tail、count 会被中断和主循环共享,编译器如果不知道它们随时可能变,可能会优化掉某些读取,导致读到旧值。
初始化、单字节写、单字节读:
void rb_init(ring_buf_t *rb) { rb->head = 0; rb->tail = 0; rb->count = 0; } int rb_write(ring_buf_t *rb, unsigned char ch) { if (rb->count >= RBUF_SIZE) { return -1; // 队列满 } rb->buf[rb->head & (RBUF_SIZE - 1)] = ch; rb->head++; rb->count++; return 0; } int rb_read(ring_buf_t *rb, unsigned char *ch) { int ret = -1; __disable_irq(); if (rb->count > 0) { *ch = rb->buf[rb->tail & (RBUF_SIZE - 1)]; rb->tail++; rb->count--; ret = 0; } __enable_irq(); return ret; }这里用count而不是“浪费一格”来区分空和满,原因很实际:查询队列里有多少字节非常直观,处理“队列满”也方便统计。如果采用head == tail表示空,head+1 == tail表示满,会浪费一个存储单元,而且无法直接知道水位。
读函数里我关中断再操作,是为了避免读指针和 count 被串口中断里更新时产生错乱。单核 MCU 上,主循环读数据时只关一个很短的时间窗口,开销可以忽略。如果你在 RTOS 里,可以用临界区或者关调度器代替。
2.2 在串口中断里生产、在主循环里消费
串口中断里只负责“把硬件寄存器里的字节塞进环形队列”,千万不要在里面做协议解析。下面是基于常见 HAL 库的写法:
void USARTx_IRQHandler(void) { unsigned char ch; if (__HAL_UART_GET_FLAG(&huart, UART_FLAG_RXNE)) { ch = (unsigned char)(huart.Instance->RDR & 0xFF); rb_write(&rx_ring, ch); } }主循环里消费:
unsigned char ch; while (1) { while (rb_read(&rx_ring, &ch) == 0) { parse_byte(ch); } // 其他业务 }这里parse_byte(ch)是状态机解析函数,后面会细说。整个循环的流程是:只要队列里有字节就取出来解析,没有就继续做其他事。即使主循环忙到没能及时消费,数据也暂时存在环形队列里,不会立刻丢。
这里有个细节:如果你的平台没有__disable_irq(),可以用__asm("cpsid i")或平台自带关中断宏。若实在不想关中断,也可以利用“单写单读”模型:中断只写、主循环只读,只要编译器不重排且用 volatile,多数情况下安全。但为了严谨,我这里保留临界区保护。
2.3 队列长度怎么定
队列不是越长越好。太长费 RAM,在只有几 KB 内存的小 MCU 上可能根本不现实;太短又容易溢出。我一般按下面这个公式估算:
队列大小 = 最大协议帧长度 + 最大突发字节数 + 安全余量
最大突发字节数取决于波特率和消费方最差阻塞时间。以 115200bps、8N1 为例,每个字节实际占用 10 bit,所以每秒大约 11520 字节,每字节约 86.8 微秒。假如你的主循环最差会阻塞 2ms,那么这 2ms 里串口最多能收到约 23 字节。
| 波特率 | 每字节耗时 (10bit) | 1ms 内最多收到字节数 |
|---|---|---|
| 9600 | 1.04ms | 约 1 |
| 115200 | 86.8us | 约 11 |
| 460800 | 21.7us | 约 46 |
| 921600 | 10.9us | 约 92 |
如果协议最大帧是 64 字节,主循环最差阻塞 2ms,那么队列至少需要:
64 + 23 + 余量 = 96 字节以上
选 128 或 256 比较合理。内存足够时,建议采用 2 的幂,方便位与索引。如果 RAM 很紧张,也可以精确写#define RBUF_SIZE 96,但索引性能会差一点,且无法用&运算。
3. 用环形队列做协议解析:状态机、半包、粘包一次说清
3.1 一个可落地的帧格式与状态机
环形队列只是把字节“存了下来”,真正干活的是解析逻辑。串口协议最常用的结构是:帧头 + 长度 + 数据 + 校验。举个例子:
- 帧头:0xAA 0x55
- 长度:1 字节,表示后面的数据字节数
- 数据:N 字节
- 校验:1 字节,对“长度 + 数据”做累加和
帧总长度 = 2 + 1 + N + 1。
用状态机解析,相比“每收到一个字节就从头判断是否能形成完整帧”要简单得多。状态包括:
typedef enum { ST_IDLE, // 空闲,等待帧头 ST_PRE_HEAD2, // 已经收到 0xAA,等待 0x55 ST_LEN, // 收到帧头,等待长度 ST_DATA, // 接收数据 ST_CHK // 接收校验字节 } parse_state_t;状态机函数:
static parse_state_t state = ST_IDLE; static unsigned char recv_buf[64]; static unsigned char rx_len; static unsigned char rx_index; static unsigned char checksum; static unsigned char frame_valid = 0; void parse_byte(unsigned char ch) { switch (state) { case ST_IDLE: if (ch == 0xAA) { state = ST_PRE_HEAD2; } break; case ST_PRE_HEAD2: if (ch == 0x55) { state = ST_LEN; } else if (ch != 0xAA) { state = ST_IDLE; } // 如果 ch == 0xAA,继续保持当前状态, // 相当于把新到的 0xAA 当作下一个候选帧头。 break; case ST_LEN: rx_len = ch; if (rx_len == 0 || rx_len > sizeof(recv_buf)) { state = ST_IDLE; } else { rx_index = 0; checksum = rx_len; // 校验包含长度字节 state = ST_DATA; } break; case ST_DATA: recv_buf[rx_index++] = ch; checksum += ch; if (rx_index >= rx_len) { state = ST_CHK; } break; case ST_CHK: if (ch == checksum) { frame_valid = 1; } else { // 校验失败,如果这个字节恰好是 0xAA, // 它可能是下一帧的帧头,不能白白丢掉。 state = (ch == 0xAA) ? ST_PRE_HEAD2 : ST_IDLE; break; } state = ST_IDLE; break; } }这段代码有几个值得说清楚的点。
校验失败时,当前字节如果是 0xAA,不能直接回到 IDLE。因为你这个字节已经消费掉了,回到 IDLE 后下一个字节如果是 0x55,会被误判成0xAA 0x55的帧头开头其实是 0x55,导致帧同步错位。我的处理是把当前字节当作新候选帧头,进入ST_PRE_HEAD2,这样同步就能马上恢复。
长度字段的取值范围必须做限制。如果协议异常,长度变成 200,而recv_buf只有 64 字节,再收到数据就会越界写爆 RAM。宁可丢掉这一帧,也不要让内存被破坏。
3.2 半包和粘包怎么处理
环形队列天然适合处理半包。发送方可能一次只发了一半数据,剩余的要隔几毫秒甚至几十毫秒才到。因为没有丢字节,状态机可以停留在ST_DATA,等后续字节继续填。如果协议规定了帧间隔,你还可以在消费循环里加一个超时:超过比如 50ms 没有收到任何新字节,就把状态机复位,避免等一帧永远等不完。
static uint32_t last_rx_tick; while (rb_read(&rx_ring, &ch) == 0) { parse_byte(ch); last_rx_tick = get_tick(); } if (get_tick() - last_rx_tick > 50) { state = ST_IDLE; }粘包是另一种常见情况:发送方连续发了两帧,中间没有间隔。状态机逐字节消费,在处理完一帧后自动回到ST_IDLE,继续解析下一帧,所以天然支持粘包。关键还是那句话:在ST_DATA阶段不要因为收到 0xAA 就当作帧头重新同步,数据区里的 0xAA 0x55 是合法数据,不是协议头。
3.3 批量读取接口:一次取出一段数据
如果你一次要处理的数据量很大,比如从环形队列里取出 100 字节做 CRC 校验,建议用批量读接口,而不是一个字节一个字节地rb_read。批量读可以一次拷贝多个字节,减少函数调用和临界区开销。
因为环形队列会绕回尾部,数据可能被分成两段,要用索引计算处理:
int rb_read_bulk(ring_buf_t *rb, unsigned char *out, int max_len) { int cnt = 0; int i; unsigned int idx; __disable_irq(); if (rb->count < max_len) { cnt = rb->count; } else { cnt = max_len; } idx = rb->tail & (RBUF_SIZE - 1); for (i = 0; i < cnt; i++) { out[i] = rb->buf[(idx + i) & (RBUF_SIZE - 1)]; } rb->tail += cnt; rb->count -= cnt; __enable_irq(); return cnt; }这样读取时不需要判断“第一段长度”和“第二段长度”,(idx + i) & (RBUF_SIZE - 1)自动完成了绕回,代码更简洁。对于 RAM 非常紧张、不能承受临时大数组的场景,可以仍然用单字节读取,但是务必要确保消费频率足够。
4. 排查丢数据的一线经验:从队列溢出到竞争保护
4.1 数据为什么会丢
排查串口丢数据,不要上来就怀疑硬件。先看看软件链路里哪一环最弱。整理成一张速查表:
| 现象 | 可能原因 | 检查点 |
|---|---|---|
| 数据莫名其妙少一段 | 队列溢出,head 追上 tail | 查写接口返回 -1 的次数,增大队列 |
| 偶发错误字符 | 读写指针竞争 | 检查读操作是否在临界区 |
| 同一区块重复出现旧数据 | 指针被编译器优化 | 确认 head、tail、count 都是 volatile |
| 程序跑飞 / HardFault | 队列大小不是 2 的幂却用了位与 | 确认 RBUF_SIZE 是 2 的幂 |
| 接收到的数据永远是半截 | 主循环消费太慢 | 用水位统计观察峰值 |
我自己排查过的最典型 bug:RBUF_SIZE是 64,但代码里用了& 0x3F,结果没问题;后来把队列调到 100,依然用& 0x7F,相当于把队列当 128 用了,越界到 buf 之外的内存,程序时不时跑飞。位与虽然快,但前提必须是 2 的幂,不能拍脑袋改大小。
另一个常见问题是中断和主循环并发访问。虽然单核处理器上不会真正并行,但读函数里如果没有关中断,读取过程可能被串口中断打断。极端情况是中断写了一个字节、修改了 head 和 count,主循环读的时候拿到了不完整状态,导致数据错位。用临界区保护后,问题立刻消失。
4.2 观测队列水位和溢出统计
数据丢没丢,不光要靠猜,最好能实证。我在调试时会在队列结构体旁加两个计数器:
typedef struct { ring_buf_t rb; unsigned int overflow_cnt; unsigned int peak_usage; } ring_stat_t;写函数返回 -1 时执行overflow_cnt++;每次读成功后,如果count比peak_usage大就更新peak_usage。调试时通过断点或者调试串口把这两个值打出来,就能知道:
- 队列最高积压了多少字节
- 系统是否发生过溢出、溢出多少次
这是最直接的证据。另一个技巧:在解析主循环入口让一个 GPIO 拉高,出口拉低,用示波器看高电平持续时间,可以测出“主循环消费这段逻辑到底占了多少时间”。如果这个时间和串口字节间隔接近,那就要小心了。
4.3 升级方案:DMA + 环形队列
如果波特率上到 460800 甚至更高,每字节中断的 CPU 占用率就会明显上升。这时候可以考虑串口 DMA 接收,把 DMA 搬入自己的环形队列,减少逐字节中断开销。常见做法是配置 DMA 循环模式接收,在每接收一段数据后把数据追加到软件环形队列。每次 DMA 传输完成中断里做一次批量写入,而不是每个字节都进中断。
这样做的代价是配置更复杂,尤其要处理好 DMA 半传输中断、传输完成中断以及空闲中断。如果你还在裸机阶段,先把中断 + 环形队列的方案跑稳,再升级 DMA,会平滑很多。我自己是先把中断+环形队列协议解析调通,验证完帧格式,再切换 DMA 模式,排查起来容易得多。
5. 写代码之外,我建议你养成这几个习惯
项目里用上串口环形队列后,我最大的体会是:务必在生产者和消费者之间画清边界。中断只做赋值、入队,主循环才做解析、组帧、调用业务函数。哪怕你觉得某个解析逻辑很快,也不要冒险放进中断里。因为中断里一旦出现 bug,整个系统的优先级都被拖乱,非常难查。
第二个习惯是给环形队列配一个明确的满处理策略。是丢弃新数据,还是覆盖旧数据,要提前想好。对串口命令来说,丢弃新数据往往更合理,因为旧数据还没有被消费,协议解析可以重发;对采样数据来说,可能旧数据更重要,那覆盖旧数据更合适。这样遇到队满,代码逻辑是明确的。
第三个建议是给调试留后门。可以在调试版本里把队列水位、溢出次数周期性地输出,用独立调试串口,不要让这类日志和业务数据混在一个串口里。否则日志本身就会拖慢消费速度,产生新的丢数据问题。等系统稳定后再把这些调试代码关掉。
如果你刚接触这个知识点,我建议不要直接复制代码到项目里,先用一个空闲的串口、一个按键或者上位机压力测试工具,发几百帧带干扰的数据测试一下。重点看看帧头错位、粘包、半包、校验失败后的恢复能力。只有这些场景都过一遍,环形队列才算真正融进你的工程里。