I/O体系结构和设备驱动程序
一、I/O体系结构概述
输入输出系统是计算机体系结构中最复杂、最容易被忽视的组成部分之一。虽然 CPU 和内存构成了计算的核心,但计算机真正与外部世界交互的能力完全依赖于 I/O 系统。硬盘上的数据读取、键盘上的每一次敲击、网卡上到达的每一个数据包、打印机输出的每一行文字,背后都有一套精密而庞大的 I/O 体系在支撑。理解 I/O 体系结构,是理解操作系统内核、编写设备驱动程序、进行系统性能调优的重要前提。
从宏观上看,I/O 体系结构承担三个基本职责:第一,屏蔽硬件差异,向用户程序和内核子系统提供统一的访问接口;第二,协调不同速度的设备与高速 CPU 之间的数据交换,避免低速设备拖垮整个系统;第三,实现错误检测与恢复,保证数据传输的可靠性。这三项职责贯穿了从硬件总线设计到驱动程序抽象层的每一个环节。
现代计算机的 I/O 体系结构通常可以用一个分层的模型来描述。最底层是物理层,包括总线、接口控制器、设备本身以及连接它们的电缆和插槽;中间层是硬件抽象层,由芯片组、I/O 控制器和固件共同构成,负责把物理信号转换成可编程的寄存器操作;再往上是操作系统内核中的 I/O 子系统,包括设备驱动模型、中断处理、缓冲管理和调度器;最上层是用户空间的系统调用接口,应用程序通过 read、write、ioctl 等接口间接访问设备。这个分层模型的美妙之处在于,每一层都只关心自己的职责,上层完全不需要知道下层设备的具体细节。
在早期的计算机系统中,CPU 直接通过 I/O 指令访问设备,每个设备有固定的端口地址。这种设计简单直接,但扩展性很差,设备数量增加后端口地址空间会迅速耗尽,而且 CPU 必须轮询设备状态,浪费大量计算能力。随着硬件技术的发展,中断机制、DMA、内存映射 I/O 等技术的出现,使得 I/O 操作逐渐从 CPU 手中解放出来,形成了今天以总线为中心、以南桥和北桥为枢纽、由各种控制器分工协作的复杂体系。
在嵌入式系统和现代 SoC 中,I/O 体系结构又呈现出另一种形态。ARM 架构的处理器通过 AMBA 总线连接各种外设,所有外设都被映射到统一的物理地址空间,通过内存映射的方式进行访问。这种设计简化了指令集,也方便了操作系统对设备的管理。Linux 内核的驱动框架正是在这种多样化的硬件环境下抽象出了一套统一的软件模型,让驱动程序开发者能够用相似的方式编写运行在完全不同的硬件平台上的驱动。
二、计算机I/O硬件基础
2.1 总线系统
总线是连接 CPU、内存和各个 I/O 设备的通信骨干。按照传递信息的类型,总线可以分为数据总线、地址总线和控制总线。数据总线负责传输实际的数据,它的宽度决定了每次能够传输的字节数;地址总线决定 CPU 能够寻址的范围;控制总线则传递读写信号、中断信号、时钟信号等控制信息。
现代 PC 的总线体系已经变得非常复杂。以 Intel 平台为例,CPU 通过 QPI 或 DMI 链路连接到平台控制器中枢,也就是通常所说的芯片组。芯片组分为北桥和南桥的历史已经随着集成度的提高而逐渐模糊,但功能分区仍然存在:高速设备如显卡通过 PCIe 通道直接连接到 CPU,而低速设备如 USB、SATA、音频则通过芯片组提供的控制器进行连接。PCIe 总线采用串行点对点通信方式,取代了老式并行的 PCI 总线,成为当前外设连接的主流标准。
在嵌入式系统中,AMBA 总线家族是事实标准。APB 总线用于连接低速外设,如 UART、I2C、SPI、GPIO 等,其特点是接口简单、功耗低;AHB 总线用于连接高速模块,如内存控制器、DMA 控制器等;AXI 总线则是新一代高性能总线,支持多主多从、乱序传输和突发传输,广泛应用于现代 SoC 内部互联。理解总线层次对于编写驱动非常重要,因为不同总线上的设备,其寄存器访问方式和性能特性差异巨大。
2.2 I/O端口与内存映射I/O
CPU 与设备通信的核心机制是寄存器访问。设备内部通常包含一组寄存器,CPU 通过读写这些寄存器来控制设备的行为、查询设备的状态、传递数据。寄存器访问有两种基本方式:端口映射 I/O 和内存映射 I/O。
端口映射 I/O 使用独立的地址空间。在 x86 架构中,I/O 端口空间有 64K 个端口,通过专门的 IN 和 OUT 指令进行访问。例如,读取键盘控制器状态寄存器的汇编代码是 IN AL, 0x64。Linux 内核中,request_region 函数用于申请端口资源,inb、outb、inw、outw 等函数用于执行实际的端口读写。端口映射的优点是访问指令清晰,与内存地址空间完全隔离,不会减少可用的内存地址空间;缺点是专用指令功能单一,处理器需要额外的引脚和逻辑来支持这种独立空间。
内存映射 I/O 将设备寄存器映射到处理器的地址空间中,读写设备寄存器就像读写普通内存一样使用 MOV 指令或其他内存访问指令。ARM 架构完全采用这种方式。在现代 x86 系统中,PCIe 设备的配置空间和 BAR 空间也都是通过内存映射方式访问的。内存映射的优点是编程简单,可以使用所有针对内存的指令,包括各种寻址模式;缺点是需要占用内存地址空间,而且 CPU 和编译器对内存访问的优化可能会导致对设备寄存器的访问被重排或缓存,这是驱动开发中需要特别注意的问题。
在 Linux 驱动编程中,内存映射 I/O 的设备寄存器通常需要先通过 ioremap 函数将物理地址映射到内核虚拟地址空间,然后使用 readl、writel 等函数进行访问。对于可能被 CPU 重排的访问,需要使用内存屏障函数如 wmb、rmb、mb 来保证访问顺序。这些细节看似琐碎,但在实际驱动开发中,忽略内存屏障往往会导致难以调试的偶发故障。
2.3 设备控制器与设备本身
I/O 设备通常由两部分组成:机械部分或电子部分构成设备本身,以及负责与总线接口通信的设备控制器。以硬盘为例,磁头、盘片、电机是设备本身,而硬盘控制器芯片则是设备控制器。软盘控制器、显卡控制器、网卡控制器都是类似的例子。操作系统的驱动程序实际上主要与设备控制器打交道,而不是直接操作设备本身。
设备控制器内部包含一组寄存器,这些寄存器的功能因设备而异,但大体上可以分为四类:控制寄存器,用于向设备发出命令;状态寄存器,用于查询设备当前的工作状态;数据寄存器,用于在 CPU 和设备之间传递数据;以及一些设备特有的寄存器。设备控制器还负责把总线上的信号转换成设备能够理解的信号,并在数据传输过程中进行错误检测,如奇偶校验或 CRC 校验。
设备控制器的另一个重要功能是缓冲。由于设备的速度通常远低于 CPU 和内存,控制器内部往往有一个数据缓冲区。以网卡为例,控制器内部有收发 FIFO,CPU 把要发送的数据写入控制器的发送缓冲区后,控制器按照网络速度逐字节发出,CPU 不必等待设备完成发送就可以继续执行其他任务。这种异步性正是中断和 DMA 技术发挥作用的基础。
三、I/O控制方式演进
3.1 程序直接控制
最简单的 I/O 控制方式是程序直接控制,也称为忙等待或轮询。在这种方式下,CPU 直接执行 I/O 指令,并在每一步操作后检查设备状态寄存器的就绪位,如果设备尚未就绪,CPU 就不断地循环检查。这种方式在早期计算机和某些简单的嵌入式系统中非常常见。
程序直接控制的主要问题是 CPU 利用率极低。假设一个硬盘的数据传输速率是 10MB/s,而 CPU 的速度是 GHz 级别,在等待设备准备好一个字节的过程中,CPU 可以执行数百万条指令。将这些指令全部浪费在循环检查状态位上是极其低效的。此外,轮询方式也无法及时响应设备的异步事件。如果一个设备在 CPU 没有检查它的状态下出现了错误或完成了操作,这个事件可能会被延迟处理。
然而,轮询并不是完全没有价值。在某些特定场景下,轮询反而是更优的选择。例如,对于延迟极低、数据到达频繁的高速设备,中断处理的开销可能超过轮询的收益。Linux 内核中的 NAPI 机制就是这样一个例子:当网络流量很高时,网卡驱动会关闭中断,改用轮询方式批量处理数据包,以提高吞吐量。在嵌入式裸机系统中,当系统只有一个任务需要处理且延迟要求非常严格时,轮询也是简单可靠的选择。
3.2 中断驱动控制
中断机制是现代 I/O 的核心。基本思想是:CPU 向设备发出 I/O 命令后,不等待设备完成,而是继续执行其他任务;当设备完成操作或发生需要 CPU 关注的事件时,设备控制器通过中断请求线向 CPU 发出中断信号;CPU 暂停当前任务,保存现场,跳转到中断处理程序执行;处理完成后恢复现场,返回被中断的任务继续执行。
中断的引入将 CPU 从无意义的等待中解放出来,大大提高了系统的并发处理能力。在中断驱动 I/O 中,数据传输仍然需要 CPU 参与:CPU 从设备控制器读取数据到内存,或者从内存写入数据到设备控制器。这种方式对低速设备非常合适,如键盘、鼠标、串口等。每次中断只传输少量数据,CPU 的参与时间很短,中断处理的频率也在可控范围内。
但中断也有它的问题。首先,每次中断都涉及上下文切换,包括保存和恢复寄存器状态、切换内核栈等工作,这些开销对于高速设备来说可能过于昂贵。如果一个高速网卡每秒需要处理百万个数据包,每个数据包都触发一次中断,中断处理的总开销会吞噬掉大量 CPU 资源。其次,中断处理程序运行在中断上下文中,不能睡眠,不能做耗时的操作,这限制了中断处理程序能完成的工作。第三,中断可能带来优先级反转、活锁等问题,需要精心设计中断处理策略。
3.3 DMA直接内存访问
DMA 是解决高速设备数据传输问题的关键技术。DMA 控制器是一个专门的硬件模块,能够在不经过 CPU 的情况下,直接在主存和设备之间传输数据。CPU 只需要设置好 DMA 传输的源地址、目标地址和传输长度,然后启动传输,DMA 控制器就会自动完成整块数据的搬运。传输完成后,DMA 控制器通过中断通知 CPU。
DMA 的好处非常明显:对于大块数据传输,CPU 的参与从逐字节搬运减少到仅设置参数和处理完成中断。以磁盘读取为例,CPU 向磁盘控制器发出读命令后,磁盘控制器将数据从盘面读出到内部缓冲区,然后通过 DMA 将数据直接写入系统内存,整个过程 CPU 完全空闲,可以执行其他进程。当整块数据传输完成时,DMA 控制器和磁盘控制器配合发出一个中断,CPU 才知道数据已经就绪。
现代系统中 DMA 的形式已经非常多样化。除了传统的 ISA 时代的专用 DMA 控制器,PCIe 设备通常支持总线主控 DMA,设备自己就是 DMA 主控,可以直接发起内存读写。分散聚集 DMA 允许一次传输涉及多块不连续的内存区域,这对于网络数据包处理非常有用,因为一个数据包可能由多个不连续的内存块组成头部和负载。在 Linux 内核中,DMA API 提供了一组抽象接口,如 dma_map_single、dma_map_sg 等,用于处理 DMA 映射、缓存一致性等问题。
DMA 的使用也带来了新的复杂性。最重要的就是缓存一致性问题。现代 CPU 有多级缓存,内存中的数据和缓存中的数据可能不一致。如果 CPU 写入了数据但数据还在缓存中没有回写到内存,此时 DMA 从内存读取就会读到旧数据;反过来,如果 DMA 写入了数据到内存,而 CPU 的缓存中还保存着这块内存的旧数据,CPU 读到的就是过时的内容。因此,在进行 DMA 操作前后,需要进行缓存刷新和失效操作。此外,DMA 地址和虚拟地址之间的映射、DMA 内存的分配和一致性处理,都是驱动开发者必须掌握的知识。
3.4 I/O通道与I/O处理器
在大型机和高性能服务器系统中,I/O 控制曾经采用过更复杂的通道结构。通道是一种专用的 I/O 处理器,拥有自己的指令集,可以执行通道程序,独立管理多个设备的 I/O 操作。IBM 的大型机系统是通道结构的典型代表。通道程序描述了一组 I/O 操作序列,CPU 只需要把通道程序的地址交给通道,通道就能独立完成整个 I/O 任务,包括设备选择、数据传输、错误处理等,完成后向 CPU 发出中断。
通道结构将 CPU 从 I/O 管理中彻底解放出来,实现了高度的并行性。但随着微处理器性能的飞速提升和集成度的不断提高,用专门硬件做 I/O 处理的做法已经不再必要,现代的通用处理器完全可以胜任 I/O 管理任务。通道的概念在某种程度上被现代的 I/O 协处理器、智能网卡上的可编程处理器等新型硬件所继承和发展。
四、I/O软件层次结构
4.1 软件分层设计思想
操作系统的 I/O 软件体系通常采用分层设计。分层的核心目标是将设备相关的代码和设备无关的代码分离开来,使得内核的绝大部分代码不需要关心具体设备的细节。这样,一个新的磁盘驱动只需要实现几个底层的设备操作函数,就能与整个系统的文件系统、缓存管理、I/O 调度器无缝配合。
经典的 I/O 软件层次从上到下依次是:用户层 I/O 函数、设备无关的操作系统软件、设备驱动程序、中断处理程序和硬件。每一层都为上一层提供服务,屏蔽下层的实现细节。用户层的 printf 函数调用会经过 C 库格式化、系统调用 write、VFS 层的处理、文件系统的翻译、通用块层的调度,最终到达磁盘驱动程序的请求队列处理函数,再由驱动通过 DMA 或 PIO 方式把数据写到磁盘设备上。
这种分层设计的价值在 Linux 内核中得到了充分体现。Linux 的 I/O 栈每一层都有精确定义的接口。VFS 层提供了统一的文件操作接口,使得 ext4、XFS、NFS 等不同文件系统可以共存;通用块层将文件系统的块请求转换成符合设备特性的请求,并进行合并、排序和调度;SCSI 子系统进一步抽象了各种存储设备的共性;而具体的 HBA 驱动只负责把 SCSI 命令传递给硬件。开发者可以独立地在某一层进行优化,而不影响其他层次。
4.2 中断处理程序
中断处理程序位于 I/O 软件栈的最底层,是响应硬件中断的第一段代码。在 Linux 中,中断处理分为顶半部和底半部。顶半部是注册到中断线上的中断处理函数,它在中断上下文中运行,必须尽可能快地完成工作。顶半部通常只做最必要的事情:确认中断来源、保存关键状态、屏蔽或清除中断、调度底半部处理,然后立即返回。
底半部的实现机制有多种:软中断、tasklet、工作队列和线程化中断处理函数。软中断运行在中断上下文中且可能并发执行;tasklet 是软中断的一种特殊形式,同一类型的 tasklet 不会并发执行;工作队列运行在进程上下文中,可以睡眠,适合处理耗时的任务;线程化中断处理函数则将整个中断处理函数放到内核线程中执行,简化了驱动开发者的编程模型。
中断处理程序的设计直接影响系统的响应能力和稳定性。处理时间过长会导致其他中断被延迟,甚至造成中断丢失;处理时间过短、底半部调度不当则会影响设备数据的及时处理。优秀的驱动开发者需要根据设备的数据速率、处理复杂度和系统负载来合理划分顶半部和底半部的工作,选择合适的底半部机制。
4.3 设备无关的I/O软件
设备无关的 I/O 软件层承担着一系列与具体设备无关的通用功能。首先是统一的命名和访问接口。在 Linux 中,所有设备都被抽象为文件,用户和应用程序通过标准的文件操作来访问设备。这种统一性使得用户完全不需要关心设备的具体类型和位置。
其次是缓冲管理。由于设备速度远低于 CPU,直接进行逐字节传输效率太低。设备无关层引入了缓冲机制,数据先写入缓冲区,累积到一定数量后再实际传输。缓冲还解决了生产者消费者速度不匹配的问题,允许数据在设备就绪之前就被接受。Linux 的页缓存是缓冲管理的一个典型例子,它缓存了文件系统的数据,使得大多数读操作不需要真正访问磁盘。
再次是错误报告和处理。设备层报告的错误是多样化的,设备无关层负责将错误分类、记录并向用户提供统一格式的报告。某些暂时性错误可以自动重试,而永久性错误则需要报告给上层处理。最后,I/O 调度也是设备无关层的重要功能。调度器决定哪个 I/O 请求先被处理,以优化设备的整体吞吐量和响应时间。对于磁盘这类机械设备,调度器需要尽量减少磁头移动;对于固态盘和高速网络设备,调度策略则需要考虑公平性和低延迟。
4.4 用户空间I/O接口
用户空间程序通过系统调用访问 I/O 设备。最常用的系统调用包括 open、read、write、close、ioctl、mmap、select、poll 和 epoll。这些系统调用在 C 库中有对应的封装函数,用户程序直接调用这些库函数即可。系统调用的实现在内核中经过一系列复杂的处理,最终映射到设备驱动程序的相应方法。
open 系统调用用于打开设备文件,内核通过设备号找到对应的驱动程序,并建立文件描述符与设备之间的关联。read 和 write 用于数据传输,它们最终调用驱动的 read 和 write 方法。ioctl 是一种万能的控制通道,用于传递设备特有的控制命令,如设置串口波特率、查询磁盘参数、配置网卡模式等。mmap 则允许用户空间直接将设备内存映射到进程地址空间,适用于需要频繁访问设备缓冲区或要求低延迟的场景,如帧缓冲设备、高性能采集卡等。
随着异步 I/O 的发展,io_uring 成为了新一代的高性能 I/O 接口。它通过共享内存中的环形队列来提交和完成 I/O 请求,大幅减少了系统调用开销。虽然 io_uring 在设备驱动层的影响还在逐步展开,但它代表了 I/O 软件体系面向高性能场景的重要演进方向。
五、设备驱动程序基础
5.1 驱动程序的角色与定位
设备驱动程序是内核中专门负责与具体硬件设备通信的软件模块。它扮演着翻译官的角色:将内核的通用请求转换成设备控制器能够理解的具体操作序列,同时将设备的状态和事件翻译成内核能够使用的信息。没有驱动程序,再强大的硬件也无法被操作系统利用。
驱动程序的首要任务是初始化和配置设备。内核启动时或设备热插拔时,驱动需要探测设备是否存在、读取设备标识、初始化设备寄存器、分配内存资源、注册中断处理函数,并将设备注册到内核的设备模型中。初始化的质量直接影响设备后续工作的稳定性和性能。
驱动程序的第二个核心任务是响应内核的数据传输请求。当用户进程读取一个文件时,文件系统将请求转换成块设备请求,块设备驱动需要将这些请求转换成设备控制器的命令序列,通过 DMA 或 PIO 方式完成数据传输,并通过完成回调通知上层。网络设备驱动则要将协议栈传下来的数据包发送到网络,同时把接收到的数据包交给协议栈处理。
驱动程序的第三个重要任务是处理设备产生的事件。设备可能会因为数据传输完成、状态变化、错误发生、外部事件等原因产生中断。驱动程序的中断处理函数需要及时响应这些事件,应用相应的处理逻辑,并唤醒等待的进程或通知上层子系统。
5.2 内核模块机制
Linux 设备驱动程序最常见的存在形式是内核模块。内核模块是一段可以在系统运行时动态加载和卸载的内核代码。使用模块的好处是灵活:不需要重新编译内核就能添加新的设备支持,不需要的模块可以随时卸载以节省内存。模块化设计也是驱动开发的基本模式。
一个最简单的内核模块结构如下:module_init 宏指定模块加载时调用的初始化函数,module_exit 宏指定模块卸载时调用的清理函数,MODULE_LICENSE 宏声明模块的许可证。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init mydriver_init(void)
{
printk(KERN_INFO "mydriver: initialized\n");
return 0;
}
static void __exit mydriver_exit(void)
{
printk(KERN_INFO "mydriver: exited\n");
}
module_init(mydriver_init);
module_exit(mydriver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Driver Developer");
MODULE_DESCRIPTION("A simple example driver");
模块的加载通过 insmod 或 modprobe 命令完成。insmod 直接加载指定的模块文件,modprobe 则还会自动加载模块依赖的其他模块。模块加载后,其符号会被加入内核符号表,其他模块可以引用。卸载通过 rmmod 命令完成,前提是模块的引用计数为零。
模块化带来灵活性的同时,也引入了风险。模块运行在内核态,拥有最高权限,一个错误可能导致整个系统崩溃。因此驱动开发需要严格遵守内核编程规范:不访问未映射的指针、不在中断上下文中睡眠、正确使用锁保护共享数据、及时释放申请的资源。内核没有像用户态那样的内存保护机制,野指针、内存越界、自旋锁死锁等错误会直接导致内核 oops 或系统挂起。
5.3 设备文件与设备号
Linux 继承了 Unix 的传统,将设备也抽象为文件系统中的节点。设备文件通常位于 /dev 目录下,用户程序通过操作这些文件来访问设备。设备文件分为字符设备和块设备两种类型。字符设备以字节流的方式进行顺序访问,不支持随机定位,如键盘、串口、声卡;块设备支持随机访问,数据以块为单位进行传输,如硬盘、SSD。
每个设备文件都有一个设备号,由主设备号和次设备号组成。主设备号标识设备的驱动程序类型,次设备号用于区分同一驱动程序管理的多个具体设备。在传统系统中,主设备号是静态分配的,有一个固定的设备号表。现代 Linux 内核使用动态设备号分配,驱动程序可以在初始化时申请一个未使用的主设备号,也可以指定一个希望使用的主设备号。
设备号的创建和分配通过 register_chrdev_region 或 alloc_chrdev_region 函数完成。释放时使用 unregister_chrdev_region。设备节点则通过 mknod 命令手动创建,或者由 udev 系统自动创建。现代系统中,udev 监听内核的 uevent,当驱动注册设备后,udev 会在 /dev 目录下自动创建设备节点,并可以设置权限、创建符号链接、触发用户态脚本。
随着设备类型的增多,传统的设备号机制显得力不从心,因此 Linux 引入了 sysfs 和基于 sysfs 的设备模型。每个设备在 sysfs 中都有一个目录条目,属性文件用于读写设备参数,uevent 文件用于报告设备状态变化。设备模型不仅提供了用户空间的可见性,也为内核内部的电源管理、热插拔和设备层次关系组织了数据基础。
六、Linux设备驱动模型
6.1 核心数据结构
Linux 内核的设备驱动模型建立在几个核心数据结构之上:bus_type 表示总线类型,device 表示一个具体的设备,device_driver 表示一个驱动程序,class 表示一类设备。这几个结构体之间的关系构成了设备模型的主体框架。
总线是设备和驱动之间的桥梁。每种总线类型,如 PCI、USB、I2C、SPI、platform,都定义了自己的 bus_type 实例。总线的核心职责是匹配设备和驱动。当一个新设备被发现时,总线遍历注册在该总线上的所有驱动,调用 match 函数判断驱动是否能支持该设备;当一个新的驱动被注册时,总线遍历该总线上已有的设备进行匹配。匹配成功后,驱动的 probe 函数被调用,开始设备的初始化。
device 结构体描述一个具体设备,包含设备的名称、所属总线、父设备指针、设备号、DMA 参数、电源管理信息等。device_driver 结构体描述一个驱动程序,包含驱动的名称、所属总线、probe 和 remove 函数指针、设备列表等。class 结构体则将一类具有相似功能的设备组织在一起,例如所有输入设备都属于 input 类,所有块设备都属于 block 类。class 为同类设备提供统一的属性接口和用户空间可见性。
6.2 平台总线与设备树
对于嵌入式系统中的片上外设和通过内存映射方式访问的设备,Linux 使用 platform 总线模型。这类设备通常不挂在标准的可枚举总线上,无法自动发现,只能通过静态描述的方式来注册。platform 总线上的设备用 platform_device 结构体描述,驱动用 platform_driver 结构体描述。匹配的依据通常是设备名称与驱动名称的字符串比较。
在传统的 ARM Linux 中,平台设备信息被硬编码在板级文件中。随着 ARM 生态系统中板卡数量的爆炸式增长,这种硬编码方式变得不可维护。设备树应运而生。设备树是一种描述硬件拓扑的数据结构,以文本格式的 DTS 文件描述硬件,编译为 DTB 二进制格式,由启动引导程序传递给内核。内核在启动时解析设备树,为每个设备节点创建对应的 platform_device。
/ {
model = "Example Board";
compatible = "vendor,example-board";
memory {
device_type = "memory";
reg = <0x80000000 0x20000000>;
};
uart0: serial@10000000 {
compatible = "vendor,example-uart";
reg = <0x10000000 0x100>;
interrupts = <0 25 4>;
clock-frequency = <115200>;
};
i2c0: i2c@10001000 {
compatible = "vendor,example-i2c";
reg = <0x10001000 0x100>;
interrupts = <0 26 4>;
#address-cells = <1>;
#size-cells = <0>;
};
};
设备树的使用使得内核镜像和硬件描述完全分离。同一份内核可以在不同的硬件平台上运行,只需要配合不同的设备树文件。驱动的 probe 函数通过设备树节点获取资源信息,如寄存器地址、中断号、时钟频率等。compatible 属性用于驱动和设备的匹配,of_match_table 中列出的 compatible 字符串与设备树节点中的 compatible 属性进行匹配。
6.3 sysfs与设备属性
sysfs 是一个虚拟文件系统,通常挂载在 /sys 目录下。它是设备模型投射到用户空间的窗口。sysfs 中的目录结构反映了内核中 device、driver、bus、class 之间的层次关系。/sys/devices 下是所有设备的树状结构,/sys/bus 下按总线类型组织设备和驱动,/sys/class 下按设备类别组织,/sys/devices/platform 下可以看到平台设备。
驱动可以通过 DEVICE_ATTR 宏创建设备属性文件。这些属性文件出现在设备对应的 sysfs 目录下,用户可以通过 cat 和 echo 命令读取和修改属性值。属性文件的读写对应驱动中定义的 show 和 store 函数。这为驱动提供了一种简单而标准的用户空间配置接口。
static ssize_t brightness_show(struct device *dev,
struct device_attribute *attr,
char *buf)
{
struct my_device *mydev = dev_get_drvdata(dev);
return sprintf(buf, "%d\n", mydev->brightness);
}
static ssize_t brightness_store(struct device *dev,
struct device_attribute *attr,
const char *buf, size_t count)
{
struct my_device *mydev = dev_get_drvdata(dev);
int value;
if (kstrtoint(buf, 10, &value) < 0)
return -EINVAL;
mydev->brightness = value;
apply_brightness(mydev, value);
return count;
}
static DEVICE_ATTR_RW(brightness);
sysfs 的设计原则是每个属性文件只包含一个值,值应该是人类可读的文本格式。这虽然牺牲了效率,但换来了简单性和可调试性。对于需要高速传输大量数据的场景,应该使用字符设备接口而不是 sysfs。
6.4 电源管理与运行时PM
电源管理是设备模型中至关重要的一个方面。现代设备,尤其是移动设备和服务器,对功耗有严格的要求。Linux 内核的电源管理框架包括系统级的挂起和恢复,以及设备级的运行时电源管理。
系统挂起时,内核按照设备树的逆序调用每个设备的 suspend 回调,让设备进入低功耗状态。唤醒时,内核按照正序调用 resume 回调,恢复设备状态。设备模型的层次关系保证了父设备总是在子设备之后挂起、在子设备之前恢复,这对于正确管理总线控制器和其下设备的电源顺序非常重要。
运行时电源管理是一个更细粒度的机制。当设备空闲时,驱动可以调用 pm_runtime_put 函数,内核会在适当的时候调用驱动的 runtime_suspend 回调将设备置于低功耗状态;当设备需要被使用时,内核调用 runtime_resume 回调唤醒设备。运行时 PM 使得每个设备可以独立地管理自己的电源状态,而不是绑在整个系统的电源状态上。驱动开发者需要正确处理 runtime PM 的引用计数,确保在设备真正空闲时才进入低功耗状态,同时要处理 runtime suspend 和 system suspend 之间的协调。
七、字符设备驱动详解
7.1 字符设备的基本框架
字符设备是 Linux 中最常见也最基础的设备类型。字符设备以字节流的形式进行访问,数据传输没有缓冲,不支持随机定位。字符设备驱动的核心是 file_operations 结构体,它是一组函数指针,定义了设备支持的操作。用户程序对设备文件进行的系统调用最终都会映射到这个结构体中的某个函数。
static const struct file_operations mydev_fops = {
.owner = THIS_MODULE,
.open = mydev_open,
.release = mydev_release,
.read = mydev_read,
.write = mydev_write,
.unlocked_ioctl = mydev_ioctl,
.mmap = mydev_mmap,
};
驱动初始化时,需要完成以下步骤:分配并初始化设备号、分配字符设备结构体、初始化 file_operations、调用 cdev_add 将字符设备注册到内核。一个完整的初始化函数大约是这样的:
static dev_t dev_num;
static struct cdev my_cdev;
static struct class *my_class;
static int __init mydev_init(void)
{
int ret;
ret = alloc_chrdev_region(&dev_num, 0, 1, "mydev");
if (ret < 0)
return ret;
cdev_init(&my_cdev, &mydev_fops);
my_cdev.owner = THIS_MODULE;
ret = cdev_add(&my_cdev, dev_num, 1);
if (ret < 0)
goto err_cdev;
my_class = class_create(THIS_MODULE, "mydev");
if (IS_ERR(my_class)) {
ret = PTR_ERR(my_class);
goto err_class;
}
device_create(my_class, NULL, dev_num, NULL, "mydev0");
return 0;
err_class:
cdev_del(&my_cdev);
err_cdev:
unregister_chrdev_region(dev_num, 1);
return ret;
}
这个初始化流程是字符设备驱动的基本模板。alloc_chrdev_region 动态分配设备号,cdev_add 注册字符设备,class_create 和 device_create 则创建 sysfs 中的类条目和设备节点,使得 udev 能够自动在 /dev 下创建对应的设备文件。
7.2 open和release
open 函数在用户打开设备文件时被调用。它的典型职责包括:初始化设备的硬件状态、分配并初始化驱动的私有数据结构、启用设备的中断、增加设备的引用计数。open 函数的原型是 int (*open)(struct inode *inode, struct file *filp)。inode 参数可以用来获取设备号,进而区分不同的次设备号;filp 参数中的 private_data 指针用于保存驱动的私有数据,这个数据在后续的其他操作函数中可以通过 filp 取回。
release 函数在最后一个打开设备文件的文件描述符被关闭时调用。它的职责与 open 相反:释放私有数据结构、关闭设备中断、将设备恢复到安全状态。需要注意 release 返回值的语义与用户态 close 不同,release 的返回值通常被忽略。release 函数的典型签名是 int (*release)(struct inode *inode, struct file *filp)。
一个关键的设计问题是:每个打开的文件描述符应该有自己的私有数据,还是所有打开共享同一份数据?对于大多数设备,允许多个进程同时打开同一个设备文件,驱动需要一种方式来处理并发访问。私有数据可以放在 filp 中,每打开一次就分配一份;设备全局状态则放在驱动的全局结构体中。open 函数中通常的做法是:如果 filp 的 private_data 尚未分配,就分配一份并保存在 filp 中;如果设备支持多次打开共享状态,也可以将全局设备结构体指针放在 filp 的 private_data 中。
7.3 read和write的数据传输
read 和 write 是字符设备最基本的数据传输函数。它们的原型完全对称:ssize_t (*read)(struct file *filp, char __user *buf, size_t count, loff_t *offp) 和 ssize_t (*write)(struct file *filp, const char __user *buf, size_t count, loff_t *offp)。返回值是实际传输的字节数,正值表示成功,负值表示错误码。
最重要的一点是:buf 指针指向用户空间的缓冲区,不能在内核中直接解引用。原因在于用户空间指针可能是无效的,直接解引用会造成内核崩溃;而且如果内核配置了 SMAP 保护,直接访问用户内存会触发 CPU 异常。必须使用 copy_to_user 和 copy_from_user 函数在内核缓冲区和用户缓冲区之间复制数据。这两个函数返回未复制的字节数,返回零表示全部复制成功。
static ssize_t mydev_read(struct file *filp, char __user *buf,
size_t count, loff_t *offp)
{
struct my_device *dev = filp->private_data;
size_t available;
int ret;
available = dev->data_size - *offp;
if (available <= 0)
return 0;
count = min(count, available);
ret = copy_to_user(buf, dev->data + *offp, count);
if (ret)
return -EFAULT;
*offp += count;
return count;
}
read 的语义是:如果数据可用就返回数据;如果数据不可用但设备设置为非阻塞模式,则返回 -EAGAIN;否则进程应该睡眠等待数据到达。等待队列是实现睡眠等待的机制。驱动在数据不可用时调用 wait_event_interruptible 将进程挂起,当数据到达时,中断处理函数或写操作调用 wake_up_interruptible 唤醒等待的进程。
static ssize_t mydev_read(struct file *filp, char __user *buf,
size_t count, loff_t *offp)
{
struct my_device *dev = filp->private_data;
size_t ret;
if (wait_event_interruptible(dev->read_queue,
dev->data_available))
return -ERESTARTSYS;
ret = copy_to_user(buf, dev->data, dev->data_size);
if (ret)
return -EFAULT;
dev->data_available = 0;
return dev->data_size;
}
等待队列的核心是 wait_queue_head_t 结构。驱动在 open 时初始化等待队列头,read 时在条件不满足的情况下挂起,中断处理函数或其他上下文在条件满足后唤醒。wait_event_interruptible 返回非零值表示进程被信号唤醒,此时应返回 -ERESTARTSYS 让上层处理信号。
阻塞与非阻塞操作由 filp->f_flags 中的 O_NONBLOCK 标志控制。如果设置了非阻塞标志,read 和 write 在数据不可用或缓冲区满时应该立即返回 -EAGAIN,而不是睡眠。驱动需要检查这个标志并相应地调整行为。除了阻塞 I/O,Linux 还支持通过 select、poll 和 epoll 进行 I/O 多路复用,驱动的 poll 函数需要报告当前设备的可读、可写和异常状态。
7.4 ioctl与设备控制
ioctl 是字符设备控制的主要通道。它允许用户空间向驱动发送设备特有的命令。ioctl 的原型是 long (*unlocked_ioctl)(struct file *filp, unsigned int cmd, unsigned long arg)。cmd 是命令号,arg 是命令参数,可以是一个数值,也可以是一个指向用户空间结构体的指针。
为了确保命令号的唯一性和可解析性,Linux 定义了一套 ioctl 命令号编码规则。命令号由四部分组成:幻数、序号、方向和数据大小。幻数是一个 8 位的字符,用于标识驱动;序号标识命令;方向表示数据传输的方向(无、读、写、读写);数据大小是命令附带数据的大小。内核提供了 _IO、_IOR、_IOW、_IOWR 宏来构造命令号,以及对应的解码宏。
struct mydev_cmd_data {
int channel;
int value;
};
#define MYDEV_MAGIC 'M'
#define MYDEV_SET_CHANNEL _IOW(MYDEV_MAGIC, 1, struct mydev_cmd_data)
#define MYDEV_GET_CHANNEL _IOR(MYDEV_MAGIC, 2, struct mydev_cmd_data)
#define MYDEV_RESET _IO(MYDEV_MAGIC, 3)
static long mydev_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
struct my_device *dev = filp->private_data;
struct mydev_cmd_data data;
switch (cmd) {
case MYDEV_SET_CHANNEL:
if (copy_from_user(&data, (void __user *)arg, sizeof(data)))
return -EFAULT;
dev->channel = data.channel;
apply_channel(dev, data.channel);
return 0;
case MYDEV_GET_CHANNEL:
data.channel = dev->channel;
data.value = read_channel_value(dev);
if (copy_to_user((void __user *)arg, &data, sizeof(data)))
return -EFAULT;
return 0;
case MYDEV_RESET:
reset_device(dev);
return 0;
default:
return -ENOTTY;
}
}
处理 ioctl 命令时,必须检查用户空间指针的有效性。copy_from_user 和 copy_to_user 会做必要的地址验证。int 类型参数直接通过 arg 传递时,不需要额外的复制。现代内核中,ioctl 的大内核锁问题已经通过 unlocked_ioctl 解决,驱动可以安全地并行处理来自不同进程的 ioctl 调用,但仍需注意保护共享数据。
八、块设备驱动详解
8.1 块设备驱动的架构
块设备驱动与字符设备驱动有本质的不同。字符设备是流式的,数据按字节依次传输;块设备则以固定大小的块为单位进行随机访问。这种差异导致了完全不同的驱动程序架构。块设备驱动不直接面对用户空间的 read 和 write 调用,而是通过一个复杂的请求处理框架与文件系统和内核的其他部分交互。
块 I/O 的处理路径大致如下:用户程序发起文件读写,VFS 层将请求转发给文件系统,文件系统将请求转换成针对块设备的 bio 请求,这些 bio 请求进入通用块层的请求队列,经过合并、排序和调度后,以 request 的形式传递给块设备驱动的请求处理函数。驱动从请求中提取数据传输信息,设置 DMA 或 PIO 操作,完成后通知通用块层。
bio 结构体是块 I/O 的基本单位,描述一个 I/O 操作涉及的磁盘扇区范围和内存页面列表。通用块层会将多个相邻的 bio 合并成一个 request,以减少设备操作的次数。request 结构体封装了一个或多个 bio,包含了 I/O 的方向、起始扇区号、数据缓冲区等信息。设备的请求队列是 request_queue 结构体,它管理着等待处理的请求,并通过请求处理函数将请求交给驱动。
static void myblk_request(struct request_queue *q)
{
struct request *req;
req = blk_fetch_request(q);
while (req) {
struct myblk_dev *dev = req->rq_disk->private_data;
int error;
error = myblk_do_request(dev, req);
if (error)
blk_end_request_all(req, error);
else
blk_end_request_all(req, 0);
req = blk_fetch_request(q);
}
}
请求处理函数使用 blk_fetch_request 从队列中取出请求,处理后通过 blk_end_request_all 完成请求。对于支持异步 DMA 的驱动,请求处理函数只需启动 DMA 传输,DMA 完成中断中再调用 blk_end_request_all。这种模型允许驱动在硬件执行 I/O 的同时继续处理其他请求,实现请求级别的并行。
8.2 块设备注册与gendisk
块设备驱动的核心数据结构是 gendisk 结构体,它表示一个磁盘设备。每个 gendisk 包含设备的主次设备号、容量信息、分区表、请求队列指针和驱动私有数据。驱动在初始化时需要分配 gendisk、设置容量和 fops、分配请求队列,然后调用 add_disk 将磁盘注册到内核。
static int __init myblk_init(void)
{
struct myblk_dev *dev;
int ret;
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
return -ENOMEM;
spin_lock_init(&dev->lock);
dev->queue = blk_init_queue(myblk_request, &dev->lock);
if (!dev->queue) {
ret = -ENOMEM;
goto err_queue;
}
dev->queue->queuedata = dev;
dev->gd = alloc_disk(1);
if (!dev->gd) {
ret = -ENOMEM;
goto err_disk;
}
dev->gd->major = dev_major;
dev->gd->first_minor = 0;
dev->gd->minors = 1;
dev->gd->fops = &myblk_fops;
dev->gd->queue = dev->queue;
dev->gd->private_data = dev;
snprintf(dev->gd->disk_name, 32, "myblk0");
set_capacity(dev->gd, MYBLK_SECTORS);
add_disk(dev->gd);
return 0;
err_disk:
blk_cleanup_queue(dev->queue);
err_queue:
kfree(dev);
return ret;
}
块设备的 file_operations 与字符设备不同。用户对块设备文件的操作通常不经过驱动的 fops,而是由通用块层处理。驱动的 block_device_operations 提供 open、release、ioctl 等函数,用于处理设备打开、关闭和特殊控制命令。数据读写则完全通过请求队列进行。
8.3 请求处理与I/O调度
I/O 调度器是通用块层的重要组成部分,它决定请求队列中请求的处理顺序。不同类型的设备有不同的性能特性,需要不同的调度策略。旋转磁盘的寻道时间远大于数据传输时间,调度器应尽量让磁头在相邻位置间移动;SSD 没有机械寻道的问题,但需要考虑写放大和并发通道的利用。
Linux 内核提供了多种 I/O 调度器:noop 调度器最简单,只做请求合并和基本的先进先出处理,适合 SSD 和虚拟化环境;CFQ 调度器为每个进程分配公平的时间片,适合交互式桌面系统;deadline 调度器为读写请求设置不同的超时时间,在保证公平的同时防止请求饥饿;BFQ 调度器则提供更精细的带宽和延迟控制。
对于高端存储设备和专用控制器,调度器可能会成为性能瓶颈。现代 NVMe 设备支持多个硬件队列,每个 CPU 可以有自己的队列,避免了传统单队列的锁竞争。Linux 的 blk-mq 框架就是为多队列设备设计的,它允许请求直接从 submit_bio 路径发往设备的硬件队列,减少了中间的处理层次。驱动开发者需要理解设备的队列特性,选择合适的多队列配置。
九、网络设备驱动详解
9.1 网络设备驱动的特殊性
网络设备驱动与字符设备和块设备驱动都有显著不同。网络设备不是文件系统中的一个节点,没有对应的设备文件,用户程序不能通过 read 和 write 直接访问网络设备。网络设备通过 socket 接口与用户空间交互,数据以数据包为单位进行传输,传输是双向同时进行的。
网络设备驱动的核心数据结构是 net_device 结构体。这个结构体包含设备的硬件信息、统计信息、操作函数指针和协议栈状态。每个网络接口在内核中有一个 net_device 实例,用户空间通过 ifconfig 或 ip 命令看到的网络接口就对应一个 net_device。
网络设备的操作函数主要通过 net_device_ops 结构体注册。核心函数包括:ndo_open 用于启动接口,ndo_stop 用于停止接口,ndo_start_xmit 用于发送数据包,ndo_set_mac_address 用于修改 MAC 地址,ndo_change_mtu 用于修改 MTU,ndo_tx_timeout 用于处理发送超时。注册网络设备使用 register_netdev 函数。
static const struct net_device_ops mynet_ops = {
.ndo_open = mynet_open,
.ndo_stop = mynet_stop,
.ndo_start_xmit = mynet_start_xmit,
.ndo_set_mac_address = mynet_set_mac,
.ndo_change_mtu = mynet_change_mtu,
.ndo_tx_timeout = mynet_tx_timeout,
};
static int __init mynet_init(void)
{
struct net_device *ndev;
int ret;
ndev = alloc_etherdev(sizeof(struct mynet_priv));
if (!ndev)
return -ENOMEM;
ndev->netdev_ops = &mynet_ops;
ndev->watchdog_timeo = HZ * 5;
ret = register_netdev(ndev);
if (ret)
goto err_register;
return 0;
err_register:
free_netdev(ndev);
return ret;
}
9.2 数据包发送
ndo_start_xmit 是网络驱动中最重要的函数之一,它被协议栈调用以发送一个数据包。函数原型是 netdev_tx_t (*ndo_start_xmit)(struct sk_buff *skb, struct net_device *dev)。skb 是内核网络栈中的套接字缓冲区,包含了数据包的所有信息:数据指针、长度、协议头指针、校验和状态等。
ndo_start_xmit 的执行需要遵循严格的规则。首先,函数运行在持有设备发送锁的上下文中,不能睡眠。其次,函数必须尽快完成,如果发送队列已满,函数需要停止队列并返回 NETDEV_TX_BUSY,协议栈会稍后重试。第三,函数负责释放 skb,通常在数据已经提交给硬件后使用 dev_kfree_skb 释放,或者在 DMA 完成后的中断中使用 dev_kfree_skb_any。
停止和唤醒发送队列使用 netif_stop_queue 和 netif_wake_queue 函数。当驱动的发送缓冲区已满时,调用 netif_stop_queue 停止队列,阻止协议栈继续发送;当缓冲区空间可用时,在中断处理函数或完成回调中调用 netif_wake_queue 唤醒队列。这个机制保证了发送路径的流量控制,避免驱动程序被数据包淹没。
static netdev_tx_t mynet_start_xmit(struct sk_buff *skb,
struct net_device *ndev)
{
struct mynet_priv *priv = netdev_priv(ndev);
int slot;
slot = find_free_tx_slot(priv);
if (slot < 0) {
netif_stop_queue(ndev);
return NETDEV_TX_BUSY;
}
priv->tx_slots[slot].skb = skb;
priv->tx_slots[slot].len = skb->len;
program_tx_descriptor(priv, slot, skb->data, skb->len);
ndev->stats.tx_packets++;
ndev->stats.tx_bytes += skb->len;
return NETDEV_TX_OK;
}
9.3 数据包接收
数据包接收通常由中断驱动。当网卡接收到数据包时,产生接收中断。驱动的中断处理函数识别出是接收事件后,调度接收处理。接收处理从硬件的接收环中取出数据包描述符,为每个数据包分配一个 skb,将数据从 DMA 缓冲区复制到 skb 中,然后通过 netif_receive_skb 或 netif_rx 将 skb 交给协议栈。
传统的中断驱动接收方式在高速网络下存在性能问题。当中断到达频率过高时,系统可能陷入中断风暴,没有足够的时间执行真正的数据包处理。NAPI 机制解决了这个问题:当中断到达时,驱动关闭接收中断,将设备加入轮询列表,内核在软中断上下文中批量处理接收数据包。处理过程中,驱动持续从接收环中取包,直到收空或达到配额。处理完成后,重新开启接收中断。这种做法在流量高时退化为轮询,流量低时保持中断驱动,兼顾了吞吐量和延迟。
static int mynet_poll(struct napi_struct *napi, int budget)
{
struct mynet_priv *priv =
container_of(napi, struct mynet_priv, napi);
int work_done = 0;
while (work_done < budget) {
struct sk_buff *skb;
skb = mynet_rx_packet(priv);
if (!skb)
break;
netif_receive_skb(skb);
work_done++;
}
if (work_done < budget) {
napi_complete_done(napi, work_done);
enable_rx_interrupt(priv);
}
return work_done;
}
NAPI 的实现需要驱动的协调配合。驱动的中断处理函数在识别接收中断后,调用 napi_schedule 将设备的 napi_struct 加入轮询列表,同时关闭接收中断。内核在软中断上下文中调用驱动的 poll 函数,批量处理数据包。poll 函数处理完预算内的数据包后,如果没有更多的数据包,则调用 napi_complete_done 完成本轮轮询,驱动重新开启接收中断。这种机制是 Linux 网络子系统高性能的关键设计之一。
十、中断处理机制详解
10.1 中断注册与管理
中断是设备驱动与硬件交互的核心机制。驱动通过 request_irq 或 request_threaded_irq 函数注册中断处理函数。注册时需要指定中断号、处理函数、中断标志、设备名称和设备标识。中断号通常从设备树或资源管理器中获取,现代系统通常通过 platform_get_irq 等辅助函数获取。
static irqreturn_t mydev_isr(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
u32 status;
status = readl(dev->regs + DEV_STATUS_REG);
if (!(status & DEV_STATUS_IRQ))
return IRQ_NONE;
writel(status & DEV_STATUS_IRQ, dev->regs + DEV_IRQ_CLEAR);
if (status & DEV_STATUS_DATA_READY) {
dev->data_available = 1;
wake_up_interruptible(&dev->read_queue);
}
if (status & DEV_STATUS_ERROR)
schedule_work(&dev->error_work);
return IRQ_HANDLED;
}
static int mydev_probe(struct platform_device *pdev)
{
struct my_device *dev;
int irq, ret;
irq = platform_get_irq(pdev, 0);
if (irq < 0)
return irq;
ret = request_irq(irq, mydev_isr, 0, "mydev", dev);
if (ret)
return ret;
return 0;
}
中断处理函数返回 IRQ_HANDLED 表示该中断已被处理,返回 IRQ_NONE 表示该中断不属于此设备。在共享中断线上,所有注册了该中断的设备处理函数都会被调用,每个函数需要检查自己的状态寄存器以确定中断是否来自自己的设备。dev_id 参数在共享中断中非常重要,它用于在释放中断时区分不同设备的处理函数。
中断标志控制了中断处理函数的执行环境。IRQF_SHARED 允许共享中断线,IRQF_TRIGGER_RISING 设置上升沿触发,IRQF_TRIGGER_FALLING 设置下降沿触发,IRQF_TRIGGER_HIGH 设置高电平触发,IRQF_TRIGGER_LOW 设置低电平触发。正确设置触发方式对于硬件正常工作至关重要,错误的触发方式可能导致中断丢失或重复触发。
10.2 顶半部与底半部
中断处理时间的优化是驱动开发的关键挑战。理想的中断处理函数应该在微秒级完成,但有些设备事件需要更复杂的处理逻辑。解决方案是将中断处理分为两部分:顶半部在中断上下文中执行,完成最小必要工作;底半部在稍后的时机执行,处理剩余工作。
顶半部的执行受到严格限制。中断上下文不是进程上下文,不能调用可能睡眠的函数,如 kmalloc with GFP_KERNEL、mutex_lock、copy_to_user 等。此外,顶半部执行期间,本地 CPU 上的其他中断被屏蔽,因此执行时间过长会延迟其他设备的中断处理。顶半部应该做的只有:检查中断来源、清除中断标志、保存必要数据、调度底半部。
底半部的机制有四种选择。软中断是最高效但最难编写的机制,它在中断返回后的软中断处理阶段运行,可以并发执行,因此需要额外的同步。tasklet 基于软中断实现,提供了更好的封装,同一 tasklet 不会并发执行。工作队列在进程上下文中运行,可以睡眠,但延迟较高。线程化中断则将整个中断处理函数放到内核线程中执行,通过 request_threaded_irq 注册,顶半部返回 IRQ_WAKE_THREAD 时内核会唤醒对应的处理线程。
static irqreturn_t mydev_threaded_isr(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
mutex_lock(&dev->lock);
process_device_data(dev);
mutex_unlock(&dev->lock);
return IRQ_HANDLED;
}
static irqreturn_t mydev_hard_isr(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
u32 status;
status = readl(dev->regs + DEV_STATUS_REG);
if (!(status & DEV_STATUS_IRQ))
return IRQ_NONE;
writel(status, dev->regs + DEV_IRQ_CLEAR);
return IRQ_WAKE_THREAD;
}
线程化中断适用于那些需要较多处理但仍然以中断为主要驱动方式的设备,如输入设备、传感器、慢速总线设备。它简化了驱动的编写:处理函数运行在进程上下文中,可以使用所有进程上下文可用的 API。代价是延迟略高于 tasklet,但对于大多数低速设备来说完全可以接受。
10.3 中断上下文的特殊约束
中断上下文的编程约束是驱动开发者最容易出错的地方。中断上下文没有关联的进程,current 指针指向被中断的进程,这意味着不能依赖进程上下文的状态。不能睡眠是最核心的约束:任何可能导致调度的操作都是非法的。这包括获取互斥锁、分配带有 GFP_KERNEL 标志的内存、等待信号量、调用可能调度的内核函数。
因此,中断处理函数中应该使用自旋锁来保护共享数据。自旋锁在获取失败时会忙等待,不涉及调度,适合在中断上下文和短临界区中使用。如果数据既被进程上下文访问也被中断上下文访问,进程上下文应该使用 spin_lock_irqsave 来获取锁,同时保存和关闭本地中断,防止死锁。单纯使用 spin_lock 在进程上下文中可能被同一个 CPU 上的中断打断,而中断处理函数又在等待同一把锁,造成死锁。
内存分配在中断上下文中有特殊的约束。GFP_KERNEL 分配可能会睡眠,不能在中断上下文中使用。中断上下文中的内存分配应该使用 GFP_ATOMIC,它从紧急池中分配,不会睡眠。GFP_ATOMIC 分配失败的可能性较高,驱动需要处理分配失败的情况。更好的做法是在进程上下文的初始化或 probe 阶段预先分配好中断处理需要的缓冲区,中断处理函数直接使用预先分配的内存。
此外,中断处理函数中禁止调用 copy_to_user 和 copy_from_user。这些函数会验证用户空间指针并可能触发页面错误,页面错误处理可能睡眠,这在中断上下文中是致命的。如果需要在中断处理后向用户空间传递数据,必须使用底半部机制,如工作队列,将数据处理延迟到进程上下文执行。
十一、DMA与总线技术详解
11.1 DMA映射与缓存一致性
DMA 是现代设备驱动高效传输数据的基础。Linux 提供了完整的 DMA API,处理 DMA 映射和缓存一致性问题。DMA 映射分为两种类型:一致性映射和流式映射。
一致性映射在驱动初始化和设备生命周期中持续存在,设备和 CPU 可以同时访问映射的内存,硬件自动维护缓存一致性。一致性映射使用 dma_alloc_coherent 分配,返回 CPU 虚拟地址和设备 DMA 地址。这种映射适用于那些需要频繁访问且生命周期与设备一致的控制结构,如设备描述符环、状态块。
struct my_dma_setup {
void *cpu_addr;
dma_addr_t dma_addr;
size_t size;
};
static int setup_dma_buffer(struct device *dev,
struct my_dma_setup *setup,
size_t size)
{
setup->cpu_addr = dma_alloc_coherent(dev, size,
&setup->dma_addr,
GFP_KERNEL);
if (!setup->cpu_addr)
return -ENOMEM;
setup->size = size;
return 0;
}
流式映射用于一次性或短期的数据传输,如网络数据包缓冲区、磁盘 I/O 缓冲区。流式映射需要显式地处理缓存一致性:在设备读取内存之前,CPU 需要将缓存数据回写到内存中;在 CPU 读取内存之前,需要使缓存中的数据失效,从内存重新读取。流式映射使用 dma_map_single 进行映射,dma_unmap_single 解除映射,并需要指定数据传输方向(DMA_TO_DEVICE、DMA_FROM_DEVICE、DMA_BIDIRECTIONAL)。
static int start_dma_transfer(struct my_device *dev,
void *data, size_t size,
int dir)
{
dma_addr_t dma_addr;
dma_addr = dma_map_single(dev->parent, data, size, dir);
if (dma_mapping_error(dev->parent, dma_addr))
return -ENOMEM;
dev->dma_addr = dma_addr;
dev->dma_size = size;
dev->dma_dir = dir;
writel(dma_addr, dev->regs + DEV_DMA_ADDR_REG);
writel(size, dev->regs + DEV_DMA_SIZE_REG);
writel(DEV_DMA_CTRL_START, dev->regs + DEV_DMA_CTRL_REG);
return 0;
}
static void dma_complete_handler(struct my_device *dev)
{
dma_unmap_single(dev->parent, dev->dma_addr,
dev->dma_size, dev->dma_dir);
}
缓存处理是驱动开发中最微妙的领域之一。dma_map_single 在映射时处理缓存:对于 TX 方向,它将缓存数据刷回内存;对于 RX 方向,它使缓存失效,确保后续 CPU 读取能获取设备写入的最新数据。匹配的 dma_unmap_single 在解除映射时做相应的处理。驱动必须保证映射和解除映射的配对正确,且方向参数与实际使用一致,否则会出现难以调试的数据损坏问题。
11.2 分散聚集DMA
分散聚集 DMA 允许一次 DMA 传输操作涉及多个不连续的内存区域。设备从内存中的多个分散位置读取数据,或将数据写入多个不连续的位置。这对网络设备特别重要,因为一个网络数据包的头部和负载通常存放在不连续的内存区域中,分散聚集 DMA 避免了将这些区域复制到一块连续缓冲区的开销。
分散聚集列表由 scatterlist 结构体数组表示。每个 scatterlist 描述一段连续的内存区域。dma_map_sg 函数将整个 scatterlist 数组映射为 DMA 可访问的形式,返回映射的段数量。设备通过 DMA 描述符环或类似机制获取每段的内存地址和长度,执行分散聚集传输。
static int dma_map_scatter(struct my_device *dev,
struct scatterlist *sg, int nents,
int dir)
{
int mapped;
mapped = dma_map_sg(dev->parent, sg, nents, dir);
if (!mapped)
return -ENOMEM;
dev->sg = sg;
dev->sg_nents = nents;
dev->sg_mapped = mapped;
dev->sg_dir = dir;
program_sg_descriptors(dev, sg, mapped);
return 0;
}
对于支持分散聚集的设备,驱动的 DMA 描述符需要能够处理多个地址和长度对。设备的 DMA 引擎读取描述符中的地址和长度信息,依次完成每段数据的传输,所有段传输完成后产生一个完成中断。Linux 的块设备层和网络栈都大量使用分散聚集 DMA 来提高数据传输效率。
11.3 PCIe设备与MSI中断
PCIe 是当前主流的高速外设接口。PCIe 设备驱动需要处理设备的枚举、配置空间访问、BAR 空间映射、中断注册和 DMA 操作。Linux 的 PCI 子系统提供了完整的框架来处理这些任务。驱动通过 pci_register_driver 注册驱动,probe 函数中使用 pci_enable_device 启用设备、pci_iomap 映射 BAR 空间、pci_alloc_irq_vectors 分配中断向量。
PCIe 设备支持 MSI 和 MSI-X 中断机制。与传统的 INTx 共享中断线不同,MSI 中断通过内存写事务传递,每个设备可以拥有独立的中断向量。MSI-X 进一步允许多个中断向量,每个向量可以绑定到不同的处理函数或不同的 CPU。对于高性能设备,多个队列分别使用不同的中断向量,每个向量绑定到不同的 CPU 核,可以显著减少缓存竞争和处理器间通信开销。
static int pcie_dev_probe(struct pci_dev *pdev,
const struct pci_device_id *id)
{
struct my_pcie_dev *dev;
int ret, nvec;
ret = pci_enable_device(pdev);
if (ret)
return ret;
ret = pci_request_regions(pdev, "my_pcie_dev");
if (ret)
goto err_regions;
pci_set_master(pdev);
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev) {
ret = -ENOMEM;
goto err_alloc;
}
dev->pdev = pdev;
dev->regs = pci_iomap(pdev, 0, 0);
if (!dev->regs) {
ret = -ENOMEM;
goto err_iomap;
}
nvec = pci_alloc_irq_vectors(pdev, 1, MY_MAX_QUEUES,
PCI_IRQ_MSIX | PCI_IRQ_MSI);
if (nvec < 0) {
ret = nvec;
goto err_irq;
}
dev->irq = pci_irq_vector(pdev, 0);
ret = request_irq(dev->irq, my_pcie_isr, 0,
"my_pcie_dev", dev);
if (ret)
goto err_request_irq;
pci_set_drvdata(pdev, dev);
return 0;
err_request_irq:
pci_free_irq_vectors(pdev);
err_irq:
pci_iounmap(pdev, dev->regs);
err_iomap:
kfree(dev);
err_alloc:
pci_release_regions(pdev);
err_regions:
pci_disable_device(pdev);
return ret;
}
PCIe 设备驱动还需要处理电源管理。pci_save_state 和 pci_restore_state 用于保存和恢复设备的配置空间。运行时电源管理方面,驱动可以通过 pci_set_power_state 控制设备的电源状态,将空闲设备置于 D3 状态。对于支持 ASPM 的设备,PCIe 链路自动进行电源管理,减少链路的功耗。
十二、设备驱动开发实战
12.1 驱动开发环境搭建
设备驱动开发需要一个完整的内核开发环境。最基本的工具链包括:内核源码树、交叉编译工具链(对于嵌入式开发)、内核头文件、构建工具。驱动模块的编译使用内核的 Kbuild 系统,通过一个简单的 Makefile 指定模块的源文件和内核源码位置。
obj-m += mydriver.o
mydriver-objs := main.o ops.o irq.o
KERNEL_DIR ?= /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KERNEL_DIR) M=$(PWD) clean
对于嵌入式开发,通常需要交叉编译。KERNEL_DIR 指向目标平台的内核源码树,同时设置 ARCH 和 CROSS_COMPILE 环境变量。例如,ARM 平台上的编译命令可能是 make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules。开发过程中还需要一个目标板或模拟器来测试驱动。QEMU 可以模拟多种硬件平台,是驱动开发初期非常有用的测试工具。
调试工具方面,printk 是最基本的日志函数。dmesg 命令查看内核日志。printk 支持不同的日志级别,从 KERN_EMERG 到 KERN_DEBUG。控制台日志级别可以通过 /proc/sys/kernel/printk 调整。对于更复杂的调试,可以使用 ftrace 跟踪内核函数调用,使用 kprobes 插入动态探测点,使用 kgdb 进行内核级调试。
12.2 常见驱动开发错误
驱动开发中错误频发,了解常见错误有助于避免踩坑。内存泄漏是最常见的问题之一。驱动中每次 kmalloc 都必须有对应的 kfree,每次 dma_alloc_coherent 都必须有对应的 dma_free_coherent,每次 ioremap 都必须有对应的 iounmap。模块卸载时忘记释放资源会导致内存泄漏,而模块反复加载卸载会逐渐耗尽系统内存。使用内核的内存泄漏检测工具如 Kmemleak 可以帮助定位泄漏点。
并发问题和锁使用不当是另一类常见错误。在 SMP 系统中,多个 CPU 可能同时访问驱动中的数据,中断处理函数也可能在任何时刻打断进程上下文的执行。忘记加锁、锁顺序错误、死锁都是常见的并发问题。spin_lock 和 mutex_lock 的选择也是容易出错的地方。spin_lock 适用于短临界区和中断上下文,mutex_lock 适用于长临界区和进程上下文。在持有 spin_lock 时调用可能睡眠的函数是致命的错误。
指针错误和内存越界会直接导致内核崩溃。设备寄存器地址映射错误可能导致访问无效地址。使用 readl 和 writel 访问未映射的内存会触发内核 oops。DMA 缓冲区大小不足会导致硬件写入越过缓冲区边界,破坏相邻内存中的数据。这些错误极其危险且难以调试,因此在编写 DMA 相关代码时需要格外小心。
12.3 驱动的并发与锁策略
设备驱动的并发控制需要仔细设计。基本的原则是:标识所有可能被并发访问的共享数据,为每组共享数据选择合适的锁保护,保持锁的粒度尽可能小以减少锁竞争,避免在持锁期间调用可能睡眠的函数。
常见的锁类型包括:自旋锁 spinlock_t 用于中断上下文和短临界区;互斥锁 struct mutex 用于进程上下文和可能较长的临界区;读写锁 rwlock_t 和 rw_semaphore 用于读多写少的场景;完成量 completion 用于在进程之间同步事件;原子变量 atomic_t 用于简单的计数和标志操作;顺序锁 seqlock_t 用于读频繁写稀少的场景。
对于需要同时保护中断上下文和进程上下文数据的驱动,经典的锁模式是在进程上下文获取锁时关闭本地中断。例如,使用 spin_lock_irqsave 保护设备状态结构体。这样可以防止进程上下文持有锁时被本地中断打断,而中断处理函数尝试获取同一把锁时导致死锁。对于多队列设备的驱动,每个队列有自己的锁,减少了锁竞争,这是现代高性能驱动常用的设计模式。
一些驱动采用更精细的并发控制策略。例如,使用无锁环形缓冲区,通过原子操作来管理读写指针。生产者和消费者各自维护自己的指针,通过内存屏障保证数据写入和指针更新之间的顺序正确。这种设计完全消除了锁的竞争,适用于高频数据传输场景。但无锁编程的复杂性很高,需要深刻理解内存模型和编译器优化行为,在实际开发中需要谨慎使用。
12.4 内核同步API详解
Linux 内核提供了丰富的同步原语,驱动开发者需要根据具体场景选择最合适的工具。完成量 completion 是一种简单的同步机制,用于一个执行单元等待另一个执行单元完成某项工作。它通常用于设备 I/O 的同步等待:发起 I/O 请求的进程等待完成量的完成信号,中断处理函数在 I/O 完成后调用 complete 唤醒等待者。completion 的接口简单:init_completion 初始化,wait_for_completion 等待,complete 或 complete_all 发出完成信号。
读拷贝更新 RCU 是一种高级的同步机制,允许读者在无锁的情况下并发访问数据,写者更新数据时使用 copy-on-write 的方式。RCU 特别适合读多写少的场景,如配置数据、路由表。驱动中使用 RCU 需要配合 rcu_read_lock、rcu_read_unlock、rcu_dereference 和 rcu_assign_pointer 等 API。RCU 读临界区内不能睡眠。
内存屏障是底层同步的重要工具。在设备驱动中,内存屏障的典型用途包括:确保 DMA 缓冲区数据在启动 DMA 之前已经写入内存、确保设备寄存器的写操作按照程序顺序执行、确保共享标志的写入在数据写入之后可见。smp_mb 提供完整的内存屏障,smp_wmb 提供写屏障,smp_rmb 提供读屏障。设备寄存器访问需要使用 mb、wmb、rmb 或 dma_wmb、dma_rmb 等专门屏障。内存屏障的误用是驱动中非常隐蔽的错误来源。
十三、驱动调试与测试
13.1 调试技术与工具链
驱动调试是驱动开发中最具挑战性的环节。内核崩溃(oops 或 panic)是驱动错误的最严重后果。当内核 oops 发生时,内核会打印寄存器的状态、调用栈和错误地址。这些信息对于定位问题至关重要。oops 信息中的 PC 值是出错指令的地址,可以通过 addr2line 工具将其映射到源文件的某一行,前提是内核或模块编译时包含了调试信息。
printk 是驱动调试的基础工具,但也有限制。在中断上下文中频繁打印可能严重拖慢系统,甚至改变时序掩盖问题的本质。因此,printk 通常用于记录关键事件和状态,而不是作为唯一的调试手段。动态调试(dynamic debug)允许开发者控制每个 printk 的启用和禁用,无需重新编译内核。
kprobes 和 jprobes 允许在运行中的内核里插入动态探测点。通过 kprobe 可以追踪特定函数的调用参数和返回值,了解驱动的执行流程。ftrace 是内核内置的跟踪框架,可以跟踪函数调用、调度事件、中断处理等。对于复杂的问题,可以组合使用这些工具构建完整的调试视角。
硬件辅助调试在疑难问题的定位中非常有用。JTAG 调试器可以暂停 CPU、检查内存和寄存器内容、设置硬件断点,适合调试那些导致系统完全挂起的问题。对于 DMA 相关问题,总线分析仪或逻辑分析仪可以捕获总线上的实际数据传输,验证硬件和软件之间的交互是否符合预期。
13.2 测试策略与自动化
驱动测试需要系统化的策略。单元测试验证驱动的各个函数模块的正确性,通常使用 kunit 框架或用户空间模拟环境。kunit 是内核的单元测试框架,允许开发者编写在内核中运行的测试用例,验证驱动的核心逻辑。
集成测试验证驱动与内核子系统的配合。例如,字符设备驱动的测试包括:设备节点的创建和权限、read 和 write 的数据完整性、阻塞与非阻塞操作的正确性、ioctl 命令的处理、多个进程并发访问的行为。块设备驱动的测试包括:mkfs 和挂载文件系统、大数据量的读写校验、随机读写的压力测试、断电恢复的模拟。
性能测试也是驱动测试的重要方面。测试指标包括吞吐量、延迟、CPU 占用率、中断率。使用 fio 工具测试块设备性能,iperf 工具测试网络设备吞吐量。性能测试不仅要关注峰值性能,还要关注不同负载下的性能表现,以及长时间运行后的性能稳定性。
自动化测试可以提高测试效率和覆盖率。LTP(Linux Test Project)提供了大量的内核和设备测试用例。自定义的测试脚本可以使用 shell、Python 编写,在 CI 环境中自动运行。对于驱动回归测试,建立一个包含多种硬件配置的测试矩阵,每次代码变更后运行完整的测试套件,是保持驱动质量的有效手段。
13.3 内核调试配置选项
内核提供了一系列调试配置选项,在开发和调试阶段应该启用这些选项。CONFIG_DEBUG_KERNEL 是内核调试的总开关。CONFIG_KALLSYMS 将内核符号保留在镜像中,使 oops 信息包含函数名而不是冷冰冰的地址。CONFIG_DEBUG_BUGVERBOSE 在 BUG 触发时提供详细的源文件位置信息。CONFIG_KASAN 是内核地址消毒器,检测越界访问和释放后使用等内存错误,对驱动调试非常有用。
锁调试选项同样重要。CONFIG_LOCKDEP 分析锁的使用,检测死锁、锁顺序错误和锁使用错误。CONFIG_DEBUG_SPINLOCK 检查自旋锁的使用是否正确。CONFIG_DEBUG_MUTEXES 检查互斥锁的睡眠和释放。这些选项会带来显著的性能开销,但在开发和测试阶段启用它们可以发现许多潜在的并发问题。
对于 DMA 相关问题的调试,CONFIG_DMA_API_DEBUG 跟踪 DMA 映射和解除映射的配对,检测 DMA 地址的使用错误。CONFIG_DEBUG_DMA 和 IOMMU 调试选项帮助定位 DMA 地址翻译问题。对于电源管理调试,CONFIG_PM_DEBUG 和 CONFIG_PM_SLEEP_DEBUG 可以提供详细的电源状态转换信息。
十四、实践案例:编写一个完整的字符设备驱动
14.1 设备规格与设计
本案例实现一个虚拟字符设备驱动 fakedev。设备模拟一个简单的硬件,包含一个数据寄存器和状态寄存器。数据寄存器存放一个字节,状态寄存器包含数据就绪位和错误位。设备支持以下操作:写入一个字节到数据寄存器、读取数据寄存器的内容、清除错误状态。设备会产生中断来通知数据就绪和错误条件。
驱动的设计要点包括:使用字符设备框架集成到 Linux 的设备模型中;使用等待队列实现阻塞读取;使用 workqueue 实现中断的底半部处理;使用 sysfs 属性暴露设备状态。该驱动虽然是虚拟设备,但完整展示了驱动开发的各个流程,可以作为真实硬件驱动的开发模板。
14.2 驱动的完整实现
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>
#include <linux/uaccess.h>
#include <linux/slab.h>
#include <linux/wait.h>
#include <linux/interrupt.h>
#include <linux/workqueue.h>
#include <linux/mutex.h>
#define FAKEDEV_NAME "fakedev"
#define FAKEDEV_CLASS "fakedev_class"
#define FAKEDEV_IRQ_ID 42
#define FAKEDEV_BUFSIZE 256
struct fakedev_device {
struct cdev cdev;
struct device *dev;
struct mutex lock;
char buffer[FAKEDEV_BUFSIZE];
size_t head;
size_t used;
wait_queue_head_t read_queue;
struct work_struct irq_work;
unsigned long irq_count;
unsigned long error_count;
};
static struct class *fakedev_class;
static struct fakedev_device *fakedev_dev;
static ssize_t fakedev_read(struct file *filp, char __user *buf,
size_t count, loff_t *offp)
{
struct fakedev_device *dev = filp->private_data;
size_t available;
int ret;
if (wait_event_interruptible(dev->read_queue, dev->used > 0))
return -ERESTARTSYS;
ret = mutex_lock_interruptible(&dev->lock);
if (ret)
return ret;
available = min(count, dev->used);
if (copy_to_user(buf, dev->buffer + dev->head, available)) {
ret = -EFAULT;
goto out_unlock;
}
dev->head = (dev->head + available) % FAKEDEV_BUFSIZE;
dev->used -= available;
ret = available;
out_unlock:
mutex_unlock(&dev->lock);
return ret;
}
static ssize_t fakedev_write(struct file *filp, const char __user *buf,
size_t count, loff_t *offp)
{
struct fakedev_device *dev = filp->private_data;
size_t space, tail, chunk;
ssize_t ret;
ret = mutex_lock_interruptible(&dev->lock);
if (ret)
return ret;
space = FAKEDEV_BUFSIZE - dev->used;
if (space == 0) {
ret = -ENOSPC;
goto out_unlock;
}
count = min(count, space);
tail = (dev->head + dev->used) % FAKEDEV_BUFSIZE;
chunk = min(count, FAKEDEV_BUFSIZE - tail);
if (copy_from_user(dev->buffer + tail, buf, chunk)) {
ret = -EFAULT;
goto out_unlock;
}
if (count > chunk) {
if (copy_from_user(dev->buffer, buf + chunk, count - chunk)) {
ret = -EFAULT;
goto out_unlock;
}
}
dev->used += count;
ret = count;
wake_up_interruptible(&dev->read_queue);
out_unlock:
mutex_unlock(&dev->lock);
return ret;
}
static long fakedev_ioctl(struct file *filp, unsigned int cmd,
unsigned long arg)
{
struct fakedev_device *dev = filp->private_data;
switch (cmd) {
case FAKEDEV_RESET:
mutex_lock(&dev->lock);
dev->head = 0;
dev->used = 0;
dev->error_count = 0;
mutex_unlock(&dev->lock);
return 0;
default:
return -ENOTTY;
}
}
static ssize_t error_count_show(struct device *device,
struct device_attribute *attr,
char *buf)
{
return sprintf(buf, "%lu\n", fakedev_dev->error_count);
}
static DEVICE_ATTR_RO(error_count);
static const struct file_operations fakedev_fops = {
.owner = THIS_MODULE,
.read = fakedev_read,
.write = fakedev_write,
.unlocked_ioctl = fakedev_ioctl,
};
static void fakedev_irq_work(struct work_struct *work)
{
struct fakedev_device *dev =
container_of(work, struct fakedev_device, irq_work);
dev->irq_count++;
wake_up_interruptible(&dev->read_queue);
}
static int __init fakedev_init(void)
{
struct fakedev_device *dev;
dev_t dev_num;
int ret;
dev = kzalloc(sizeof(*dev), GFP_KERNEL);
if (!dev)
return -ENOMEM;
mutex_init(&dev->lock);
init_waitqueue_head(&dev->read_queue);
INIT_WORK(&dev->irq_work, fakedev_irq_work);
ret = alloc_chrdev_region(&dev_num, 0, 1, FAKEDEV_NAME);
if (ret < 0)
goto err_alloc;
cdev_init(&dev->cdev, &fakedev_fops);
dev->cdev.owner = THIS_MODULE;
ret = cdev_add(&dev->cdev, dev_num, 1);
if (ret < 0)
goto err_cdev;
fakedev_class = class_create(THIS_MODULE, FAKEDEV_CLASS);
if (IS_ERR(fakedev_class)) {
ret = PTR_ERR(fakedev_class);
goto err_class;
}
dev->dev = device_create(fakedev_class, NULL, dev_num,
NULL, FAKEDEV_NAME);
if (IS_ERR(dev->dev)) {
ret = PTR_ERR(dev->dev);
goto err_device;
}
ret = device_create_file(dev->dev, &dev_attr_error_count);
if (ret)
goto err_attr;
fakedev_dev = dev;
pr_info("fakedev: initialized with major %d\n",
MAJOR(dev_num));
return 0;
err_attr:
device_destroy(fakedev_class, dev_num);
err_device:
class_destroy(fakedev_class);
err_class:
cdev_del(&dev->cdev);
err_cdev:
unregister_chrdev_region(dev_num, 1);
err_alloc:
kfree(dev);
return ret;
}
static void __exit fakedev_exit(void)
{
dev_t dev_num = fakedev_dev->cdev.dev;
device_remove_file(fakedev_dev->dev, &dev_attr_error_count);
device_destroy(fakedev_class, dev_num);
class_destroy(fakedev_class);
cdev_del(&fakedev_dev->cdev);
unregister_chrdev_region(dev_num, 1);
kfree(fakedev_dev);
pr_info("fakedev: module removed\n");
}
这个完整的驱动示例展示了字符设备驱动的标准开发模式。初始化流程遵循标准的错误处理风格:每步失败都回滚之前已经完成的资源分配。读写函数演示了等待队列的使用、互斥锁的保护、环形缓冲区的管理以及用户空间数据的安全复制。ioctl 提供了设备重置操作。sysfs 属性将错误计数暴露给用户空间。整个驱动结构清晰、错误处理完整,可以直接作为真实驱动开发的起点。
十五、I/O体系结构的未来趋势
15.1 高性能存储与NVMe
存储设备的演进正在重塑 I/O 体系结构。传统的 SATA 和 SAS 接口逐渐被 NVMe 取代。NVMe 是一种专为闪存存储设计的协议,直接在 PCIe 总线上传输,消除了传统存储协议栈的转换层。NVMe 设备支持多达 65535 个队列,每个队列最多 65536 个命令,充分利用了现代多核处理器的并行能力。
在 Linux 内核中,NVMe 驱动是 blk-mq 多队列架构的典型应用。每个 CPU 可以有自己的提交队列和完成队列,命令的提交和完成都在本地 CPU 上完成,避免了跨处理器通信。NVMe 协议的精简命令集和低延迟特性使得存储性能达到了前所未有的水平,单盘吞吐量可以达到数 GB/s,延迟低至几十微秒。
15.2 智能网卡与可编程硬件
网络设备正在发生深刻的变革。智能网卡不仅完成数据包的收发,还内置了可编程处理器,可以卸载网络功能,如防火墙、负载均衡、加密解密、存储协议处理。DPU(数据处理单元)是这一趋势的代表,它集成了强大的处理能力和高速网络接口,专门用于处理数据平面任务。
对于驱动程序开发者来说,智能网卡带来了新的范式。驱动的职责不再是简单的数据搬运,而是与网卡上的固件和可编程逻辑协同工作。需要理解固件的接口协议、可编程引擎的配置方式、卸载功能的启用和监控。Linux 内核对智能网卡的支持也在快速发展,如对 RDMA 的完整支持、对 XDP 卸载的支持等。
15.3 异构计算与统一内存
异构计算平台的发展也对 I/O 体系结构产生了深远影响。GPU、FPGA、AI 加速器等设备与 CPU 之间的数据交换成为性能瓶颈。传统的通过 PCIe 交换数据的方式引入了高延迟和带宽限制。统一内存架构试图让 CPU 和加速器共享同一个地址空间,数据不再需要在设备之间复制。
这种趋势对驱动开发提出了新的要求。驱动需要管理跨设备的内存一致性,处理页面的迁移和映射,协调多个设备对共享内存的访问。Linux 内核的 IOMMU、异构内存管理等相关子系统正在快速发展,为这些新场景提供基础设施支持。驱动开发者需要关注这些技术的发展,理解它们对 I/O 编程模式的影响。
15.4 虚拟化与设备直通
虚拟化和云计算环境对 I/O 体系结构提出了特殊要求。虚拟机中的 I/O 操作需要经过虚拟机监控器的处理,性能损失严重。设备直通技术通过 SR-IOV 将物理设备划分为多个虚拟功能,每个虚拟机直接拥有一个虚拟功能,绕过了监控器的干预。IOMMU 将虚拟机的物理地址空间映射到实际的设备地址空间,保证了安全隔离。
SR-IOV 驱动的开发与传统驱动有所不同。物理功能驱动负责管理整个设备,包括虚拟功能的创建和配置;虚拟功能驱动运行在虚拟机中,只操作分配给自己的资源。驱动的设计需要考虑物理功能和虚拟功能之间的交互,以及热迁移时的状态保存和恢复。在云计算的推动下,SR-IOV 已经成为高性能虚拟化 I/O 的标准技术。
十六、总结
I/O 体系结构和设备驱动程序是计算机系统中连接软件与硬件的关键纽带。本文从 I/O 体系结构的基本概念出发,系统地介绍了硬件层面的总线系统、I/O 控制方式演进,软件层面的 I/O 分层设计、设备驱动模型,以及实践层面的字符设备、块设备、网络设备驱动开发。通过对中断处理机制、DMA 技术、并发控制等核心主题的深入讨论,建立了从理论到实践的完整知识框架。
设备驱动开发是一项需要深厚功底的技术工作。它要求开发者同时理解硬件的工作原理、内核的编程规范、并发控制的精妙之处,以及对性能和稳定性不懈的追求。一个优秀的驱动开发者不仅需要掌握本文论述的各种技术,更需要积累大量的实践经验,从实际的故障和调试中学习。
随着硬件技术的快速发展,I/O 体系结构正在经历深刻的变革。NVMe 存储、智能网卡、异构计算、虚拟化直通等新技术不断涌现,对驱动开发者提出了新的挑战和机遇。掌握扎实的 I/O 基础知识和内核编程技能,跟踪技术发展趋势,保持对底层系统的好奇心,是每一位系统级开发者持续成长的关键。
最后需要强调的是,设备驱动开发的核心不是技巧的堆砌,而是严谨的态度和对正确性的追求。在内核空间中,任何一个疏忽都可能导致系统崩溃或数据丢失。保持谨慎、充分测试、深入理解每一行代码的含义,这些看似基本的素养,恰恰是驱动开发中最珍贵的品质。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)