☰
Linux设备模型全解析:从kobject到sysfs的驱动开发核心
2026/9/25 17:36:59 网站建设 项目流程

搞了十几年内核驱动,回头再看设备模型这一块,我是真有一种“相见恨晚”的感觉。刚入行那会儿啃代码,总是盯着platform_driver_register、device_create这些函数看,以为把接口调明白了就算会写驱动了。直到后来被几个莫名其妙的问题折腾到怀疑人生——驱动加载顺序乱了、probe不执行、sysfs属性读写掉了上下文、设备卸载时直接Oops——才老老实实把设备模型从底层翻了一遍。翻完之后最大的感慨是:这玩意儿不是一两个API的事,它是整个Linux内核管理硬件的“地基”,不懂它,写驱动就是在盲人摸象。

我一直觉得,设备模型是Linux内核里最值得花时间搞懂的基础设施之一。它的设计目标是回答三个问题:系统里有哪些硬件设备?这些设备是如何与驱动匹配上的?用户态怎么去查看和控制这些设备?为了回答这三个问题,内核搞出了一整套对象体系:kobject、kset、ktype,以及基于它们搭建的device、device_driver、bus、class四大件。这套体系藏在驱动框架的底层,像高速公路下面的地基,平时看不见,但出了问题,基本上都跟它脱不了干系。

这篇文章,我想站在一个踩过坑的从业者的角度,把这套东西从头到尾拆一遍。受众是那些已经知道怎么调probe、懂一点内核编程、但还没系统捋过设备模型的驱动开发者,以及想深入内核的嵌入式工程师。我会先讲清楚设备模型到底为了解决什么问题而生,再把kobject那套底层机制掰开揉碎,接着用较大的篇幅梳理device / driver / bus / class之间的协作关系,然后结合sysfs和属性文件的实现带你实操一把,最后按照惯例,分享一些我在实际开发中遇到的离奇问题。

1. 设备模型到底在解决什么问题

1.1 从驱动开发的真实痛点说起

在没有设备模型之前,内核里管理硬件的方式可以说相当“原始”。每个驱动自己管自己的设备,注册方式千奇百怪,没有一个统一的框架去描述“这块硬件现在处于什么状态”“它和哪个驱动绑定”“它的电源该怎么管理”。这样做最直接的后果就是,驱动之间老打架,设备热插拔完全没有章法,用户态想看设备信息更是门儿都没有。

我记得有一次调一个USB转串口的驱动,设备拔掉之后再插上,驱动直接找不到设备了。当时不懂设备模型,只能怀疑是中断有问题,把request_irq附近翻了个底朝天,全然没意识到问题可能出在设备节点的生命周期管理上。后来才明白,设备模型的核心诉求之一,就是把“设备”本身抽象成一个对象,让这个对象从诞生到注销都有清晰的状态流转,并且把设备与驱动、总线、电源管理这些周边系统的关系全部理顺。

这个抽象带来的直接好处是:设备不在是驱动的附属品,而是一等公民。驱动只是“服务员”,设备来了,它去匹配,匹配上了就服务;设备走了,它就收工。这种思想贯穿了设备模型的每一个角落。

1.2 三个核心问题的拆解

设备模型要回答的第一个问题是“系统里有哪些设备”。这看起来简单,实际很难。PC上插着PCIe显卡、USB键盘、NVMe硬盘,ARM板子上连着I2C触摸屏、SPI Flash、GPIO扩展芯片,它们形态各异、连接方式不同,你怎么统一描述?设备模型的答案是:给每个设备抽象成一个struct device,挂在某个struct bus上,再放进一棵设备树里(这里的设备树指的是设备模型的父子拓扑树,不是Device Tree)。

第二个问题是“设备和驱动怎么配对”。这是驱动开发中最常见的场景:你写了一个platform_driver,它的id_table里写了一个名字叫“my_i2c_device”的兼容项,内核怎么知道系统里那颗I2C设备该归你管?靠的就是总线(bus)的match回调。每种总线有自己的一套匹配逻辑,PCI总线看vendor_id和device_id,I2C总线看compatible字符串或name,USB总线看idVendor和idProduct。匹配上了,总线就会调用驱动的probe,从此设备和驱动正式“领证”。

