毫不夸张地说*《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、class2.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_TYPEversion); 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 是 0444DEVICE_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 目录的对应关系。多看、多写、多崩几次设备的生命周期和匹配原则迟早会长在你脑子里。