10Linux 内核设计与实现(第3版)

设备驱动

硬件抽象

设备模型字符设备块设备设备树

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第10章 设备驱动(Device Drivers)

一、导读

1.1 本章学习目标

理解 Linux 设备驱动的分类:字符设备、块设备、网络设备

掌握 Linux 统一设备模型(Device Model)的核心结构:kobject、device、driver

了解字符设备驱动的基本框架:file_operations、设备号注册、设备节点创建

理解块设备驱动的 I/O 请求处理流程:bio 结构、请求队列、I/O 调度

掌握设备树(Device Tree)的概念及其在 ARM 等嵌入式平台上的作用

了解内核模块的加载、卸载和参数传递机制

理解 sysfs 文件系统如何导出设备和驱动信息

掌握中断处理、DMA 和内存映射 I/O 在设备驱动中的应用

1.2 为什么本章如此重要

设备驱动是 Linux 内核中数量最多的代码,占内核源码的 70% 以上。Linux 支持数千种不同的硬件设备——从最简单的串口到复杂的 GPU 和网卡——每种设备都需要专门的驱动程序。理解设备驱动的框架和机制,是进行内核开发和嵌入式系统开发的基础。

Linux 的设备驱动设计经历了从混乱到统一的演进。早期的驱动代码各自为政,缺乏统一的管理框架。Linux 2.5/2.6 引入了统一设备模型(基于 kobject 和 sysfs),为所有设备提供了统一的管理、枚举和电源管理接口。设备树(Device Tree)的引入进一步将硬件描述从驱动代码中分离,使得同一个驱动可以支持不同的硬件配置。

对于内核开发者而言,即使不直接编写驱动程序,也需要理解设备模型——因为驱动与内核其他子系统(中断、内存管理、电源管理)的交互都通过设备模型进行。对于嵌入式开发者而言,设备树和驱动框架是日常工作的核心内容。

1.3 核心问题引导

字符设备、块设备和网络设备在驱动接口上有什么本质区别?

Linux 设备模型中的 kobject 是什么?它与 sysfs 的关系是什么?

为什么需要设备号(major/minor number)?内核如何通过设备号找到对应的驱动?

file_operations 结构体中的各个函数指针分别在什么时候被调用?

设备树解决了什么问题?在没有设备树之前,ARM 平台是如何描述硬件的?

内核模块的动态加载机制是如何工作的?insmod 和 modprobe 有什么区别?

DMA(直接内存访问)在设备驱动中起什么作用?为什么需要 DMA 一致性保证?

sysfs 中的每个文件和目录代表什么?它们与设备模型的关系是什么?


二、核心概念详解

10.1 Linux 统一设备模型

Linux 统一设备模型(Linux Device Model)是在 2.5/2.6 开发周期中引入的,目的是为所有设备提供统一的管理框架。该模型基于三个核心结构:

kobject:是所有设备模型对象的基础类型。每个 kobject 对应 sysfs 中的一个目录,提供引用计数、对象层级关系(父子关系)和 uevent 通知(用于通知用户空间的 udev 等设备管理工具)。kobject 本身不包含设备信息,只是一个通用的对象管理基础设施。

device(struct device):代表系统中的一个硬件设备。包含设备名称、父设备指针、总线类型、设备资源(内存区域、中断号等)、设备的 PM(电源管理)操作等。每个设备在 sysfs 中对应一个目录,目录中包含设备的属性文件。

driver(struct device_driver):代表一个设备驱动程序。包含驱动名称、匹配函数(用于判断驱动是否支持某个设备)、探测函数(probe,当匹配的设备被发现时调用)、移除函数(remove)等。

设备模型的核心机制是设备与驱动的自动匹配。当新设备被发现时(如 USB 设备插入、设备树解析完成),总线驱动遍历所有已注册的驱动,调用匹配的 probe 函数。probe 函数初始化设备并注册设备节点。这种设计实现了设备和驱动的解耦——驱动不需要知道设备在哪里,设备也不需要知道使用哪个驱动。

10.2 字符设备驱动

字符设备是按字节流访问的设备,如串口、键盘、GPIO 等。字符设备驱动的核心是 file_operations 结构体,它定义了设备支持的操作:

c
struct file_operations my_fops = {
    .owner   = THIS_MODULE,
    .open    = my_open,
    .read    = my_read,
    .write   = my_write,
    .release = my_close,
    .ioctl   = my_ioctl,  // 或使用 unlocked_ioctl
};