第三个问题是“用户态怎么和设备交互”。设备模型给的答案就是sysfs。它是内核对象模型的“投影”,内核里每创建一个kobject,就会在/sys下对应出现一个目录。你可以通过/sys/class/xxx/yyy/查看设备属性,往某些属性文件里写东西还能控制设备的行为。这样一来,用户态不需要打开/dev节点,甚至不需要写任何应用层代码,就能对设备进行基本的查看和配置。sysfs是设备模型连通内核态和用户态的窗口,也是调试驱动时最趁手的工具。

1.3 设备模型与普适架构之间的关系

设备模型设计出来的时候,考虑的不只是某一个子系统,而是一个可以通吃的“主板加总线”架构。主板是struct device,总线是struct bus_type,CPU、内存控制器、PCI控制器、I2C控制器统统可以作为总线存在。设备挂到总线上,总线负责协调设备和驱动之间的通信。

这种普适架构最大的价值在于让代码得以复用。总线那一套match、probe、remove、shutdown的流程是通用的,各个子系统只需要填好自己的回调函数就行。你掌握了设备模型,再去看PCI、USB、I2C、SPI、Platform这些驱动框架,会发现它们只是形态不同,骨子里全是一样的套路。这就是我为什么说设备模型值得“相见恨晚”——它是打通内核驱动知识体系的钥匙。

2. 底层基石:kobject、kset、ktype、uevent

2.1 kobject:一个被管理的内核对象

kobject是设备模型的最小单元。你可以把它理解成一张“身份证”,内核给任何一个需要被管理、被引用计数、需要出现在sysfs里的对象,都发一张这样的身份证。身份证本身不复杂,关键字段就那几个:name(名字)、kref(引用计数)、parent(父对象)、ktype(类型)、kset(所属集合)。

每次看到kobject_init_and_add这个函数,我都会提醒新手:它干了两件事,第一是初始化,第二是注册并创建对应的sysfs目录。注册之后,这个对象就有了“社会身份”,可以被外部引用,也可以被外部发现。这里有个很重要的细节:kobject大多数情况下不是单独使用的,它一般内嵌在某个更大的结构体里,比如struct device的第一个成员就是struct kobject kobj。这种“内嵌”的写法,让内核可以把任何结构体“升级”成可管理对象,同时保留结构体本身的语义。

从实践角度讲,我建议大家不要自己单独创建kobject,而是通过device_create、class_create这类更高层的接口去用。直接操作kobject是内核子系统开发干的事情,普通驱动开发者直接面对kobject的场景,无非是写属性文件时要理解struct attribute背后其实是kobj_attribute和sysfs_ops的联动,那时候才需要回头琢磨kobject的细节。

2.2 kset:对象的“圈子”

kset描述的是一个“集合”,把相同性质的kobject归拢到一起。举个例子,/sys/bus/platform/devices/目录下挂着所有platform设备,这个目录对应一个kset。当你往里添加一个新设备时,kset保证它在目录里有一个唯一的名字,并且把相关的uevent事件广播出去。

理解kset的另一个角度是:一个kset可以看成一组同类的kobject的“家长”。设备模型做目录层级管理时,靠parent指针和kset成员协作完成。具体来说,kobject的parent决定它在sysfs树里的父目录,而kset则决定它挂在哪个“集合”下,并且从集合那里继承热插拔事件的发送能力。

2.3 ktype:对象的“出厂设置”

ktype是三个里面最好理解的,它定义了某一类kobject统一具备的能力。每个kobject内核都要求它有一个ktype,里面主要装着两个东西:一个release函数和一个sysfs_ops操作集。

release函数决定了这个对象生命周期结束的时候怎么销毁。这是新手特别容易踩坑的地方。内核对象不是你想kfree就kfree的,因为可能有别人还持有着它的引用。正确的姿势是:当引用计数归零时,内核自动调用ktype里定义好的release,在那里释放资源。如果release没实现或者实现得不干净,大概率会出现两种纠纷:一种是对象被释放后还有代码在用,导致内核崩溃;另一种是对象永远释放不掉,内存泄漏。

