做了这么多年嵌入式 Linux 开发,回头看看,当初最让我头疼的不是某个外设驱动怎么调通,反而是"设备驱动模型"这套看起来不起眼的基础框架。不少朋友跟我聊内核学习时都说:寄存器操作、中断回调、环形缓冲区这些都能看懂,但一看到 bus、device、driver、class 这几个概念纠缠在一起,再有 device tree 和 sysfs 掺和进来,整个人就懵了。
但我可以负责任地说一句话:设备驱动模型就是内核底层的骨架,你不把它骨头的接法看清楚,后面读任何驱动源码都是隔着一层纱。换句话说,搞懂了设备驱动模型,你才算真正把内核底层"吃透"了,而不是天天背接口。
这篇文章我想从设计思想入手,把总线、设备、驱动、类这四个核心角色讲清楚,再展开到嵌入式场景里几乎天天用的 platform 总线、设备树,最后落到 sysfs、uevent 这些跟用户空间打交道的机制上,再加上我自己在调试驱动时踩过的一堆坑。内容不追求把 struct 里的每个字段都讲完,那是啃书的事;我重点帮你把"模型"立起来,把链路串通。
1. 先搞懂设计思想:设备驱动模型的四个核心角色
1.1 为什么内核非要整出这么一套模型
很多人第一次接触内核里"注册一个驱动"的代码,心里会冒出一个疑问:我写个驱动直接操作硬件不就行了,为什么要绕这么大一圈子,搞什么 bus、device、driver 的匹配机制?
这是因为内核面对的硬件环境远比我们想象中复杂。一块 SoC 上,既有挂在 CPU 总线上的内部控制器(比如 GPIO、UART 控制器、MMC 控制器),也接出了 SPI、I2C、PCIe、USB 等物理总线,每种总线上挂的设备成千上万,而且很多设备支持热插拔。如果每个驱动都自己管理硬件设备,各自为政,那会出现三件事:第一,驱动代码里到处是重复的"找设备"逻辑;第二,系统无法统一管理电源、热插拔、设备生命周期;第三,内核层面没有一个公共的视图,用户空间想知道系统里有哪些设备都无从下手。
所以 Linux 内核的设计者做了一件事:把设备相关的所有对象抽象成统一的模型,并引入一套标准化的注册、匹配、加挂机制。这个模型不是针对某个具体驱动设计的,它是一个"框架",所有 driver 都在这套框架里运行。这就好比一个小区的物业管理系统:业主(设备)、车辆(驱动)、道路(总线)和车辆分类(类)必须统一登记管理,才能做到车辆进出门岗自动抬杆、车位自动分配、异常情况自动告警,而不是每家自己雇个人盯在门口。
设备驱动模型的核心是四个对象:bus(总线)、device(设备)、driver(驱动)、class(类)。接下来的内容,基本都围绕这四者的关系展开。
1.2 bus、device、driver、class 各自到底干啥
先给这四个角色定性:
- device:代表一个实实在在的硬件设备,描述的是"有什么",比如"地址为 0x10000000 的 UART 控制器""挂在 I2C 总线地址 0x48 上的温度传感器"。
- driver:代表一段驱动逻辑,描述的是"怎么操作",也就是你写的那些初始化、read、write、ioctl、中断处理函数。
- bus:是 device 和 driver 之间的"中间人",负责让双方互相找到对方。PCI、USB、SPI、I2C、platform 都是 bus 的具体实例。
- class:从功能角度对设备分类,比如 net(网络设备)、input(输入设备)、tty(终端设备)、gpio 等。它主要面向用户空间,让用户不用关心设备挂在哪条总线上,只需要按"这是一个网卡""这是一个键盘"的方式去找设备。
在内核代码里,这几个角色分别对应 struct bus_type、struct device、struct device_driver、struct class。我们日常写驱动,本质上就是在填充这些结构体,把驱动挂到对应的总线上,然后等内核来 match 和 probe。
总线这个角色特别关键,因为总线上有两个链表:一个挂设备,一个挂驱动。当一个新设备出现,总线会遍历驱动链表,逐个检查是否有驱动能处理它;反过来,新驱动注册时,总线也会遍历设备链表,看有没有设备等着它。这个"配对"的动作就叫 match,配对成功就会触发 probe。
struct bus_type { const char *name; // 总线名 int (*match)(struct device *dev, struct device_driver *drv); // 匹配回调 int (*probe)(struct device *dev); // 探测回调 int (*remove)(struct device *dev); // 移除回调 // 其他字段省略... };bus、device、driver、class 四者之间的关系,简单来说就是:bus 负责撮合 device 与 driver,成功绑定后就一起工作;class 是给用户空间看的分类视图,和总线层面是"正交"的,同一个设备既挂在某个总线上,又属于某个 class。
实际中我观察到的现象是,很多入门者把 platform_bus 当成了一个"设备",这是理解上最容易跑偏的地方。platform_bus 是虚拟总线,它本身不传输数据、不分配地址,它只是内核用来把那些"不好归类"的设备组织起来的一根线。
1.3 一次注册过程的完整链路:从 device 到 driver 到 probe
把模型关系看明白之后,下一步就是把"注册"这条链路串通。我以 platform 总线为例,因为嵌入式场景里大量驱动都是 platform_driver。
当你调用platform_driver_register(&my_driver)时,内核内部实际走的是:
platform_driver_register内部调用driver_register(&drv->driver);driver_register会把这个 driver 挂到总线的 driver 链表上,然后调用bus_add_driver;bus_add_driver会触发driver_attach(drv),总线会遍历自己设备链表里的每个 device,调用__driver_attach;__driver_attach会调用总线的match函数,也就是platform_match,去比较设备和驱动是否匹配;- 如果匹配成功,调用
driver_probe_device,最终执行到总线的probe回调,进而调用到drv->probe(dev)。
如果反过来,先注册的是 device(比如设备树里声明的节点被内核解析后生成了 platform_device),内核同样会触发一次遍历:device_add里调用bus_probe_device,去驱动链表里找匹配的驱动,匹配到之后同样进入 probe。
所以无论设备先来还是驱动先来,只要双方都能匹配上,probe 必定会被调用。这背后靠的其实就是"双向扫描"的机制。理解了这一条链,你就明白为什么probe函数是绝大多数驱动的入口:它代表设备和驱动成功见面的那一瞬间,内核正式把设备的控制权交给了驱动。
很多同学在学这部分的时候,喜欢硬背platform_driver_register的调用流程,但我更建议你打开源码读一遍,路径在drivers/base/driver.c、drivers/base/dd.c、drivers/base/platform.c,这三个文件把大部分答案都写了。源码里可以看到我上面描述的关键函数:driver_register、driver_attach、bus_for_each_dev、__driver_attach、driver_probe_device。读完之后你会对这套框架有"原来是这么回事"的感觉。
2. platform 总线:嵌入式内核中出场率最高的虚拟总线
2.1 为什么需要一条"假的"总线
上一节提到了 platform 总线,我想单独拿一节出来专门讲,因为在嵌入式 Linux 驱动里,platform 驱动占了绝对的大头。SD 卡控制器、网卡控制器、LCD 控制器、音频控制器……很多 SoC 内部的设备驱动,都是 platform_driver。
那问题来了:这些设备明明没有接在 PCI 之类的物理总线上,为什么内核要给它们虚拟出一条 platform 总线?
关键在于"枚举能力"。PCI、USB 这类总线上的设备,硬件层面就支持扫描枚举,设备自己会报告"我是谁、需要什么资源",内核插上就能发现。但 SoC 内部的那些控制器不一样,它们是用固定的物理地址映射在 CPU 总线上的,数量也是固定的,CPU 没法通过硬件扫描去"发现"它们,只能靠代码或者设备树把它们一个一个"登记"出来。
于是内核就把这些设备统一挂到一条虚拟总线上,叫 platform_bus。这条总线的存在,让这些设备也能复用前面说的 device/driver 配对机制和生命周期管理。你可以把 platform 总线理解成物业给"内部车辆"专门开辟的一条通道:没有外来车辆那种自动识别闸机,但物业照样会登记每辆车的信息,车牌对了就放行。
2.2 手写一个 platform 驱动核心骨架
我直接给你一段最精简、能编译过的 platform 驱动骨架,把这个作为参考模板,反复对照理解:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> #include <linux/io.h> #define MYDEV_NAME "demo-ctrl" // 设备树匹配表 static const struct of_device_id demo_of_match[] = { { .compatible = "vendor,demo-ctrl", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_of_match); // 驱动与设备匹配成功后触发 static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; pr_info("demo-ctrl probe success\n"); // 从设备树获取寄存器资源 res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; // 映射物理地址到虚拟地址 base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); // 这里可以继续注册字符设备、初始化中断等等 return 0; } static int demo_remove(struct platform_device *pdev) { pr_info("demo-ctrl remove\n"); return 0; } static struct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = MYDEV_NAME, .of_match_table = demo_of_match, }, }; module_platform_driver(demo_driver); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("demo platform driver");注意上面代码里的module_platform_driver,这个宏自动帮你生成了 module_init 和 module_exit,分别对应platform_driver_register和platform_driver_unregister。如果驱动编译成模块,insmod 加载时就会到 platform 总线上做一次匹配。
probe 函数是核心。它做的事情通常包括:
- 从 platform_device 里拿资源(寄存器地址、中断号、DMA 通道等);
- 映射物理地址(ioremap 或 devm_ioremap_resource);
- 注册子设备(比如 miscdevice、cdev、input_device);
- 初始化硬件(关看门狗、配置时钟、复位外设等);
- 把必要的数据保存起来,供 remove 和其他回调使用。
在 probe 里我特别说明一个习惯做法:尽量用devm_*系列函数。比如devm_ioremap_resource、devm_kzalloc、devm_clk_get。它们最大的优势是"谁申请、谁在脱离时自动释放",也就是说即使 probe 中途失败,或者设备被卸载,内核会帮你把已申请的资源统一释放,避免泄漏。这个在驱动热插拔频繁的嵌入式场景里特别省心。
2.3 资源管理和 devm_* 系列函数
既然提到 devm,就多说一点。在一个成熟的驱动里,probe 往往要申请很多资源:内存、寄存器映射、中断、时钟、GPIO、DMA、regulator。按传统模式,申请之后必须手动管理释放,而且一旦 probe 中某个环节出错跳转,前面的资源很容易漏掉。
devm(managed device resources)的机制就是给资源加上"生命周期跟随 device"的语义。设备 detach 时,内核会自动释放所有 devm 资源。实际上在设备驱动模型里,device 这个对象本身就关联了一个资源链表,devm 申请的东西都会挂到这个链表上,释放的时候从头到尾回收。
我自己写驱动几乎不碰非 devm 版本了,除非是极老的 Linux 版本。有一个例子能说明它多省事:某个模块的 probe 里申请了一个 IRQ,后面初始化失败返回 -EINVAL,如果不小心忘了free_irq,下次再加载模块要么报 resource busy,要么中断乱跳。用devm_request_irq就完全不担心这类问题。
3. 设备树:设备模型在嵌入式场景的必经之路
3.1 设备树解决什么问题
聊完 platform 总线,下一个绕不开的话题就是设备树(Device Tree)。现在市面上主流嵌入式 Linux,几乎都基于设备树管理硬件资源。设备树本质上是一种描述硬件信息的树形数据格式,它告诉内核"这个板子上有哪些设备、每个设备用什么地址、中断是哪个、时钟是多少",至于驱动怎么操作这些设备,那是 driver 自己的事。
在设备树出现之前,内核维护者每支持一块开发板,都要在 C 代码里静态定义一个struct platform_device数组,把板级硬件信息(寄存器地址、中断号、GPIO 配置)写死在源码里。这导致大量板级代码塞进内核,版本一多就冗余到爆炸。设备树的好处就是把"硬件清单"与"驱动代码"彻底解耦:同一份驱动代码,配合不同的 dts/dtsi 文件,就能适配不同板卡。
dts、dtsi、dtb 这三者的关系,类比一下就是:dtsi 是公共头文件的硬件描述,dts 是具体板卡的设备树源文件,dtb 是编译生成的二进制文件。启动时 bootloader 会把 dtb 加载进内存,内核解析后动态生成平台设备。
3.2 一个设备树节点的构成与匹配原理
设备树节点的基本格式是先写成文本,再编译成 dtb。我贴一段实际的设备树节点示例:
demo_ctrl: demo-ctrl@10000000 { compatible = "vendor,demo-ctrl"; reg = <0x10000000 0x1000>; interrupts = <0 42 4>; clocks = <&clk 10>; status = "okay"; };- compatible:最重要的匹配字段。内核驱动里
of_match_table写的是什么字符串,就要和它一致。vendor,demo-ctrl这种格式是社区惯例,前面的厂商名/前缀,后面是设备名,避免不同厂商的同名设备产生冲突。 - reg:设备寄存器物理地址和长度。上面
0x10000000 0x1000表示起始物理地址和映射长度。probe 里通过platform_get_resource读到的就是它。 - interrupts:中断号、触发方式等描述。
- clocks:设备的时钟来源,依赖对应的时钟控制器。
- status:
okay代表启用,disabled代表禁用。这个属性是个大坑,后面会说。
设备树节点被内核解析后,会生成struct device_node,再进一步转换成struct platform_device,这就是所谓"设备树中每个 compatible 节点都有可能变成一个 platform_device"的来源。具体走of_platform_default_populate这类函数完成。
驱动侧匹配的原理是:probe 前,platform_match会依次尝试多种匹配方式,其中之一就是比较设备节点的compatible属性和驱动的of_match_table。匹配成功,才会走进demo_probe。所以如果你的驱动加载了但不能 probe,第一件事就是检查 compatible 是否一字不差。
3.3 设备树调试中的几个大坑
设备树相关的调试,占了我实际项目排错的很大比例。先说几个最典型的。
第一个坑就是 compatible 不一致。常见场景是 dts 里写的是xxx,demo-ctrl,驱动里of_match_table写的是xxx,demo_ctrl,中间一个横线一个下划线,不仔细看根本发现不了。我习惯的做法是先 dmesg 看内核是否警告 "No dtb found" 之类,再把设备树导出确认实际加载的节点内容。
第二个坑是status = "disabled"。设备树中的节点如果被标记为 disabled,即便 compatible 完全匹配,内核也会把它当作不存在,直接跳过。经常有人新加设备后怎么都不 probe,查了半天代码没问题,最后发现是 dtsi 从公共头文件拿来的节点默认是 disabled,自己忘了在板级 dts 里覆盖成status = "okay"。
第三个坑是修改设备树后没生效。编译了 dts、生成新的 dtb、烧进去了,但启动时 bootloader 加载的还是旧的 dtb。这个问题在开发板平台上特别容易出,因为 dtb 存放位置可能是 boot 分区、ext4 分区根目录、或者通过 tftp 加载。排查时先确认 bootloader 日志里实际加载的 dtb 路径,不要盲目以为是代码问题。
设备树这块我想补充一个观点:它虽然是一个独立的数据格式,但它和设备驱动模型是深度耦合的。你只有把 device、driver、platform 总线的匹配流程搞明白,才能理解设备树节点在什么时机变成 platform_device、什么时候触发 probe。所以不要孤立地学设备树语法,一定要结合设备模型来理解。
4. 设备模型如何"通"到用户空间:sysfs 与 uevent
4.1 sysfs:内核设备模型对用户空间的投影
前面讲的 bus、device、driver、class,大部分是内核内部概念,你看不到摸不着。内核为了把设备模型暴露给用户空间,专门搞了一套虚拟文件系统,叫 sysfs,通常挂载在 /sys 下。
sysfs 的目录结构和你理解的内核模型一一对应:
/sys/bus:按总线组织设备与驱动。比如/sys/bus/platform/devices/下能看到所有 platform_device,/sys/bus/platform/drivers/下能看到所有已注册的 platform 驱动。/sys/class:按设备功能分类。比如/sys/class/net/、/sys/class/leds/、/sys/class/gpio/。用户程序想找"哪个网卡对应哪个设备"时,从这里查最方便。/sys/devices:按设备在系统中的拓扑关系组织,是最底层的设备目录。
sysfs 里最有意思的是符号链接(symlink)。当你把一个 device 和一个 driver 绑定成功,内核会在 device 目录下创建一个driver软链接,指向对应 driver 的 sysfs 目录。反过来,driver 目录下也会关联它当前绑定的设备。所以在调试一个驱动时,我经常直接到/sys/bus/platform/drivers/xxx/下面 ls 一下,看看有没有预期的设备目录出现,如果有,说明绑定成功;如果没有,说明 match 出问题了。
举个实际调试例子。某次我要确认一个 SPI 控制器驱动是否注册成功,但又不想翻 dmesg,直接执行:
ls /sys/bus/platform/drivers/spi-master/ ls /sys/bus/spi/devices/第一个命令能看到驱动是否注册成功,第二个命令能看到 SPI 总线上当前有哪些设备。这些信息比 dmesg 更直观,而且是可以交互验证的。
4.2 uevent 与 udev/mdev 动态创建设备节点
sysfs 是静态视图,而 uevent 是动态事件通知机制。当一个设备被注册(比如插入 U 盘、加载驱动后创建了某个设备),内核会通过kobject_uevent向用户空间发送一条事件,这个事件就叫 uevent。
uevent 里包含什么?主要是一系列环境变量,比如设备名、设备路径、子系统类别、动作(add/remove/change)等。用户空间的 udev(桌面系统)或 mdev(嵌入式 busybox 环境)收到这个消息后,会根据规则动态创建 /dev 下的设备节点,或者触发固件加载、设置权限。
我举一个嵌入式场景里最常见的例子:设备树里配置了一个 GPIO 按键,驱动用 input 子系统注册后,系统启动时 Uevent 通知用户空间,udev 规则检查到这个 input 设备后自动创建/dev/input/event0节点;之后你在应用层 open 这个节点,就能读到按键事件。如果没有 uevent/udev 这套机制,要么 /dev 节点不存在,要么设备路径写死、在拔插后会错乱。
对于写驱动的同学,我的建议是不要太纠结 uevent 的每个字段,但一定要理解设备模型和用户空间设备节点之间的因果关系。很多人学驱动时会有一个困惑:我明明写的是内核态驱动,为什么还要依赖 udev 规则?原因就在于,内核只负责注册设备和产生事件,不负责直接面对用户空间去创建各种节点。节点要什么权限、命名成什么、落在哪个目录,这些属于策略,策略放在用户空间更灵活。
4.3 从热拔插流程看模型协同
我把 USB 设备热插入的过程串一遍,你可以看到前面概念的协同:
- USB 控制器检测到设备插入,触发中断;
- USB core 枚举设备,读取设备描述符,确定设备类型和厂商 ID;
- core 创建一个
struct device,挂到 USB 总线; - USB 总线执行 match,找到匹配的 usb_driver;
- 调用驱动 probe,驱动完成初始化,注册接口(比如 cdc_acm 注册 tty,usb-storage 注册块设备);
- 新生成的子设备同样走 model,触发 uevent;
- 用户空间 udev 根据 uevent 创建 /dev/ttyACM0 或 /dev/sda1 等节点。
这里面,总线、设备、驱动、类、uevent、sysfs 全都用上了。理解了一个热插拔流程,基本就理解了设备驱动模型的运转方式。
5. 调试、避坑与内核源码阅读指引
5.1 probe 不调用怎么排查
probe 不调用,是内核驱动调试中出现频率最高的问题,没有之一。它的排查思路可以按链路一步步来:
先确认驱动是否成功注册。模块加载后执行 lsmod 看模块在不在,或者在/sys/bus/platform/drivers/下找驱动目录。如果驱动目录都没有,说明module_init没生效或者注册失败。
再确认设备是否存在。查看/sys/bus/platform/devices/下有没有对应设备节点,或者/sys/firmware/devicetree/base/下有没有对应设备树节点。设备树节点不存在,通常是因为 dtb 没生效、节点被 status 禁用、或者 compatible 被写错。
再确认司机和设备的 match 是否成功。驱动目录下如果有设备软链接但没有 probe,那就要查 match 为什么会失败。常见原因除了 compatible 不一致,还有of_match_table没设置、MODULE_DEVICE_TABLE 没写导致模块热插拔时无法自动加载等。
最后一个容易被人忽略的是-EPROBE_DEFER。现代内核里,如果驱动 probe 时需要某个资源(比如时钟、regulator、IOMMU),而资源还没准备好,驱动会返回 -EPROBE_DEFER,内核会把这个驱动放到延迟队列,等资源可用后再次尝试 probe。所以如果 dmesg 里看到类似 "probe deferred" 的提示,不要以为驱动挂了,它只是暂时没排上队。
5.2 常见问题速查表
我把日常开发中高频问题整理成了一张表,方便你排查时快速定位:
| 现象 | 常见原因 | 排查方向 |
|---|---|---|
| 驱动加载成功但 probe 没执行 | compatible 不一致 / 设备树节点 status disabled | 检查 of_match_table 和 dts 节点属性 |
| probe 拿到资源为空 | reg 属性未写 / 资源顺序不对 | 检查平台资源定义,platform_get_resource 参数 |
| insmod 报 "Unknown symbol" | 依赖的符号未导出 / 依赖模块未加载 | 检查 Module.symvers 和依赖顺序 |
| 设备树修改后启动不生效 | dtb 未更新 / bootloader 加载位置不对 | 确认 dtb 实际路径,先 md5 一下 dtb |
| probe 返回 EPROBE_DEFER | 时钟、电源等资源暂不可用 | dmesg 查 deferred 队列 |
| /dev 节点不生成 | udev/mdev 规则缺失 / 驱动未注册对应设备接口 | 查 /sys/class 下是否有设备目录 |
| rmmod 后系统崩溃 | 资源未释放 / 中断还在触发 / 并发未同步 | 检查 remove 里是否释放中断和注销设备 |
这表里的每一条,都是我实际项目里踩过、同事踩过、或者社区里高频出现的,对应的方法可以直接抄。注意,驱动开发里 up 一个模块后反复 reload,最怕的就是资源没释放干净,所以调试时养成习惯:每次 rmmod 后 dmesg 看有没有 BUG/use-after-free 的提示。
5.3 内核源码阅读路径建议
最后聊一下怎么看内核源码,才能把设备驱动模型吸收成自己的东西。
我推荐的路径是:先读drivers/base/core.c里的device_add,它是设备对象加入内核模型的主入口;再读drivers/base/driver.c里的driver_register,理解驱动加入的过程;然后读drivers/base/dd.c,这个文件是整个匹配和 probe 调度的核心,重点看driver_probe_device和really_probe;最后回到drivers/base/platform.c,看 platform 总线如何实现 match 和资源获取。
这几份文件读下来,你对"总线-设备-驱动"这套模型的运转机制就会有一个整体画面。之后再去读具体驱动(比如 i2c 驱动、spi 驱动、usb 驱动),会发现它们完全是同一套模板的变体,无非是总线类型不同、match 规则不同、数据接口不同。
我当初把dd.c里really_probe的代码一行一行读完后,有一种豁然开朗的感觉:原来前面学的 platform、device tree、sysfs 全是围着这一个函数在转,它是整个设备模型的心脏。
另外,学习设备驱动模型,不要停留在看上面。我强烈建议你在自己电脑上搭一个 QEMU 环境,用 virtio 设备做实验,或者直接在开发板上尝试给一个简单的 GPIO 控制器写 platform 驱动。只有亲眼看到 probe 被调用、sysfs 里出现目录、uevent 触发节点创建,这套模型才算真正被"吃掉"。
设备驱动模型这套东西,你说它难,它就只是一堆结构体和回调函数;你说它不难,它又串起了内核里几乎所有的子系统。但只要你把总线、设备、驱动、类这四个角色以及它们之间的"注册—匹配—probe"链路吃透,再去看那些看似高深的"内核底层",你会发现它们全都建立在这个最简单也最核心的框架之上。后面我会再单独写一篇关于设备树属性的详细拆解,以及一个从零编写可运行字符设备驱动的完整案例,感兴趣的朋友可以持续关注。