
开篇先聊一个我观察了很久的现象很多人写Linux驱动字符设备、platform驱动、中断、ioctl、sleep/wakeup样样都能上手但真被问一句“设备驱动模型到底是干什么的、bus/device/driver三个结构体是怎么被串起来的”立马就卡壳。这其实不怪大家因为大部分教程都是“怎么调API”的路线没有把内核那套组织设备的逻辑讲透。可一旦你想往底层深挖想搞明白内核怎么自动匹配驱动、怎么管理电源、怎么在/sys里把设备树状结构暴露出来就绕不开Linux设备驱动模型Device Driver Model简称DDM。这篇文章我就把这个模型从底层kobject到上层class、从总线匹配到设备树接入一层一层剥开讲清楚。这篇内容适合两类人一类是刚看完LDD3、对字符设备已经比较熟、正准备往内核底层深入的人另一类是面试中被“请简述Linux设备驱动模型”问倒、想系统补课的人。看完你至少能回答清楚设备模型由哪几层组成、sysfs是怎么生成的、probe函数为什么会被调用、platform设备和设备树之间是什么关系。1. 设备驱动模型解决的核心痛点从散装注册到统一管理早期内核2.4时代及其之前的设备驱动管理基本是各写各的。每个驱动自己找硬件、自己注册中断、自己申请资源。那会儿设备数量少、平台相对固定这么干能跑。但到了2.6以后片上系统SoC越来越复杂同一个内核要同时支持ARM、x86、MIPS等各种平台外设种类也爆炸式增长。如果每个驱动还是各搞一套初始化逻辑内核根本没法维护。设备驱动模型最直接的贡献是把“设备”和“驱动”这两个概念抽象成了独立对象然后定义了一套统一的总线匹配规则。这样一来硬件插入、驱动加载、设备节点创建、电源管理这些动作都能被框架接管而不是靠驱动作者自己手写一堆if-else。1.1 内核里设备信息为什么需要“描述化”老式驱动里硬件资源信息通常直接hardcode在代码里比如寄存器地址就写成一个宏。换一块板子同一个IP核换了基地址你得改源码重新编译。这在设备模型出现之后变得不可接受了——设备模型希望驱动只关心“怎么操作这类硬件”至于硬件在哪里、中断号是多少、使用哪条时钟全部通过描述性的数据结构比如platform_device或设备树节点传入驱动。这种“驱动即逻辑、设备即数据”的分离是DDM的核心设计思想之一。你写一个驱动应该尽量做到板级无关。后面讲platform和设备树的时候你会更深刻地体会到这一点。1.2 从字符设备cdev到设备模型的过渡关系很多初学者会混淆两个概念字符设备char device和设备模型。简单说字符设备是“文件系统视角”的抽象而设备模型是“系统拓扑视角”的抽象。字符设备通过cdev结构体注册到VFS让用户空间可以用open/read/write来访问硬件设备模型则把硬件、总线、驱动、类之间的关系建立起来让系统知道“这个设备由哪个驱动管理、电源状态如何、在sysfs中长什么样子”。这两者不是对立的而是配合关系。一个完整的驱动通常既要注册cdev提供文件操作接口也要接入设备模型让系统能管理它。这就是为什么你在写驱动时既会看到cdev_init又会看到device_create这类函数。理解这层配合你才算真正跨过了“只会调API”的阶段。2. 底层地基kobject、kset和sysfs之间的协作机制如果你想直接跳到bus/device/driver那先停一下。设备驱动模型底层还有一套基础设施它们是整个DDM的“地基”这就是kobject、kset和ktype。这三个东西决定了设备模型最核心的两个能力引用计数管理和在sysfs中呈现层次结构。2.1 kobject内核里的“对象基类”kobject从名字就能看出它像面向对象里的基类。它本身没有太多业务含义主要作用是提供引用计数、父子关系和sysfs目录的关联。// include/linux/kobject.h struct kobject { const char *name; struct list_head entry; struct kobject *parent; struct kset *kset; struct kobj_type *ktype; struct kernfs_node *sd; /* sysfs目录项 */ struct kref kref; ... };关键字段就那几个kref内核的引用计数。当kobject被创建时计数为1每次获取引用用kobject_get()释放用kobject_put()。计数归零会自动触发release回调把占用的内存释放掉。parent对象在sysfs里的父节点体现层次。ktype这个对象的“类型”里面定义了这个对象在sysfs里怎么显示属性、怎么释放。在头文件里追一下kref你会发现它其实是一个原子引用计数结构。所有对kobject的使用都必须遵守“谁获取谁释放”的规则破坏了这一点内核就会出现use-after-free这类bug排查起来非常痛苦。我实际调试过的某个驱动崩溃就是因为在probe里kobject_put提前释放了对象而另一个线程还在用这个kobject发uevent。2.2 kset和ktype对象的集合与共同行为kset是kobject的集合或者说一个bus、class内部维护的对象集合。它本身也内嵌了一个kobject所以在sysfs里有自己的目录。kset最核心的作用是把同一类kobject挂到一个链表中并让这些kobject共享一组操作。ktypekobj_type则用来定义“这类对象的行为”struct kobj_type { void (*release)(struct kobject *kobj); const struct sysfs_ops *sysfs_ops; const struct attribute_group **default_groups; const struct kobj_ns_type_operations *(*child_ns_type)(struct kobject *kobj); ... };release回调最值得关注。它是kobject生命周期结束时的“析构函数”。内核里有一个经典的警告kobject: xxx (address): does not have a release() function看到这个基本就是kobject没有注册ktype或者ktype里release为空。这种错误会直接导致对象内存泄漏因为内核不知道该怎么释放它。我在自研的驱动框架里就踩过这个坑——kobject创建了没有正确设置ktype.release结果每次卸载模块内存都不干净。2.3 sysfs内核对象在用户空间的“投影”sysfs是一个基于内存的文件系统通常挂载在/sys。设备驱动模型每个“节点”基本都能在/sys下找到对应目录。它的目录树不是随便排的严格反映了kobject的parent关系。快速看一下本机$ ls /sys/bus/ i2c pci platform spi usb ... $ ls /sys/class/ backlight gpio input i2c-dev mem misc net power_supply tty ... $ ls /sys/devices/ platform pci0000:00 .../sys/devices是所有设备对象的真正所在位置/sys/bus和/sys/class里其实是指向真实kobject的符号链接。这个概念非常重要树只有一个根就是/sys/devices其他的目录是“视图”。总线和类是把同一批设备按不同维度重新组织起来。理解这个之后你在写驱动或者排查问题时通过路径就能反向推断出设备模型内部是什么状态。3. 模型主干bus、device、driver三者如何配合工作在设备模型里最核心的三角关系就是bus、device和driver。可以把bus想象成“红娘”device是“待匹配的硬件信息”driver是“匹配成功后负责打理硬件的服务方”。3.1 bus_type定义“总线规则”Linux里总线不止物理意义上的PCI、USB、I2C还包括虚拟总线比如platform_bus。每条总线在内核里就是一个bus_typestruct bus_type { const char *name; int (*match)(struct device *dev, struct device_driver *drv); int (*probe)(struct device *dev); int (*remove)(struct device *dev); ... };match函数是设备模型里最有意思的一个回调。它解决了“这个设备到底该由哪个驱动处理”的问题。每个bus_type可以自定义匹配规则。比如PCI总线喜欢比对vendor/device IDI2C总线比对名字字符串platform总线先比对设备树compatible属性、再比对平台设备名字。3.2 device与device_driver两个结构体如何被绑定struct device描述一个真实的硬件设备它包含资源、电源管理状态、引脚、中断等信息。struct device_driver描述处理某类设备的软件逻辑它包含probe、remove、suspend、resume等回调。当内核检测到新设备时比如USB设备插入它会调用总线注册时的match逻辑在所有同一条总线的driver里找合适的。如果匹配成功框架会调用driver的probe函数。这就是“probe被调用”的本质。反过来如果driver先注册比如模块加载内核会遍历总线上已有的设备反向去匹配该driver。两种顺序最终都会落到同一套匹配流程上。匹配成功的核心动作有两个把dev-driver指针指向匹配上的driver。调用driver的probe回调。probe返回0代表驱动初始化成功设备状态进入online。如果probe中途失败设备保持unbound状态之后可以重新触发绑定。3.3 match函数背后到底写了什么以platform总线为例platform_match函数的基本流程是这样的我按源码逻辑简化描述先查of_match_table即设备树匹配。比较设备节点里的compatible属性是否在驱动的of_device_id列表中。再用ACPI匹配x86平台常用。然后比较platform_device.name和platform_driver.id_table-name。最后直接比较platform_device.name和driver.name。我画过一张草稿把匹配顺序记成这样ACPI匹配 → OF设备树匹配 → id_table名字匹配 → driver名字匹配实际开发中写一个platform_driver需要在driver结构体里填好of_match_table否则在设备树环境下很可能匹配不上。static const struct of_device_id my_ip_of_match[] { { .compatible myvendor,my-ip, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_ip_of_match); static struct platform_driver my_driver { .probe my_probe, .remove my_remove, .driver { .name my-ip, .of_match_table my_ip_of_match, }, }; module_platform_driver(my_driver);提示MODULE_DEVICE_TABLE不是写给别人看的注释它会让modinfo和内核模块加载系统知道这个驱动支持哪些设备。很多新手漏掉它结果modprobe死活自动加载不了驱动。3.4 手动绑定与解绑调试必备技能设备模型提供了一套sysfs接口让我们可以在运行时手动绑定/解绑驱动。这在驱动开发调试阶段非常有用。# 查看设备当前绑定在哪个驱动上 $ ls -l /sys/bus/platform/devices/xxx/driver # 手动解绑 $ echo xxx /sys/bus/platform/drivers/xxx/unbind # 手动绑定 $ echo xxx /sys/bus/platform/drivers/xxx/bind还有更常用的场景修改了驱动源码重新编译成模块后先rmmod再insmod这时候就能通过bind文件手动触发probe而不用重新插拔硬件。电源管理调试时也常用这个技巧可以让某个设备在驱动不加载的情况下持续运行便于单独测量功耗。4. class与设备节点的自动创建向用户空间交付能力设备模型的管理职责不仅仅在内核内部。为了让用户空间应用程序、udev、系统管理工具能使用设备内核需要向用户空间暴露统一接口。这就是class和设备节点的意义。4.1 class把设备按“功能”分组bus是按“连接方式”给设备分类在PCI上还是I2C上class则是按“设备功能”分类输入设备、网络设备、LED、gpio等。这两者可以并存一个设备既属于某条总线也属于某个class。class目录下的项通常是指向/sys/devices下真实目录的符号链接。写驱动时最常用的class相关函数是struct class *my_class class_create(my_devices); device_create(my_class, parent_dev, devno, NULL, mydev%d, index);device_create成功之后内核会做两件事在/sys/class/my_devices/下创建mydev0、mydev1等符号链接。在/sys/devices/virtual/或对应父设备目录下创建实际kobject挂上dev节点。同时内核会向用户空间发送ueventudev或嵌入式里的mdev收到之后根据规则在/dev下创建对应的设备节点。4.2 为什么嵌入式里常见mdev、pc-linux常见udev这背后其实是uevent机制在起作用。内核检测到新设备时会广播一个uevent内核对象事件里面包含ACTIONadd/remove、DEVPATH、DEVNAME、MAJOR、MINOR等环境变量。用户空间的udev守护进程监听netlink socket收到这些事件后根据/etc/udev/rules.d里的规则创建设备节点。在嵌入式环境里没有udev很多系统用busybox的mdev来处理。如果你的驱动只调用了device_create而没有对应热插拔规则你在/dev下看不到节点就说明udev/mdev那边没配好。这是一个排查设备节点不出现问题的关键思路内核侧看uevent有没有发出来用户侧看规则有没有生效。4.3 class属性给用户空间提供查询和控制入口class下还可以创建额外的属性文件让用户空间通过读写文件来控制设备行为或者读取状态。static ssize_t state_show(struct device *dev, struct device_attribute *attr, char *buf) { return sprintf(buf, %d\n, get_device_state(dev)); } static DEVICE_ATTR_RO(state);然后在class创建后device_create_file(dev, dev_attr_state);这样用户空间就可以直接cat /sys/class/my_devices/mydev0/state来查询状态。不需要写一个完整的ioctl驱动也不需要打开设备节点简单高效。这个习惯在服务端开发和运维场景里很常用因为shell环境下不方便写C程序调用ioctl。5. platform设备与设备树当前主流硬件的接入方式聊完通用模型必须聊聊当前实际开发中最常见的platform总线。很多人在ARM嵌入式开发中绝大多数设备都是platform设备而不是PCI/USB这类枚举型设备。5.1 platform总线为什么存在像PCI、USB这类总线硬件有标准枚举协议设备插入时能自动上报ID。但片上系统SoC内部集成的那些控制器比如UART、SPI、I2C控制器、DMA控制器等没有枚举机制它们是焊死在SoC内部、固定地址、固定中断号的。这类设备不能指望“插上就发现”只能由板级代码或设备树直接声明。于是内核设计了platform总线把所有不挂在标准总线上的设备统一归到这条虚拟总线上管理。platform_device描述硬件资源platform_driver描述驱动逻辑匹配规则走上面说的of_match_table那套。这样即使硬件信息变了只要设备树描述更新驱动代码基本不用动。5.2 设备树如何与驱动模型衔接设备树DT文件通过编译变成dtb内核启动时解析并生成一个个platform_device。这个过程中设备树里的每个带compatible属性的节点都可能变成一个platform_device。举个例子设备树里有这样一个节点myip: my-ip1c00000 { compatible myvendor,my-ip; reg 0x01c00000 0x1000; interrupts 0 42 4; clocks ccu 12; };内核解析后生成一个platform_device其of_node指向这个设备树节点。当上面那个platform_driver注册时通过compatible匹配成功probe被调用。你在probe里用of_property_read_u32、platform_get_resource等方式读取寄存器和中断号这些都是从设备树节点读取的。platform_get_resource的底层实现其实就是从device的resource数组里取数据。这些resource是在解析设备树时根据reg属性创建的。所以probe里拿到的资源不是你自己定义出来的而是硬件描述信息经过设备树框架转换而来的。5.3 设备树没有匹配成功会怎样这是嵌入式开发最常遇到的问题之一。设备树写了节点驱动也写了compatible但probe不执行基本就是下面几种情况compatible字符串拼写不一致多一个空格都不行。我经历过一次肉眼完全看不出来的全角字符混入排查了一下午。设备树节点被status disabled禁用了。内核解析时会跳过禁用节点。驱动编译成模块但modprobe没有依赖MODULE_DEVICE_TABLE生成的信息自动加载。设备树里缺少必要的电源管理节点probe里获取clock或regulator失败返回了-EINVAL。快速排查手段是查看$ ls /sys/bus/platform/devices/ $ ls /sys/bus/platform/drivers/xxx/如果设备节点存在但driver链接没建立说明匹配失败如果设备节点根本不存在说明设备树解析阶段就出问题了。6. 还原现场/sys目录到底在表达什么很多初学者面对/sys目录会觉得信息量太大不知道从哪看起。这里给你一个我常用的排查路径把前面讲的知识点串起来。我把排查步骤整理成一个固定的顺序先看设备是否被枚举成功/sys/bus/platform/devices/或/sys/devices/下有没有目标设备目录。没有说明硬件枚举或设备树解析没完成。再看设备和驱动是否完成绑定设备目录下有没有driver符号链接。没有说明match失败或驱动没注册。看驱动是否加载ls /sys/bus/platform/drivers/下有没有对应驱动目录lsmod看模块是否在内存中。看驱动内部状态通过驱动在/sys下导出的属性文件查看寄存器状态、中断计数、运行模式等。看事件流用udevadm monitor嵌入式没有就临时编译一个或者加日志观察uevent是否正常发送。这套链路走下来设备驱动模型在内核里是不是健康运转基本一目了然。比如你在调一个外部I2C触摸屏驱动现象是触摸没反应。你按上述顺序检查后发现I2C设备节点存在driver也绑定了但/sys/class/input/下没有生成对应input设备。那问题大概率出在驱动probe后初始化失败或者中断申请失败。这时候直接在probe里加dev_err打印比在任何地方瞎猜都有效。7. 我自己吃过的苦头几类高频雷区与对应排查思路最后把我在实际驱动开发里反复踩过的坑集中说一下主观性比较强但都是真金白银换来的经验。7.1 kobject的release缺失导致崩溃我早期用kobject封装一个虚拟设备只调了kobject_create_and_add没有设置ktype里的release函数。卸载模块时kobject_put触发内核警告然后直接oops。后来老老实实补上release回调在回调里kfree容器结构体问题才解决。有个经验是只要在驱动里看到kobject_put相关的崩溃先去看release有没有正确定义八成是这个问题。它非常隐蔽因为编译不报错运行也时有发生、时不发生全看运气。7.2 probe执行顺序与设备依赖某个项目中A设备的probe需要B设备已经就绪提供时钟。但两者在设备树里没有显式指定依赖关系导致B先probe还是A先probe完全不确定。板子偶尔能在uboot里手动初始化B后才正常但内核自启动时A先跑直接拿不到时钟。处理方式有很多在A的probe里请求时钟时用devm_clk_get如果返回-EPROBE_DEFER就原样返回该错误码内核会把这个设备放回待匹配队列等B注册完时钟后再重新触发A的probe。这背后的机制是deferred probe它本身也是设备驱动模型的一部分。这个经验告诉我设备树里描述清楚依赖关系比自己写一堆同步逻辑靠谱得多内核框架已经替你考虑了这类问题。7.3 系统休眠唤醒时probe会被再次触发吗有段时间调suspend/resume怀疑系统唤醒后设备驱动是不是重新走了probe。实际调试发现正常流程下内核走的是dpm_suspend/dpm_resume链路挂在dev_pm_ops里的suspend/resume回调上而不是重新probe。设备模型在注册device时就把电源管理回调挂到了PM核心的链表中系统休眠时PM核心会按依赖顺序调用这些回调。只有在设备被真正移除比如USB设备物理拔出时才会走remove路径之后重新插入才会再次probe。理解这个区别可以避免在调试省电状态时白费功夫。7.4 用devm_系列函数减少内存管理负担现代内核驱动开发中凡是devm_开头的资源管理函数devm_kzalloc、devm_clk_get、devm_gpiod_get、devm_request_irq等都建议优先使用。它们把资源生命周期跟struct device绑定设备remove时会自动释放省去手动错误处理的一大堆麻烦。这套机制本质上是设备模型提供的资源管理框架。设备在probe里分配的资源全挂在设备的资源链表上remove时统一回收避免probe过程中途失败导致资源泄漏。8. 学习路径建议如何把这些知识真正内化这篇文章信息密度不低但读一遍肯定不够。我的建议是动手做一个小实验把这个模型完整地“跑通”一遍写一个最简platform_driver不操作任何硬件只在probe里打一条日志。写一个最简platform_device也可以直接在模块里用platform_device_register_simple注册名字跟驱动匹配。加载后观察/sys/bus/platform/devices/和/sys/bus/platform/drivers/下的变化。给驱动加一个device_attribute通过/sys节点读写一个全局变量。加一个class使用device_create创建设备节点观察/dev下的变化配合udev/mdev。这个实验做完你对设备模型的理解就不是“好像明白了”而是“这玩意就是我写的这几行代码串起来的”。这几步看起来简单但覆盖了kobject、bus、device、driver、class、sysfs、uevent、设备节点等全部核心概念。之后你再去看真实的子系统代码比如GPIO子系统、Input子系统、I2C子系统会发现它们全是建立在这套模型之上的具体应用案例。从我个人经验来看设备驱动模型的“顿悟时刻”通常不是听来的而是某一天在/proc/interrupts里看到一个中断号、在/sys里看到一个属性文件、在dmesg里看到一条probe日志突然把它们全部串起來的时候。希望这篇文章能帮你缩短通往那个时刻的路程。