当前位置:首页>Linux>中断里还在关中断?移植 Linux 内核 kfifo,打造 MCU 最强无锁环形队列

中断里还在关中断?移植 Linux 内核 kfifo,打造 MCU 最强无锁环形队列

  • 2026-10-21 13:29:40
中断里还在关中断?移植 Linux 内核 kfifo,打造 MCU 最强无锁环形队列

抛弃取模运算与互斥锁,揭秘 Linux 高效缓冲区的底层魔法


0. 引言:一个经典的串口接收"惨案"

搞嵌入式的朋友,大概率都被这个场景折磨过——

你手上有个项目,串口波特率 921600,数据哗哗往进来。DMA?没有。只能靠中断一个字节一个字节接。你小心翼翼地写了个环形缓冲区,在中断里往里塞数据,在主循环里往外取数据。

跑起来一看,丢包。

你慌了,赶紧在中断里加上 __disable_irq(),把临界区保护起来。再一跑,丢得更狠了——因为你关中断的时候,其他高优先级的中断也被你挡在门外了。

更要命的是,你那个 head = (head + 1) % BUFFER_SIZE 的取模运算,在 Cortex-M0 上能吃掉几十个时钟周期。中断里每一微秒都是金子,你就这么挥霍了。

有没有一种方案,既不用关中断,又能榨干 CPU 的性能?

有。Linux 内核里有个叫 kfifo 的东西,专治这种疑难杂症。今天我们就把它扒下来,移植到 MCU 上。


1. 传统环形队列的"两宗罪"

在动手之前,我们先来看看传统写法到底错在哪。

罪状一:低效的取模运算

翻翻你的代码,八成能找到这么一行:

head = (head + 1) % BUFFER_SIZE;

看着挺简洁,对吧?但这行代码藏着一个性能杀手——取模运算。

在 x86 上,除法指令可能就几个时钟周期,你感觉不出来。但在很多 Cortex-M0、M3 这类没有硬件除法器的内核上,一次除法/取模运算可能要消耗 20~40 个时钟周期。

什么概念?假设你的 MCU 跑在 48MHz,一个时钟周期大约 20ns。取模一次就是 400~800ns。如果你在高速采样或者高波特率通信的中断里反复取模,这时间累积起来相当可观。

中断里的每一微秒都是命。这种写法,就是在烧钱。

罪状二:必须加锁的无奈

传统环形队列还有一个更头疼的问题:竞争。

想象这个场景:主循环正在读取数据,读到一半被中断打断。中断里往队列塞了一堆数据,改了 head 指针。等中断返回,主循环继续跑,但它手里的 head 值已经过时了。

轻则读到重复数据,重则数组越界,程序直接跑飞。

怎么办?最直接的做法就是加锁——进中断前关中断,访问临界区时用互斥量。但这又带来了新问题:

  • • 关中断时间过长,会导致其他中断响应不及时
  • • 互斥锁本身也有开销,在实时性要求高的场景下得不偿失

说白了,传统方案就是在"安全"和"效率"之间反复横跳,两边不讨好。


2. Linux kfifo 的核心魔法:巧用"整数溢出"

Linux 内核的开发者们早就想明白了这件事,他们给出的答案就是 kfifo。这东西只用了两个小技巧,就把上面两个问题全解决了。

魔法一:Size 必须是 2 的幂次

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 的特性玩得明明白白。


3. 手把手移植:打造 MCU 版 kfifo

原理讲完了,来点干货。下面是精简后的 MCU 移植版本。

数据结构定义

typedefstruct {
    uint8_t
  *buffer;    // 缓冲区指针
    uint32_t
  size;      // 缓冲区大小(必须是 2 的幂次)
    uint32_t
  in;        // 写入指针
    uint32_t
  out;       // 读取指针
} kfifo_t;

干净利落,四个成员搞定。

初始化:强制校验 Size

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。具体用哪个取决于你的编译器和芯片平台,但这个意识一定要有。


4. 为什么它不需要锁?

讲到这里,可能有人还是不放心:中断和主循环同时访问这个队列,真的不会出问题吗?

答案是:在特定条件下,真的不会。

前提条件:单生产者 - 单消费者

kfifo 的无锁特性有一个大前提——SPSC(Single Producer, Single Consumer)模型。

翻译成人话就是:

  • • 只有一个地方往里写(比如中断)
  • • 只有一个地方往外读(比如主循环)

这个条件在 MCU 上非常容易满足。串口接收中断往队列里塞数据,主循环负责解析处理,这就是典型的 SPSC 场景。

各司其职,互不干扰

在 SPSC 模型下:

  • • in 指针:只有生产者(中断)会修改
  • • out 指针:只有消费者(主循环)会修改

两个指针各管各的,根本不存在"两个人同时改同一个变量"的情况。

消费者在读取数据时,会先读 in 的值来判断有多少数据可读。这时候 in 可能正在被生产者修改,但没关系——最坏情况不过是少读到几个刚写入的字节,下次循环再读就是了,不会出错。

反过来,生产者在写入时会读 out 来判断还有多少空间。同样的道理,最坏情况就是少写几个字节,数据不会丢,也不会覆盖。

这就是无锁的精髓:通过合理的数据结构设计,让并发访问变得天然安全,而不是靠锁来硬堵。


5. 总结

回顾一下,移植 kfifo 到 MCU 带来了三个实打实的好处:

  1. 1. 代码更简洁:不用再写繁琐的回绕判断逻辑,in - out 一句话搞定长度计算
  2. 2. 中断响应更快:扔掉了 __disable_irq(),中断里不再需要关中断保护
  3. 3. 运行效率更高:位与运算代替取模,在低端 MCU 上性能提升立竿见影

其实学习 Linux 内核代码,不仅仅是为了做 Linux 开发。内核里沉淀了几十年的软件工程智慧,很多设计思想放到 MCU 上一样好使。

kfifo 就是个典型例子——不到 100 行代码,干掉了取模运算,干掉了互斥锁,还把并发安全性给你安排得明明白白。

这就是向高手学习的意义。


🚀 嵌入式底层架构·进阶实战推荐

💡 深度进阶:C 语言设计模式实战合集

专为嵌入式工程师打造,拒绝纸上谈兵,教你如何在 MCU/RTOS 环境下玩转面向对象。(点击标题跳转对应文章)

本周重点推荐:


【往期推荐】
嵌入式软件模块解耦进阶:构建高内聚、低耦合的系统架构
给你的设备做一套"砖不死"的 OTA 升级方案
STM32中的双栈指针:MSP和PSP是如何切换的?

最新文章

随机文章