sysfs_ops则决定了对这类对象的属性文件读写时会发生什么,它提供show和store两个回调。你平时看到的那些/sys/...文件,本质上都是调用这类回调来读取或修改内核对象的内部状态。

2.4 uevent:设备世界的“广播系统”

uevent机制是设备模型连接用户空间的重要通道。内核里设备状态发生变化时——插入、移除、改变——会通过kobject_uevent函数发送一个事件报文。这个报文最终会被udev(或mdev、systemd-udevd)接收,用户态根据这些事件去创建/dev节点、加载固件、设置权限等等。

事件报文的格式是ACTION=add、DEVPATH=/devices/platform/...、SUBSYSTEM=platform之类的键值对。你写驱动时如果调用了device_create,内核会自动帮你发add事件,不需要额外操心。这块最常见的需求是自定义uevent属性:比如你想给用户态多传点设备信息,可以在struct device里实现uevent回调,往环境变量里追加自己的KEY=VALUE。

不过我提醒一句:uevent不要乱设,有的初学者会把敏感信息塞进去,这倒不是安全不安全的问题,主要是用户态解析麻烦,没那个必要。保持uevent变量的精简和规范,是驱动开发者的基本素养。

3. 四大支柱:device、device_driver、bus、class

3.1 struct device:设备的实体化身

struct device是设备模型中描述“一个设备”的核心结构体。它非常庞大,从kobject kobj、设备树相关的of_node,到电源管理相关的power,再到release回调,有几十个字段。对驱动开发者来说,你通常不需要亲自填充所有的字段,大部分由总线子系统和设备模型框架初始化时搞定。

写驱动时和struct device打交道最常见的方式,一个是platform_device里的dev成员,一个是在probe里拿到的那个struct device *dev。它是你访问设备树属性、申请GPIO、获取中断号、操作devm_系列函数时的“证件”。

关于release回调和设备生命周期,我多说一句。struct device不允许在release里简单kfree,因为设备节点可能在sysfs里还被引用着。正确的做法是,在驱动里定义一个包含struct device的封装结构体,在release里kfree(container_of(dev, struct my_device, dev))。这套模式在你写自定义设备驱动时几乎是标准动作,顺手了就不觉得麻烦,不顺手的时候三天两头死机。

3.2 struct device_driver:驱动的自白

struct device_driver描述了一个驱动具备的能力。它包含了驱动的名字、所属总线(bus)、匹配规则(of_match_table、id_table)、还有一堆操作回调:probe、remove、shutdown、suspend、resume等等。

这里有一个很多人理解偏差的地方:device_driver本身和“具体硬件设备”没有直接关系,它只是一个“能力声明”,表示“我这种驱动能处理什么样的设备”。至于能不能处理某个特定设备,要靠总线的匹配机制去判断。匹配上了,框架调用驱动的probe;设备移除时,框架调用驱动的remove。所以,probe和remove是驱动的灵魂,写得不好,直接表现就是系统不稳定或者设备不可用。

实际写驱动时,你会发现platform_driver是device_driver上套了一个壳,struct platform_driver的第一个成员就是struct device_driver driver。因此,研究设备模型时,你不妨把platform_driver_register看成driver_register的一个封装,理解了这个封装,i2c_driver、spi_driver的路子也就通了。

3.3 struct bus_type:设备与驱动的“红娘”

bus_type是设备模型里最巧妙的设计。每个总线对象定义了一套匹配、热插拔、电源管理、DMA等操作的规则。platform_bus_type就是一个最著名的例子,它负责匹配platform_device和platform_driver。

我的理解是,总线是整个体系里的“裁判”。设备注册到总线上,驱动也注册到同一个总线上,总线就会挨个去尝试匹配。匹配成功,就调用驱动的probe把这个设备“认领”走;设备被拔掉时,总线又负责通知驱动执行remove,完成善后。这套“先注册先匹配、动态增删”的设计,天然支持了设备热插拔。

需要提醒的是,总线内部的match策略因总线而异。有的总线(如PCI)靠硬件ID匹配,有的(如platform)靠设备树兼容字符串或名字匹配,有的(如USB)靠idVendor/idProduct匹配。当你发现probe怎么都不执行时,不一定是驱动写得不对,先查查总线的match规则,80%的问题其实出在这里。

