上一篇聊协议版本兼容,提到 payload 用 append-only。这篇往更底层走一步,讲一个几乎所有嵌入式项目都会用、但十有八九第一次都写错的小结构:环形缓冲区。
先说个真事。我刚工作那会儿做一个串口收发模块,需求很简单:上位机发一串数据下来,MCU 收完解析。我用了最朴素的办法,开个数组,中断里往里塞,主循环里往外读。自测没问题,一发一收好好的。可一上压力测试,上位机连续发几十帧,数据就开始丢字节、错位。我盯着示波器看了半天,波特率没问题,硬件没问题,最后发现是缓冲区写满了没处理,新数据直接把没读走的旧数据覆盖了。那是我第一次认真琢磨环形缓冲区这东西。
环形缓冲区看着是个小结构,用对和用错,差一个数量级的稳定性和性能。这篇把它讲透。
为什么是"环形"
先说清楚环形缓冲区到底是个啥。它本质就是一个固定大小的数组,加两个指针:一个写指针(head),一个往里塞数据;一个读指针(tail),一个往外取数据。数组本身是线性的,但读写指针走到末尾后绕回头部,逻辑上把数组首尾接起来,形成一个环。所以叫环形缓冲区,也叫 circle buffer、ring buffer。
为什么不用普通队列、不用链表?嵌入式里内存金贵,链表每个节点要额外存指针,还要动态分配,碎片化是大忌。环形缓冲区用一块连续静态内存,大小编译期定死,没有动态分配,没有碎片,缓存命中率高。对串口、音频流、事件队列这种"持续来、持续走"的场景,它几乎是最优解。
核心就三个动作:写一个字节,head 往前走一步;读一个字节,tail 往前走一步;head 追上 tail 说明满了,tail 追上 head 说明空了。难点全在那句"追上"上。
读写指针的追及问题:满和空怎么分
环形缓冲区最容易写错的地方,就是"满"和"空"的判断。
你想啊,head 和 tail 指向同一个位置时,到底是"缓冲区空"还是"缓冲区满"?两种状态指针长得一模一样。这就是追及问题:head 追上 tail 是满,tail 追上 head 是空,可两者重合时你分不清。新手最常犯的错就是用一个条件判断两种状态,结果要么少存一个字节、要么覆盖数据。
业界有三种解法,各有取舍。
留一个空位。最常用。缓冲区大小为 N,但只存 N-1 个数据,永远留一个位置空着。判空:head 等于 tail。判满:head 的下一个位置等于 tail,也就是(head + 1) % N == tail。靠那个永远不写的空位,把满和空区分开。代价是浪费一个字节的位置,换来判断逻辑极简。绝大多数嵌入式实现都这么干,简单可靠。
单独维护一个计数。不靠指针关系判断,额外存一个长度变量 count,写一个 count++,读一个 count--。判空 count==0,判满 count==N。优点是缓冲区利用率 100%,缺点是多了一个变量的读写,而且 count 的自增自减在多线程下要考虑原子性。资源够、要榨干每个字节的场景用这个。
镜像标志位。比较巧妙的办法。让 head 和 tail 用无符号整数,范围 0 到 2N-1(不是 0 到 N-1),访问数组时用& (N-1)取模(要求 N 是 2 的幂)。这样 head 和 tail 一直递增不回绕,判空 head 等于 tail,判满 head-tail 等于 N。靠高位区分满空,不用留空位也不用额外计数。Linux 内核的 kfifo 就是这套。缺点是要求缓冲区大小必须是 2 的幂。
这三种里,留空位最无脑、最不容易错,我建议默认就用它,除非有明确的"一个字节都不能浪费"的需求。
幂2大小:用位与替代取模
刚才提到镜像标志位要求大小是 2 的幂,其实"大小取 2 的幂"这个优化本身值得单独说。
环形缓冲区指针回绕要取模:(pointer + 1) % N。取模运算在 MCU 上是除法,慢。如果 N 是 2 的幂,取模等价于位与:(pointer + 1) & (N - 1)。位与是一条单周期指令,比取模快得多,尤其在没有硬件除法器的 M0/M0+ 上差距明显。
所以工程实践里,环形缓冲区大小一般直接定成 2 的幂:16、32、64、128、256、1024 这种。哪怕你只需要 200 字节,也开 256,多出来的 56 字节当冗余,换来取模变位与的性能提升和代码简洁。这个取舍在嵌入式里几乎总是划算的。
顺带一提,这个优化和"留空位"判满方案是兼容的:大小 256,实际存 255,判满用(head + 1) & 0xFF == tail,位与取模,干净利落。
单生产者单消费者:无锁的底气
环形缓冲区在嵌入式里最香的场景,是单生产者单消费者(SPSC):一个写者(通常是中断),一个读者(通常是主循环)。这种场景下,环形缓冲区可以做到完全无锁,不用关中断,不用互斥量。
为什么能无锁?因为 SPSC 下,head 只有写者改,tail 只有读者改。写者只读 tail 来判断满不满(不写它),读者只读 head 来判断空不空(不写它)。只要保证 head 的写对读者可见、tail 的写对写者可见,两边就互不干扰。这叫"一个变量只有一个写者"原则,是无锁环形缓冲区的根基。
但有个坑:编译器优化和 CPU 流水线可能重排读写顺序。写者更新了数据,还没更新 head,读者却先看到了新的 head,读到旧数据。这在单核 MCU 上一般不会出问题(M0/M3/M4 的内存模型比较强),但在带 cache 的多核或乱序执行的处理器上,必须加内存屏障(DMB/DSB)保证顺序。所以严谨的无锁实现里,head 和 tail 要声明成 volatile,更新后还要插内存屏障。单核简单场景 volatile 就够,多核必须上屏障。
串口接收缓存:一个能用的实现
把这些揉到一起,一个串口接收缓存的实现长这样。先定义结构:
#define RB_SIZE 256 /* 必须是 2 的幂 */ #define RB_MASK (RB_SIZE - 1) typedef struct { uint8_t buf[RB_SIZE]; volatile uint16_t head; /* 写者改:中断 */ volatile uint16_t tail; /* 读者改:主循环 */ } ring_t; void rb_init(ring_t *r) { r->head = 0; r->tail = 0; }中断里写(生产者):
/* 在 USART RX 中断里调用 */ void rb_push(ring_t *r, uint8_t byte) { uint16_t next = (r->head + 1) & RB_MASK; if (next == r->tail) { /* 满了,丢字节或扩容,这里选择丢 */ return; } r->buf[r->head] = byte; r->head = next; /* 先写数据再更新 head,顺序很重要 */ }主循环里读(消费者):
/* 返回 0 表示空,非 0 表示取到一个字节 */ int rb_pop(ring_t *r, uint8_t *out) { if (r->head == r->tail) { return 0; /* 空 */ } *out = r->buf[r->tail]; r->tail = (r->tail + 1) & RB_MASK; return 1; }注意 push 里"先写数据再更新 head"的顺序:必须先把字节写进 buf,再移动 head,否则读者可能看到 head 前进了但数据还没写进去,读到垃圾。这是无锁正确性的关键,顺序反了就出 bug。
这套实现就是 SPSC 无锁:中断只改 head、读 tail,主循环只改 tail、读 head,两边互不写对方的变量,不用关中断。串口高速接收下,中断里不用做关中断这种重操作,实时性拉满。
事件队列:环形缓冲区的另一面
上一篇讲事件驱动提过一嘴,事件队列底层也是个环形缓冲区,只是存的不是字节,是事件结构体。中断里投递事件(push),主循环取事件(pop)分发。逻辑和串口缓存一模一样,只是元素类型从 uint8_t 换成 event_t。
这种"环形缓冲区 + 函数指针分发"的组合,是嵌入式事件驱动架构的标配。环形缓冲区解决了"中断和主循环异步解耦",函数指针解决了"事件到处理函数的路由"。两个加一起,就是一个轻量级的事件系统,比 RTOS 的消息队列轻得多,适合裸机或轻量级项目。
多生产者多消费者:该上锁就上锁
SPSC 能无锁是因为"一个变量一个写者"。一旦变成多生产者(多个中断往一个缓冲区写)或多消费者(多个任务从一个缓冲区读),这个前提就破了,必须加锁。
最简单的锁是关中断:push 和 pop 时关掉全局中断,操作完再开。简单粗暴,但关中断期间所有中断都被阻塞,实时性受损,所以临界区要尽量短,只保护 head/tail 的读改写,别在里面干耗时操作。
更优雅的是用 RTOS 的互斥量或自旋锁,但那就引入了 RTOS 依赖。裸机项目一般还是关中断最省事。记住一条:能用 SPSC 无锁就别上多生产者,架构上把"一个缓冲区一个写者一个读者"设计好,能省掉一大堆锁的麻烦。
落地建议
最后给几条实战建议。第一,大小定成 2 的幂,哪怕浪费一点,换位与取模和代码简洁。第二,默认用"留一个空位"判满,最不容易错,除非真的一个字节都不能浪费。第三,SPSC 场景大胆用无锁,head/tail 加 volatile,注意"先写数据后更新指针"的顺序。第四,缓冲区大小要留余量,按峰值流量算完再翻倍,串口缓存宁可大一点也别丢字节。第五,满了要有策略,丢最老的、丢最新的、还是阻塞等,要想清楚,别默认啥也不干。
环形缓冲区这东西,看着小,用对是基石,用错是坑。我自己当年那个串口丢字节的 bug,就是没处理好"满"的状态。后来把环形缓冲区这套吃透,串口、事件队列、音频流,全用它,再没出过这类问题。
下一篇聊聊链表,这个在 GUI 页面管理、数据流转里到处用的结构,和环形缓冲区是一对好搭档,一个管流式数据,一个管离散节点。
标签:嵌入式 环形缓冲区 数据结构 无锁编程 串口 事件队列