Linux设备驱动模型深度解析:从kobject到设备树
2026/9/18 6:16:34 网站建设 项目流程

开篇先聊一个我观察了很久的现象:很多人写Linux驱动,字符设备、platform驱动、中断、ioctl、sleep/wakeup样样都能上手,但真被问一句“设备驱动模型到底是干什么的、bus/device/driver三个结构体是怎么被串起来的”,立马就卡壳。这其实不怪大家,因为大部分教程都是“怎么调API”的路线,没有把内核那套组织设备的逻辑讲透。可一旦你想往底层深挖,想搞明白内核怎么自动匹配驱动、怎么管理电源、怎么在/sys里把设备树状结构暴露出来,就绕不开Linux设备驱动模型(Device Driver Model,简称DDM)。这篇文章,我就把这个模型从底层kobject到上层class、从总线匹配到设备树接入,一层一层剥开讲清楚。

这篇内容适合两类人:一类是刚看完LDD3、对字符设备已经比较熟、正准备往内核底层深入的人;另一类是面试中被“请简述Linux设备驱动模型”问倒、想系统补课的人。看完你至少能回答清楚:设备模型由哪几层组成、sysfs是怎么生成的、probe函数为什么会被调用、platform设备和设备树之间是什么关系。

1. 设备驱动模型解决的核心痛点:从散装注册到统一管理

早期内核(2.4时代及其之前)的设备驱动管理,基本是各写各的。每个驱动自己找硬件、自己注册中断、自己申请资源。那会儿设备数量少、平台相对固定,这么干能跑。但到了2.6以后,片上系统(SoC)越来越复杂,同一个内核要同时支持ARM、x86、MIPS等各种平台,外设种类也爆炸式增长。如果每个驱动还是各搞一套初始化逻辑,内核根本没法维护。

设备驱动模型最直接的贡献,是把“设备”和“驱动”这两个概念抽象成了独立对象,然后定义了一套统一的总线匹配规则。这样一来,硬件插入、驱动加载、设备节点创建、电源管理这些动作都能被框架接管,而不是靠驱动作者自己手写一堆if-else。

1.1 内核里设备信息为什么需要“描述化”

老式驱动里,硬件资源信息通常直接hardcode在代码里,比如寄存器地址就写成一个宏。换一块板子,同一个IP核换了基地址,你得改源码重新编译。这在设备模型出现之后变得不可接受了——设备模型希望驱动只关心“怎么操作这类硬件”,至于硬件在哪里、中断号是多少、使用哪条时钟,全部通过描述性的数据结构(比如platform_device或设备树节点)传入驱动。

这种“驱动即逻辑、设备即数据”的分离,是DDM的核心设计思想之一。你写一个驱动,应该尽量做到板级无关。后面讲platform和设备树的时候,你会更深刻地体会到这一点。

1.2 从字符设备cdev到设备模型的过渡关系

很多初学者会混淆两个概念:字符设备(char device)和设备模型。简单说,字符设备是“文件系统视角”的抽象,而设备模型是“系统拓扑视角”的抽象。字符设备通过cdev结构体注册到VFS,让用户空间可以用open/read/write来访问硬件;设备模型则把硬件、总线、驱动、类之间的关系建立起来,让系统知道“这个设备由哪个驱动管理、电源状态如何、在sysfs中长什么样子”。

这两者不是对立的,而是配合关系。一个完整的驱动通常既要注册cdev提供文件操作接口,也要接入设备模型让系统能管理它。这就是为什么你在写驱动时既会看到cdev_init,又会看到device_create这类函数。理解这层配合,你才算真正跨过了“只会调API”的阶段。

2. 底层地基:kobject、kset和sysfs之间的协作机制

如果你想直接跳到bus/device/driver,那先停一下。设备驱动模型底层还有一套基础设施,它们是整个DDM的“地基”,这就是kobject、kset和ktype。这三个东西决定了设备模型最核心的两个能力:引用计数管理在sysfs中呈现层次结构

2.1 kobject:内核里的“对象基类”

kobject从名字就能看出,它像面向对象里的基类。它本身没有太多业务含义,主要作用是提供引用计数、父子关系和sysfs目录的关联。

// include/linux/kobject.h struct kobject { const char *name; struct list_head entry; struct kobject *parent; struct kset *kset; struct kobj_type *ktype; struct kernfs_node *sd; /* sysfs目录项 */ struct kref kref; ... };

