1. 为什么 Linux 需要设备模型
1.1 早期内核的驱动注册方式
很多人接触 Linux 内核时,第一个绕不过去的坎就是设备模型。我第一次翻开 LDD3 的设备模型章节时,满屏的kobject、kset、ktype,看了三遍还是一头雾水。后来工作里真正去调驱动、查 sysfs、看 udev 日志,才慢慢把这套东西的脉络捋清楚。这篇笔记就当是我自己的学习总结,也是给想入门 Linux 设备模型的朋友铺个台阶。
先把时间拉回 Linux 2.4 时代,那时候内核里的驱动注册方式非常简单粗暴。以字符设备为例,驱动只需要调用register_chrdev,把设备号和一个file_operations结构体注册进去,系统里就多了一个设备。至于这个设备叫什么名字、对应哪条总线、电源状态如何、能不能热插拔,内核一概不管。早期设备数量少,硬件平台也相对固定,这种方式确实够用。但到了 2.6 以后,PC 上的 USB 设备、嵌入式平台上的各种外设数量激增,系统需要一种统一的机制来管理这些硬件对象。这就催生了设备模型的诞生。
设备模型本质上是一套“对象的对象”,它把内核里的硬件设备、驱动、总线、类都抽象成统一的、具备层次关系的内核对象,并提供生命周期管理、引用计数、用户态可见性等机制。这套机制不是为了让内核更好看,而是为了解决几个实际问题:驱动与设备的自动匹配、热插拔事件的处理、电源管理的层级协调,以及/sys文件系统的数据来源。可以这么说,没有设备模型,现代 Linux 就没法做到“插上 U 盘自动识别”,也没法做到系统休眠时按设备树逐层断电。
1.2 设备模型要解决的四个核心痛点
设备模型的出现,是对下面这四个问题的直接回应。
第一个痛点是设备和驱动的匹配。早期驱动需要手动指定major号,或者自己扫描硬件端口,不同厂商各写各的,重复代码极多。设备模型引入了总线的 match 机制,让驱动声明“我支持哪些设备”,让设备表声明“我属于谁”,两者由总线自动撮合。这样驱动代码只需要写一次,就能适配同一条总线下的所有匹配设备。
第二个痛点是热插拔与用户态联动。USB 设备插入、拔出时,内核需要第一时间感知,并通知用户态来做配置。设备模型的 uevent 机制正是干这个的,它把内核事件通过 netlink 发出去,用户态的 udev 程序收到后创建设备节点、加载固件、设置权限。没有设备模型,这一切都会失去统一的出口。
第三个痛点是电源管理。现代内核的休眠、唤醒、运行时挂起都需要按设备树层级管理,设备模型的父子关系天然构成了一棵电源管理树。每个设备都可以有自己的runtime_suspend和runtime_resume回调,内核按照层级顺序统一调度。这一点在嵌入式设备上尤其重要,直接关系到功耗表现。
第四个痛点是用户态的可观测性。sysfs 文件系统挂载在/sys下,它把设备模型的各个对象暴露成目录和文件。用户和上层工具可以通过cat /sys/class/net/eth0/address这样的命令直接查询设备属性,也可以写属性文件触发内核动作。这种设计让内核硬件状态对用户态完全透明。
理解这四个痛点之后,再去看设备模型的具体组件,就有了主线。
2. 设备模型的骨架:kobject、kset 与 ktype
2.1 kobject:所有内核对象的共同基类
如果说设备模型是一个庞大的对象管理框架,那么kobject就是这个框架的最小积木。它有点类似 C++ 里的基类,但用 C 语言通过结构体嵌套来实现继承。实际看到的写法是:任何想被设备模型管理的对象,都把struct kobject放在自己结构体的第一个字段,比如struct device里就嵌入了struct kobject kobj。
kobject本身并不描述“硬件设备”这种具体概念,它只关心三件事:名字、引用计数、父子关系。内核通过这些信息把所有对象组织成一棵树,再映射到 sysfs 的目录结构上。你可以把它理解成一个“身份牌”,谁挂上这个身份牌,谁就可以被设备模型统一管起来。
这里有个新手常犯的误解:以为device、driver这些概念是设备模型的核心,其实它们的底层都建立在kobject之上。device是带更多属性(比如资源、电源状态)的kobject,driver同样如此。正因如此,理解kobject的引用计数与生命周期,比死记各种 API 重要得多。
2.2 kset 与 ktype:对象的集合与共同行为
kobject解决了“单个对象如何被管理”的问题,但内核里对象从来不是孤立存在的,同类对象经常需要被组织到一起遍历、分类。kset就是对象集合的容器。每个kset内部有一个链表,挂载着若干同类kobject,同时它在 sysfs 中体现为一个目录。比如/sys/bus/platform/devices就是一个典型的 kset 目录,里面聚集了所有挂在该总线上的平台设备。
ktype则定义了一组操作,描述这类对象的行为方式。它包含一个关键的回调:release。这个回调决定了对象被销毁时如何释放占用的内存。内核反复强调:kobject 的 release 回调必须存在,否则当引用计数降到零时,内核不知道如何释放这个对象,只能 panic。这是设备模型里一条不能踩的红线。
kobject、kset、ktype 三者配合,组成了设备模型的最底层建筑。理解它们的关系,可以用一个不太严谨但非常形象的类比:kobject是小区里的住户,kset是居委会的登记簿,ktype是全体住户的共同物业服务条款。没有登记簿,住户散落各地找不到;没有物业条款,住户出问题没人处理。
2.3 引用计数与生命周期管理
引用计数是设备模型里最容易被忽略、却最容易出 bug 的地方。内核使用kref机制管理kobject的生命周期,每次有人持有一个对象,就调用kobject_get增加计数;使用完毕,调用kobject_put减少计数。当计数降到零,内核自动调用ktype里的release回调释放对象。
我见过很多初学者在这里栽跟头:写驱动时获取了一个设备指针,使用后直接kfree,结果内核崩溃。正确做法是:获取时引用计数加一,使用完再减一,让计数器归零时由内核来释放。为什么这么设计?因为设备模型中的对象可能同时被内核其他模块、sysfs、用户态打开的 fd 引用,如果某个持有者直接释放,其他持有者的指针就变成了悬空指针。
举个实际场景:你在驱动的 probe 函数里拿到了struct device *dev并保存到全局变量。此时你应当get_device(dev)增加引用计数。模块卸载时先调用put_device(dev),确保没有其他人还在使用这个设备,再安全释放资源。这套规则虽然啰嗦,却是设备模型稳定运行的基础。
3. sysfs:设备模型对用户态的窗口
3.1 sysfs 与 procfs 的分工
学习设备模型之前,很多人对 sysfs 只有一个模糊的概念,知道它在/sys目录下,但不知道它和/proc有什么区别。简单说,/proc主要用来输出进程和内核的运行状态,是“动态信息”的窗口;而/sys表达的是内核对象的“层级结构”,反映设备模型中的设备、驱动、总线、类之间的关系。前者偏运行时状态,后者偏拓扑结构。
sysfs 的文件系统操作并不复杂,它把每个kobject映射为一个目录,把每个属性映射为一个文件。读取属性文件时,内核调用该属性的show回调;写入属性文件时,调用store回调。这种设计极其简洁,用户态只需要cat和echo就能与内核对象互动。
这也是设备模型对初学者最友好的地方:不需要写任何代码,直接在终端里遍历/sys目录,就能一步步看清内核对象的组织方式。我建议任何入门者都先做这一步,建立直观印象之后再回来看概念,效率会高很多。
3.2 从目录结构读懂设备模型
在终端输入ls /sys,看到的大概有block、bus、class、dev、devices、firmware、kernel、module这几个顶层目录。每个目录对应设备模型的一部分:
| 目录 | 对应对象 | 作用 |
|---|---|---|
/sys/devices | 全体设备对象 | 所有设备按拓扑关系组织的目录树,是设备模型的根 |
/sys/bus | 总线对象 | 按总线类型划分,如 platform、pci、usb、i2c |
/sys/class | 类对象 | 按功能分类,如 net、input、block、tty |
/sys/module | 内核模块 | 每个已加载模块一个目录 |
/sys/block | 块设备 | 块设备对象的视图 |
/sys/devices是最接近设备模型原始树状结构的入口,目录的嵌套关系就体现了设备之间的父子关系。比如一个 PCI 网卡设备下挂接着它的子设备,目录层级一眼可见。/sys/bus和/sys/class则是不同视角的索引:bus告诉你设备挂在哪条总线上,class告诉你设备属于哪类功能。同一个设备对象在这几个视角下都存在,但底层都是同一个struct device,只是通过不同 kset 挂载到不同目录。
3.3 属性文件与读写回调
属性是设备模型向用户态暴露数据的主要手段。写驱动时常见的操作是创建DEVICE_ATTR宏定义的结构体,并实现show和store函数。一个简单的属性定义看起来像这样:
static ssize_t my_attr_show(struct device *dev, struct device_attribute *attr, char *buf) { return sprintf(buf, "%d\n", dev->driver_data); } static DEVICE_ATTR(my_attr, 0644, my_attr_show, NULL);这里的权限0644表示所有用户可读,只有 root 可写。用户态执行cat /sys/devices/.../my_attr时,内核会调用my_attr_show把数据写入缓冲区并返回到用户态。明白了这个机制之后,调试驱动时你完全可以只通过读写 sysfs 属性来验证内核状态,而不用依赖打印日志。
属性文件在设计时要遵循一个原则:show函数必须快速返回,不能做耗时操作,否则用户态cat会被阻塞,甚至影响整个内核的文件系统响应。我见过有人在show里做 msleep 的,结果整个 shell 界面卡住。属性文件是给用户态查询的窗口,不是干活的地方。
4. 设备、驱动、总线的三角关系
4.1 总线:设备与驱动之间的“媒婆”
设备模型里最核心的三角关系是 bus、device、driver。总线不只是一个物理概念,在代码里它是一个struct bus_type对象,定义了设备和驱动如何匹配、如何枚举、如何热插拔。常见的platform_bus_type、pci_bus_type、usb_bus_type都是它的实例。
总线最重要的一个回调是match。内核每次向总线注册一个新设备或一个新驱动时,都会调用match,拿设备和驱动双方的 ID 表做比对。设备侧有struct device里的 ID 信息,驱动侧有struct driver里的 ID 表,两者匹配上了,总线就调用驱动的probe函数,正式让驱动接管设备。
拿 platform 总线举例,它是最常用的虚拟总线,几乎所有 SoC 内部集成的外设都挂在这里。platform 设备的匹配方式通常有三种:设备树 compatible 字符串、ACPI 表 ID、以及最传统的 platform 设备名。在嵌入式 Linux 的语境下,设备树匹配是最常见的方式。设备树里写compatible = "vendor,device-name",驱动侧在of_match_table里写下相同的字符串,总线 match 时就会命中。
4.2 从注册到 probe 的完整过程
一个典型的 U 盘插入场景可以完美展示设备模型的联动。USB 控制器检测到新设备后,usb_device结构被创建,并注册到 USB 总线。总线遍历已注册的驱动列表,调用match函数比对 USB 的 VID/PID。匹配成功后,调用驱动的probe。驱动的probe里通常做资源申请、初始化设备状态、注册字符设备等操作,然后设备进入正常工作状态。整个过程对用户态是透明的,用户态只能看到 udev 事件,随后系统里多出一个/dev/sdX节点。
probe函数是驱动开发者写得最多的地方。它接收的参数是struct platform_device *pdev,从中可以拿到资源、设备树属性、平台数据等。实际操作中,probe里常见的流程是:获取硬件资源、映射寄存器地址(ioremap)、申请中断、初始化私有数据、注册杂项设备或字符设备、创建 sysfs 属性。任何一个环节失败,都要把之前申请到的资源全部释放,并返回错误码。probe返回非零,内核会认为驱动初始化失败,设备将保持无人接管状态。
这里有个极其实用的排查经验:驱动probe函数如果没有被调用,九成是匹配没成功。很多人在设备树中改了 compatible 字符串,却没有同步修改驱动里的of_match_table,或者字符串里混入了空格,导致匹配静默失败。这些坑靠读日志很难发现,最好的方式是直接查看/sys/bus/platform/drivers下的驱动目录,看是否出现了绑定设备的符号链接。
4.3 class 的真正作用:为用户态提供稳定入口
设备模型里还有一个容易被轻视的概念:class。class 不关心设备挂在哪条总线上,它只关心“这个设备能干啥”。比如网卡设备,无论它走 PCI 还是 USB,用户态都希望从/sys/class/net/eth0这个路径访问。class 提供的正是这种稳定的功能视角。
内核驱动里常用的class_create和device_create配合,用于在用户态自动创建设备节点。老式的驱动需要在init_module里手动调用register_chrdev并固定一个主设备号,再配合 mknod 创建设备节点。使用设备模型之后,驱动只需要创建一个 class,然后在设备注册时调用device_create,内核就会自动在/dev下生成节点,无需用户干预。这套机制配合 udev 的使用,是现在所有主流驱动创建设备节点的标准做法。
class 的存在还有一层深意:它为上层应用提供了屏蔽硬件差异的抽象接口。应用程序不必知道设备具体挂在哪条总线、如何访问寄存器,只需要通过/sys/class/xxx下统一的接口操作即可。这种“按功能不按硬件”的视角,正是设备模型设计者最想达到的效果。
5. 实操:用最小模块理解设备模型
5.1 环境准备与最小实验思路
读设备模型的理论容易让人犯困,真正动手才有感觉。这里提供一个不需要完整内核开发环境也能观察设备模型的方法:用一台 Linux 机器(虚拟机即可),在 shell 里操作 sysfs 观察总线、设备、驱动的关系。如果你还希望写代码验证 kobject 的行为,则需要准备一个内核模块编译环境,内核源码加 build-essential 就够了。
我的建议是两条腿走路:先不做任何编程,纯用命令行在/sys里探索,把 bus、device、driver、class 之间呈现的链接关系观察一遍;然后再写一个小模块,在模块里注册一个 platform 设备并观察它在 sysfs 里产生的目录变化。这样理论、现象、代码三方面互相印证,知识才能真正落地。
5.2 注册一个简单 platform 驱动并观察 sysfs
下面用一个最简化的 platform 驱动示例来演示。这个模块注册一个虚拟平台驱动,并在 probe 时打印消息:
#include <linux/module.h> #include <linux/platform_device.h> static int demo_probe(struct platform_device *pdev) { pr_info("demo: probe called\n"); return 0; } static int demo_remove(struct platform_device *pdev) { pr_info("demo: remove called\n"); return 0; } static struct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo-platform", }, }; module_platform_driver(demo_driver); MODULE_LICENSE("GPL");编译并加载这个模块后,立刻在/sys/bus/platform/drivers/下会多出demo-platform目录。但此时还没有匹配的设备,所以 probe 不会执行。为了触发 probe,还需要在设备树里添加一个 compatible 匹配的节点,或者通过/sys/bus/platform/drivers/demo-platform/bind手动绑定一个已存在的 platform 设备。
手动绑定是新手最容易上手的验证方式。比如系统里有一个名为my_device的平台设备,只需要执行echo my_device > /sys/bus/platform/drivers/demo-platform/bind,内核就会匹配并调用 probe。快速测试驱动匹配逻辑时,这个操作比改设备树接着重启高效得多。
5.3 通过 bind/unbind 验证生命周期
bind和unbind是 sysfs 提供给用户态手动控制驱动和设备绑定关系的接口,这对学习和调试来说简直是神器。每个驱动目录下都有这两个文件,写驱动名就是绑定,卸载时执行echo my_device > .../unbind,驱动与设备解除绑定,remove回调被调用。
这套机制对理解设备模型特别有帮助。你可以观察绑定前后/sys/bus/platform/devices/my_device/driver这个符号链接的变化:绑定前不存在,绑定后指向驱动目录。它把设备和驱动程序之间的关联关系直接用文件系统符号链接呈现出来,所见即所得。
我强烈建议初学者做一个实验:随便选一个系统里的 platform 设备,先cat /sys/bus/platform/devices/xxx/uevent查看该设备的匹配信息,再手动 bind 到对应驱动,全程用dmesg看内核日志。做完这个实验,你对“总线的 match 驱动 probe”这句话的理解会完全不一样。
5.4 模块卸载与引用计数教训
模块卸载是实现中容易出问题的环节。初学者写设备模型相关模块时,常见错误是直接在exit函数里释放设备自己的内存。但实际上,设备的释放一定要交给内核的release回调,而不是模块自己。如果模块里用platform_device_alloc和platform_device_add创建设备,卸载时应当调用platform_device_unregister,而不是kfree。
引用计数的错误往往不会立刻暴露,而是在某个巧合时刻导致崩溃。我在调试中遇到过一个经典问题:驱动在 probe 里保存了dev指针到全局变量,模块卸载时没有put_device,后来另一个模块访问了这个全局指针,直接 crashed。排查了很久才发现是引用计数没有配对,地址已经被复用,指针指向了完全无关的内核对象。设备模型的生命周期管理最忌讳“短视”,所有保存出去的指针都应当用引用计数管住。
6. 学习设备模型的常见坑与排查技巧
6.1 kobject 与 device 的关系理解混乱
很多刚接触设备模型的开发者会把struct device和struct kobject混在一起,以为它们是两个不同的东西。实际上,struct device内嵌了struct kobject,两者的生命周期是一体的。device_register内部会调用kobject_init和kobject_add,device_unregister内部会调用kobject_put。你在代码里看到的device_initialize、device_add这些函数,最终都会走到 kobject 层面的操作。
理解这一层之后,很多问题的排查方向就清晰了。比如使用device_add失败时,内核会报出kobject_add_internal failed的日志,说明问题在对象插入设备模型树这一环节,常见原因是同名对象已经存在,或者父对象尚未注册。排查时优先检查设备和父设备的注册顺序,再检查 name 是否冲突。
6.2 probe 不调用如何排查
probe 不被调用,是驱动开发中最常遇到的问题。按下面的顺序排查,基本都能找到答案。先确认设备和驱动是否注册成功,分别查看/sys/bus/platform/devices/和/sys/bus/platform/drivers/下是否有对应目录。再检查匹配信息:查看设备的uevent文件里的 MODALIAS 字段,以及驱动的 modaliases,两者应该能对上。如果设备树匹配,重点检查 compatible 字符串是否完全一致,包括大小写和逗号后面有没有空格。
最后还有一种可能:设备已经被其他驱动绑定。每个设备同一时间只能绑定一个驱动,查看/sys/bus/platform/devices/xxx/driver符号链接,如果已经指向别的驱动,probe 自然不会再次调用。使用unbind解除旧驱动,再绑定新驱动即可。
6.3 sysfs 中看不到设备目录
注册了设备但 sysfs 里看不到,这个问题的原因多数在设备模型初始化环节。device_register要求设备必须有名字,如果没有设置dev_name(dev)或者 name 为空,kobject_add会失败,设备不会被挂入 sysfs。另一个常见原因是设备的父设备没有正确设置。devices 目录的层级依赖父对象,如果父对象不存在或没有注册,子设备无法挂接。代码里常见的设置方式是dev->parent = &some_parent_device->dev。
写驱动时,我建议在device_register前后都加一条日志打印设备名和父设备名。这些日志在排查目录缺失时非常有用。内核日志里如果出现kobject_add_internal failed for xxx with -EEXIST,则是名字冲突,确认系统里是否已经存在同名设备。
6.4 常用命令速查表
最后整理一份我平时排查设备模型问题时最常用的命令,直接照着用:
| 命令 | 用途 |
|---|---|
ls /sys/bus/platform/devices/ | 列出所有 platform 设备 |
ls /sys/bus/platform/drivers/ | 列出所有 platform 驱动 |
cat /sys/bus/platform/devices/xxx/uevent | 查看设备的匹配 ID 信息 |
cat /sys/bus/platform/devices/xxx/driver | 查看设备当前绑定的驱动 |
echo dev > /sys/bus/platform/drivers/xxx/bind | 手动绑定设备和驱动 |
echo dev > /sys/bus/platform/drivers/xxx/unbind | 手动解绑设备和驱动 |
dmesg | grep -i platform | 查看平台总线相关的内核日志 |
ls /sys/class/xxx/ | 按功能类查看设备 |
这套命令是在实际项目里反复验证过的,它能覆盖绝大多数设备模型相关的调试场景。最初看这些 sysfs 目录时可能会觉得信息太杂,但只要理解了设备、驱动、总线、类四个角色的职责,目录结构就是顺理成章的呈现。
我个人在学习设备模型时最大的体会是:不要在概念里打转,把大量时间花在/sys的观察上,再结合源码逐步对照,吸收速度会快很多。设备模型是一个非常典型的“先有实践,后有理论”的框架,它的设计目标从来都是解决真实问题。理解了它为什么存在,再去看每一个组件,所有的 API 和数据结构都变得顺理成章。这套框架是 Linux 设备驱动开发的基石,值得反复琢磨。下一篇笔记我打算继续深入设备模型的内核实现路径,把device_add和driver_probe_device这两个核心函数的完整调用链梳理出来。