干了这么多年 Linux 底层开发,我越来越觉得,真正把内核“撑起来”的那根骨架,不是进程调度,也不是内存管理,而是设备驱动模型。很多人花大把时间啃 task_struct、mm_struct,一碰到 device、driver、bus、class 就昏头,觉得无非是一堆结构体挂链表,没什么好聊的。可直到我自己写驱动、调板子、看启动日志,才意识到:如果不懂设备驱动模型,你看到的内核是平的,所有子系统都是散点;一旦把模型吃透,内核在你眼里立刻就有了层级,有了流动性。设备什么时候出现、驱动什么时候被调用、sysfs 里的目录是怎么长出来的、为什么probe函数会被执行,这些问题的答案全藏在这套模型里。
那这篇文章就从我实际调试的经验出发,把 Linux 设备驱动模型的整体思路、核心机制、匹配流程、实战示例,以及它和内核底层能力的联动,一层层拆开讲清楚。想入内核门、做驱动开发、或者搞嵌入式 BSP 的朋友,这篇文章应该能帮你少走不少弯路。
1. 为什么说设备驱动模型是内核的“骨架”
1.1 内核里那么多子系统,凭什么它最底层
我们平时聊内核,聊得最多的往往是进程调度、内存管理、文件系统、网络协议栈,这些子系统看起来各自为政,但底层都需要跟真实硬件打交道。CPU 要访问串口、磁盘要读写块设备、网卡要收发数据包,这些动作最终都会落到“设备”上。而设备驱动模型,就是内核用来统一管理“谁在什么总线上、哪个驱动对应哪个设备、什么时候加载、怎么通知用户态”的一套基础设施。
你可以把调度器和内存管理想成公司的行政部门和财务部,设备驱动模型则是整个公司的组织架构图。没有组织架构,行政部门不知道谁负责哪块业务,财务部不知道该给谁批预算。设备驱动模型就是这样一个公共底座,让每个子系统都能在此基础上寻找自己的设备、绑定自己的驱动、创建对应的接口。比如块设备层需要调用存储设备驱动的接口,网络层需要调用网卡驱动的 ops,它们都不直接跟设备硬件发生关系,而是通过设备驱动模型注册出来的对象去工作。
1.2 模型要解决的三个核心问题
我调试过 PCIe 网卡、USB 转串口、I2C 触摸屏,回头总结,这套模型无非在解决三件事。
第一件事是热插拔和动态管理。USB 设备插上、拔掉,PCIe 设备可能出现也可能消失,内核需要知道设备什么时候加入、什么时候离开,然后在运行过程中动态创建和销毁对应的数据结构,而不是像老式内核那样把设备写死在代码里。模型通过device_register和device_unregister把设备的生命周期管起来,让热插拔变得自然。
第二件事是驱动与设备的解耦。驱动不再直接“指定”某个硬件地址,而是声明自己能处理什么类型的设备。设备则表达出“我是谁、我在哪条总线上”。两边各自注册到同一条总线上,由总线来负责匹配。驱动模块可以独立编译、独立加载、独立卸载,不用把整个内核重新编译一遍,这就是我们常说的动态加载机制的基础。
第三件事是面向用户态的可视化和事件通知。设备模型把每个设备、驱动、总线都挂到一个叫 kobject 的对象体系里,再通过 sysfs 导出到/sys,让用户态用ls、cat就能看到设备结构。同时,当设备出现或消失时,内核发送 uevent 事件,udev 收到之后自动创建/dev下的节点。没有这套机制,用户态根本无法知道硬件发生了什么变化。
2. 设备驱动模型的核心组成与关键机制
2.1 device、driver、bus、class 各自扮演什么角色
设备驱动模型里最常听到的就是 device、driver、bus、class,这四个概念必须彻底搞清楚。我的理解是:总线是路,设备是路边的房子,驱动是熟悉这条路线的快递员,class 则是快递站里的分拣标签。没有总线,设备和驱动就是孤岛,谁也找不到谁;没有 class,设备只是一堆文件名,用户不好分类。
struct device:描述一个物理或虚拟设备。它记录了设备在总线上的名字、设备号、资源、电源状态等。每个设备在被注册时,会生成一个 kobject,并挂到 sysfs 的某个目录下。struct device_driver:描述一段驱动代码。它包含驱动的名字、所属总线的类型、以及关键的probe和remove回调。驱动不是说“我要控制哪个设备”,而是说“我能服务哪类设备”。struct bus_type:描述一种总线的匹配规则。PCI、USB、platform 都是常见总线。每一个总线都会维护两条链表:一条挂设备,一条挂驱动。总线上的match函数负责判断设备和驱动是否匹配。struct class:描述设备的分类。例如串口设备属于 tty 类,块设备属于 block 类,杂项设备属于 misc 类。class 主要用来在/sys/class下生成分类目录,并配合 udev 在/dev下创建节点。
光看结构体定义很容易觉得抽象,我建议你打开终端执行ls /sys/bus、ls /sys/class、ls /sys/devices,把目录结构挨个看一遍。你会发现/sys/devices下是真实存在的硬件拓扑,/sys/bus下是总线对设备和驱动的逻辑分组,/sys/class下则是从用户使用角度对设备的分类。三个目录是从不同维度看同一套模型。
2.2 藏在背后的 kobject 与 kref:一切对象化的基础
设备模型能做到如此统一,背后有一个看不见的功臣,就是 kobject。kobject 是内核中的“对象基类”,几乎所有设备模型相关的结构体,比如struct device、struct device_driver、struct bus_type,都把 kobject 内嵌在第一个字段位置。为什么这么做?因为内核需要一套统一的机制来管理这些对象的引用计数、命名、层级关系和 sysfs 导出。
kref 则是基于 kobject 的引用计数器。当一个内核对象被多个子系统持有时,只要有人引用它,就不能随便释放;只有当计数归零时,才会调用release回调释放内存。我最开始在写驱动的remove函数时,忘记给 device 结构体实现release回调,结果卸载模块时内核直接打出一串警告:Device 'xxx' does not have a release() function。这个警告不是随便说说的,它说明 kobject 机制发现这个对象没有释放函数,如果把对象销毁掉就会内存泄漏。搞清楚 kobject 和 kref,你对“设备模型管理生命周期”这句话会有真正切身的体会。
2.3 sysfs 与 uevent:模型对外开放的两个窗口
设备模型再漂亮,如果用户态看不到,价值就大打折扣。sysfs 就是设备模型暴露给用户态的一扇窗。它通常挂载在/sys,目录结构就是 kobject 层级结构的镜像。设备驱动模型每注册一个设备,就会在 sysfs 下创建对应的目录和属性文件;你cat这些文件,实际上就是在调用对应 kobject 的 show 回调。
另一扇窗是 uevent。内核在设备注册、注销时,会向用户态发送一条事件,包含设备路径、设备类型、动作(add/remove)等关键信息。用户态的 udev 守护进程收到事件后,根据规则动态加载驱动,并创建/dev节点。所以整个流程是:设备插入 -> 总线枚举 -> 生成 kobject -> 弹出 uevent -> udev 收到消息 -> 创建设备节点 -> 用户态打开设备节点。很多时候我们以为设备节点是系统装好就有的,其实背后完全是这套模型在驱动。
3. 驱动与设备怎么“牵手”:match 的完整过程
3.1 从注册到绑定,内核做了什么
驱动和设备是如何走到一起的?我刚开始看这段代码时,总以为有一个全局的大循环在到处找配对,其实不是。内核的做法非常巧妙,每注册一个设备,就会让设备所在的总线对所有驱动做一次匹配;每注册一个驱动,又会让该驱动所在的总线对设备列表再做一次匹配。这样无论设备先来还是驱动先来,都不影响最终匹配结果。
具体流程可以拆成这么几步:
- 总线注册:
bus_register初始化总线的设备链表和驱动链表。 - 设备注册:
device_register创建 kobject,挂入总线的设备链表,然后调用总线的match函数,看看有没有驱动已经等着了。一旦匹配成功,立刻调用驱动的probe。 - 驱动注册:
driver_register类似,创建 kobject,挂入总线的驱动链表,再遍历设备链表,找到所有还没绑定驱动的设备。每匹配到一个,就调用一次probe。 - 绑定成功:设备结构体的
driver字段指向驱动,驱动结构体的 klist_devices 中加入该设备,随后设备会出现在/sys/bus/xxx/drivers/yyy/目录下。
这里的关键是总线的match函数。PCI 总线的 match 会比较 vendor ID 和 device ID;USB 总线的 match 会比较 interface 描述符;而 platform 总线的 match 则会先看设备树 compatible 字符串,再看 driver 的 id_table,最后退回到平台设备名字字符串。理解了match的调度逻辑,很多“driver 写好了怎么就是不 probe”的问题就迎刃而解。
3.2 platform bus 与 device tree:嵌入式场景的主角
日常 x86 环境下,PCI、USB、ACPI 承担了大量设备枚举工作,但在嵌入式 Linux 里,最常见的是 platform bus。platform bus 是一条虚拟总线,专门用来承载那些既不在 PCI 上、也不在 USB 上、没有标准枚举协议的设备,比如片上外设、GPIO 控制器、I2C 适配器。
以前嵌入式开发者会在 BSP 里注册一堆platform_device,在代码里指定内存地址、中断号、名字,然后写对应的platform_driver来匹配它们。现在越来越多的平台改用设备树(Device Tree),Board 级信息从 C 代码里剥离到.dts文件里。设备树中每个带compatible属性的节点,在启动阶段会被解析成platform_device,然后自动走上我们前面说的总线匹配流程。
我在 i.MX6ULL 平台上移植内核时,就踩过设备树匹配的坑。当时驱动代码里明明写了of_match_table,也加了MODULE_DEVICE_TABLE(of, ...),但就是 probe 不了。后来用dtc反编译设备树,对比 compatible 字符串,发现设备树那边少了个逗号,导致字符串不一致。这类问题用grep查dmesg根本看不出来,必须静下心把两边的字符串逐字对齐。所以做嵌入式开发的朋友,一定要养成“先看 dts,再查 match”的排查习惯。
3.3 probe 函数里到底该写些什么
设备与驱动匹配成功之后,内核会调用驱动的probe函数。很多新手不清楚probe和module_init里究竟该干什么。我打个比方:module_init只是“快递员接单”,告诉平台“我这里有个人会送快递”;probe才是“正式开工”,拿到具体地址后开始处理业务。
在probe函数里,常规动作包括:从平台资源里获取内存地址和中断号、用ioremap映射寄存器、申请 GPIO 或 DMA 通道、注册中断处理函数、初始化自旋锁或互斥锁、注册字符设备(cdev_add)或者杂项设备(misc_register)、调用device_create生成/dev节点。所有跟具体设备相关的初始化,都应该放在probe里,而不是放在module_init里。因为module_init执行时驱动还不知道自己会绑定哪个设备,只有probe才意味着真正和一个设备成功配对。
remove函数则做反向操作:注销设备、释放中断、释放映射、回收内存。一个合格的驱动,必须保证probe和remove成对出现,能够反复加载卸载而不出问题。我见过很多驱动能加载但一卸载就 panic,基本上都是remove路径上资源处理不干净。
4. 实战:手写一个最小驱动,验证模型全流程
4.1 环境准备与内核配置注意点
理论讲再多,不如动手跑一遍。这里我以一个虚拟的 platform 设备为例,从零编写一个驱动,通过设备树完成匹配,并在/sys下观察整个模型的表现。建议环境用一台 Ubuntu 虚拟机或者物理机,提前安装好内核编译工具链,并准备好当前内核版本的源码或 headers。
安装依赖可以执行:
sudo apt-get update sudo apt-get install build-essential libncurses-dev bc flex bison libssl-dev接下来确认内核构建目录存在,通常是在/lib/modules/$(uname -r)/build。如果没有,可以先安装linux-headers-$(uname -r)。不过我更推荐直接准备一份完整的内核源码,因为设备模型相关的头文件比较多,只有构建目录完整,编译时才不会缺这少那。
另外,为了让设备节点能自动创建,内核需要开启 devtmpfs 和 uevent 支持。一般发行版默认都开着,但如果你是裁剪内核,请留意CONFIG_DEVTMPFS和CONFIG_UEVENT_HELPER,否则会出现“设备注册了但/dev下没节点”的诡异问题。
4.2 驱动代码与 Makefile
下面是一段非常精简的 platform 驱动代码,我在虚拟机上实测过,用来验证设备驱动的匹配与 probe 流程完全够用。
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/io.h> #include <linux/of.h> #include <linux/miscdevice.h> #define DRIVER_NAME "demo" static int demo_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t demo_read(struct file *filp, char __user *buf, size_t size, loff_t *off) { return 0; } static const struct file_operations demo_fops = { .owner = THIS_MODULE, .open = demo_open, .read = demo_read, }; static struct miscdevice demo_misc = { .minor = MISC_DYNAMIC_MINOR, .name = "demo", .fops = &demo_fops, }; static int demo_probe(struct platform_device *pdev) { int ret; ret = misc_register(&demo_misc); if (ret) { dev_err(&pdev->dev, "register misc device failed\n"); return ret; } dev_info(&pdev->dev, "demo probed\n"); return 0; } static int demo_remove(struct platform_device *pdev) { misc_deregister(&demo_misc); dev_info(&pdev->dev, "demo removed\n"); return 0; } static const struct of_device_id demo_dt_ids[] = { { .compatible = "demo,chardev" }, { } }; MODULE_DEVICE_TABLE(of, demo_dt_ids); static struct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = DRIVER_NAME, .of_match_table = demo_dt_ids, }, }; module_platform_driver(demo_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("A simple platform driver for demo");对应的 Makefile 长这样:
obj-m := demo.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean因为设备驱动模型是围绕设备树匹配展开的,所以还需要一个虚拟设备树节点。你可以把下面这段追加到设备树的某个根节点下,也可以在 bootloader 的 overlay 里动态加载:
/dts-v1/; / { demo@0 { compatible = "demo,chardev"; status = "okay"; }; };如果不想折腾设备树,也可以在驱动初始化时手动注册一个platform_device:
static struct platform_device *demo_pdev; static int __init demo_init(void) { demo_pdev = platform_device_register_simple(DRIVER_NAME, PLATFORM_DEVID_NONE, NULL, 0); return PTR_ERR_OR_ZERO(demo_pdev); } static void __exit demo_exit(void) { platform_device_unregister(demo_pdev); }这两种方式都能让驱动和设备在 platform 总线上碰面。但设备树方式更贴近真实嵌入式开发,也更能体现of_match_table的匹配逻辑,所以我更推荐先用设备树方式跑一遍。
4.3 编译、加载、观察 sysfs 与日志
在设备树节点准备好之后,执行编译:
make如果一切正常,目录下会生成demo.ko。接着加载模块:
sudo insmod demo.ko马上查看内核日志:
dmesg | tail -20如果匹配成功,你会看到类似这样的输出:
demo demo@0: demo probed这一步说明 platform 总线已经把设备树里的demo@0节点转换成了platform_device,并且与demo_driver完成了匹配,probe函数被调用了。接下来验证模型导出的 sysfs 目录:
ls /sys/bus/platform/devices/ ls /sys/bus/platform/drivers/demo/ ls /sys/devices/platform/demo@0/ cat /sys/class/misc/demo/dev你会在多个视角下看到同一个对象:总线设备列表里有它,驱动绑定目录里有它,设备树生成的物理设备目录里也有它。这就是设备驱动模型“统一对象、多维视角”的直观体现。
卸载驱动时执行:
sudo rmmod demo dmesg | tail -5又会看到demo removed的日志。反复 insmod / rmmod,你就能感受到设备模型对驱动生命周期管理的精细程度。
4.4 常见报错与排查技巧
实战最常见的问题,我整理成一张速查表,遇到可以直接对照。
| 现象 | 原因 | 处理思路 |
|---|---|---|
insmod报No such device | 设备树节点和驱动compatible不匹配 | 对比设备树中的 compatible 和驱动中的of_device_id,看字符串是否一字不差 |
加载成功但probe没被调用 | module_platform_driver注册时机和平台设备解析时机错位 | 确认平台设备已经被注册;如果是设备树,确认节点在平台总线上 |
dmesg 报Device 'demo' does not have a release() function | 设备对象只被创建没有被正确释放 | 为platform_device提供 release 回调,或让platform_device_register_simple走标准注销流程 |
make时报找不到 build 目录 | 内核头文件未安装或源码路径不对 | 检查/lib/modules/$(uname -r)/build是否存在;必要时安装linux-headers |
| 加载后模块版本格式不符 | 模块编译所用内核源码版本与运行内核不一致 | 用uname -r确认版本,确保 KDIR 指向真正的当前内核构建目录 |
/dev下没有demo节点 | devtmpfs 未开启或 udev 规则没生效 | 确认CONFIG_DEVTMPFS=y,确认misc类注册成功;也可手动mknod验证 |
我特别想强调一点:设备模型相关的坑,大多是“对象生命周期”问题,而不是“语法”问题。你在编译阶段可能一切顺利,但运行时却问题百出。这时候不要急着改代码,先去看看/sys下对象是否存在、引用计数是否异常,再回头看驱动和设备的匹配路径,效率会高得多。
5. 设备驱动模型与内核底层能力的联动
5.1 file_operations、动态加载与安全拦截
很多人分不清设备驱动模型和file_operations之间是什么关系,这里我展开讲一下。设备驱动模型负责“把设备对象管理起来,并让用户态能看到它”,而file_operations则负责“用户态打开设备节点之后,具体怎么读写数据”。驱动在probe阶段注册字符设备或 misc 设备时,会把一组fops挂在设备上;用户态对/dev下的节点执行open、read、write,最终都会走到这组 fops。
那么热词里提到的“动态加载 file_operations 拦截 read write”是怎么回事?从设备模型角度看,驱动仍然是通过标准file_operations挂到 VFS 层的,但如果要做透明加密或安全审计,就需要在 VFS 到驱动之间再插入一层拦截逻辑,比如使用 Linux Security Module 或者更底层的 hook 机制。这些机制本身并不影响设备模型的匹配流程,但它们能对已经绑定的设备节点做访问控制。换句话说,设备模型负责“接通”,安全模块负责“检查”。
我在做一个数据防泄漏项目时,就写过类似的东西。当时需要拦截对指定磁盘设备的 read/write,最开始直接替换驱动里的 fops,后来发现这样做非常危险,因为设备模型和 VFS 会把 fops 指针缓存到多处,动态替换容易引发竞态。稳妥的做法是在 fops 之外挂一层自己的处理逻辑,或者用 lsm hook 对设备的读写操作做过滤。这算一个提醒:fops 是驱动和 VFS 之间的契约,别轻易在运行时撕裂它。
5.2 内核同步方法在驱动中的落地
设备驱动只要是并发的,就绕不开内核同步。热词里的“linux内核同步的方法”在驱动开发里体现得尤其明显。设备模型本身在注册、注销设备时,也会有并发操作,内核内部用 mutex 和引用计数保护对象。而驱动里的probe、read、write、中断处理函数之间,则会访问共享资源,必须自己考虑同步。
通常的做法是:如果共享数据可能在进程上下文被多个线程访问,就用 mutex 保护;如果会在中断上下文或 bottom half 中被访问,就用 spinlock 或者原子操作;如果驱动需要等待某个异步事件完成,用 completion;如果有生产者和消费者模型,用 waitqueue。我在写设备节点驱动时,最简单的同步方式就是给读写都加一把 mutex,虽然损失一些并发性能,但能显著降低复杂度和踩坑概率。
为什么不能全都用 spinlock?因为 spinlock 在持锁期间不能睡眠,而很多驱动操作,比如 copy_to_user、ioremap、等待硬件响应,都可能引起睡眠。反过来,mutex 不能用在中断上下文,因为中断处理函数不允许阻塞。所以在驱动里设计同步方案,先得想清楚每一段代码运行在什么上下文。设备模型很少给你“一刀切”的答案,它只是规定好对象生命周期,而同步策略完全看你对设备行为的理解。
5.3 从模型视角看内核裁剪、实时补丁与虚拟化
最后把视野拉高一点。设备驱动模型和内核裁剪、实时补丁、虚拟化这些底层工作也密切相关。做内核裁剪时,大家关注的是体积和启动时间,但裁掉一个驱动远不止是去掉一个.ko文件,还要考虑它所依附的总线、class、以及依赖的内核配置项。例如,你把CONFIG_INPUT去掉,不仅鼠标键盘驱动没了,整个输入子系统的设备节点和事件接口也会一并消失。设备模型像一张大网,裁剪时很容易牵一发而动全身,所以每次裁剪完,我都习惯在/sys下逛一圈,看看哪些设备还在、哪些驱动没匹配上。
实时补丁方面,比如很多人提到的 RT 内核,它对驱动最大的要求就是“临界区要短、关中断时间要可控”。设备模型自身维护锁和链表时,也会影响到实时性。如果你的驱动在probe里做了大量耗时操作,或者read路径持锁太久,即使在 RT 内核上跑,实时性也会被打折扣。所以实时补丁的优化,既在调度器,也在每个驱动的实现细节里。
虚拟化场景里,设备模型同样扮演关键角色。虚拟机里的 virtio 设备,本质上是虚拟总线上的设备,通过匹配 virtio driver 来绑定,依然走bus_type的match、probe流程。每次我调虚拟化环境下的 IO 性能问题,都会先在/sys/bus/virtio/devices下确认设备驱动绑定情况,再往下排查队列深度和中断路由。说到底,这些五花八门的底层能力,全都离不开设备驱动模型这个共同底座。
我个人在实际调试中的体会是,设备驱动模型不是一段可以“背下来”的代码,而是一种“对象化”的思维方式。你在/sys下看到的每一个目录,背后都是一个 kobject;驱动和设备之间的每一次绑定,背后都是一次总线的 match 回调。真正吃透这套模型之后,再看内核启动日志、看设备树解析、看驱动 probe 失败,你会有一种“一眼看穿底牌”的感觉。做嵌入式或者内核驱动开发,不怕起步慢,怕的是只背 API 不追机制。这套模型,值得反复锤。