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 与 ktype2.1 kobject所有内核对象的共同基类如果说设备模型是一个庞大的对象管理框架那么kobject就是这个框架的最小积木。它有点类似 C 里的基类但用 C 语言通过结构体嵌套来实现继承。实际看到的写法是任何想被设备模型管理的对象都把struct kobject放在自己结构体的第一个字段比如struct device里就嵌入了struct kobject kobj。kobject本身并不描述“硬件设备”这种具体概念它只关心三件事名字、引用计数、父子关系。内核通过这些信息把所有对象组织成一棵树再映射到 sysfs 的目录结构上。你可以把它理解成一个“身份牌”谁挂上这个身份牌谁就可以被设备模型统一管起来。这里有个新手常犯的误解以为device、driver这些概念是设备模型的核心其实它们的底层都建立在kobject之上。device是带更多属性比如资源、电源状态的kobjectdriver同样如此。正因如此理解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_adddevice_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这两个核心函数的完整调用链梳理出来。
阅读完成 · 觉得有帮助?