3.4 struct class:用户视角的分类目录

class和我们要写的驱动关系非常紧密,因为device_create必须挂在某个class下面。class解决的是“用户从哪个角度去看设备”的问题。比如块设备有一个block类,字符设备有tty、gpio等类,它们都以目录形式出现在/sys/class/下面。

很多初学者不明白class和bus的区别。打个比方:bus描述的是设备“物理上怎么连的”,class描述的是设备“功能上是什么”。同一个USB鼠标,物理上它挂在usb总线上,功能上它在input类里。这两套视角是独立的,可以并存。驱动里通常的做法是:在probe时创建class和device,在remove时销毁,这样用户空间的/dev节点和/sys目录就能跟随设备状态自动出现和消失。

我自己的习惯是:自定义设备驱动的class名要尽量见名知义,比如my_led就是my_led,别起一个通用的misc。起得好,调试时一眼就知道该去哪里找设备;起不好,自己过两个星期都找不到自己的设备在哪儿。

3.5 设备模型与电源管理的暗线

设备模型里还有一条容易被忽略的暗线:它和电源管理框架紧密绑定。struct device里那个power成员,就是电源管理子系统在设备模型上做的扩展。内核要挂起(suspend)系统时,必须知道系统里有哪些设备、它们处于什么状态,才能依次调各驱动的suspend回调,把硬件放到低功耗模式;唤醒时再按反序调用resume回调。

理解这一点,对你排查挂起唤醒类问题特别有帮助。系统挂起后唤醒不了,很多时候不一定是哪个驱动写错了,而是设备模型的拓扑结构不完整,导致内核在遍历设备树时漏掉了某个需要恢复的设备。所以,注册设备时尽量把parent关系设对,在sysfs里看到的拓扑结构,基本上也是电源管理遍历的设备树结构。

4. sysfs与属性文件:设备模型对用户态的一张脸

4.1 从kobject到sysfs目录的映射

sysfs是设备模型“外在的呈现”。每当你创建一个kobject并注册,就会在/sys下出现一个目录;每当你在这个对象下添加一个属性(attribute),目录里就多一个文件。整个/sys目录树,本质上是内核里设备模型对象树的一个投影。

这里有个特别实用的调试技巧:你写完一个驱动,注册完设备,先别急着写应用层代码,直接去/sys/bus/platform/devices/或者/sys/class/你的类名/下面看看有没有你设备的目录。如果目录没出现,说明设备注册有问题;如果目录出现了但属性文件不全,说明属性创建有问题。这种“目录树就是调试树”的思路,能帮你把问题快速分层定位。

4.2 attribute的生命周期管理与show/store回调

在驱动里最常打交道的是DEVICE_ATTR宏,它定义一个属性,参数是名字、权限位和一个show函数、一个store函数。show函数负责读操作,store函数负责写操作。两个函数拿到的第一个参数是struct device *dev,第二个是struct device_attribute *attr,第三个是缓冲区。

写show/store时,有几点我得反复强调:

第一,这两个回调都是在进程上下文执行的,可能睡眠,所以不要在里头用自旋锁保护长时间的操作。第二,store不像你想的那样一次读一整行,它是按页缓冲的,每次最多传PAGE_SIZE字节。第三,返回值必须准确表示实际读写的字节数,否则用户态读到的数据是脏的,或者写入失败。

我自己写属性文件时,最常见的坑是忘记检查store里传入的长度。用户往文件里写了一个超长的字符串,如果缓冲区处理不严谨,越界写入内核堆,那可真就是“开着车把变速箱踩碎”的体验,系统崩溃连带着一片混乱。所以,凡是涉及store,第一行先判断count,然后取字符串时用strndup或者限制复制长度。

4.3 解析一个完整的attribute示例

来看一个典型场景:我们给某个虚拟设备增加一个“状态”属性,用户态可以读当前状态,也可以写入新状态。

static ssize_t status_show(struct device *dev, struct device_attribute *attr, char *buf) { struct my_device *mdev = to_my_device(dev); return sysfs_emit(buf, "%d\n", mdev->status); } static ssize_t status_store(struct device *dev, struct device_attribute *attr, const char *buf, size_t count) { struct my_device *mdev = to_my_device(dev); unsigned long val; int ret; ret = kstrtoul(buf, 10, &val); if (ret) return ret; mdev->status = val; return count; } static DEVICE_ATTR_RW(status);

