[TOC]
lib/kfifo.c 内核FIFO实现(Kernel FIFO Implementation) 高效的无锁字节流缓冲区
历史与背景
这项技术是为了解决什么特定问题而诞生的?
lib/kfifo.c
中的kfifo(Kernel First-In-First-Out)是为了在Linux内核中提供一个通用、高效、线程安全的先进先出字节流缓冲区(FIFO)而诞生的。在它出现之前,许多内核子系统和驱动程序都需要在生产者(写入数据方)和消费者(读取数据方)之间传递数据,并且它们都各自实现了自己的环形缓冲区(Ring Buffer)逻辑。
这导致了几个突出问题:
- 代码重复:大量驱动中存在功能相似但实现各异的环形缓冲区代码,造成了代码冗余和维护困难。
- 容易出错:环形缓冲区的边界条件处理,特别是索引的回绕(wrap-around)逻辑,是常见的bug来源。手写实现很容易在多线程并发场景下出现问题。
- 缺乏标准化:没有一个标准的接口,使得代码的可读性和可重用性都很差。
- 性能不佳:一些简单的实现可能使用了不必要的锁,或者没有充分利用CPU架构的特性来优化性能。
kfifo的诞生就是为了用一个经过充分测试、高度优化和标准化的实现来取代所有这些分散的、临时的解决方案。
它的发展经历了哪些重要的里程碑或版本迭代?
kfifo由Stefani Dogcu在2004年左右引入内核,其设计从一开始就非常精巧,后续的发展主要集中在API的完善和优化上:
- 初始设计:kfifo的核心设计从一开始就奠定了其高效的基础:
- 大小必须是2的幂:这是kfifo最重要的设计约束。它允许使用按位与操作(
index & (size - 1)
)来代替昂贵的模运算(index % size
)来实现索引的回绕,极大地提高了性能。 - 索引永不回绕:读写索引(
in
和out
)被定义为无限增长的无符号整数。这巧妙地避免了在多线程场景下因读写指针同时回绕到0而产生的混淆(空还是满?),是实现无锁操作的关键。
- 大小必须是2的幂:这是kfifo最重要的设计约束。它允许使用按位与操作(
- API的演进:
- 最初的API比较基础,主要有
kfifo_put()
和kfifo_get()
。 - 后来增加了
kfifo_in()
和kfifo_out()
等函数,它们允许生产者/消费者直接在kfifo的内存空间上进行操作,避免了一次不必要的数据拷贝(即从用户提供的缓冲区拷贝到kfifo)。 - 引入了动态分配kfifo的函数(
kfifo_alloc
)和静态定义的宏(DEFINE_KFIFO
),使其使用更加灵活。 - 为了支持需要传递离散记录(records)而非字节流的场景,内核还引入了
kfifo_rec
变体。
- 最初的API比较基础,主要有
目前该技术的社区活跃度和主流应用情况如何?
kfifo是Linux内核中一个非常基础、稳定且被极其广泛使用的组件。它已经成为在内核中实现生产者-消费者数据传递的标准工具。其应用遍布内核的各个角落:
- 字符设备驱动:TTY(终端)和串口驱动使用kfifo来缓冲用户输入和程序输出。
- USB驱动:用于缓冲USB设备发来的数据。
- 网络驱动:在某些场景下用于缓冲数据包。
- 工业I/O(IIO)子系统:用于缓冲来自传感器的数据。
核心原理与设计
它的核心工作原理是什么?
kfifo是一个经过精心设计的环形缓冲区,其高效和线程安全的特性主要源于以下几个核心设计点:
- 2的幂大小:如上所述,强制kfifo的大小必须是2的幂。这使得索引的回绕操作可以通过位运算
index & (size - 1)
来完成,效率极高。 - 无限增长的索引:
in
(写入)和out
(读出)索引被定义为无符号整数(unsigned int
),它们只会单调递增,永远不会被显式地重置为0。当索引的值超过其最大表示范围时,它会自动利用无符号整数的溢出特性从0开始继续增长。 - 计算已用/可用空间:kfifo中已存储的数据量总是等于
in - out
。可用的空闲空间总是size - (in - out)
。由于索引是无限增长的,这个简单的减法就避免了传统环形缓冲区中需要比较in
和out
指针大小来判断缓冲区是空是满的复杂逻辑。 - 内存屏障(Memory Barriers):这是实现**单生产者/单消费者(SPSC)**场景下无锁操作的关键。
- 在
kfifo_put()
(生产者)中,首先将数据拷贝到缓冲区,然后才更新in
索引。这两步操作之间需要一个写内存屏障(smp_wmb()
)。这确保了对数据的写入操作一定先于in
索引的更新对其他CPU可见。 - 在
kfifo_get()
(消费者)中,首先读取in
索引的值,然后才从缓冲区拷贝数据,最后更新out
索引。在读取in
索引和拷贝数据之间需要一个读内存屏障(smp_rmb()
)。这确保了消费者能看到生产者更新后的in
索引,然后才去读取相应的数据。 - 正是这种精巧的屏障使用和操作顺序,保证了在SPSC场景下,生产者和消费者可以安全地并发访问kfifo而无需任何锁。
- 在
它的主要优势体现在哪些方面?
- 极高的性能:基于2的幂大小和位运算,以及在SPSC场景下的无锁设计,使其数据传输的开销极小。
- 线程安全:为最常见的SPSC场景提供了开箱即用的无锁安全保证。对于多生产者或多消费者(MPMC)的场景,kfifo也明确要求用户必须在外部使用锁(如自旋锁)来保护访问,提供了清晰的安全模型。
- 代码简洁:将复杂的环形缓冲区逻辑封装成简单易用的API,极大地简化了驱动程序的开发。
- 健壮性:作为一个通用的内核组件,它经过了社区的广泛使用和严格测试,非常可靠。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 大小限制:大小必须是2的幂,这可能会导致一些内存的浪费。例如,如果你需要一个33KB的缓冲区,你必须分配一个64KB的kfifo。
- 字节流导向:标准的kfifo是为字节流设计的。它不保留消息边界。如果生产者写入了两次数据,消费者一次性读取可能会将两次写入的数据合并在一起。对于需要保持消息边界的场景,需要使用
kfifo_rec
或在应用层自己处理分包。 - 无锁限制:其无锁特性仅适用于单生产者、单消费者的场景。只要存在多个生产者或多个消费者,就必须由调用者负责加锁。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?请举例说明。
在内核中,任何需要在两个执行单元(如中断上下文和进程上下文,或者两个内核线程)之间传递字节流数据的生产者-消费者模型中,kfifo都是首选。
- 例一:串口驱动
当串口硬件收到数据时,会触发一个中断。串口驱动的中断处理函数(生产者)需要快速地将收到的字节存放到一个缓冲区中,然后退出中断。内核中的一个内核线程(消费者)会从这个缓冲区中读取数据,并将其传递给上层的TTY子系统。这个“中断上下文生产,线程上下文消费”的场景是典型的SPSC模型,使用kfifo可以实现无锁、高效的数据缓冲。 - 例二:用户空间I/O缓冲
一个字符设备驱动在处理用户空间的write()
系统调用时,可以将用户传入的数据放入一个kfifo(生产者是进程上下文)。设备的硬件发送逻辑(可能是由一个工作队列或内核线程驱动)则从kfifo中取出数据并发送(消费者是另一个内核上下文)。
是否有不推荐使用该技术的场景?为什么?
- 需要保持消息边界:如果生产者写入的是一个个独立的数据包,且消费者需要以同样的数据包单位来读取,那么标准的kfifo不适用,因为它会合并字节。此时应考虑使用
kfifo_rec
或专门的消息队列。 - 数据量巨大且非2的幂:如果需要一个非常大的缓冲区(如几MB),且大小需求恰好不是2的幂(如3MB),那么使用kfifo导致的内存浪费可能会比较显著。在这种情况下,可能需要评估是否值得为了性能而接受这种空间开销。
- 需要复杂队列操作:如果需要的不仅仅是FIFO,还需要如优先级、乱序处理等复杂队列功能,那么kfifo不适用。应选择更高级的队列数据结构。
对比分析
请将其 与 其他相似技术 进行详细对比。
特性 | kfifo | 手动实现的环形缓冲区 | 链表 (Linked List) |
---|---|---|---|
核心功能 | 高效、线程安全的字节流FIFO | 字节流FIFO | 离散对象(节点)的序列 |
实现方式 | 连续的内存块,2的幂大小,无限增长的无符号索引 | 连续的内存块,手动处理索引回绕 | 离散的内存节点,通过指针连接 |
性能 | 非常高。无锁(SPSC),位运算代替模运算。 | 可变。性能取决于实现质量,通常不如kfifo优化。 | 较低。每个节点都需要单独的内存分配/释放,缓存局部性差。 |
内存占用 | 固定。一次性分配连续内存。大小为2的幂可能导致浪费。 | 固定。一次性分配连续内存。 | 动态。根据元素数量变化,但每个节点有额外的指针开销。 |
线程安全 | 内置SPSC无锁。MPMC需外部加锁。 | 需手动实现。非常容易出错。 | 需手动加锁。 |
数据模型 | 字节流。不保留消息边界。 | 字节流。 | 离散记录。每个节点是一个独立的消息。 |
典型用途 | 内核驱动中的生产者-消费者数据缓冲(串口,USB)。 | (应被kfifo取代) | 管理离散的对象列表,如任务队列、设备列表。 |
include/linux/kfifo.h
KFIFO: 内核高性能无锁循环缓冲区
此代码片段来自Linux内核的<linux/kfifo.h>
头文件, 它定义了**KFIFO (内核FIFO)**的数据结构和初始化宏。KFIFO是一个高度优化的、通用的、固定大小的循环缓冲区(circular buffer)实现。
它的核心原理有两个:
- 强制容量为2的幂: KFIFO要求其容量必须是2的幂(2, 4, 8, 16, …)。这个看似严格的限制是为了一个巨大的性能优势: 它可以使用**位掩码(bitmask)来代替昂贵的模运算(modulo)**来实现索引的回绕(wrap-around)。例如, 对于一个大小为16的缓冲区, 其掩码为15(二进制
1111
)。当索引增加到16时,16 & 15
的结果是0, 实现了从末尾到开头的回绕, 这比16 % 16
的计算速度快得多。 - 生产者/消费者模型解耦: KFIFO的设计旨在安全高效地在两个不同的执行上下文之间传递数据, 最经典的场景就是中断处理程序(生产者)和进程上下文(消费者)。通过精心设计的内存屏障和原子操作(在
kfifo_in
/kfifo_out
函数中实现), KFIFO可以在单生产者/单消费者的场景下实现**无锁(lock-free)**操作, 极大地降低了系统开销和中断延迟。
此代码片段本身专注于KFIFO的数据结构定义和静态初始化。它通过一系列复杂的C预处理器宏, 提供了两种主要的FIFO声明方式:
DECLARE_KFIFO
: 用于在编译时声明一个缓冲区内嵌的FIFO。FIFO的管理结构和其实际的数据存储区是一块连续的内存。这适用于大小在编译时就已确定的场景。DECLARE_KFIFO_PTR
: 用于声明一个缓冲区外置的FIFO。FIFO的管理结构本身不包含数据存储区, 数据区需要稍后通过kfifo_alloc
或kfifo_init
动态分配并关联。这适用于大小在运行时才能确定的场景。
在STM32H750这样的单核系统中, KFIFO的价值依然巨大:
- 性能: 位掩码操作带来的效率提升对于资源受限的MCU至关重要。
- 中断处理: 它是处理异步数据流的理想工具。例如, UART或SPI的接收中断可以将收到的数据快速推入一个KFIFO, 而主循环或一个低优先级任务可以安全地从这个KFIFO中取出数据进行处理。这避免了在中断上下文中进行耗时操作, 保证了系统的实时响应能力。
- 并发安全: 即使在单核系统上, 如果内核是抢占式的, 一个任务也可能在操作KFIFO的中间被抢占。KFIFO内部的实现(虽然在此代码片段中未完全展示)确保了这种并发访问的安全性。
宏和结构体详解
1 | /* 前向声明一个分散-聚集列表结构体, 此处未使用但kfifo可能与其他内核API交互. */ |
KFIFO 状态检查宏
此代码片段继续展示了Linux内核 KFIFO (内核FIFO) 的接口, 这次是一组用于检查KFIFO状态 (是否为空或已满) 的宏。这些宏是KFIFO生产者/消费者模型中进行流量控制和状态判断的基础。它们被设计得既高效又安全, 提供了不同级别的并发保护。
其核心原理非常直观:
- 空 (Empty): 当”放入”索引(
in
)与”取出”索引(out
)相等时, 缓冲区为空。 - 满 (Full): 当缓冲区中已存储的元素数量等于其总容量时, 缓冲区为满。
然而, 这些宏的实现细节体现了内核编程的高度技巧性, 特别是在类型安全和并发控制方面。
+ 1
的目的不是为了计算地址, 而是一个编译时(compile-time)的 “诡计”, 用来强制进行类型检查, 确保传入宏的 fifo 是一个指向完整结构体(complete type)的指针, 而不是一个 void * 或者指向一个不完整类型(incomplete type)的指针。
kfifo_is_empty
这是最基础的、非线程安全的空状态检查宏。
原理与工作流程:
它直接比较in
和out
索引。如果相等, 则FIFO为空。
1 | /** |
适用场景: 只能在可以确定没有其他执行绪(无论是任务还是中断)会同时修改FIFO的上下文中使用, 或者由调用者自己提供外部锁定。
kfifo_is_empty_spinlocked
和 kfifo_is_empty_spinlocked_noirqsave
这两个宏提供了线程安全的空状态检查。它们是实际并发编程中必须使用的版本。
原理与工作流程:
它们都通过在执行kfifo_is_empty
检查前后获取和释放一个自旋锁(spinlock
)来保证操作的原子性。
kfifo_is_empty_spinlocked
:
这是最常用、最安全的版本, 特别是用于中断上下文与进程上下文之间的同步。
1 | /** |
适用场景: 在STM32H750这类系统中, 当一个中断处理程序作为生产者向FIFO写入数据, 而一个内核任务(进程上下文)作为消费者读取数据时, 消费者在检查FIFO是否为空时必须使用这个宏。
kfifo_is_empty_spinlocked_noirqsave
:
这是一个轻量级的版本, 它不处理中断状态。
1 | /** |
适用场景: 用于两个内核任务之间的同步, 或者可以确定与FIFO交互的代码路径都不会在中断上下文中执行的场景。在单核系统上, 这足以防止任务间的并发冲突。我们之前分析的lineinfo_watch_poll
函数就使用了这个版本, 因为它是在进程上下文中被调用, 而其生产者(中断处理程序)在推入数据后会使用spin_lock_irqsave
版本的锁, 这种”非对称”的锁使用是正确且高效的。
kfifo_is_full
这是最基础的、非线程安全的满状态检查宏。
原理与工作流程:
它通过kfifo_len()
宏(此处未显示, 但其作用是计算in - out
)来获取当前FIFO中的元素数量, 然后将该数量与FIFO的容量(mask + 1
)进行比较。为了效率, 它直接与mask
比较。
1 | /** |
适用场景: 由生产者在尝试向FIFO写入数据之前调用, 以防止数据溢出和丢失。同样, 这个基础版本需要调用者自己处理锁定。
kfifo_out: 从KFIFO中获取数据
此宏是Linux内核 KFIFO (内核FIFO) 接口中负责从缓冲区取出数据的核心消费者API。它的根本原理是以一种高效且在特定条件下无需外部加锁的方式, 从循环缓冲区中复制出指定数量的元素。
这个宏的设计集成了多项内核编程的最佳实践, 以实现高性能、类型安全和并发安全。
核心工作原理 (内部函数 __kfifo_out
所做的事情):
- 计算可用长度: 它首先读取
in
和out
索引, 计算出当前FIFO中可用元素的数量 (len = in - out
)。 - 确定复制数量: 它取用户请求的数量
n
和FIFO中实际可用的数量len
之间的最小值, 作为本次要复制的元素数量。 - 分段复制: 由于数据在循环缓冲区中可能被分割在两段 (一部分在数组末尾, 一部分在数组开头),
__kfifo_out
会智能地处理这种情况。它可能会执行一次或两次memcpy
操作, 将数据从FIFO的内部data
缓冲区复制到用户提供的buf
中。 - 更新
out
索引: 在所有数据被安全地复制出去之后, 它会原子地更新out
索引, 将其增加实际复制出去的元素数量。这个操作顺序是至关重要的。 - 无锁(Lock-Free)操作: 在单生产者/单消费者的场景下,
kfifo_out
是无锁的。这是因为:- 只有消费者(调用
kfifo_out
的代码)会修改out
索引。 - 只有生产者(调用
kfifo_in
的代码)会修改in
索引。 __kfifo_out
内部使用了内存屏障(memory barrier, 如smp_rmb()
), 确保对in
索引的读取发生在我们开始复制数据之前, 并且对out
索引的更新发生在我们完成复制之后。这可以防止编译器或CPU对操作进行不安全的乱序优化, 保证了生产者和消费者之间看到的数据状态是一致的。
- 只有消费者(调用
宏定义详解
kfifo_out
本身是一个复杂的宏, 它封装了所有必要的类型检查和逻辑分派。
1 | /** |
lib/kfifo.c
KFIFO 面向记录的数据出队核心实现
此代码片段展示了Linux内核 KFIFO (内核FIFO) 中用于面向记录(record-based)的数据出队(dequeue)操作的底层核心实现。与简单的、逐元素(element)的FIFO不同, 面向记录的FIFO用于处理可变长度的数据包或消息。它的核心原理是: 在存入每条可变长度的数据记录之前, 先存入一个固定大小的头部, 这个头部记录了紧随其后的数据记录的长度。
这组函数协同工作, 实现了一个安全、分层的记录出队过程:
__kfifo_out_r
(API层): 这是外部调用的顶层函数。它负责完成一次完整的”记录出队”事务: 检查非空, 调用下一层来复制数据, 最后更新out
指针以宣告记录已被消耗。kfifo_out_copy_r
(逻辑层/ framing层): 它负责处理”记录”的逻辑。它首先”窥探”(peek)记录的头部以获取其长度, 然后调用物理层来复制记录的数据体, 但它不会更新out
指针。kfifo_copy_out
(物理层/ copy层): 这是最底层的内存复制引擎。它不关心”记录”的概念, 只负责高效、安全地将指定长度的字节从循环缓冲区中复制出来, 并正确处理地址回绕(wrap-around)的情况。
kfifo_copy_out
: 从循环缓冲区中复制数据
这是最底层的物理复制函数。
原理与工作流程:
1 | /* |
kfifo_out_copy_r
: “窥探”并复制一条记录的数据
这个函数负责读取记录的头部, 并据此复制记录的数据体。
原理与工作流程:
1 | /* |
__kfifo_out_r
: 完成一次面向记录的出队操作
这是导出的API函数, 它将上述两个函数的功能组合起来, 完成一次完整的记录出队。
原理与工作流程:
1 | /* |
__kfifo_out
: 核心数据出队函数
这是标准的、用于**面向元素(element-based)**的FIFO的数据出队函数。它的作用是从FIFO中复制出指定数量的元素, 并更新FIFO的状态以表示这些元素已被消耗。
原理与工作流程:
它将”出队”这个动作优雅地分解为两个独立的步骤:
- 窥探与复制 (
__kfifo_out_peek
): 它首先调用__kfifo_out_peek
。这个函数负责计算实际可复制的元素数量, 并执行从KFIFO内部缓冲区到用户目标缓冲区的内存复制(memcpy
), 但它不会修改FIFO的任何状态指针。 - 消耗与更新 (
fifo->out += len
): 在数据被安全地复制出去之后,__kfifo_out
才执行最关键的一步: 将out
索引向前移动实际复制出去的元素数量(len
)。这个更新操作宣告了缓冲区中的这部分空间现在可以被生产者重新使用。
这种两步分离的设计是KFIFO在单生产者/单消费者场景下实现无锁(lock-free)的关键。
1 | /* |
__kfifo_out_peek
: 窥探并复制数据
此函数的作用是”窥探”FIFO中的数据——即复制数据, 但不更新out
索引。这允许消费者在不消耗数据的情况下检查队列头部的内容。
原理与工作流程:
1 | /* |
__kfifo_peek_n
: 窥探记录头以获取记录长度
这是一个内部辅助函数, 专门用于面向记录(record-based)的FIFO。它的唯一作用是窥探下一条记录的头部, 并解析出该记录的数据体长度。
原理与工作流程:
它从fifo->out
的当前位置读取1个或2个字节, 并将它们组合成一个代表长度的整数。
1 | /* __KFIFO_PEEK: 一个安全的宏, 用于读取内部数据缓冲区中的一个元素. */ |