搞Linux内核或者嵌入式开发的人应该都有过这种经历明明驱动代码写对了设备树也配了结果设备就是不出现在/dev下dmesg里也没有 probe 日志。翻来覆去查半天最后发现是设备模型里“设备”和“驱动”压根没匹配上或者总线上根本没注册这号东西。说白了Linux 设备模型是整个驱动体系的骨架只要你想看懂driver_register、device_register、probe这些“行话”就必须先把总线、设备、驱动、类这几个角色的关系理顺。这篇笔记是我自己从“看代码看懵”到“能独立写一个虚拟平台设备验证流程”之后沉淀下来的理解适合刚接触设备模型、被各种struct绕晕的人也适合想快速复习整套机制的老手。这套设备模型解决的核心问题只有一个用一套统一的机制把物理世界里五花八门的设备和内核里形态各异的驱动规范地关联起来。它管着硬件资源枚举、总线挂接、驱动匹配、热插拔事件上报甚至连/sys里的目录结构都是它搭起来的。理解了它往后看 PCI、USB、I2C、SPI、平台设备这些具体总线的代码都会轻松非常多。1. 设备模型到底是什么为什么非学不可1.1 没有设备模型的世界我们把时间拨回 Linux 发展的早期。那个时候驱动通常是各自为政的一个驱动对应一个或几个固定的设备编号硬件怎么枚举、怎么匹配全靠驱动自己想办法。代码里经常能看到一堆request_region、ioremap、中断号写死的逻辑换个硬件、改个地址就得改代码重新编译。这种模式对“一台机器上设备种类很固定”的场景勉强够用但到了 PC 这种 PCI 总线可以动态插拔或者手机、嵌入式板卡这种一个 SoC 上挂几十个外设的场合就完全失控了。硬件数量和内核模块数量都在涨谁和谁配对、资源冲突怎么解决、插拔事件怎么通知上层都需要一套标准规则。设备模型就是被这样的诉求逼出来的把设备、驱动、总线、类全部抽象成内核里的对象并且用注册、匹配、探测这套流程统一管理。从 2.6 内核开始这个框架逐渐完善到今天已经是内核驱动开发逃不掉的地基。1.2 设备模型的三个主角围着设备模型转的无非是三样东西总线bus、设备device、驱动driver。三者关系可以打个比方总线就是婚介所设备是姑娘驱动是小伙子婚介所里备着两份花名册一份是待嫁设备名单一份是待业驱动名单凡是来了新成员就拿着名单互相撮合撮合成功了就走“probe”流程也就是结婚。总线负责维护两棵链表挂在它下面的设备链表和注册在它下面的驱动链表。设备代表一个具体的硬件实体比如某个 PCI 网卡、某个 I2C 触摸屏、某个平台设备。驱动代表一段可以操作某类硬件的代码它需要声明自己支持哪些设备。设备模型的核心工作就是设备来了去总线驱动链表里找有没有能“配得上”它的驱动驱动来了去总线设备链表里找有没有它能“服务”的设备。找到之后就调用驱动的probe函数让驱动把硬件真正“管起来”。1.3 模型对外露出的两个窗口这套内部机制不是黑盒它有非常直观的对外表现。一个窗口是sysfs挂载在/sys目录下你在文件系统里看到的每一个目录、每一个符号链接背后都对应内核里的一个kobject。设备模型让设备、驱动、总线在/sys/bus、/sys/devices、/sys/class、/sys/drivers等路径下形成了清晰完整的目录树。另一个窗口是uevent也就是由内核向上层用户空间发送的“热插拔事件”。当设备被添加、移除或者属性发生变化时内核会通过kobject_uevent机制把事件广播出去用户态的 udev、systemd-udevd 接收之后负责在/dev下创建设备节点、加载固件、执行自定义规则。很多初学者只盯着字符设备的register_chrdev看忽略了设备模型这条主干结果就是只能看明白“节点怎么来的”看不明白“节点为什么自动创建”。后面会展开细说。2. 设备模型的四大核心数据结构2.1 kobject 和 kset设备模型的“地基砖块”想理解设备模型必须先认识kobject这是内核里所有设备相关对象的最底层抽象。struct kobject本身不存业务数据但它提供了三样基础能力引用计数kref、名称name、父节点指针parent以及对应的 sysfs 目录入口。可以这么理解kobject就是一个“可以被挂进 sysfs 目录树的节点”。你在/sys下看到的一个目录本质上就是一个kobject在文件系统里的投影。kset则是 kobject 的集合它把同一类 kobject 聚合到一起比如/sys/bus下的每个总线就是一个子系统subsystem。真正充满业务逻辑的大结构体比如struct device、struct device_driver、struct bus_type都会在内部嵌入一个struct kobject或者使用相关的subsys_private结构。这种嵌入方式在外人看来可能很绕但内核开发里非常常用外层大结构通过container_of拿到自己的完整数据kobject只负责“树形挂载、引用计数、事件通知”这些通用工作。2.2 device 和 device_driver设备与驱动的“工牌”struct device是设备模型的核心对象之一。它承载了设备的总线归属、名称、资源、电源管理状态等信息。关键字段大致有dev_name/init_name设备的名字也就是你在/sys/devices/下看到的目录名。bus设备挂在哪条总线上。parent父设备指针用来维护设备树层次。driver设备当前绑定的驱动指针。driver_data设备私有数据驱动经常用它来存放自定义结构。release回调这是设备被释放时一定要实现的回调device_register创建了引用release负责清理漏了它内核会在设备释放时打警告甚至崩溃。struct device_driver则是驱动的“工牌”它同样要声明自己所属的总线bus以及名字name。驱动里最常见的是probe、remove、shutdown、suspend、resume这些回调其中probe是设备模型调到驱动代码的第一道门。此外驱动还可以提供id_table用于告诉总线“我支持哪些设备”这是匹配流程里的关键输入。2.3 bus_type把设备和驱动拴在一起的“红娘”struct bus_type是设备模型里我一开始最忽略、但其实是整套机制的“胶水层”。它除了保存总线的名字更重要的是提供了一组回调函数用来定义“设备和驱动怎么判断是否匹配”。struct bus_type { const char *name; int (*match)(struct device *dev, struct device_driver *drv); int (*uevent)(struct device *dev, struct kobj_uevent_env *env); int (*probe)(struct device *dev); int (*remove)(struct device *dev); };match判定设备与驱动是否匹配返回非 0 表示匹配成功。uevent在设备发生添加、移除、变更时向用户空间环境变量里补充总线相关信息。probe有些总线会提供总线路由的probe封装把设备探测交给总线先处理一步然后再调驱动的probe。每种具体总线都会注册自己的bus_type实例并实现自己的match逻辑。比如平台总线有platform_matchPCI 总线有pci_bus_matchUSB 有usb_bus_match。一个设备在sysfs里以什么姿态呈现、有没有驱动愿意接纳它这套决策都发生在总线层而不是驱动层。2.4 class 和 attribute给用户一个“友好视角”设备模型里还有一类对象叫class类。它和总线、设备不同class不是从硬件连接角度出发而是从“这个设备给用户干什么用”的角度出发。同一个物理设备可能既属于某个总线又属于某个类。比如硬盘在/sys/bus/scsi/devices下能找到它在/sys/class/block下也能看到它前者看的是硬件连接关系后者看的是块设备角色。class里经常配合struct class_attribute或者struct device_attribute向外暴露设备的状态。比如/sys/class/net/eth0/statistics/rx_bytes你cat一下就能看到网卡接收字节数这个值就是设备模型里某个show回调函数返回的。设备模型的 attr 机制本质就是给内核对象开放了一组“虚拟文件”接口让用户态可以用最朴素的文件读写方式查询或控制硬件。3. 设备与驱动的“相亲”match、probe 机制3.1 设备注册时发生的完整路径当你调用device_register(dev)时设备模型的引擎就启动了。简化流程大概是这样设备添加进 sysfs创建对应目录。设备加入其bus-p-devices_kset链表也就是挂到总线的“设备花名册”上。总线层收到“有新设备来了”的通知调用bus-p-drivers_kset里的逻辑去遍历总线上所有已注册驱动。对每个驱动调用bus-match(dev, drv)判断是否匹配。匹配成功就调用device_attach(dev)进入真正的绑定流程。绑定过程中会调用drv-probe(dev)如果 probe 返回 0说明绑定成功设备和驱动之间建立关联。这条路径最关键的触发点在于新设备注册时是“主动”去总线上找驱动的。不是等驱动上门而是设备“先发出征婚信息”。3.2 驱动注册时的逆向路径反过来当调用driver_register(drv)时流程也对称驱动添加进 sysfs在对应总线下创建drivers/xxx目录。驱动加入总线的驱动链表。总线遍历自己的设备链表对每一个还没有绑定的设备调用bus-match(dev, drv)。一旦某台设备匹配成功同样启动 probe 绑定流程。这就是为什么你经常会看到加载一个驱动模块dmesg里立刻出现了某个设备的初始化日志。因为设备早就挂在总线上等着了驱动一注册模型马上配婚。3.3 match 函数的实际匹配顺序match是设备模型里最核心的“决策函数”。拿最常见的平台总线platform bus举例它内部的platform_match匹配顺序很有意思匹配方式匹配条件优先级设备树匹配设备节点 compatible 属性和驱动of_match_table匹配最高ACPI 匹配ACPI 表里的硬件 ID 与驱动acpi_match_table匹配第二id_table 匹配驱动id_table里的.name字段和设备名相等第三名称兜底匹配驱动名的driver.name和设备名的init_name完全相同最后第一次看到这个顺序的时候我非常不理解为什么要留一个“名称兜底匹配”。实际写过模块之后才明白很多板级验证、快速原型开发根本不需要搞设备树直接给设备取个名字驱动driver.name也用同样的名字就能快速匹配上。这套机制给了开发者极大的弹性。PCI 总线的匹配就比较“硬核”它会比较pci_device_id里的 vendor、device、subvendor、subdevice、class 这些字段逐项比对硬件 ID。这也是为什么 PCI 设备要么有标准 ID要么驱动声明兼容厂商 ID否则就是驱动想匹配也无从谈起。3.4 probe 成功之后发生了什么probe成功只是“配对成功”离“设备可用”还差好几步。驱动在probe里通常要做的是解析设备资源比如platform_get_resource拿到寄存器物理地址、中断号。ioremap或request_mem_region把物理地址映射到内核虚拟地址空间。初始化硬件写寄存器、复位、配置模式。注册各类内核子系统比如register_netdev、register_blkdev、i2c_add_adapter、input_register_device。创建 sysfs 属性文件或者 proc 节点。把设备关联到某个 class比如device_create在/dev下生成节点。probe返回0驱动模型才认为绑定成功。如果返回负的错误码例如-ENOMEM、-EINVAL模型会认为初始化失败解除绑定设备继续停留在“无驱动”状态等待下一次匹配机会。4. sysfs 和 uevent用文件系统“看见”设备模型4.1 sysfs 的五个关键入口设备模型建立之后sysfs 就成了我们观察模型的窗口。刚开始看/sys容易发懵目录太多太杂。我建议只盯住五个入口/sys/devices全局设备层次树所有设备都真实挂在下面。/sys/bus按总线分类里面每个总线目录下又有devices和drivers两个子目录。/sys/class按功能分类是别从用户角度看设备的视图。/sys/block块设备专属视角现在很多实际是/sys/class/block的符号链接。/sys/module已加载驱动模块的相关参数和状态。我在看设备有没有被正确识别时习惯先ls /sys/bus/xxx/devices看看设备名的形态是否正常然后ls /sys/bus/xxx/drivers看看驱动加载了没有最后再进/sys/bus/xxx/drivers/xxx/里面看有没有设备的符号链接。有符号链接意味着绑定成功。4.2 sysfs 里符号链接的意义第一次在/sys/bus/pci/devices/0000:00:1f.0/下面看到一堆符号链接时可能觉得这是冗余信息。其实这些链接非常关键。# 查看一个 PCI 设备下链接到了哪些驱动和总线 ls -l /sys/bus/pci/devices/0000:00:1f.0/driver ls -l /sys/bus/pci/devices/0000:00:1f.0/subsystemdriver链接指向当前绑定的驱动目录如果这项不存在说明设备没有匹配到驱动。subsystem链接指回设备所在的总线。符号链接的存在让 sysfs 即使只靠目录树也能表达“多对多”关系一个设备属于一个总线可对应多个类一个驱动可以绑定多个设备。文件系统里的“虚拟引用”加上目录层级把这套关系网描述得非常清楚。4.3 uevent内核向用户空间递消息设备模型里另一个重要输出是 uevent。每当设备注册、移除、改名、改变属性时内核会组织一堆环境变量例如ACTIONadd、DEVPATH/devices/pci0000:00/...、SUBSYSTEMpci、DEVTYPE...然后通过netlink发送到用户空间。用户态的 udev 接收到事件后会做这些事根据/etc/udev/rules.d/下的规则匹配设备。为设备在/dev下创建节点指定权限、属主、软链接。触发固件加载、模块自动加载等额外动作。我在调一个 USB 转串口设备时最喜欢用udevadm monitor --property来实时看事件。设备一插上事件内容马上就打出来能直观看到SUBSYSTEMusb、ID_VENDOR、ID_MODEL这些字段。这就是设备模型“内核态 —— 用户态”通信的典型现场。4.4 一根 PCI 网卡从插入到可用的完整时间线把前面这些串起来看一个 PC 上 PCI 设备插上后的完整流程PCI 总线枚举硬件读取配置空间生成struct pci_dev最终调用device_register。设备注册时设备模型把设备挂到pci_bus_type下。PCI 总线的 match 函数遍历已注册驱动比对pci_device_id。找到匹配驱动调用驱动的probe函数驱动初始化硬件并注册net_device。设备模型发送 ueventudev 收到后在/dev或网络子系统里完成设备命名、权限设置。用户态工具比如 NetworkManager 接收到net子系统事件开始配置 IP。一个硬件从物理插入到系统可用中间全是设备模型在驱动。这一条线看懂再去看各种具体设备子系统就轻松了。5. 最小演示写一个虚拟平台设备驱动验证匹配流程前面讲了很多概念但概念不落地总觉得不踏实。我自己验证这套机制时写过一对极简的虚拟平台设备和驱动。这里把主要代码贴出来结构刻意精简到只剩匹配关键路径。5.1 驱动模块代码这个模块注册一个platform_driver并且带上id_table让它去匹配名字叫demo-dev的设备。#include linux/module.h #include linux/platform_device.h static int demo_probe(struct platform_device *pdev) { dev_info(pdev-dev, matched! device name %s\n, pdev-name); return 0; } static int demo_remove(struct platform_device *pdev) { dev_info(pdev-dev, remove device\n); return 0; } static const struct platform_device_id demo_ids[] { { .name demo-dev, }, { } }; MODULE_DEVICE_TABLE(platform, demo_ids); static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo-drv, .owner THIS_MODULE, }, .id_table demo_ids, }; module_platform_driver(demo_driver); MODULE_LICENSE(GPL);模块初始化时module_platform_driver宏会去调用platform_driver_register。如果总线上已经存在名为demo-dev的平台设备platform_match会在第一步尝试设备树匹配失败后走到id_table匹配发现demo_ids[0].name等于设备名于是调用demo_probe。5.2 设备侧与完整实验步骤设备侧还需要手动注册一个struct platform_device。这里写一个单独的设备模块或者直接在内核启动时用设备树添加节点。最省事的方式是用一个小模块#include linux/module.h #include linux/platform_device.h static struct platform_device *demo_pdev; static int __init demo_dev_init(void) { demo_pdev platform_device_register_simple(demo-dev, -1, NULL, 0); return PTR_ERR_OR_ZERO(demo_pdev); } static void __exit demo_dev_exit(void) { platform_device_unregister(demo_pdev); } module_init(demo_dev_init); module_exit(demo_dev_exit); MODULE_LICENSE(GPL);实验流程如下# 1. 先加载设备模块让总线上先有“设备” sudo modprobe demo_dev # 2. 确认设备在 sysfs 里出现了 ls /sys/bus/platform/devices/demo-dev/ # 3. 再加载驱动模块观察 dmesg 输出 sudo modprobe demo_drv # 4. 看内核日志里有没有 probe 信息 dmesg | tail -n 10我实际跑下来dmesg会出现类似这样的一行demo-dev demo-dev: matched! device name demo-dev同时/sys/bus/platform/drivers/demo-drv/下会出现一个指向/sys/devices/platform/demo-dev的符号链接。到这一步整套“驱动注册 → 汽车扫描设备 → 匹配成功 → probe”的流程就完整验证了。5.3 名称兜底匹配的测试把驱动模块的id_table删除只保留driver.name demo-dev再加载驱动同样能触发 probe。这说明平台总线最后的“名称兜底匹配”确实存在。这个特性在做早期产品原型时很有用不用纠结设备树和 ID 表名字对上就直接绑定。不过要注意这只是平台总线的规则其他总线未必有这种兜底。比如 PCI 总线就必须老老实实写pci_device_id哪怕产品和标准 PC 兼容也要有厂商 ID、设备 ID。所以不要把这个便捷特性当成通用规则到处套用。6. 常见问题与排查技巧实录做设备模型相关开发时我踩过的坑基本都集中在“设备没匹配到驱动”和“probe 执行失败”这两类。下面整理了自己的排查套路按优先级排列。6.1 设备在 sysfs 里出现但就是没有 probe这是最常见的现象ls能看到设备目录dmesg却没有驱动的 probe 日志。一般按这几步查确认驱动是否真的注册到了总线上。ls /sys/bus/xxx/drivers/下看看有没有驱动目录。确认设备挂的总线和驱动挂的总线一致。一个平台设备不会去找 PCI 驱动反之亦然。确认 match 规则。读懂总线的 match 函数比对设备名、compatible、ID 表。查看dmesg里有没有驱动加载失败的信息比如module verification failed、参数错误。查看是否有别的驱动已经抢先绑定。一个设备同一时间只能被一个驱动绑定如果已经被占后来的驱动不会 probe。我自己最常犯的错误是设备树 compatible 写错。内核里of_match_table要求 compatible 完全一致少一个逗号、多一个空格都对不上。排查时把compatible一项项复制到驱动代码里比对比肉眼盯着设备树 DTS 文件更可靠。6.2 probe 执行了但一半失败dmesg里能看到驱动进入了 probe但系统没有生成预期节点。这种情况通常是这几类原因ioremap或者资源申请失败比如寄存器地址被别的驱动占用。中断申请失败request_irq返回非 0。内核子系统注册失败比如register_netdev因为名称冲突失败。probe 里某个kmalloc或devm_kzalloc失败内存不足导致返回错误码。排查这类问题我建议在驱动代码关键路径里顺手加dev_err/dev_dbg输出把返回错误码打出来。比如ret request_irq(irq, demo_isr, 0, demo, dev); if (ret) { dev_err(pdev-dev, failed to request irq %d, ret%d\n, irq, ret); return ret; }错误码的负数换算之后再对照errno-base.h里-ENOMEM、-EBUSY之类的宏定位就会快很多。6.3 调设备模型时最趁手的一组命令日常调试设备模型我几乎离不开下面这些命令复制到终端就能用# 实时监控 uevent观察设备插拔事件 udevadm monitor --property # 查看设备树在 sysfs 里的实际状态方便和设备树源文件对照 ls /proc/device-tree/ cat /sys/firmware/devicetree/base/model # 列出 PCI 设备及其绑定的内核驱动 lspci -k # 查看总线上有哪些设备和驱动以及绑定关系 ls -l /sys/bus/i2c/drivers/xxx/ ls -l /sys/bus/platform/devices/ # 查看内核日志中关于设备和驱动的信息 dmesg | grep -i platform | tail -n 30如果用strace追踪 udev 或某个用户态服务也能看到它读哪些 sysfs 文件、监听哪个 netlink 组。但在这个场景里我更常先直接开dmesg -w和udevadm monitor两个终端窗口一个看内核态一个看用户态设备模型的整条链路就会特别清晰。6.4 容易忽略的 release 回调问题还有一个我早期踩过的大坑写了自定义设备却忽略了release回调。设备模型要求每个struct device都必须有release函数否则在引用计数归零、设备销毁时内核会打出一个Device xxx does not have a release() function的警告并拒绝释放设备。这个问题在调试信息里不算特别醒目但一旦触发后续重复注册同名设备就会一直失败。所以写设备模块时哪怕是最普通的测试代码也要给device提供完整的release回调这个回调里通常只需要释放设备本身的内存static void demo_release(struct device *dev) { /* 如果设备是动态分配的在这里释放 */ kfree(container_of(dev, struct demo_dev, dev)); }留这个习惯能帮你省掉很多莫名其妙的“设备注册失败”问题。我自己的体会是Linux 设备模型初学时觉得结构多、概念绕但只要抓着“总线、设备、驱动、类”这根主线再把 sysfs 作为观察窗口多写几个最小实验去验证配套流程很多抽象概念会迅速落地。读具体总线代码时也不再是一头雾水而是能一眼看出它在哪里实现 match、在哪里调 probe。学设备模型最忌讳的就是光看代码不跑实验像上面那种虚拟平台设备配合dmesg观察的验证方式建议有条件的人可以跟着做一遍收获会比自己啃源码大得多。