ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Linux设备模型深度解析:从kobject到驱动匹配的完整指南

Linux设备模型深度解析:从kobject到驱动匹配的完整指南 1. 为什么设备模型是内核里最容易被跳过的一章很多人学Linux内核的路径都差不多先啃进程调度再看内存管理然后一头扎进文件系统最后被驱动开发按在地上摩擦。设备模型这一块往往是在看驱动代码时“顺便”扫一眼觉得无非就是几个结构体套来套去等真正要写一个带总线、带属性、带热插拔的驱动时才发现自己连/sys下面那些目录是怎么冒出来的都说不清楚。我最早接触设备模型是因为要给一块自定义板卡写平台驱动。当时probe函数死活不进dmesg里也没有明显报错折腾了大半天才意识到设备根本没被注册进对应的总线内核压根不知道有这么个东西存在。那一刻我才明白设备模型不是“可选知识”它是驱动能不能被正确匹配、设备能不能被系统识别的地基。这篇内容适合三类人一是正在学驱动开发、被kobject、kset、bus、device、driver绕晕的人二是做运维或系统调优想搞懂/sys目录结构背后逻辑的人三是面试前想系统梳理设备模型这条线的人。我会尽量把抽象的结构体关系讲成“人话”把sysfs里看到的目录和内核里的对象一一对应起来让你看完之后再打开/sys/devices不再是看天书。需要先说明一点设备模型涉及的概念确实多但它本质上只解决三个问题——设备怎么被表示、设备和驱动怎么匹配、这些关系怎么暴露给用户空间。抓住这三条主线剩下的都是细节填充。2. 从一次probe不执行说起设备模型的三个核心问题2.1 设备、驱动、总线三者的关系到底怎么理解先抛开代码用一个生活场景类比。假设你开了一家维修店总线店里有各种待修的设备device也有擅长修不同设备的师傅driver。维修店本身不生产设备也不修东西它只做一件事把设备和师傅配对。设备来了登记一下师傅来了也登记一下然后店长看谁和谁能对上就安排师傅去修。内核里的总线就是这个“店长”。platform_bus、pci_bus、usb_bus都是具体的总线类型它们各自维护两条链表一条挂设备一条挂驱动。每当有新设备或新驱动注册进来总线就会触发一次匹配流程匹配成功就调用驱动的probe函数。这里有个关键点很多人会忽略总线不是必须存在的物理实体。platform_bus就是一条虚拟总线专门用来挂那些不依赖具体物理总线比如PCI、USB的设备比如SoC内部集成的控制器。所以你在/sys/bus/platform/devices下面能看到一大堆设备它们并不对应某个插槽。匹配的规则由总线自己定义。以platform_bus为例它比较的是device的名字和driver的id_table或者driver.name。名字对上了就调用probe。这就是为什么你改个设备名驱动可能就加载不上了——不是驱动坏了是“店长”认不出这俩是一对了。2.2 为什么内核非要搞出kobject这一层如果只是设备、驱动、总线其实用普通结构体也能实现。但内核面临一个更麻烦的问题这些对象需要被用户空间看到需要支持引用计数需要支持层级关系还需要在对象生命周期结束时自动清理。于是kobject被设计出来作为所有可被sysfs表示的对象的“基类”。kobject本身很轻核心就几样东西一个名字、一个引用计数、一个父指针、一个所属的kset。它不关心你是设备还是驱动只负责“我是一个可以被引用的、有名字的、有归属的对象”。引用计数这块特别值得说。内核里对象销毁最怕的就是“还有人用着就被释放了”。kobject用kref做引用计数每多一个引用就加一少一个就减一减到零才真正释放。你在写驱动时如果手动创建了kobject就必须清楚谁持有引用否则要么内存泄漏要么use-after-free。我见过不少驱动在remove时忘了kobject_put结果模块卸载后/sys里还残留着目录就是这个原因。2.3 sysfs到底暴露了什么给用户空间sysfs是kobject层级在用户空间的一面镜子。你在/sys下看到的每一个目录背后基本都对应一个kobject每一个属性文件背后对应一个attribute。内核通过sysfs_create_file、device_create_file这类接口把对象的某些字段以文件形式暴露出来用户空间cat一下就能读到echo一下就能写进去。但要注意sysfs不是随便什么都能暴露的。内核社区对sysfs的ABI有严格要求一个属性文件一旦加入就不能随意改语义因为用户空间可能已经依赖它了。所以你会看到很多驱动宁愿用debugfs或者procfs做调试接口也不轻易往sysfs加东西。从运维角度看/sys是排查硬件问题的好帮手。比如/sys/class/net/eth0/下面能看到网卡的速率、双工模式、MAC地址/sys/block/sda/下面能看到磁盘的队列参数、分区信息。这些都不是凭空来的全是设备模型在背后支撑。3. kobject、kset、ktype设备模型的三块基石3.1 kobject的初始化与释放那些容易写错的细节创建一个kobject标准做法是先用kobject_create动态分配或者把kobject嵌入到自己的结构体里再用kobject_init初始化。初始化时必须指定ktype因为ktype决定了这个对象在释放时怎么清理、在sysfs里默认有哪些属性。struct my_obj { struct kobject kobj; int value; }; static struct kobj_type my_ktype { .release my_release, .sysfs_ops my_sysfs_ops, .default_attrs my_default_attrs, }; struct my_obj *obj kzalloc(sizeof(*obj), GFP_KERNEL); kobject_init(obj-kobj, my_ktype); kobject_add(obj-kobj, parent, myobj);这里有几个坑。第一kobject_init之后如果kobject_add失败必须调用kobject_put来释放否则引用计数不对。第二release回调里要做真正的内存释放不能只释放kobject本身而忘了外层结构体。第三kobject_add时如果父对象为NULL对象会挂到/sys根下这通常不是你想要的最好显式指定父对象。我个人的习惯是能用device、class这些上层封装就尽量别直接碰kobject。直接操作kobject只在你需要创建自定义的sysfs层级时才考虑比如某些子系统内部的目录组织。3.2 kset对象的集合与热插拔事件的源头kset是kobject的集合它本身也内嵌一个kobject所以kset在sysfs里也表现为一个目录。kset的主要作用是把同类对象组织在一起并在对象增删时向用户空间发送uevent。uevent这个东西很关键。当你在系统里插入一个USB设备用户空间的udev或者systemd-udevd之所以能收到通知并创建/dev节点就是因为内核通过kset发送了uevent。kset的uevent_ops里可以定义filter、name、uevent三个回调分别决定哪些事件发、事件里的设备名是什么、额外附加哪些环境变量。static struct kset_uevent_ops my_uevent_ops { .filter my_filter, .name my_name, .uevent my_uevent, };filter返回0表示不发送该事件返回非0才发。这个机制让内核可以控制哪些设备变化需要通知用户空间避免事件风暴。比如某些内部虚拟设备就不需要发uevent。3.3 ktype与attribute属性文件的读写是怎么落到驱动上的ktype里的sysfs_ops定义了属性文件的读写函数struct sysfs_ops { ssize_t (*show)(struct kobject *, struct attribute *, char *); ssize_t (*store)(struct kobject *, struct attribute *, const char *, size_t); };当用户cat一个属性文件时sysfs层会找到对应的attribute然后调用ktype的show。show函数里通常用container_of从kobject反推出外层结构体再读取字段格式化输出。attribute本身只包含名字和权限位struct attribute { const char *name; umode_t mode; };实际使用中更常见的是device_attribute、driver_attribute这些封装它们把attribute嵌进去并提供了DEVICE_ATTR、DRIVER_ATTR宏来简化定义。比如static ssize_t value_show(struct device *dev, struct device_attribute *attr, char *buf) { struct my_dev *mdev dev_get_drvdata(dev); return sprintf(buf, %d\n, mdev-value); } static DEVICE_ATTR_RO(value);DEVICE_ATTR_RO会自动生成一个只读属性名字叫value权限是0444。注册时用device_create_file或者把属性放进attribute_group里批量注册。注意show函数返回的字符串长度不要超过PAGE_SIZE并且要包含结尾换行符否则用户空间读到的内容可能不符合预期。4. 总线、设备、驱动匹配流程的完整拆解4.1 bus_type的注册与匹配函数bus_type是总线的抽象核心字段包括name、match、probe、remove以及两条链表devices和drivers。struct bus_type platform_bus_type { .name platform, .match platform_match, .uevent platform_uevent, .pm platform_dev_pm_ops, };match函数决定设备和驱动是否配对。platform_match的逻辑大致是先看driver_override再比较id_table最后比较driver.name和device.name。任何一条命中就返回非零。匹配成功后总线会调用驱动的probe。注意probe是驱动提供的不是总线提供的。总线只负责“牵线”具体怎么初始化设备是驱动的事。4.2 device_register背后发生了什么device_register是设备注册的入口它内部会做几件事初始化device的kobject、设置parent、把设备加入所属总线的设备链表、在sysfs里创建目录、发送uevent。int device_register(struct device *dev) { device_initialize(dev); return device_add(dev); }device_initialize负责初始化引用计数和kobjectdevice_add负责实际的注册动作。分开设计是为了让调用者有机会在device_add之前设置一些字段比如dev-parent、dev-release。device_add里最关键的一步是bus_add_device和bus_probe_device。前者把设备挂到总线链表后者触发匹配。如果此时已经有匹配的驱动probe会立刻被调用如果没有设备就静静躺在链表里等驱动注册时再匹配。4.3 driver_register与probe的触发时机驱动注册走driver_register它会把驱动加入总线的驱动链表然后遍历设备链表尝试匹配。所以设备和驱动谁先注册都行后到的那个会触发匹配。int driver_register(struct device_driver *drv) { ... ret bus_add_driver(drv); ... }bus_add_driver里会调用driver_attachdriver_attach遍历总线上的设备对每个设备调用bus_match匹配成功就device_driver_attach最终走到probe。这里有个常见误区很多人以为probe只会在驱动注册时调用一次。实际上如果设备在驱动之后注册probe会在设备注册时调用如果设备先注册probe会在驱动注册时调用。两种情况都会触发只是时机不同。4.4 匹配失败时该往哪里看probe不执行排查顺序建议这样排查点检查方法常见问题设备是否注册ls /sys/bus/bus/devices/设备名拼写错误、注册失败未检查返回值驱动是否注册ls /sys/bus/bus/drivers/模块未加载、id_table为空名字是否匹配对比设备名和驱动名大小写、后缀不一致总线是否匹配确认设备和驱动挂同一条总线设备挂错总线probe返回值dmesg查看probe内部提前返回错误我遇到过一次设备名是mydev.0驱动里写的是mydevplatform_match比较的是完整名字结果死活匹配不上。后来把驱动名改成mydev.0才通过。这种问题看代码看不出来必须对着/sys一个个核对。5. sysfs目录结构从/sys/devices到/sys/class的映射逻辑5.1 /sys/devices设备的真实层级/sys/devices是设备模型最底层的视图它反映的是设备之间的物理或逻辑层级关系。比如一个PCI设备下面挂着USB控制器USB控制器下面挂着USB设备这种父子关系在/sys/devices里一目了然。这个层级的根是/sys/devices下面直接挂的是各种根设备比如platform、pci0000:00、system。每个设备目录里都有uevent文件往里写特定字符串可以手动触发事件调试时很有用。5.2 /sys/bus按总线类型分类的视图/sys/bus下面每个子目录对应一种总线类型每个总线目录里又有devices和drivers两个子目录。这是一种“分类视图”方便你按总线类型查找设备和驱动。同一个设备会同时出现在/sys/devices和/sys/bus/bus/devices里它们是符号链接关系。/sys/bus下的条目大多是指向/sys/devices的软链接这样既保持了单一数据源又提供了多角度查看的便利。5.3 /sys/class按功能分类的视图/sys/class是按设备功能分类的比如net、block、tty、input。这个视图对用户空间最友好因为用户通常关心的是“我要找网卡”而不是“我要找挂在PCI总线上的设备”。class的引入是为了解决一个实际问题很多设备的功能和总线无关。比如一个网卡可能挂在PCI上也可能挂在USB上但用户空间只想知道“有哪些网卡”。class就是这层抽象。5.4 三个视图之间的关系与选择建议视图组织方式适用场景/sys/devices物理/逻辑层级排查设备父子关系、电源管理/sys/bus总线类型驱动开发、匹配问题排查/sys/class功能类型用户空间工具、运维脚本写驱动时你通常需要同时注册device和class这样设备既能在总线视图里被匹配又能在功能视图里被用户空间找到。class_create和device_create就是干这个的。6. 动手写一个最小设备模型示例6.1 模块框架与总线定义下面这个例子创建一个自定义总线mybus一个设备mydev一个驱动mydrv并在/sys下暴露一个属性文件。#include linux/module.h #include linux/init.h #include linux/device.h #include linux/kobject.h #include linux/slab.h static int my_match(struct device *dev, struct device_driver *drv) { return !strcmp(dev_name(dev), drv-name); } static int my_probe(struct device *dev) { dev_info(dev, my_probe called\n); return 0; } static int my_remove(struct device *dev) { dev_info(dev, my_remove called\n); return 0; } static struct bus_type my_bus_type { .name mybus, .match my_match, .probe my_probe, .remove my_remove, };6.2 设备与驱动的注册代码static void my_dev_release(struct device *dev) { kfree(dev); } static int __init my_init(void) { int ret; struct device *dev; struct device_driver *drv; ret bus_register(my_bus_type); if (ret) return ret; dev kzalloc(sizeof(*dev), GFP_KERNEL); dev-init_name mydev; dev-bus my_bus_type; dev-release my_dev_release; ret device_register(dev); if (ret) { put_device(dev); bus_unregister(my_bus_type); return ret; } drv kzalloc(sizeof(*drv), GFP_KERNEL); drv-name mydev; drv-bus my_bus_type; ret driver_register(drv); if (ret) { device_unregister(dev); bus_unregister(my_bus_type); return ret; } return 0; }加载这个模块后/sys/bus/mybus/devices/mydev和/sys/bus/mybus/drivers/mydev都会出现dmesg里能看到my_probe called。6.3 添加属性文件并验证读写在my_probe里加一个属性static ssize_t magic_show(struct device *dev, struct device_attribute *attr, char *buf) { return sprintf(buf, hello from mydev\n); } static DEVICE_ATTR_RO(magic); static int my_probe(struct device *dev) { device_create_file(dev, dev_attr_magic); dev_info(dev, my_probe called\n); return 0; }重新加载后cat /sys/bus/mybus/devices/mydev/magic就能看到输出。这个流程跑通说明你对设备模型的基本链路已经掌握了。6.4 卸载顺序与资源释放的注意事项卸载时顺序要反过来先driver_unregister再device_unregister最后bus_unregister。如果顺序错了可能出现驱动还在但设备已释放的情况内核会报引用计数错误。另外device_unregister会调用put_device最终触发release回调。如果你在release里没释放干净内存就泄漏了。建议在release里加一句dev_info确认它被调用了。7. 面试与实战中高频踩坑记录7.1 probe被调用两次的诡异现象有次我写了一个驱动发现probe被调用了两次。查了半天原因是设备注册了两次一次在板级初始化代码里一次在模块加载时。设备模型允许同名设备存在但总线匹配时会分别匹配导致probe多次执行。解决办法是在probe里做幂等检查或者确保设备只注册一次。内核里有些子系统会用device_find_child先查找是否已存在同名设备。7.2 引用计数泄漏导致模块无法卸载模块卸载时报Module mydrv is in use但明明没有进程在用。这种情况多半是kobject引用计数没减到零。常见原因创建了sysfs属性但没删除、device_register失败后没put_device、class_create后没class_destroy。排查方法是看/sys/kernel/debug/kobject/下面的引用计数信息需要开启DEBUG_KOBJECT或者用lsmod看模块引用计数。7.3 sysfs属性文件权限与命名规范sysfs属性文件命名只能用字母、数字、下划线不能用空格和特殊字符。权限位要合理设置只读属性用0444可写用0644。不要给普通用户可写权限除非确实需要。另外属性文件的内容格式要稳定。内核ABI约定一个属性文件要么只读要么只写不要既读又写还带副作用。如果确实需要复杂交互考虑用ioctl或者netlink。7.4 热插拔事件丢失的排查思路uevent丢失通常有几个原因kset的uevent_ops里filter返回了0、用户空间守护进程没运行、netlink缓冲区溢出。排查时可以先手动触发echo add /sys/bus/mybus/devices/mydev/uevent然后用udevadm monitor观察是否有事件。如果没有检查内核配置里CONFIG_UEVENT_HELPER和CONFIG_NET是否开启。8. 把设备模型用起来从调试到驱动开发的实际收益设备模型学到最后最大的收益不是记住多少结构体而是建立起一种“对象关系”的思维方式。你看到一个/sys目录能立刻反应出它背后是哪个kobject、属于哪个kset、挂在哪条总线上你写驱动时能清楚地知道每一步注册会触发什么、失败时该回滚什么。对运维来说/sys是排查硬件问题的第一现场。网卡不工作先看/sys/class/net/eth0/operstate磁盘性能异常先看/sys/block/sda/queue/scheduler。这些接口稳定、标准比各种厂商工具靠谱得多。对驱动开发者来说设备模型是绕不过去的门槛。但一旦跨过去你会发现内核里大部分子系统都遵循同样的套路总线匹配、设备注册、属性暴露、事件通知。掌握了这套模式再看i2c、spi、usb这些子系统会发现它们只是换了总线和匹配规则骨架是一样的。我个人建议的学习路径是先跑通上面那个最小示例然后去读drivers/base/下面的源码重点看core.c、bus.c、class.c、dd.c这几个文件。不用全看懂先把device_add、bus_probe_device、driver_attach这几条调用链跟下来。跟完之后再回头看/sys目录你会发现那些曾经杂乱的文件夹突然变得有逻辑了。最后分享一个调试小技巧在probe和remove里加上dev_info(dev, %s\n, __func__)配合dmesg -w实时观察能快速定位匹配和卸载问题。这个习惯我保持了多年至今仍然有效。
返回列表