毫不夸张地说,*《Linux设备模型详解》*这篇内核文章,是我进入内核世界以来后劲最大的一份资料。第一次读的时候,脑子里那些关于/sys、驱动匹配、设备树、热插拔的零散碎片,就像被一根线彻底串了起来。很多在平时开发中“能跑但说不清”的东西,在那篇文章里找到了底层逻辑。这里我把自己对 Linux 设备模型的完整理解、亲手验证过的代码路径、以及在驱动开发实战中踩过的坑,一次性梳理出来。无论你是刚接触内核的新人,还是已经在写驱动的老手,这篇内容都能给你一套“从抽象到落地”的完整认知。
1. 设备模型:理解 Linux 内核如何“管”设备的关键
1.1 为什么设备模型是内核的骨架
先抛一个很直接的问题:没有设备模型,Linux 能跑吗?能跑,但你会看到一个非常混乱的场面——每个驱动自己管理设备、自己维护列表、自己设计“找到设备”的约定,整个内核很快就会变成一个无法维护的巨型补丁集合。Linux 设备模型存在的意义,不是单纯为了“好看”,而是统一解决了三件大事:设备枚举、驱动与设备匹配、以及设备的生命周期管理。
你可以把设备模型想象成一座房子的水电布线系统。设备是每个房间里的电器,驱动是插座协议,而总线则是墙里那套标准的管线。没有统一管线,每一台电器都得自己拉电线,改造、维修、排查都会疯掉。内核里的总线、设备、驱动三者之间,正是通过这套“管线标准”相互配合,让数据能有序地从硬件寄存器流向用户空间。
从代码层面看,设备模型的核心文件分布在drivers/base/下,比如core.c、bus.c、dd.c、sysfs.c,以及include/linux/device.h、kobject.h这些头文件。整个模型以kobject为基础,向外延展出kset、kobj_type、attribute、bus_type、device、device_driver、class这一大串对象。你平时在/sys里看到的每个目录、每个文件,背后几乎都对应着一个 kobject 和一组属性操作函数。
1.2 设备模型的三层架构:设备、驱动、总线
在 Linux 设备模型里,最核心的三角关系就是bus、device、driver。
- bus是一条“匹配通道”。它定义了两件关键事:
match函数怎么判断某驱动能处理某设备;总线私有的数据结构和操作方式如何统一。 - device描述一个物理或虚拟设备的存在,包含资源信息(如中断号、寄存器地址、DMA 通道等),它的职责是“我在这里”。
- driver是驱动逻辑的载体,包含
probe和remove等核心回调,它的职责是“我能驱动谁”。
以最基础的 platform 总线为例,内核启动时会注册platform_bus_type。当你调用platform_driver_register()注册驱动,或者通过设备树、ACPI、platform_device_register()注册设备时,内核都会在总线上做一次“配对”。配对成功,就调用对应驱动的probe方法;配对失败,设备就一直挂在总线上等待驱动现身。
在drivers/base/dd.c中,driver_probe_device()这个函数就是配对执行的关键路径。它会先通过总线特有的match函数做匹配,匹配成功后再执行really_probe(),依次完成:设备初始化检查、驱动绑定、调用probe、建立 sysfs 关联关系。
1.3 设备模型到底解决了哪些痛点
没有设备模型的世界是什么样?我举个极端例子:假设一个嵌入式系统上有 3 个 SPI 控制器、8 个外设挂在 SPI 总线上,每个外设的驱动都要自己扫描总线、自己做 CS 片选管理、自己规定“我怎么知道我的设备在哪里”。一旦外设更换地址或中断号,驱动代码就要跟着大改。
有了设备模型之后,事情变成:总线层统一扫描设备树或 ACPI 表,生成struct device对象;驱动只需要声明自己支持哪些compatible字符串,剩下的匹配、绑定、解绑、电源管理协作,全部由模型完成。而且设备模型天然支持延迟探测、热插拔、电源域管理、DMA 与 IOMMU 约束,这些在现代 SoC 上几乎是不可或缺的能力。
实际开发里,设备模型带来的最大红利,是驱动代码和硬件描述彻底解耦。一个驱动可以不加修改,在不同板卡上通过设备树适配不同资源;一个 SoC 上的多个复用功能,也能通过pinctrl、中断域等框架有机整合进模型里。这一点在跑“嵌入式内核源码”项目时感受特别深——没有设备模型,任何一个板级改动都是灾难。
2. 核心数据结构拆解:kobject、kset、bus/device/driver、class
2.1 kobject:设备模型的最基本“细胞”
kobject 是整个设备模型的地基。虽然你在写绝大多数驱动时并不会直接操作struct kobject,但每个device、driver、class内部,都内嵌了一个 kobject 实例。
struct kobject { const char *name; struct list_head entry; struct kobject *parent; struct kset *kset; struct kobj_type *ktype; struct sysfs_dirent *sd; struct kref kref; ... };注意kref这个成员,它是一个引用计数器。kobject 的核心价值就是通过引用计数管理生命周期:kobject_init()初始化,kobject_add()把对象关联到设备模型树,kobject_get()/kobject_put()增减引用。当引用计数归零时,触发ktype->release()回调,释放对象占用的内存。
这里有一个非常关键的编程习惯:谁拿到了对象的引用,谁就必须负责释放。如果你在驱动里保存了一个device *指针,却没有用get_device()增加引用,那么设备一旦意外移除,你手里的指针就变成了悬空指针。这种 bug 在设备模型里极其隐蔽,且非常难排查。
2.2 kset 与 kobj_type:把对象组织成“类”并按树管理
kset可以被理解成一组同类型 kobject 的集合,它本身也是一个 kobject,内部维护了一个链表。当你调用kobject_add()时,kobject 会根据parent指针挂到某个目录下,同时加入所属kset的链表。在 sysfs 层面,kset 往往对应一个目录;在逻辑层面,kset 提供了一种“遍历同类对象”的途径。
kobj_type则定义了某类 kobject 的通用行为,最重要的是三个成员:
struct kobj_type { void (*release)(struct kobject *kobj); const struct sysfs_ops *sysfs_ops; const struct attribute_group **default_groups; ... };release:对象释放时的回调,必须实现。sysfs_ops:定义show/store方法,用于读写属性文件。default_groups:默认创建的属性组。
还记得DEVICE_ATTR_RO这类宏吗?它最终会生成一个struct device_attribute,读写函数分别是show和store。当你在/sys/devices/...下面 echo 或 cat 一个属性文件时,内核走的就是kobj_type->sysfs_ops->show/store,再转发到具体属性的回调。这样一层层函数指针转发,就是设备模型“灵活到让人找不着北”的原因。
2.3 bus、device、driver 三者的配对逻辑
bus_type是设备模型中连接设备和驱动的中介。内核里的struct bus_type包含了很多回调:
struct bus_type { const char *name; int (*match)(struct device *dev, struct device_driver *drv); int (*probe)(struct device *dev); void (*sync_state)(struct device *dev); int (*uevent)(struct device *dev, struct kobj_uevent_env *env); ... };match是总线上最重要的一环。platform 总线的匹配逻辑遍历了设备树compatible、dev_name、ACPI等多个条件,任一满足就算匹配。probe是总线级别的探测定义,通常会被模型的really_probe()间接调用,然后实际执行驱动自己的drv->probe。
device_driver则包含驱动名、所属总线、probe/remove 回调、of_match_table(设备树匹配表)、pm_ops(电源管理操作)等。真正写驱动时,你一般填of_match_table和probe就足够;更多逻辑往往在次设备驱动中体现。
匹配成功后,模型会把device与driver通过dev->driver指针互相绑定,is_driver标记被置位,然后在 sysfs 里建立driver符号链接,同时调用驱动的probe方法。如果probe失败,绑定关系会被回滚,设备继续留在总线上等待下一次机会。
2.4 class:面向用户空间的“语义视图”
class 解决的问题是:用户空间不关心设备挂在哪个总线上,只关心它是什么类型——是块设备、网络设备,还是输入设备、LED 设备。为此,内核在设备模型之上又建立了一层 class 视图。
struct class { const char *name; struct module *owner; const struct attribute_group **class_groups; int (*dev_uevent)(struct device *dev, struct kobj_uevent_env *env); ... };以写一个简单的虚拟字符类设备为例,你经常会做:
cls = class_create("my_demo_class"); device_create(cls, parent, devno, NULL, "demo_dev%d", minor);device_create()会创建一个struct device设备,同时把它接入cls类目录之下。于是你在/sys/class/my_demo_class/下能看到demo_dev0、demo_dev1这样的符号链接,指向真正的设备目录。
class 的另一个关键价值是热插拔环境变量与属性组。很多用户态程序通过 udev 规则监听class下的设备节点,靠的就是uevent里带出的MAJOR、MINOR等环境变量。
2.5 attribute:在 sysfs 中呈现状态的开关
属性文件是设备模型与用户态交互的主要手段。在 sysfs 中,一个属性文件就是一个普通的文件节点,读它对应 show 回调,写它对应 store 回调。属性分三类:
- 设备属性:
DEVICE_ATTR_RO/RO/WO,挂在设备目录下。 - 驱动属性:
DRIVER_ATTR_*,挂在驱动目录下。 - 总线属性:
BUS_ATTR_*,挂在总线目录下。
属性组则通过attribute_group批量管理:
static struct attribute *demo_attrs[] = { &dev_attr_state.attr, &dev_attr_echo.attr, NULL, }; static const struct attribute_group demo_group = { .name = "demo_info", .attrs = demo_attrs, };之后在probe中调用sysfs_create_group(&dev->dev.kobj, &demo_group),就能在/sys/devices/platform/demo_dev/下创建demo_info/子目录。这是非常规整的一种扩展方式,也是很多正规驱动管理 sysfs 文件的标准姿势。
3. sysfs 与 uevent:设备模型如何与用户空间交互
3.1 sysfs 属性文件的读写机制
sysfs 是一个基于内存的文件系统,挂载于/sys。它不做块设备式的缓存,而是所有读写直接走到内核的回调函数里。这一点决定了它的两个重要特性:一是操作非常轻量,二是读写过程中拿锁、做复杂操作都要格外小心。
一个典型属性文件展示如下:
static ssize_t state_show(struct device *dev, struct device_attribute *attr, char *buf) { return sysfs_emit(buf, "state: %s\n", "ok"); } static ssize_t echo_store(struct device *dev, struct device_attribute *attr, const char *buf, size_t count) { if (!strncmp(buf, "on", 2)) device_set_state(dev, 1); else device_set_state(dev, 0); return count; } static DEVICE_ATTR_RO(state); static DEVICE_ATTR_WO(echo);注意,show回调里推荐使用sysfs_emit()而不是sprintf()。sysfs_emit()会自动限制写入长度,避免越界,这是内核社区后来强推的写法。store回调里则必须严格检查用户输入长度与内容,别把用户态数据直接当可信参数使用。
3.2 uevent 与内核热插拔事件
设备模型的热插拔机制,本质上就是“内核主动对外发声”。当设备添加、移除或属性发生变化时,内核会调用kobject_uevent()发出 KOBJ_ADD、KOBJ_REMOVE、KOBJ_CHANGE 事件。这些事件会被uevent用户态程序(如 udev/systemd-udevd)接收,再执行一系列规则:创建设备节点、加载固件、设置权限等。
如果你自己设计设备驱动,想额外给用户态递环境变量,可以覆写总线或 class 的uevent回调:
static int demo_uevent(struct device *dev, struct kobj_uevent_env *env) { add_uevent_var(env, "DEMO_EVENT_TYPE=version"); add_uevent_var(env, "DEMO_FW_VERSION=%d", fw_ver); return 0; }这里有个非常实用的工具:udevadm monitor。在调试热插拔问题时,它的输出会直接按时间顺序展示 uevent 的完整链路,帮你判断事件到底有没有发出来、环境变量是否符合预期。我调内核 + 用户态协同问题时,几乎必开这个命令。
3.3 读 /sys 的实践:命令与路径解释
我建议你准备一个 5.x 内核的 Linux 机器,一边对着/sys目录一边读代码,印象会非常深。几个必看的路径:
| 路径 | 含义 |
|---|---|
/sys/bus/platform/devices/ | 所有 platform 设备 |
/sys/bus/platform/drivers/ | 所有 platform 驱动 |
/sys/devices/platform/ | 设备树的默认平铺设备目录 |
/sys/class/ | 面向设备类型的分类视图 |
/sys/module/ | 已加载内核模块及其参数 |
/sys/kernel/uevent_seqnum | 全局 uevent 序号 |
举个例子,你注册了一个叫demo_dev的 platform 设备,驱动加载成功后,/sys/bus/platform/devices/demo_dev这个符号链接会指向/sys/devices/platform/demo_dev。如果你看到链接存在,但driver子链接不存在,说明驱动还没匹配上或 probe 失败;只有驱动绑定成功后,driver -> ../../bus/platform/drivers/demo_drv这个链接才会出现。这就是设备模型挂在“文件系统树”上的直观证据。
4. 实操:写一个最小 platform 设备驱动,把设备模型“跑起来”
4.1 平台总线为什么是嵌入式 Linux 的第一站
多数嵌入式开发者接触的第一个框架就是 platform 总线。它不依赖具体硬件总线协议,而是用一种“伪总线”的方式,把零散在 SoC 上的外围设备统一管理起来。设备既可以来自设备树,也可以像老式板卡那样手动注册。正因为资源可以手动指定,platform 设备非常适合用来练手设备模型。
我先给一个无设备树、纯动态注册的最小例子,省掉设备树配置,直接在内核模块里注册设备和驱动,效果一样能看,流程更透明。实际项目里,设备侧一般会在板级文件或设备树里描述,但“配对与 probe”的机制完全相同。
4.2 小驱动代码分段解析
下面是完整可编译的模块示例。为省篇幅,我把头文件和模块信息去掉一部分核心逻辑,保留主要结构。
#include <linux/init.h> #include <linux/module.h> #include <linux/platform_device.h> #include <linux/device.h> #include <linux/uaccess.h> #define DEMO_DEV_NAME "demo_dev" static u32 demo_counter; /* 设备移除时的回调:必须存在,避免释放报错 */ static void demo_dev_release(struct device *dev) { pr_info("demo device released\n"); } /* 设备对象:动态注册用。真实项目通常放设备树里描述 */ static struct platform_device demo_pdev = { .name = DEMO_DEV_NAME, .id = 0, .dev = { .release = demo_dev_release, }, }; /* sysfs 属性:读状态 */ static ssize_t counter_show(struct device *dev, struct device_attribute *attr, char *buf) { return sysfs_emit(buf, "%u\n", demo_counter); } /* sysfs 属性:写计数值 */ static ssize_t counter_store(struct device *dev, struct device_attribute *attr, const char *buf, size_t count) { u32 val; if (kstrtou32(buf, 0, &val)) return -EINVAL; demo_counter = val; return count; } static DEVICE_ATTR_RO(counter); static DEVICE_ATTR_WO(counter); static struct attribute *demo_attrs[] = { &dev_attr_counter.attr, NULL, }; static const struct attribute_group demo_group = { .name = "status", .attrs = demo_attrs, }; /* 驱动核心:probe */ static int demo_probe(struct platform_device *pdev) { int ret; ret = sysfs_create_group(&pdev->dev.kobj, &demo_group); if (ret) return ret; demo_counter = 0; dev_info(&pdev->dev, "probe ok\n"); return 0; } /* 驱动移除 */ static int demo_remove(struct platform_device *pdev) { sysfs_remove_group(&pdev->dev.kobj, &demo_group); dev_info(&pdev->dev, "remove ok\n"); return 0; } static struct platform_driver demo_drv = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = DEMO_DEV_NAME, }, }; static int __init demo_init(void) { int ret; ret = platform_device_register(&demo_pdev); if (ret) return ret; ret = platform_driver_register(&demo_drv); if (ret) { platform_device_unregister(&demo_pdev); return ret; } pr_info("demo module loaded\n"); return 0; } static void __exit demo_exit(void) { platform_driver_unregister(&demo_drv); platform_device_unregister(&demo_pdev); pr_info("demo module unloaded\n"); } module_init(demo_init); module_exit(demo_exit); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("Device model demo driver");这段代码表面简单,但几个点值得细讲。
第一,demo_dev_release()绝不能留空。struct device的release回调是设备生命周期最后关头的清理入口。如果你把它设成 NULL,设备释放时内核会直接打印 “Device 'xxx' does not have a release() function”,甚至触发 BUG,因为内核认为你在搞内存泄漏。
第二,模块加载顺序有讲究。我先注册设备再注册驱动,是为了演示先有设备、后有驱动也能配对成功。如果反过来,platform_driver_register时设备还没出现,驱动会挂到总线上等待;之后设备注册时再触发匹配。这种“被动等待”机制正是设备模型热插拔思想的体现。
第三,platform_device_register会立即把设备挂入 platform 总线,并触发一次设备模型的事件通知。此时如果驱动尚未注册,设备会稳态地挂在总线上;驱动注册时,总线会遍历所有未绑定设备,执行匹配。
4.3 编译加载与在 /sys 下观测
编译这个模块,核心 Makefile 如下:
obj-m += demo_dev.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean建议在 5.10+ 内核环境编译。实测时,我通常直接用 QEMU 起一个最小内核镜像,加载驱动后跑到/sys底下验证目录结构。
加载后依次执行:
insmod demo_dev.ko ls -l /sys/bus/platform/devices/demo_dev ls -l /sys/bus/platform/devices/demo_dev/driver cat /sys/bus/platform/devices/demo_dev/status/counter echo 42 > /sys/bus/platform/devices/demo_dev/status/counter cat /sys/bus/platform/devices/demo_dev/status/counter rmmod demo_dev如果你看到driver链接正常指向.../platform/drivers/demo_dev,说明probe成功,驱动与设备已经绑定。写入 42 再读回 42,则证明属性文件的 store/show 环形链路是通的。
rmmod时它会先调用demo_remove删掉属性组,再解绑设备,最后platform_device_unregister释放设备对象。整个顺序如果乱了,你会在 dmesg 里看到各种“未知”或“use-after-free”的信息。这块我后面专门写了一段排查心得。
4.4 属性文件与用户态交互扩展
真实项目中,属性文件不光读写简单值,还可能涉及二进制数据、多参数命令、超大缓冲等场景。此时推荐使用bin_attribute或者在 store 回调里做命令解析。一个通用经验是:别把 store 回调写成“一个文件对应一个功能”的死板模式。如果这个设备未来要支持多种指令,不如统一设计成一个cmd属性,输入格式为cmd arg1 arg2,内部用sscanf或match_token解析。这样 sysfs 目录不会被无限扩张,用户态脚本也更灵活。
当然,设计属性文件时也得克制。sysfs 目录和文件的布局一旦固化,就意味着 ABI 稳定。你不能明天就把status/counter改成status/cnt,那会直接破坏用户态工具。内核社区对 sysfs ABI 有严格审查,宁可多设计一层子目录,也不要随意抛弃旧节点。
5. 常见问题与排查心得
5.1 设备与驱动不匹配
现象:驱动加载没有任何报错,probe 没有被调用,/sys/bus/platform/devices/demo_dev/driver链接不存在。
排查顺序:
- 确认
driver.name与设备platform_device.name是否完全一致。名字匹配是 platform 总线的基础匹配规则,大小写、后缀都不行。 - 如果走设备树,确认
of_match_table有定义,并且设备树节点compatible字符串一致。 - 查看
dmesg中是否出现platform demo_dev: Driver demo_dev requests probe deferral这样的信息。如果有,说明依赖的资源还没就绪,内核把 probe 延后了。你需要确认依赖驱动(如时钟、中断控制器、regulator)是否加载完成。 - 用
ls /sys/bus/platform/drivers/看驱动列表是否注册成功。如果驱动目录不存在,说明driver_register阶段就失败了,问题可能在probe之前。
我在实际调试中,遇到最多的情况其实是设备树 compatible 与驱动表不匹配。比如驱动里写了"vendor,device-rev2",但设备树节点里是"vendor,device-rev1",看似相近,匹配不上。这种问题只能用compatible = "vendor,device-rev2", "vendor,device-rev1";这一招列出所有兼容名,或者反向调整设备树,才能解决。
5.2 属性文件读写异常与权限问题
现象:cat /sys/...返回Operation not permitted,或写入后值不生效。
原因通常有三个:
- 权限位不对。
DEVICE_ATTR_RO生成的 mode 是 0444,DEVICE_ATTR_WO是 0220,如果用户态进程没有对应权限,自然无法读写。 store回调里没有校验输入长度和值域。比如kstrtou32解析失败却不返回错误码,驱动可能忽略了非法输入。此时返回-EINVAL才是正道,用户态才会收到明确的Invalid argument。- 回调函数里 sleep 或者调用会睡眠的函数,恰好又处于中断上下文或被锁保护。sysfs 的属性回调本身是进程上下文,可以睡眠,但如果你的读函数里还拿了一个自旋锁,就可能在临界区里睡死,导致系统 hang 或者读写卡死。建议保持属性回调短小、快速、可重入。
5.3 生命周期管理与释放问题
现象:rmmod时内核报kernel BUG at kernel/kobject.c:xxx,或者 dmesg 里有Object ... is being deleted, still has ... references。
这类问题十有八九出在引用计数不平衡上。设备模型里,device_register成功后,设备就获得了初始引用计数。如果你额外保存了指针,就必须get_device;用完必须put_device。没有这个习惯,等设备注销时,引用计数没能归零,release不会被调用,资源就永久泄漏。
还有一种是release 回调为空。我在第一节代码里特意写了demo_dev_release,就是因为平台设备的struct device在释放时,内核会强制调用该回调去释放内存。很多从网上下载的示例驱动漏掉这一步,一旦卸载模块就会触发猛烈的 backtrace。这是新手最常踩的坑。
针对生命周期,我强烈推荐开启内核的调试选项:CONFIG_DEBUG_OBJECTS、CONFIG_DEBUG_KOBJECT_RELEASE。开启后,内核会输出更详细的引用计数调试信息,能够精准定位是哪个路径多拿引用、哪个路径少释放。我自己排查一个 USB 转串口驱动的卸载崩溃时,就是靠CONFIG_DEBUG_OBJECTS里的一行“未释放对象所属模块”的提示,锁定了问题模块。
5.4 设备树与 platform_device 的冲突
在这个小 demo 里我们手动注册了platform_device。真实项目中,设备树已经描述了大量设备,如果你又用模块代码注册同名设备,就会出现EEXIST或者资源冲突。一个很丑但常见的现场:设备树里已经有一个demo_dev节点,驱动又动态注册同名的 platform device,结果 sysfs 里出现两个相同名字的目录或者其中一个注册失败。
这种场景我给出的建议是:设备树能描述的资源,就不要在 C 代码里再次注册。设备树存在的目的,恰恰是让设备描述与驱动代码分离。而驱动侧,你只该做platform_driver_register,然后通过dev->platform_data或者pdev->dev.of_node读取资源即可。
5.5 模块卸载时 sysfs 目录残留
有时候你会遇到sysfs: cannot create duplicate filename '/sys/bus/platform/drivers/demo_dev'之类的报错。这通常说明上一次模块卸载时没有删除系统自动创建的驱动 kobject 目录,或者driver_register时目录已经存在。排查方向是:确认driver_unregister是否真的被调用,模块退出函数是否完整。如果用了platform_driver_unregister,内核会负责清理驱动 kobject 和属性。如果你手动调用driver_unregister又单独释放驱动结构内的 kobject,就会造成二次释放,反而更容易出问题。
这里分享一个通用脚本习惯:调试模块时,加载前先ls /sys/bus/platform/drivers/ | grep xxx,卸载后再查一次目录,确认 kobject 目录确实消失。如果目录还在,说明模块清理逻辑有缺陷。这个方法非常原始,但比看任何静态分析都直接。
写在最后的一个实操习惯
设备模型源码初看又大又绕,但只要你建立“kobject 是节点、attribute 是文件、bus/device/driver 是主力、class 是视图”的框架,再赶紧上手写一个最小的 platform 驱动,把/sys目录一点点翻透,它就再也不是黑盒了。我自己看这篇内核文章最有收获的一点,正是它引导我从“会用驱动框架”走向“理解框架为什么这么设计”,所以强烈建议你手里常备一个iterate_over_sysfs的小脚本,随时随地观察设备树和 sysfs 目录的对应关系。多看、多写、多崩几次,设备的生命周期和匹配原则迟早会长在你脑子里。