创建属性时,在probe里调用device_create_file(dev, &dev_attr_status),在remove里调用device_remove_file(dev, &dev_attr_status)。sysfs_emit是内核推荐的向用户态输出内容的函数,它会自动处理缓冲区边界。我见过不少老代码喜欢用sprintf(buf, ...),在某些场景下并没有问题,但一旦缓冲区估计出错就麻烦了,所以新代码一律建议sysfs_emit。

4.4 sysfs在调试中的附加价值:直接改设备行为

属性文件不只用于信息展示,它还是调整设备行为的一扇门。比如你想动态切换某个传感器的量程,不想重新编译内核,也不想写应用层程序,直接往属性文件里写个值就行。有的属性文件甚至可以触发一次硬复位、导出一份内部寄存器dump,这种玩法在芯片调试阶段非常实用。

调试时我经常这么干:把设备的关键寄存器读出来放到show里,把调试开关和参数放到store里,配合echo命令做实验。不需要写任何用户态工具,echo 1 > /sys/class/xxx/xxx/debug就够用。等验证得差不多了,再把这些调试属性从正式驱动里摘掉或者隐藏,保持“产品态”的简洁。

5. 设备模型三件套实操:一个最小平台驱动

5.1 编写一个模拟platform设备与驱动

理论讲得再多,不如上手一遍。这里我给出一个可以编译加载的最小示例:一个platform_driver配合一个platform_device,展示设备、驱动、总线的协作关系。

/* my_platform_dev.c */ #include <linux/platform_device.h> #include <linux/module.h> #include <linux/init.h> static struct platform_device my_pdev = { .name = "my_platform_device", .id = -1, }; static int __init my_pdev_init(void) { return platform_device_register(&my_pdev); } static void __exit my_pdev_exit(void) { platform_device_unregister(&my_pdev); } module_init(my_pdev_init); module_exit(my_pdev_exit); MODULE_LICENSE("GPL");

对应的驱动:

/* my_platform_drv.c */ #include <linux/platform_device.h> #include <linux/module.h> #include <linux/init.h> static int my_probe(struct platform_device *pdev) { dev_info(&pdev->dev, "my platform device probed\n"); return 0; } static void my_remove(struct platform_device *pdev) { dev_info(&pdev->dev, "my platform device removed\n"); } static struct platform_driver my_pdrv = { .probe = my_probe, .remove = my_remove, .driver = { .name = "my_platform_device", }, }; static int __init my_pdrv_init(void) { return platform_driver_register(&my_pdrv); } static void __exit my_pdrv_exit(void) { platform_driver_unregister(&my_pdrv); } module_init(my_pdrv_init); module_exit(my_pdrv_exit); MODULE_LICENSE("GPL");

5.2 加载顺序与probe触发的关键条件

这个例子看着简单,但背后涉及一个非常关键的问题:加载顺序。platform_device先注册,驱动后注册,行不行?反过来呢?

答案都是可以的。平台总线(platform_bus_type)是内建的,它会把所有注册过的platform_device和platform_driver都记下来。新驱动注册时,总线会遍历当前所有设备,找到匹配项就立刻probe;新设备注册时,总线会遍历当前所有驱动,找到匹配项也会立刻probe。也就是说,probe的触发条件是“设备、驱动双方都已在同一条总线上”,而不在乎谁先谁后。

这个特性和PCI等“枚举型”总线不一样,PCI设备是枚举出来的,设备先存在,驱动后加载。理解了时序,你就不至于在设备还没注册时就怀疑驱动不对了。实战中,我经常在调试时先注册设备,再insmod驱动,看到probe立刻打印出来,就说明匹配逻辑没问题。

5.3 设备树方式下的匹配路径

在现代内核里,platform_device绝大多数不是代码里手动注册出来的,而是设备树(Device Tree)解析出来的。每个设备树node里有一个compatible属性,内核把它变成一个设备挂在平台总线上。而驱动这边在of_match_table里列出自己支持的compatible字符串。

