在嵌入式开发中,生产者/消费者是最常见的一类问题:串口中断收数据、DMA 搬运、音频流、协议解析……总是一边往里写、一边从里读。很多人第一反应是加互斥锁,但在「一个生产者、一个消费者」(SPSC,Single Producer Single Consumer)的场景下,锁完全是多余的——用对方法,不但不用加锁,还能做到零等待、无惊群、几乎零开销。
本文给出一个经过实战检验的 SPSC 无锁环形缓冲区(Ring Buffer)实现,核心只有两点:容量取 2 的幂(2^n),以及生产/消费各只改自己的索引。文末附可直接编译运行的 demo 和中断场景用法。
环形缓冲区要在一维数组上"绕圈"。最常见的麻烦是「满和空都用 head==tail 表示,产生歧义」,于是大家被迫空出一个槽(用 (head+1)%size==tail 判满),既浪费一个字节又多一次取模。
2^n 大小可以一并解决这两个问题:
index & (SIZE-1),编译器一条 and 指令搞定,比 % SIZE 快得多,且没有除法。write_idx / read_idx 一直往上加(uint32_t 到 42 亿后自然回绕),用 write_idx - read_idx 直接得到"已写入未读取"的字节数。可用量 = 索引差,等于 SIZE 就是满,等于 0 就是空——满/空不再歧义,也不用预留空槽。#define FIFO_SIZE (1u << 4) // 16,必须是 2 的幂
#define FIFO_MASK (FIFO_SIZE - 1)
// 下标: buf[write_idx & FIFO_MASK]
// 可用: write_idx - read_idx
// 满: write_idx - read_idx == FIFO_SIZE
// 空: write_idx - read_idx == 0
无锁的前提是没有任何一个变量会被双方同时写:
write_idx,只读 read_idx(判满);read_idx,只读 write_idx(判空)。双方各自改自己的字段,不存在"两个上下文同时写一个变量"的竞态,所以不需要互斥锁。这正是 SPSC 与 MPMC(多生产者多消费者)的本质区别:一旦有两方都写同一个索引,就必须加锁或用原子 CAS。
单核 MCU 上,生产者可能在主循环、消费者在中断里(或反过来)。编译器可能会把索引缓存在寄存器里、或重排"写数据"和"写索引"的顺序,导致消费者读到一个还没写完的槽。解决办法:
volatile,禁止编译器把它放进寄存器、跨中断优化掉;下面用 GCC 内建全屏障 __sync_synchronize()(ARM 上对应 DMB),简单且正确。
#ifndef SPSC_FIFO_H
#define SPSC_FIFO_H
#include <stdint.h>
#include <stdbool.h>
#define FIFO_SIZE (1u << 4) /* 16,必须是 2 的幂 */
#define FIFO_MASK (FIFO_SIZE - 1)
typedef struct {
uint8_t buf[FIFO_SIZE];
volatile uint32_t write_idx; /* 仅生产者写 */
volatile uint32_t read_idx; /* 仅消费者写 */
} spsc_fifo_t;
static inline void fifo_init(spsc_fifo_t *f) {
f->write_idx = 0;
f->read_idx = 0;
}
/* 生产者:成功返回 true,满了返回 false */
static inline bool fifo_push(spsc_fifo_t *f, uint8_t byte) {
uint32_t next = f->write_idx + 1;
if (next - f->read_idx > FIFO_SIZE) /* 无符号减法,回绕也正确 */
return false;
f->buf[f->write_idx & FIFO_MASK] = byte;
__sync_synchronize(); /* 释放屏障:先写数据,后发索引 */
f->write_idx = next;
return true;
}
/* 消费者:成功返回 true,空了返回 false */
static inline bool fifo_pop(spsc_fifo_t *f, uint8_t *out) {
uint32_t w = f->write_idx;
__sync_synchronize(); /* 获取屏障:先读索引,后读数据 */
if (w == f->read_idx) /* 空 */
return false;
uint8_t b = f->buf[f->read_idx & FIFO_MASK];
f->read_idx = f->read_idx + 1;
*out = b;
return true;
}
/* 当前未消费字节数(生产者/调试用) */
static inline uint32_t fifo_count(spsc_fifo_t *f) {
return f->write_idx - f->read_idx;
}
#endif
要点:write_idx/read_idx 用 volatile;fifo_push 里先写数据、再用屏障、最后更新索引(发布);fifo_pop 里先读索引、再用屏障、最后读数据。判满用 next - read_idx > SIZE,判空用 write_idx == read_idx,配合 2^n 下标,没有任何取模、没有空槽浪费。
最典型的用法:串口接收中断当生产者,主循环当消费者。中断里只碰 write_idx,主循环里只碰 read_idx,互不抢。
extern spsc_fifo_t g_uart_fifo;
/* 生产者:UART 接收中断,只碰 write_idx */
void USART1_IRQHandler(void) {
if (USART1->SR & USART_SR_RXNE) {
uint8_t c = (uint8_t)USART1->DR;
fifo_push(&g_uart_fifo, c);
}
}
/* 消费者:主循环,只碰 read_idx */
int main(void) {
fifo_init(&g_uart_fifo);
uart_hw_init();
uint8_t c;
while (1) {
while (fifo_pop(&g_uart_fifo, &c)) {
app_handle(c); /* 解析 / 转发 */
}
__WFI(); /* 没数据时休眠,等中断唤醒 */
}
}
相比"中断里直接处理协议"或"关中断拷数据",这种方式把"收"和"用"彻底解耦:中断只需几条指令把字节塞进 FIFO 就返回,主循环按自己的节奏消费,吞吐和实时性都更好。
把第四节的头文件和下面的 main 放一起,gcc fifo_demo.c 即可运行:
#include <stdio.h>
#include "spsc_fifo.h"
int main(void) {
spsc_fifo_t f;
fifo_init(&f);
int dropped = 0;
for (int i = 0; i < 20; i++) { /* FIFO 只有 16 字节 */
if (!fifo_push(&f, (uint8_t)i)) {
dropped++;
printf("full, drop %d\n", i);
}
}
uint8_t c;
int n = 0;
while (fifo_pop(&f, &c)) {
printf("%d ", c);
n++;
}
printf("\npopped %d bytes, dropped %d\n", n, dropped);
return 0;
}
预期输出(容量 16,塞 20 个,后 4 个因满被丢弃):
full, drop 16
full, drop 17
full, drop 18
full, drop 19
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
popped 16 bytes, dropped 4
从输出可以验证:写进去的 0~15 被原序读出,符合 FIFO;满之后的写入被正确拒绝而不是覆盖未消费数据。
单核 + volatile + 全屏障足够。但到了多核(双核 Cortex-A、Linux 双线程等),不能只靠 volatile——弱内存序下,只用 volatile 会让消费者读到"索引已更新、但数据还没写完"的半成品。正确做法是用 C11 原子,配合 acquire/release 语义,开销比全屏障更小:
#include <stdatomic.h>
typedef struct {
uint8_t buf[FIFO_SIZE];
_Atomic uint32_t write_idx;
_Atomic uint32_t read_idx;
} spsc_fifo_a_t;
static inline bool fifo_push_a(spsc_fifo_a_t *f, uint8_t b) {
uint32_t w = atomic_load_explicit(&f->write_idx, memory_order_relaxed);
uint32_t r = atomic_load_explicit(&f->read_idx, memory_order_acquire);
if (w + 1 - r > FIFO_SIZE) return false;
f->buf[w & FIFO_MASK] = b;
atomic_store_explicit(&f->write_idx, w + 1, memory_order_release);
return true;
}
static inline bool fifo_pop_a(spsc_fifo_a_t *f, uint8_t *out) {
uint32_t r = atomic_load_explicit(&f->read_idx, memory_order_relaxed);
uint32_t w = atomic_load_explicit(&f->write_idx, memory_order_acquire);
if (w == r) return false;
*out = f->buf[r & FIFO_MASK];
atomic_store_explicit(&f->read_idx, r + 1, memory_order_release);
return true;
}
这里生产者用 release 发布索引、消费者用 acquire 读取索引,正好配对,保证"看到新索引就一定看到了对应数据",比 __sync_synchronize() 全屏障更省。
& MASK 才能正确闭环;不是 2^n 会下标错位。uint32_t 到 42 亿自然回绕,千万不要用取模把索引拉回 0~SIZE-1,否则 write_idx - read_idx 这种差值算法会算错。volatile + 屏障;多核用 _Atomic + acquire/release:只靠 volatile 在多核下不安全。fifo_push 和 fifo_pop 各只在一个上下文调用:别两边都 push 或都 pop。SPSC 无锁 FIFO 是嵌入式里性价比极高的一个工具:2^n 容量 + 各改各的索引 + 必要屏障,就能做到无锁、无系统调用、O(1)、Cache 友好。把它用在串口、DMA、传感器采样等"一进一出"的流水线上,能显著降低延迟和 CPU 占用。理解了这套思路,你看任何 RTOS 的 stream buffer、无锁队列,都会觉得"原来就这么回事"。