关键字段就那几个:

  • kref:内核的引用计数。当kobject被创建时计数为1,每次获取引用用kobject_get(),释放用kobject_put()。计数归零会自动触发release回调,把占用的内存释放掉。
  • parent:对象在sysfs里的父节点,体现层次。
  • ktype:这个对象的“类型”,里面定义了这个对象在sysfs里怎么显示属性、怎么释放。

在头文件里追一下kref,你会发现它其实是一个原子引用计数结构。所有对kobject的使用都必须遵守“谁获取谁释放”的规则,破坏了这一点,内核就会出现use-after-free,这类bug排查起来非常痛苦。我实际调试过的某个驱动崩溃,就是因为在probekobject_put提前释放了对象,而另一个线程还在用这个kobject发uevent。

2.2 kset和ktype:对象的集合与共同行为

kset是kobject的集合(或者说一个bus、class内部维护的对象集合)。它本身也内嵌了一个kobject,所以在sysfs里有自己的目录。kset最核心的作用是把同一类kobject挂到一个链表中,并让这些kobject共享一组操作。

ktype(kobj_type)则用来定义“这类对象的行为”:

struct kobj_type { void (*release)(struct kobject *kobj); const struct sysfs_ops *sysfs_ops; const struct attribute_group **default_groups; const struct kobj_ns_type_operations *(*child_ns_type)(struct kobject *kobj); ... };

release回调最值得关注。它是kobject生命周期结束时的“析构函数”。内核里有一个经典的警告:kobject: 'xxx' (address): does not have a release() function,看到这个基本就是kobject没有注册ktype或者ktype里release为空。这种错误会直接导致对象内存泄漏,因为内核不知道该怎么释放它。我在自研的驱动框架里就踩过这个坑——kobject创建了,没有正确设置ktype.release,结果每次卸载模块内存都不干净。

2.3 sysfs:内核对象在用户空间的“投影”

sysfs是一个基于内存的文件系统,通常挂载在/sys。设备驱动模型每个“节点”基本都能在/sys下找到对应目录。它的目录树不是随便排的,严格反映了kobject的parent关系。

快速看一下本机:

$ ls /sys/bus/ i2c pci platform spi usb ... $ ls /sys/class/ backlight gpio input i2c-dev mem misc net power_supply tty ... $ ls /sys/devices/ platform pci0000:00 ...

/sys/devices是所有设备对象的真正所在位置,/sys/bus/sys/class里其实是指向真实kobject的符号链接。这个概念非常重要:树只有一个根,就是/sys/devices,其他的目录是“视图”。总线和类是把同一批设备按不同维度重新组织起来。理解这个之后,你在写驱动或者排查问题时,通过路径就能反向推断出设备模型内部是什么状态。

3. 模型主干:bus、device、driver三者如何配合工作

在设备模型里,最核心的三角关系就是bus、device和driver。可以把bus想象成“红娘”,device是“待匹配的硬件信息”,driver是“匹配成功后负责打理硬件的服务方”。

3.1 bus_type:定义“总线规则”

Linux里总线不止物理意义上的PCI、USB、I2C,还包括虚拟总线比如platform_bus。每条总线在内核里就是一个bus_type

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); ... };

match函数是设备模型里最有意思的一个回调。它解决了“这个设备到底该由哪个驱动处理”的问题。每个bus_type可以自定义匹配规则。比如PCI总线喜欢比对vendor/device ID,I2C总线比对名字字符串,platform总线先比对设备树compatible属性、再比对平台设备名字。

3.2 device与device_driver:两个结构体如何被绑定

struct device描述一个真实的硬件设备,它包含资源、电源管理状态、引脚、中断等信息。struct device_driver描述处理某类设备的软件逻辑,它包含probe、remove、suspend、resume等回调。

当内核检测到新设备时(比如USB设备插入),它会调用总线注册时的match逻辑,在所有同一条总线的driver里找合适的。如果匹配成功,框架会调用driver的probe函数。这就是“probe被调用”的本质。

反过来,如果driver先注册(比如模块加载),内核会遍历总线上已有的设备,反向去匹配该driver。两种顺序最终都会落到同一套匹配流程上。

匹配成功的核心动作有两个:

  1. dev->driver指针指向匹配上的driver。
  2. 调用driver的probe回调。