匹配时,平台总线会优先查设备的compatible是否在驱动的of_match_table中。如果不在,再退回用id_table里的name字段和设备的名字比对,最后退回用驱动名和设备名比对。了解了这个优先级,你就知道驱动里到底是哪个名字在起作用了。

有一个我几乎每个项目都会踩一遍的坑:设备树里compatible写的是“vendor,device”格式,驱动的of_match_table里却只写了“device”或用了别的厂商前缀,匹配不上,probe死活不执行。排查方法很简单:看/sys/bus/platform/devices/xxx/目录下的uevent文件,里面会打出OF_COMPATIBLE_N,对比一下驱动注册时的compatible,错了一个字符都匹配不上。

5.4 devm_系列API与设备模型的联动

写驱动时,推荐你尽量使用devm_(managed device resource)系列API,比如devm_kzalloc、devm_request_irq、devm_ioremap_resource。它们的资源生命周期和设备绑定,在probe成功时分配,在设备移除(或probe失败)时自动释放。

具体到设备模型层面,这些资源挂在struct device的devres链表上,device_del时会统一释放。这意味着你的remove函数往往只需要做很少的事,比如删除sysfs属性文件、注销一些字符设备接口,其他的kfree、free_irq都交给devres框架去处理。

最早我不太习惯这种写法,总觉得“不自己释放就不踏实”。后来看内核代码多了,发现上游驱动几乎清一色devm_路线。说实话,devm_系列不仅减少了我泄漏内存的机会,也让错误处理路径变得极简:probe中间任何一个devm_调用失败,只需要返回错误码,之前分配的资源框架自动清理。这种“框架托底”的设计,背后就是设备模型对整个资源生命周期的管理能力。学会用devm_,等于学会了信任设备模型。

6. 常见问题与排查技巧实录

6.1 probe不执行,设备目录也没出现

probe不执行,是驱动开发里最常见的“劝退”问题。我的排查顺序一般是这样:

第一步,确认设备真的注册了。看/sys/bus/platform/devices/下有没有设备目录,没有就查设备树是否解析成功,设备地址是否正确。第二步,确认设备名和驱动匹配条件对得上。去/sys/bus/platform/devices/xxx/uevent或/sys/bus/platform/drivers/xxx/目录下看是否有设备被挂过来。第三步,查看dmesg里有没有关于注册失败的日志,比如platform my_device: probe failed with error -22之类的。

probe失败的返回码非常关键。-ENODEV说明匹配逻辑认为不匹配,-EINVAL多半是参数不对,-EPROBE_DEFER表示依赖的资源(比如某个clk、phy)还没就绪,需要等待下次重试。我一度被-EPROBE_DEFER整得头皮发麻,后来才反应过来这是内核的“延迟再试”机制,不是错误,这是正常等待。系统里存在依赖关系时,很多驱动会经历几次EPROBE_DEFER才能成功probe,这是设备模型和电源管理、时钟、中断等子系统协同工作的结果。

6.2 属性文件读写无响应,或返回错误

属性文件读写不对劲,排查要从三个角度同时出发:

第一,属性文件是否真的创建了。很多驱动在probe里加了一堆device_create_file,但忘了在remove里对应删除,卸载驱动后再插入,旧文件指向已经释放的内存,读写直接崩溃。第二,show/store的逻辑是否有问题。show函数里不能用snprintf做复杂格式化,推荐sysfs_emit;store里取数字建议用kstrtoint或kstrtoul,不要用早期的simple_strtol。第三,权限是否对。DEVICE_ATTR_RO只给读权限,DEVICE_ATTR_WO只给写权限,如果你要用echo往只读属性里写值,返回的是Permission denied,别觉得驱动坏了,先看看权限位。

有一个小技巧:sysfs目录里的文件大部分是内核生成的假的“平铺文件”,可以直接用cat和echo读写。如果读写后看不到任何效果,不一定是属性没生效,可能是你的驱动只更新了内存里的变量,没同步到硬件寄存器。这一点我在调试FPGA驱动时深有体会,属性读了半天全是零,查到最后是MMIO映射基址算错了。

6.3 设备引用计数失衡导致Oops