字符设备驱动的注册流程:首先分配设备号(通过 alloc_chrdev_region() 动态分配或 register_chrdev_region() 静态指定);然后初始化 cdev 结构体,关联 file_operations;调用 cdev_add() 将设备加入内核;最后在 /dev/ 下创建设备节点(通过 device_create() 自动创建,由 udev 管理)。

当用户程序调用 open("/dev/mydevice") 时,内核通过设备号找到对应的 cdev,调用其 file_operations 中的 open 函数。后续的 read()、write() 等调用也通过 file_operations 路由到驱动的实现函数。

10.3 块设备驱动

块设备是按固定大小块(通常 512 字节或 4 KB)访问的设备,如硬盘、SSD、USB 存储等。块设备驱动比字符设备复杂得多,因为需要处理 I/O 请求的排队、合并和调度。

块设备驱动的核心组件:bio 结构(Block I/O),描述一次 I/O 操作的基本单元,包含目标设备、扇区号、数据缓冲区、完成回调等;request_queue(请求队列),缓存和排序 I/O 请求;request,由多个 bio 合并而成的 I/O 请求。

I/O 调度器(如 mq-deadline、BFQ、kyber)对请求队列中的请求进行排序和合并,以优化磁盘访问模式。例如,将相邻扇区的写入合并为一次大写入,按扇区号排序减少磁头移动(对机械硬盘尤为重要)。

块设备驱动通过 gendisk 结构体注册,指定请求处理函数。当 I/O 请求到达时,内核调用驱动的处理函数,驱动将请求转换为硬件命令(如 SATA、NVMe 命令),通过中断和 DMA 完成数据传输。

10.4 设备树(Device Tree)

设备树是一种描述硬件信息的数据结构,主要用于 ARM、PowerPC 等嵌入式平台。在设备树出现之前,ARM 平台的硬件信息(如设备地址、中断号、GPIO 配置)硬编码在内核源码中,每支持一种新的硬件板子就需要添加新的板级文件(board file),导致内核中充斥着大量板级代码。

设备树将硬件描述从内核代码中分离出来。设备树源文件(.dts)以树形结构描述硬件拓扑:根节点代表系统,子节点代表总线、设备控制器,叶子节点代表具体设备。每个节点包含设备的寄存器地址、中断号、时钟配置等信息。设备树编译为二进制格式(.dtb),由引导加载程序(bootloader)传递给内核。

内核启动时解析设备树,为每个设备节点创建 platform_device。设备驱动通过 of_match_table 声明支持的设备兼容字符串(compatible string),内核自动匹配设备和驱动。这样,同一个驱动可以支持不同厂商的同类设备,无需修改代码。


三、深入分析与讨论

3.1 内核模块机制

内核模块(Kernel Module)是可以动态加载和卸载的代码单元,无需重新编译或重启内核。模块机制使得内核可以在运行时扩展功能,如添加设备驱动、文件系统等。

模块通过 insmod 命令加载,内核的模块加载器完成以下工作:分配内核内存加载模块代码;解析模块的符号依赖(模块使用的外部函数和变量),在内核符号表中查找并填充地址;调用模块的初始化函数(module_init() 注册的函数)。modprobe 命令在 insmod 基础上增加了依赖解析功能,自动加载模块依赖的其他模块。

模块可以接受参数,通过 module_param() 宏声明。用户在加载模块时通过 insmod mymodule.ko param=value 传递参数。模块参数也可以通过 /sys/module/[module_name]/parameters/ 在运行时查看和修改(如果参数允许)。

模块卸载通过 rmmod 命令触发。内核调用模块的清理函数(module_exit() 注册的函数),模块必须确保没有正在使用的资源(如打开的设备文件、注册的回调)。如果模块仍在使用中,rmmod 会失败。

3.2 DMA 与内存映射 I/O

DMA(Direct Memory Access)允许外设直接与内存交换数据,无需 CPU 参与。这大幅提高了 I/O 吞吐量并释放了 CPU。在设备驱动中,DMA 的使用流程:分配 DMA 一致的缓冲区(通过 dma_alloc_coherent());配置设备的 DMA 控制器,指定缓冲区物理地址和传输方向;启动 DMA 传输;通过中断或轮询等待传输完成。