probe返回0代表驱动初始化成功,设备状态进入online。如果probe中途失败,设备保持unbound状态,之后可以重新触发绑定。

3.3 match函数背后到底写了什么

以platform总线为例,platform_match函数的基本流程是这样的(我按源码逻辑简化描述):

  • 先查of_match_table,即设备树匹配。比较设备节点里的compatible属性是否在驱动的of_device_id列表中。
  • 再用ACPI匹配(x86平台常用)。
  • 然后比较platform_device.nameplatform_driver.id_table->name
  • 最后直接比较platform_device.namedriver.name

我画过一张草稿,把匹配顺序记成这样:

ACPI匹配 → OF(设备树)匹配 → id_table名字匹配 → driver名字匹配

实际开发中,写一个platform_driver需要在driver结构体里填好of_match_table,否则在设备树环境下很可能匹配不上。

static const struct of_device_id my_ip_of_match[] = { { .compatible = "myvendor,my-ip", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_ip_of_match); static struct platform_driver my_driver = { .probe = my_probe, .remove = my_remove, .driver = { .name = "my-ip", .of_match_table = my_ip_of_match, }, }; module_platform_driver(my_driver);

提示:MODULE_DEVICE_TABLE不是写给别人看的注释,它会让modinfo和内核模块加载系统知道这个驱动支持哪些设备。很多新手漏掉它,结果modprobe死活自动加载不了驱动。

3.4 手动绑定与解绑:调试必备技能

设备模型提供了一套sysfs接口,让我们可以在运行时手动绑定/解绑驱动。这在驱动开发调试阶段非常有用。

# 查看设备当前绑定在哪个驱动上 $ ls -l /sys/bus/platform/devices/xxx/driver # 手动解绑 $ echo "xxx" > /sys/bus/platform/drivers/xxx/unbind # 手动绑定 $ echo "xxx" > /sys/bus/platform/drivers/xxx/bind

还有更常用的场景:修改了驱动源码重新编译成模块后,先rmmodinsmod,这时候就能通过bind文件手动触发probe,而不用重新插拔硬件。电源管理调试时也常用这个技巧,可以让某个设备在驱动不加载的情况下持续运行,便于单独测量功耗。

4. class与设备节点的自动创建:向用户空间交付能力

设备模型的管理职责不仅仅在内核内部。为了让用户空间(应用程序、udev、系统管理工具)能使用设备,内核需要向用户空间暴露统一接口。这就是class和设备节点的意义。

4.1 class把设备按“功能”分组

bus是按“连接方式”给设备分类(在PCI上还是I2C上),class则是按“设备功能”分类(输入设备、网络设备、LED、gpio等)。这两者可以并存,一个设备既属于某条总线,也属于某个class。class目录下的项通常是指向/sys/devices下真实目录的符号链接。

写驱动时最常用的class相关函数是:

struct class *my_class = class_create("my_devices"); device_create(my_class, parent_dev, devno, NULL, "mydev%d", index);

device_create成功之后,内核会做两件事:

  1. 在/sys/class/my_devices/下创建mydev0、mydev1等符号链接。
  2. 在/sys/devices/virtual/或对应父设备目录下创建实际kobject,挂上dev节点。

同时,内核会向用户空间发送uevent,udev(或嵌入式里的mdev)收到之后根据规则在/dev下创建对应的设备节点。

4.2 为什么嵌入式里常见mdev、pc-linux常见udev

这背后其实是uevent机制在起作用。内核检测到新设备时,会广播一个uevent(内核对象事件),里面包含ACTION(add/remove)、DEVPATH、DEVNAME、MAJOR、MINOR等环境变量。用户空间的udev守护进程监听netlink socket收到这些事件后,根据/etc/udev/rules.d里的规则创建设备节点。

在嵌入式环境里没有udev,很多系统用busybox的mdev来处理。如果你的驱动只调用了device_create而没有对应热插拔规则,你在/dev下看不到节点,就说明udev/mdev那边没配好。这是一个排查设备节点不出现问题的关键思路:内核侧看uevent有没有发出来,用户侧看规则有没有生效。

4.3 class属性:给用户空间提供查询和控制入口

class下还可以创建额外的属性文件,让用户空间通过读写文件来控制设备行为或者读取状态。

static ssize_t state_show(struct device *dev, struct device_attribute *attr, char *buf) { return sprintf(buf, "%d\n", get_device_state(dev)); } static DEVICE_ATTR_RO(state);

然后在class创建后:

device_create_file(dev, &dev_attr_state);

这样用户空间就可以直接cat /sys/class/my_devices/mydev0/state来查询状态。不需要写一个完整的ioctl驱动,也不需要打开设备节点,简单高效。这个习惯在服务端开发和运维场景里很常用,因为shell环境下不方便写C程序调用ioctl。

5. platform设备与设备树:当前主流硬件的接入方式

聊完通用模型,必须聊聊当前实际开发中最常见的platform总线。很多人在ARM嵌入式开发中,绝大多数设备都是platform设备,而不是PCI/USB这类枚举型设备。

5.1 platform总线为什么存在

像PCI、USB这类总线,硬件有标准枚举协议,设备插入时能自动上报ID。但片上系统(SoC)内部集成的那些控制器(比如UART、SPI、I2C控制器、DMA控制器等)没有枚举机制,它们是焊死在SoC内部、固定地址、固定中断号的。这类设备不能指望“插上就发现”,只能由板级代码或设备树直接声明。

于是内核设计了platform总线,把所有不挂在标准总线上的设备统一归到这条虚拟总线上管理。platform_device描述硬件资源,platform_driver描述驱动逻辑,匹配规则走上面说的of_match_table那套。这样即使硬件信息变了,只要设备树描述更新,驱动代码基本不用动。

5.2 设备树如何与驱动模型衔接

设备树(DT)文件通过编译变成dtb,内核启动时解析并生成一个个platform_device。这个过程中,设备树里的每个带compatible属性的节点都可能变成一个platform_device。

举个例子,设备树里有这样一个节点:

myip: my-ip@1c00000 { compatible = "myvendor,my-ip"; reg = <0x01c00000 0x1000>; interrupts = <0 42 4>; clocks = <&ccu 12>; };

内核解析后生成一个platform_device,其of_node指向这个设备树节点。当上面那个platform_driver注册时,通过compatible匹配成功,probe被调用。你在probe里用of_property_read_u32platform_get_resource等方式读取寄存器和中断号,这些都是从设备树节点读取的。

platform_get_resource的底层实现,其实就是从device的resource数组里取数据。这些resource是在解析设备树时根据reg属性创建的。所以probe里拿到的资源不是你自己定义出来的,而是硬件描述信息经过设备树框架转换而来的。

5.3 设备树没有匹配成功会怎样

这是嵌入式开发最常遇到的问题之一。设备树写了节点,驱动也写了compatible,但probe不执行,基本就是下面几种情况:

  1. compatible字符串拼写不一致,多一个空格都不行。我经历过一次肉眼完全看不出来的全角字符混入,排查了一下午。
  2. 设备树节点被status = "disabled"禁用了。内核解析时会跳过禁用节点。
  3. 驱动编译成模块,但modprobe没有依赖MODULE_DEVICE_TABLE生成的信息自动加载。
  4. 设备树里缺少必要的电源管理节点,probe里获取clock或regulator失败,返回了-EINVAL。

快速排查手段是查看:

$ ls /sys/bus/platform/devices/ $ ls /sys/bus/platform/drivers/xxx/

如果设备节点存在但driver链接没建立,说明匹配失败;如果设备节点根本不存在,说明设备树解析阶段就出问题了。

6. 还原现场:/sys目录到底在表达什么

很多初学者面对/sys目录会觉得信息量太大,不知道从哪看起。这里给你一个我常用的排查路径,把前面讲的知识点串起来。

我把排查步骤整理成一个固定的顺序:

  1. 先看设备是否被枚举成功/sys/bus/platform/devices//sys/devices/下有没有目标设备目录。没有,说明硬件枚举或设备树解析没完成。
  2. 再看设备和驱动是否完成绑定:设备目录下有没有driver符号链接。没有,说明match失败或驱动没注册。
  3. 看驱动是否加载ls /sys/bus/platform/drivers/下有没有对应驱动目录,lsmod看模块是否在内存中。
  4. 看驱动内部状态:通过驱动在/sys下导出的属性文件查看寄存器状态、中断计数、运行模式等。
  5. 看事件流:用udevadm monitor(嵌入式没有就临时编译一个或者加日志)观察uevent是否正常发送。

这套链路走下来,设备驱动模型在内核里是不是健康运转,基本一目了然。

比如你在调一个外部I2C触摸屏驱动,现象是触摸没反应。你按上述顺序检查后发现:I2C设备节点存在,driver也绑定了,但/sys/class/input/下没有生成对应input设备。那问题大概率出在驱动probe后初始化失败,或者中断申请失败。这时候直接在probe里加dev_err打印,比在任何地方瞎猜都有效。

7. 我自己吃过的苦头:几类高频雷区与对应排查思路

最后把我在实际驱动开发里反复踩过的坑集中说一下,主观性比较强,但都是真金白银换来的经验。

7.1 kobject的release缺失导致崩溃

我早期用kobject封装一个虚拟设备,只调了kobject_create_and_add,没有设置ktype里的release函数。卸载模块时kobject_put触发内核警告,然后直接oops。后来老老实实补上release回调,在回调里kfree容器结构体,问题才解决。

有个经验是,只要在驱动里看到kobject_put相关的崩溃,先去看release有没有正确定义,八成是这个问题。它非常隐蔽,因为编译不报错,运行也时有发生、时不发生,全看运气。

7.2 probe执行顺序与设备依赖

某个项目中,A设备的probe需要B设备已经就绪提供时钟。但两者在设备树里没有显式指定依赖关系,导致B先probe还是A先probe完全不确定。板子偶尔能在uboot里手动初始化B后才正常,但内核自启动时A先跑,直接拿不到时钟。

处理方式有很多:在A的probe里请求时钟时用devm_clk_get,如果返回-EPROBE_DEFER就原样返回该错误码,内核会把这个设备放回待匹配队列,等B注册完时钟后再重新触发A的probe。这背后的机制是deferred probe,它本身也是设备驱动模型的一部分。

这个经验告诉我,设备树里描述清楚依赖关系比自己写一堆同步逻辑靠谱得多,内核框架已经替你考虑了这类问题。

7.3 系统休眠唤醒时probe会被再次触发吗

有段时间调suspend/resume,怀疑系统唤醒后设备驱动是不是重新走了probe。实际调试发现,正常流程下内核走的是dpm_suspend/dpm_resume链路,挂在dev_pm_ops里的suspend/resume回调上,而不是重新probe。设备模型在注册device时就把电源管理回调挂到了PM核心的链表中,系统休眠时PM核心会按依赖顺序调用这些回调。

只有在设备被真正移除(比如USB设备物理拔出)时,才会走remove路径,之后重新插入才会再次probe。理解这个区别,可以避免在调试省电状态时白费功夫。

7.4 用devm_系列函数减少内存管理负担

现代内核驱动开发中,凡是devm_开头的资源管理函数(devm_kzallocdevm_clk_getdevm_gpiod_getdevm_request_irq等),都建议优先使用。它们把资源生命周期跟struct device绑定,设备remove时会自动释放,省去手动错误处理的一大堆麻烦。

这套机制本质上是设备模型提供的资源管理框架。设备在probe里分配的资源全挂在设备的资源链表上,remove时统一回收,避免probe过程中途失败导致资源泄漏。

8. 学习路径建议:如何把这些知识真正内化

这篇文章信息密度不低,但读一遍肯定不够。我的建议是动手做一个小实验,把这个模型完整地“跑通”一遍:

  1. 写一个最简platform_driver,不操作任何硬件,只在probe里打一条日志。
  2. 写一个最简platform_device(也可以直接在模块里用platform_device_register_simple注册),名字跟驱动匹配。
  3. 加载后观察/sys/bus/platform/devices/和/sys/bus/platform/drivers/下的变化。
  4. 给驱动加一个device_attribute,通过/sys节点读写一个全局变量。
  5. 加一个class,使用device_create创建设备节点,观察/dev下的变化(配合udev/mdev)。

这个实验做完,你对设备模型的理解就不是“好像明白了”,而是“这玩意就是我写的这几行代码串起来的”。这几步看起来简单,但覆盖了kobject、bus、device、driver、class、sysfs、uevent、设备节点等全部核心概念。之后你再去看真实的子系统代码(比如GPIO子系统、Input子系统、I2C子系统),会发现它们全是建立在这套模型之上的具体应用案例。

从我个人经验来看,设备驱动模型的“顿悟时刻”通常不是听来的,而是某一天在/proc/interrupts里看到一个中断号、在/sys里看到一个属性文件、在dmesg里看到一条probe日志,突然把它们全部串起來的时候。希望这篇文章能帮你缩短通往那个时刻的路程。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询