公司动态

嵌入式环形缓冲区设计与实现:追及问题+幂2优化+SPSC无锁

📅 2026/8/5 12:13:09
嵌入式环形缓冲区设计与实现:追及问题+幂2优化+SPSC无锁
上一篇聊协议版本兼容提到 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--。判空 count0判满 countN。优点是缓冲区利用率 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 页面管理、数据流转里到处用的结构和环形缓冲区是一对好搭档一个管流式数据一个管离散节点。标签嵌入式 环形缓冲区 数据结构 无锁编程 串口 事件队列