DMA 一致性是一个关键问题。CPU 和 DMA 控制器可能通过不同的缓存路径访问内存:CPU 通过 CPU 缓存,DMA 控制器直接访问物理内存。如果不做一致性保证,CPU 缓存中的数据和内存中的数据可能不一致。dma_alloc_coherent() 分配的缓冲区绕过 CPU 缓存(或使用缓存一致性协议),保证 CPU 和 DMA 看到相同的数据。

内存映射 I/O(Memory-Mapped I/O, MMIO)是将设备的寄存器映射到内存地址空间,CPU 通过读写特定内存地址来访问设备寄存器。驱动通过 ioremap() 将设备的物理地址映射到内核虚拟地址空间,然后使用 readl()/writel() 等函数访问设备寄存器。这些函数包含必要的内存屏障,保证 I/O 操作的顺序性。


四、实践案例

4.1 案例一:编写简单的字符设备驱动

编写一个内核模块,实现一个简单的字符设备:分配设备号,创建 cdev 并注册 file_operations。open 函数打印设备打开信息;read 函数返回一段固定文本;write 函数将用户数据保存到内核缓冲区;release 函数清理资源。使用 device_create() 在 /dev/ 下自动创建设备节点。编写用户程序测试设备的读写功能。

4.2 案例二:分析 sysfs 中的设备层次

通过 ls /sys/devices/ 浏览系统的设备层次结构。找到具体的设备(如网卡 eth0),查看其属性文件(/sys/class/net/eth0/)。分析设备属性(如 MAC 地址、链路状态、MTU)如何通过 sysfs 暴露给用户空间。理解设备与驱动的绑定关系(/sys/bus/pci/drivers/ 下的符号链接)。


五、常见误区与注意事项

误区:设备节点(/dev/xxx)是驱动创建的。实际上,驱动只注册设备号和设备类,设备节点由用户空间的 udev(或 mdev)根据内核发出的 uevent 自动创建。

误区:内核模块可以随意卸载。如果模块的资源正在被使用(如设备文件被打开、网络接口在使用),rmmod 会失败。强制卸载(rmmod -f)可能导致内核崩溃。

误区:ioremap 返回的地址可以直接用指针访问。必须使用 readl()/writel() 等 I/O 访问函数,它们包含必要的内存屏障和类型转换。直接使用指针可能导致编译错误或运行时问题。

注意:设备树中的地址是物理地址。驱动通过 of_ioremap() 或 platform_get_resource() 获取设备树的地址信息,然后映射到虚拟地址空间。

注意:DMA 缓冲区不能被交换到磁盘。DMA 缓冲区必须在物理内存中,不能被页面回收机制换出。使用 GFP_DMA 标志分配 DMA 可用的内存。


六、本章小结

本章详细讲解了 Linux 设备驱动的开发框架和核心机制。Linux 统一设备模型通过 kobject、device、driver 三个核心结构,为所有设备提供了统一的管理接口。字符设备驱动通过 file_operations 实现用户空间的读写操作。块设备驱动通过 bio 结构和请求队列处理 I/O 请求,配合 I/O 调度器优化磁盘访问。设备树将硬件描述从驱动代码中分离,简化了嵌入式平台的设备支持。内核模块机制支持动态加载和卸载驱动代码。DMA 和内存映射 I/O 是设备与内存交互的核心机制。理解这些框架和机制,是进行 Linux 设备驱动开发和嵌入式系统开发的基础。


七、思考题与练习

编写一个最简单的字符设备驱动(回显设备:写入什么就读回什么),编译为内核模块并测试。

分析 /sys/devices/ 的目录结构,画出系统中一个具体设备(如 USB 鼠标)的设备模型层次。

比较字符设备驱动和块设备驱动的接口差异。为什么块设备需要请求队列而字符设备不需要?

研究设备树的语法(.dts 文件格式),为一个假想的 SPI 设备编写设备树节点。

分析 module_init() 和 module_exit() 的实现机制。内核如何找到模块的初始化和清理函数?

解释 DMA 一致性问题:为什么 CPU 缓存和 DMA 控制器可能看到不同的数据?有哪些解决方案?

研究 udev 的工作原理:它如何接收内核的 uevent?它如何决定设备节点的名称和权限?

比较 platform driver 和 PCI driver 的区别。它们各自的设备匹配机制是什么?

10/10设备驱动
0%