1. 设备模型到底在解决什么问题很多刚开始碰 Linux 驱动的人都会被一套看似冗长的机制绕晕又是 kobject 又是 kset又是 device 又是 driver还有注册、匹配、probe 这些流程。说白了设备模型并不是单纯给驱动开发人员增加概念负担它是在操作系统层面做了一次设备信息的统一管理与优雅解耦。如果你不理解它要解决的问题后面看任何总线、平台设备、设备树都会觉得散成一团。我最早接触设备模型是因为一个实际的困惑写一个简单的字符设备驱动时代码里既要register_chrdev又要自己创建/sys/class下面的目录和属性文件还要手动维护设备号分配。后来发现内核里有一批现成的 API比如device_create、class_create只要调一下/dev节点、/sys里的目录、设备号管理全都自动办好。当时我就想背后肯定有一套统一的框架在干活那就是设备模型。这套模型要解决的核心问题概括起来是三个统一设备生命周期内核里挂着成百上千的设备有的开机就在有的热插拔随时消失。每个设备从创建、注册、绑定驱动、正常运行到注销释放必须有统一的状态管理否则光靠驱动各自写早乱套了。设备与驱动的解耦与自动匹配设备是硬件实体驱动是软件逻辑。它们不应该写死耦合在一起否则换一块板卡就要改驱动代码。设备模型提供一套匹配机制让驱动能够根据设备 ID、兼容性字符串、设备树节点等条件自动找到对应的设备。向用户态提供统一视图/sys文件系统就是设备模型的对外窗口。你在/sys里看到的目录层级基本就是内核里设备对象组织结构的镜像。有了这套统一视图应用层工具如 udev、systemd才能实现对设备的动态管理。理解了这个动机再去读代码你就会发现所有结构体字段和 API 设计其实都围绕这三个目标展开没有一处是多余的。以我自己的学习经验最好上手的方式不是直接扑进.c文件里漫无目的地追结构体而是先“用”设备模型再从/sys目录反推内核结构。这样落地感极强效率比拿着源码从头看高很多。2. 从 /sys 目录入手建立第一手感性认识打开一个 Linux 系统进入/sys你会看到一堆子目录。很多人第一次进来是懵的不知道这些目录之间什么关系。我先给一个“地图”路径内容含义/sys/devices系统所有设备按真实拓扑连接的树状排列这是设备模型的“根视图”真实反映设备挂载层级/sys/class按设备功能分类的视图如 net、block、gpio、input这是“功能视图”同类设备聚合看/sys/bus按总线类型分类的视图如 pci、spi、i2c、platform设备与驱动通过总线进行匹配的场所/sys/block所有块设备的符号链接块设备专属的功能视图/sys/module已加载内核模块的信息每个模块的驱动与参数信息一开始我不建议去读内核源码先在自己的板子或者虚拟机上多敲几个命令# 查看一个具体设备在 /sys 下的完整路径 udevadm info --queryall --name/dev/mmcblk0 # 直接查看设备树路径下的 uevent 信息 cat /sys/devices/platform/soc/xxx.uart/uevent # 查看某驱动的绑定状态 ls -l /sys/bus/platform/drivers/我最开始就是靠cat /sys/.../uevent来认识设备模型的。这个文件非常有用它导出了设备的名称、对应的驱动、设备号等关键属性。你去看一个块设备目录下的 uevent会看到类似MAJOR8、MINOR0、DEVNAMEmmcblk0这样的信息。这些信息说白了就是设备对象内部属性的“快照”通过 sysfs 暴露给了用户空间。设备模型的底层节点对象是kobject几乎每个设备、驱动、总线目录背后都会有一个 kobject 在支撑。sysfs 里的每个目录就对应一个 kobject你能看到的每个属性文件都是这个对象的 attribute 被映射出来的。Kobject 是设备模型的最小骨架没有它sysfs 目录撑不起来。用ls -l看/sys/class/...下面的条目你还会发现大量符号链接指向/sys/devices/...。这就是前面说的“两个视图”/sys/devices保存真实拓扑/sys/class是以功能分类的软链接集合两者指向同一个内核对象。理解了这点你在调试设备节点异常时就能顺着/sys/class的软链接一路追踪到真正的物理挂载路径。在这个阶段我强烈建议办齐“三板斧”命令行udevadm info、ls -l和cat .../uevent。别急着写代码先把系统和设备树的关系摸熟。3. 揭开 kobject / kset 的底牌内核文档里把 kobject 称为设备模型的核心结构其实小看它了它更像设备的“祖先类对象”。很多初学者看结构体定义只见一两个字段完全意识不到它在忙什么。我拆开给你看。struct kobject { const char *name; // sysfs 里的目录名 struct list_head entry; // 挂在 kset 的双向链表上 struct kobject *parent; // 父对象构成 sysfs 层级 struct kset *kset; // 归属于哪个 kset struct kobj_type *ktype; // 对象的类型属性操作集合 struct kernfs_node *sd; // sysfs 在内核里对应的内置节点 struct kref kref; // 引用计数负责生命周期管理 unsigned int state_initialized:1; unsigned int state_in_sysfs:1; unsigned int state_add_uevent_sent:1; unsigned int state_remove_uevent_sent:1; unsigned int uevent_suppress:1; };用生活化的类比来理解kobject 相当于每个设备对象的“身份证 户口本”。身份证字段包括 name名字和 parent归属户口本记录了它在哪个 kset 集合里kref是引用计数用于确认对象何时可以被安全销毁ktype则是描述“这类对象有哪些公共操作”的类型信息。具体来说ktype几乎决定了 kobject 的行为。在实际代码中每个驱动都会给自己的设备对象定义一个kobj_type里面最重要的就是release回调。这个回调负责在引用计数归零时释放对象内存。为什么内核要强调每个 kobject 必须实现 release因为大部分 kobject 其实不是孤立的结构体而是内嵌在更加庞大的数据结构里像struct device如果找不到正确的释放函数随意释放整个结构体后果就是内核直接 panic。kset 就更好理解了它是一组同类型或同归属的 kobject 集合。kset 本身也是一个 kobject有名字、有所属层级。它在设备模型里承担“分组”和“事件广播”的职责。内核对象加入 kset 后kset 能向其关联的用户态通过 uevent广播事件。这一步虽然对驱动平时看起来透明但它正是 udev 自动创建/dev节点的信号来源。你如果去看device_initialize()的实现会看到它本质上就是在初始化一个 kobjectdevice_add()的本质之一也包含把 kobject 挂到 sysfs 树里并发布 uevent。理解这个层级后你再去读驱动代码就会发现很多外层 API 都在做 kobject 相关的事情。别被 API 名字带偏底层的东西都绕不开它。还有一点我在实际调试中感受很深kobject 的名字name不是随便取的它直接决定你在/sys里看到什么目录名。例如平台设备注册时用的名字是pl061你在/sys/devices/platform/pl061就能看到对应目录。如果你在驱动里初始化 kobject 的名字取重复了或者取了带/的非法字符注册时会直接报错。这一点在调试时非常容易成为暗坑。4. device / bus / driver 三者的配合与匹配流程当你把 kobject 搞明白过后就可以进入设备模型的真正主场景device、bus、driver 三个核心对象如何在 bus 的调度下完成匹配与绑定。这是驱动从注册到probe回调被执行的全过程。4.1 三个结构体各管什么struct device代表一个物理或虚拟设备它包含资源如中断号、寄存器地址、电源管理字段、厂商 ID 等。struct device_driver代表针对某一类设备的驱动软件它里面最重要的是probe和remove回调。struct bus_type则是连接设备和驱动的“调度台”它不关心设备具体做什么只负责维护两张链表设备链表和驱动链表并提供匹配算法。关系图用文字描述就是物理设备在总线上驱动也在总线上总线负责让二者牵手牵手成功后就执行驱动的probe函数。这三个结构体在内核源码里分别有完整的定义和操作函数深入学习时从include/linux/device.h下手最方便。4.2 注册流程谁先来谁后到系统启动时总线和驱动的顺序有讲究通常是总线先注册因为设备和驱动都要依赖总线来匹配。驱动注册时会执行driver_register()这个函数干两件事把驱动对象挂到总线的驱动链表上然后调用总线提供的扫描函数遍历总线上已经存在的设备逐一尝试匹配。设备注册时则调用device_add()它会做更多事情将设备挂到总线的设备链表、在/sys中创建设备路径、向用户空间发送 uevent 事件。发送 uevent 的这一步非常关键它是热插拔机制的核心用户态的 udev 收到 uevent 事件后会根据其中的属性信息加载驱动模块或创建节点。整个注册流程可以简单概括为总线先行驱动或设备随后加入总线负责撮合。撮合成功后probe被调用失败则等待后续事件比如驱动后加载时会重新触发一次匹配。4.3 匹配算法bus_type-match 的艺术bus_type结构体里的match函数指针是所有逻辑的核心。不同总线的 match 实现各不相同这也解释了为什么 PCI 设备和平台设备在设备树匹配时的行为有差异。举个例子platform_bus_match()的匹配顺序大概是优先比较驱动 ID 表of_match_table或id_table接着比较驱动名与设备名最后比较设备树 compatible 字符串。而 PCI 总线的匹配则主要是比较 vendor ID 和设备 ID。你明白这一点后在添加新硬件支持时就知道该改id_table还是compatible字符串而不是盲目拿设备树瞎试。掌握了匹配优先级你对“为什么我在设备树里加了节点驱动却不 probe”这类问题排查会快很多。4.4 probe 干了什么匹配成功后内核调用驱动的probe回调。这个地方是驱动自己写的“激活代码”映射 IO 地址、注册中断、初始化子设备、创建接口并暴露到用户态。需要注意的是probe不是在原子上下文里执行的可以睡可以分配内存所以代码写起来比较自由。我调试驱动时的经验是如果probe函数返回错误码驱动绑定就失败内核会回滚之前做的部分工作。很多初学者喜欢在probe里一路往下写结果失败了也不知道哪一步出错。我的做法是在probe的每个可能失败点都加上串口日志并返回带负值的错误码比如-ENOMEM、-EINVAL这样 dmesg 里就能精确定位到失败的具体环节。5. 用热插拔事件搞懂 uevent 与用户态协同很多人调设备模型时最想不通的一个问题是为什么设备一接上系统就像“通了灵”一样自动创建/dev节点、自动加载驱动背后的“通灵”机制就是 uevent 事件。当你在内核里调用device_add()或device_del()时设备子系统会向用户空间发送一个 uevent。这个事件本质是内核通过 netlink具体来说是 NETLINK_KOBJECT_UEVENT发送的字符串消息里面包含事件类型add、remove、change 等以及设备的关键属性。用户态的 udev 守护进程监听这个消息再根据其规则去加载驱动、创建节点或者切换权限。来看一个实际事件的内容可以通过工具监听# 监听内核 uevent 事件 udevadm monitor --kernel --property --subsystem-matchblock然后插入一个 USB 存储设备你会看到类似这样的消息KERNEL[12345.123456] add /devices/pci0000:00/.../usb1/1-1/1-1.2/1-1.2:1.0/host4/target4:0:0/4:0:0:0/block/sda (block) ACTIONadd DEVNAMEsda DEVTYPEdisk MAJOR8 MINOR0 SUBSYSTEMblock看到没有uvent 里直接携带了主设备号、次设备号、子系统名等关键信息。用户态根据这些信息就能确定该创建设备节点并决定它的权限和归属。如果把 udev 比作一个“管家”那 uevent 就是设备在内核里的一声“报到”。这段机制对驱动开发者很有用当你在用户态写自动化脚本时可以直接基于这些 uevent 属性写 udev 规则比如指定ACTIONadd, SUBSYSTEMtty, RUN/your/script.sh在特定设备插入时自动执行任务。这是非常实用的嵌入式开发手法。反过来在调试时如果发现设备插上了但/dev里没有节点排查思路就要先分清是内核层面没发 uevent还是用户态 udev 没处理还是处理了但创建失败。看udevadm monitor是最直观的定位手段。6. 平台设备与静态设备设备模型在嵌入式中的特殊一面前面讲了设备模型的一般架构接下来看它在嵌入式场景中最常见的实例平台设备platform device与设备树device tree的结合。这一部分我单独写一节因为它跟普通 PC 上看到的 PCI/USB 设备模型行为差异很大也是很多嵌入式新手卡壳的地方。6.1 平台设备的本质平台设备platform device本质上是一种“没有硬件总线”的设备模型抽象。SoC 里面的很多外设控制器如串口、I2C 控制器、GPIO 控制器并不是挂在可枚举的标准总线上但它们依然需要遵循设备模型于是内核提供了一个虚拟缓冲区总线叫“platform 总线”。所有没有物理总线的设备都挂在这条虚拟总线上通过 platform_driver 与 platform_device 匹配。你可以把它理解成一种“临时代办处”没有专属组织的设备统一挂靠在这里由 platform 总线负责调度。6.2 设备树如何提供设备信息嵌入式 Linux 下平台设备和硬件的表达方式通常是设备树DT。设备树里每个节点对应一个设备节点里的compatible属性是匹配驱动的关键字符串。比如uart0: serial10000000 { compatible myvendor,uart-v1; reg 0x10000000 0x1000; interrupts 32; };内核启动时解析设备树把它变成一组 platform_device 对象并注册到 platform 总线上。驱动侧则声明一个of_device_id表static const struct of_device_id my_uart_of_match[] { { .compatible myvendor,uart-v1 }, {} }; MODULE_DEVICE_TABLE(of, my_uart_of_match);platform 总线的匹配逻辑会在设备树节点里找到 compatible 为myvendor,uart-v1的设备节点再执行驱动 probe。这就是现代嵌入式设备模型运作的方式——不再在板级文件里一个个硬编码 platform_device而是通过设备树统一描述。6.3 注册时机的坑在平台设备模型里一个最常踩的坑是“设备和驱动谁先注册”的问题。如果驱动先注册后扫描设备它能在总线上找到设备并 probe如果设备先注册驱动后续加入时也会触发匹配。看起来两种顺序都能行但实际上在设备树环境下平台设备是在内核启动早期由of_platform_default_populate_init()统一注册的。如果你的驱动声明为模块CONFIG_xxx_MODULE但不支持异步加载那么可能因为模块加载时机太晚错过了设备树设备注册的窗口。我实际遇到过一次驱动编译成模块设备树节点正确compatible也匹配但启动后 dmesg 就是看不到 probe 调用。后来发现是模块加载前设备已经完成注册但找不到驱动系统没有再次触发匹配的机制。解决办法是使用late_initcall或module_init时机不对时改为subsys_initcall或者把驱动编入内核而不是模块。这类问题排查起来非常费神但理解了注册流程和匹配发起时机后就有明确方向了。6.4 platform 设备里的资源获取在 probe 里平台设备拿资源通常通过platform_get_resource()和devm_platform_ioremap_resource()这两个函数。背后的逻辑是内核从设备树节点的reg、interrupts等属性生成struct resource驱动按索引获取。写错索引是新手常犯的错比如第一个内存资源是 0但你写了个IORESOURCE_MEM, 1probe 直接返回-EINVAL。我自己调试时会把 probe 函数里第一步就打印出所有资源信息struct resource *res; int i; for (i 0; (res platform_get_resource(pdev, IORESOURCE_MEM, i)); i) { dev_info(pdev-dev, mem resource %d: %pR\n, i, res); }这个办法虽然粗暴但能在排查资源问题时省很多时间尤其是设备树节点修改频繁的场景。7. 了解设备的深层生命周期管理引用计数与释放设备模型的精细之处在于对设备生命周期细节的掌控。设备可能会被多次引用被驱动持有、被用户态持有、被内核子系统持有如果无人使用就必须释放。如果这个释放时机把握不好容易出现“用后即焚”的崩溃或者“永远不释放”的内存泄漏。设备模型引入引用计数结构kref来解决这个问题。kref是一个简单的原子引用计数器。每次“持有”调用kobject_get()释放则调用kobject_put()。计数器归零时kobject 的 release 回调会被调用最终释放整个设备结构体。你如果写驱动时经常需要保存某个 device 的指针一定要保持引用而不只是裸指针保存否则设备可能在你想不到的时刻被卸载回收。在我熟悉这套机制前曾经因为直接从全局链表里保存了一个 device 指针结果设备热拔出指针变成悬垂引用下一版操作时直接触发了内核崩溃。这个经历让我印象特别深。想知道引用的实时状态可以借助 debugfs 或者跟踪特定的函数但说实话作为驱动开发者你更需要的是“习惯”而不是“监控平台”。每次跨模块传设备指针一律用get_device()/put_device()包裹这比自己记“什么时候该不该引用”要可靠得多。设备模型里还有一套 devm 管理机制managed device resource也就是常说的“托管资源”。比如devm_kzalloc()、devm_request_irq()等。这类 API 将资源与设备生命周期绑定设备注销时内核自动帮你释放所有 devm 申请的资源不需要在 remove 里手动清理。这个设计对驱动开发者来说是巨大福利能减少很多删除路径上的遗漏。我如今的写驱动习惯是能用 devm 就用 devm资源申请顺序按初始化顺序走释放顺序不需要操心。唯一要注意的是某些资源不适用 devm如直接创建的 sysfs 属性文件如需长期持有最好用devm_device_add_group()而不是手动创建这点要查文档确认。8. 调试与观察设备模型的通用工具箱最后这部分我把平时调试设备模型时会用到的命令和思路整理成清单方便大家直接在系统上验证。8.1 观察设备树的常用命令# 查看系统总线和设备树结构 ls -l /sys/bus/ ls -l /sys/devices/platform/ # 查看设备树 ls /sys/firmware/devicetree/base/ cat /sys/firmware/devicetree/base/soc/xxx/uart10000000/compatible设备树编译后的二进制会在/sys/firmware/devicetree/base中以目录树形式呈现可以直接读 compatible 属性验证内核看到的内容与源文件是否一致。8.2 绑定与解绑的调试方法设备模型允许手动操作驱动的绑定关系这在调试时有奇效# 解绑某个设备与其驱动 echo device-name /sys/bus/platform/drivers/driver-name/unbind # 重新绑定 echo device-name /sys/bus/platform/drivers/driver-name/bind这招可以模拟设备从系统消失再出现用 udevadm monitor 就能看到 uevent 的 add/remove 事件。调试驱动 remove 路径时我经常用它反复强制解绑来验证资源释放是否彻底。8.3 日志怎么开所有设备模型相关的调试优先打开动态调试# 打开设备模型层面的动态调试输出 echo file drivers/base/* /sys/kernel/debug/dynamic_debug/control echo file drivers/base/core.c p /sys/kernel/debug/dynamic_debug/control然后再结合 dmesg 观察device_add、device_del、probe、remove等关键调用链。在遇到“设备没出现”或“驱动不 probe”时先看动态调试日志比猜测快得多。8.4 一个完整的最小观察流程在设备树里添加一个节点带compatible test,mydev加载一个只匹配该 compatible 的最小 platform 驱动udevadm monitor观察 uevent查看/sys/bus/platform/devices/下是否出现该设备目录查看/sys/bus/platform/drivers/my_driver/下是否生成绑定链接dmesg 查看 probe 是否被调用。按照这个流程走一遍你会对设备模型的各个阶段建立起直观认知后续再深入细节就轻松了。从 kobject 到底层 device/driver/bus 的匹配过程再到用户态 uevent 协同整个设备模型其实是一座桥它把内核里的设备信息翻译成用户空间能感知、能操作的东西。我第一次把这整条链路想通的时候之前很多驱动开发上的疑惑一下子全解开了。设备模型的代码不算难读但它的抽象层次多建议你一边敲命令观察/sys的实际变化一边回头读源码像我一样挑几个典型驱动顺着platform_driver_register追一遍probe的调用路径比单纯背概念强太多了。