这一类问题最隐蔽,只在热插拔、卸载驱动的场景下偶现。典型场景是:驱动A持有了设备B的指针,设备B被拔掉后驱动A还在用这个指针。设备模型对此早有准备:每次你想要长期保存一个设备指针,应该先get_device(dev)增加引用计数,用完再put_device(dev)。如果没这么做,设备释放后那个struct device可能已经被release回调kfree了,你再访问就是典型的“野指针”,内核直接Oops。

它们之间的关系就像一张饭票:get_device就是取票,put_device就是退票。内核只在所有get_device都配对的put_device完成后,才真正销毁设备对象。所以在驱动里保存贯穿整个生命周期的设备指针时,务必get_device,而不是简单地把dev存下来就瘸着跑。

6.4 热插拔期间自定义uevent不生效

有些驱动需要让用户态感知到设备的特殊状态变化,比如电池的电量突变、温度越界,这时候会自定义uevent。排查时第一看事件是否发出,用udevadm monitor监听就可以看到;第二看uevent里有没有自定义的键值,没有就检查设备的uevent回调是否被正确覆盖。很多驱动实现的是struct device的uevent成员,但注册时用了错误的设备结构体,回调根本没被调用,事件自然没有自定义内容。

这类问题最好的调试方法是先打印uevent的环境变量,在内核日志里把你拼出来的KEY=VALUE打出来,确认无误后再让用户态去解析。有一点值得注意:uevent回调里的打印不要太频繁,因为环境变量会被用户态进程接收,如果每次状态变化都打印大量内容,会把dmesg刷爆,影响其他调试。

6.5 设备模型视角下的模块生命周期管理

模块的init和exit函数里,隐藏着大量和设备模型交互的细节。init函数注册驱动、创建设备、创建类,exit函数则要反序执行注销。很多人习惯在exit里只注销驱动,不注销设备,导致设备节点残留。下次insmod时又是新设备注册,旧目录还在,两套混在一起,sysfs目录就乱象丛生。

我建议你严格遵循“你创建什么,你销毁什么”的对称原则:init里如果创建了class,exit里就class_destroy;init里如果注册了platform_device,exit里就platform_device_unregister;init里如果创建了设备属性文件,exit里就全部移除。宁可多写几行注销代码,也别偷懒只注销驱动。不然后面排查问题时,你光分辨那些“幽灵”设备节点就要折腾半天。

结语

写了这么多,我最想分享的心得是:设备模型不是一堆API的堆砌,而是一套自洽的“对象管理哲学”。它把设备、驱动、总线、类这四件事拆得泾渭分明,又通过kobject和sysfs把它们串联起来。学它的正确姿势不是背函数名,而是先理解“设备是对象、驱动是能力、总线是规则、类是视角”这四句话,再去看代码,很多曾经过目就忘的细节一下子就扎进脑子里了。

我最早学设备模型时,总嫌kobject那层抽象碍事,觉得直接调register_chrdev就完事了,反正设备也能用。后来写复杂驱动,要处理多个设备实例、要动态创建设备节点、要在热插拔时做好资源回收,才意识到register_chrdev只是管了字符设备的“接口”,而设备模型管的是设备本身“的一生”。没有设备模型那层抽象的托底,你就得自己管理实例的生命周期、自己处理事件通知、自己在复杂的拓扑里找人背锅,想想就头皮发麻。

如果你现在正在为驱动的匹配、设备的生命周期、sysfs的读写而头疼,我强烈建议你花一整天时间,不调业务逻辑,专门把设备模型的源码读一遍。重点看drivers/base/core.c、drivers/base/dd.c、drivers/base/bus.c这三个文件,这三个文件基本涵盖了设备模型所有的核心流程。读的过程中对照/sys目录实盘查看,你会慢慢发现,内核里的很多“玄学”问题,本质上都只是设备模型在不同场景下的正常行为。

最后再留一个小技巧:调试驱动时,别急着写测试程序,先在/sys目录里手动验证设备和驱动的匹配状态。看/sys/bus/platform/drivers/下你的驱动目录里有没有挂着设备名,看/sys/bus/platform/devices/下你的设备是否存在。这两个目录就像“前台登记表”和“已入住房间表”,全对上了,再往下走,能省掉你一大半的无用功。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询