抛弃取模运算与互斥锁,揭秘 Linux 高效缓冲区的底层魔法
搞嵌入式的朋友,大概率都被这个场景折磨过——
你手上有个项目,串口波特率 921600,数据哗哗往进来。DMA?没有。只能靠中断一个字节一个字节接。你小心翼翼地写了个环形缓冲区,在中断里往里塞数据,在主循环里往外取数据。
跑起来一看,丢包。
你慌了,赶紧在中断里加上 __disable_irq(),把临界区保护起来。再一跑,丢得更狠了——因为你关中断的时候,其他高优先级的中断也被你挡在门外了。
更要命的是,你那个 head = (head + 1) % BUFFER_SIZE 的取模运算,在 Cortex-M0 上能吃掉几十个时钟周期。中断里每一微秒都是金子,你就这么挥霍了。
有没有一种方案,既不用关中断,又能榨干 CPU 的性能?
有。Linux 内核里有个叫 kfifo 的东西,专治这种疑难杂症。今天我们就把它扒下来,移植到 MCU 上。
在动手之前,我们先来看看传统写法到底错在哪。
翻翻你的代码,八成能找到这么一行:
head = (head + 1) % BUFFER_SIZE;看着挺简洁,对吧?但这行代码藏着一个性能杀手——取模运算。
在 x86 上,除法指令可能就几个时钟周期,你感觉不出来。但在很多 Cortex-M0、M3 这类没有硬件除法器的内核上,一次除法/取模运算可能要消耗 20~40 个时钟周期。
什么概念?假设你的 MCU 跑在 48MHz,一个时钟周期大约 20ns。取模一次就是 400~800ns。如果你在高速采样或者高波特率通信的中断里反复取模,这时间累积起来相当可观。
中断里的每一微秒都是命。这种写法,就是在烧钱。
传统环形队列还有一个更头疼的问题:竞争。
想象这个场景:主循环正在读取数据,读到一半被中断打断。中断里往队列塞了一堆数据,改了 head 指针。等中断返回,主循环继续跑,但它手里的 head 值已经过时了。
轻则读到重复数据,重则数组越界,程序直接跑飞。
怎么办?最直接的做法就是加锁——进中断前关中断,访问临界区时用互斥量。但这又带来了新问题:
说白了,传统方案就是在"安全"和"效率"之间反复横跳,两边不讨好。
Linux 内核的开发者们早就想明白了这件事,他们给出的答案就是 kfifo。这东西只用了两个小技巧,就把上面两个问题全解决了。
kfifo 有一条铁律:缓冲区大小必须是 2 的幂次(2、4、8、16、32……)。
为什么?因为这样一来,取模运算就可以用位与运算来代替:
// 当 SIZE 是 2 的幂次时
x % SIZE == x & (SIZE - 1)举个例子,假设 SIZE = 64,那 SIZE - 1 = 63,二进制是 0011 1111。任何数跟它做位与操作,效果就跟对 64 取模一模一样。
位与运算有多快?在几乎所有架构上都只需要 1 个时钟周期。跟取模运算比起来,这差距可不是一星半点。
这是 kfifo 最精妙的设计,也是大多数人一开始想不通的地方。
传统环形队列的 head 和 tail 指针都在 0 ~ SIZE-1 之间循环。但 kfifo 不这么干——它的 in(写指针)和 out(读指针)定义成 unsigned int,然后让它们一直加,加到溢出也不管。
听起来很疯狂?其实这才是真正的巧思。
逻辑索引 vs 物理索引:
in 的值可能是 10000、100000 甚至更大in & (SIZE - 1) 映射到实际的物理位置打个比方:你可以把 unsigned int 想象成一条无限长的数轴,而物理 Buffer 是一个圆环。数轴上的点通过位与运算"投影"到圆环上。
妙处在哪?
计算队列里有多少数据,只需要一句话:
len = in - out;完事。不用判断 head 是不是绑了 tail 一圈,不用分情况讨论。
有人会问:in 一直加,不会溢出吗?
会溢出,但没关系。unsigned int 的溢出行为在 C 语言标准里是有明确定义的——它会自动回绕到 0。而且由于我们只关心 in - out 的差值,只要这个差值不超过缓冲区大小,减法的结果永远是对的。
这就是所谓的"自然溢出"。内核开发者们把 CPU 的特性玩得明明白白。
原理讲完了,来点干货。下面是精简后的 MCU 移植版本。
typedefstruct {
uint8_t *buffer; // 缓冲区指针
uint32_t size; // 缓冲区大小(必须是 2 的幂次)
uint32_t in; // 写入指针
uint32_t out; // 读取指针
} kfifo_t;干净利落,四个成员搞定。
int kfifo_init(kfifo_t *fifo, uint8_t *buffer, uint32_t size)
{
// 检查 size 是否为 2 的幂次
if (size == 0 || (size & (size - 1)) != 0) {
return -1; // 不是 2 的幂次,拒绝初始化
}
fifo->buffer = buffer;
fifo->size = size;
fifo->in = 0;
fifo->out = 0;
return 0;
}这里有个常见的位运算技巧:(size & (size - 1)) == 0 可以判断一个数是否是 2 的幂次。原理很简单,2 的幂次在二进制下只有一个 1,减 1 后所有低位都变成 1,两者做与运算结果必然是 0。
uint32_t kfifo_put(kfifo_t *fifo, const uint8_t *data, uint32_t len)
{
uint32_t l;
// 计算可用空间
len = min(len, fifo->size - (fifo->in - fifo->out));
// 计算从 in 到缓冲区末尾的空间
l = min(len, fifo->size - (fifo->in & (fifo->size - 1)));
// 先拷贝尾部
memcpy(fifo->buffer + (fifo->in & (fifo->size - 1)), data, l);
// 再拷贝头部(如果需要回绕)
memcpy(fifo->buffer, data + l, len - l);
// 内存屏障,确保数据写入完成后再更新指针
__sync_synchronize();
fifo->in += len;
return len;
}这段代码的精髓在于分段拷贝:当数据跨越缓冲区尾部时,先把能放下的部分拷到尾部,剩下的从头部开始放。两次 memcpy,干净利索。
注意那行 __sync_synchronize(),这是整个移植过程中最容易踩的坑。
现代编译器为了优化性能,可能会对指令进行重排。如果它把 fifo->in += len 提前到 memcpy 之前执行,消费者可能会读到"指针已更新,但数据还没写进去"的脏数据。
在 MCU 上,你也可以用 __DSB() 或者把 in、out 声明为 volatile。具体用哪个取决于你的编译器和芯片平台,但这个意识一定要有。
讲到这里,可能有人还是不放心:中断和主循环同时访问这个队列,真的不会出问题吗?
答案是:在特定条件下,真的不会。
kfifo 的无锁特性有一个大前提——SPSC(Single Producer, Single Consumer)模型。
翻译成人话就是:
这个条件在 MCU 上非常容易满足。串口接收中断往队列里塞数据,主循环负责解析处理,这就是典型的 SPSC 场景。
在 SPSC 模型下:
in 指针:只有生产者(中断)会修改out 指针:只有消费者(主循环)会修改两个指针各管各的,根本不存在"两个人同时改同一个变量"的情况。
消费者在读取数据时,会先读 in 的值来判断有多少数据可读。这时候 in 可能正在被生产者修改,但没关系——最坏情况不过是少读到几个刚写入的字节,下次循环再读就是了,不会出错。
反过来,生产者在写入时会读 out 来判断还有多少空间。同样的道理,最坏情况就是少写几个字节,数据不会丢,也不会覆盖。
这就是无锁的精髓:通过合理的数据结构设计,让并发访问变得天然安全,而不是靠锁来硬堵。
回顾一下,移植 kfifo 到 MCU 带来了三个实打实的好处:
in - out 一句话搞定长度计算__disable_irq(),中断里不再需要关中断保护其实学习 Linux 内核代码,不仅仅是为了做 Linux 开发。内核里沉淀了几十年的软件工程智慧,很多设计思想放到 MCU 上一样好使。
kfifo 就是个典型例子——不到 100 行代码,干掉了取模运算,干掉了互斥锁,还把并发安全性给你安排得明明白白。
这就是向高手学习的意义。
专为嵌入式工程师打造,拒绝纸上谈兵,教你如何在 MCU/RTOS 环境下玩转面向对象。(点击标题跳转对应文章)
本周重点推荐: