☰
Linux设备模型详解:从kobject到probe匹配的完整链路
2026/9/25 5:01:40 网站建设 项目流程

干嵌入式这些年,如果让我挑一篇真正“相见恨晚”的文章来写,我会毫不犹豫选Linux设备模型。回想我第一次调I2C触摸屏驱动时的狼狈:设备树配好了,驱动也写了,可Probe死活不进来,dmesg里一会儿报Failed to create sysfs entry,一会儿driver_register返回负数,我对着源码看半天也不知道问题出在哪个环节。后来啃完drivers/base目录下那几百KB代码,才恍然大悟——原来所有总线、设备、驱动之间真正的“隐藏主线”就是设备模型。这篇文章我准备把Linux设备模型一次讲透,从核心数据结构到注册匹配的完整链路,再到sysfs实操和嵌入式内核源码阅读路径,全部整理出来。适合正在做内核驱动开发、嵌入式系统移植,或者面试前想系统梳理linux内核知识的朋友。

1. linux内核里的隐藏主线:设备模型到底解决了什么

1.1 一台设备进入内核后的“物业管理”逻辑

先举个生活中大家都能瞬间理解的例子。一座小区要正常运转,必须有清楚的“物业登记系统”:哪一栋楼有哪些房间(设备对象),每个房间用来做什么(类Class),谁负责管理这栋楼(总线Bus),进来提供服务的人是谁(驱动Driver)。如果没有这套统一的管理体系,小区就是一堆无规则的房间,别说收水电费,连消防检查都无从下手。

内核面对的局面比小区复杂得多:PCI设备、USB设备、I2C设备、SPI设备、平台设备,保守估计型号超过几千种。如果没有设备模型这个统一抽象,每个驱动都按自己的方式乱注册,系统根本没法管理电源、热插拔和用户态交互。设备模型干的事情,就是给所有设备对象提供一套统一的注册、枚举、匹配、生命周期管理机制,并最终通过sysfs和uevent实现与用户空间的对接。

这套机制解决了三个核心问题:一是设备和驱动的自动配对,不用人类去手动指定谁对应谁;二是设备资源可以随时创建和销毁,尤其热插拔场景下设备来了就注册、走了就注销;三是把电源管理统一到设备生命周期里,系统休眠时知道按什么顺序关设备、唤醒时按什么顺序开设备。理解了这些,你就知道设备模型不是一个“可学可不学”的东西,而是linux内核驱动开发的公共地基。

1.2 设备模型藏在内核源码的哪个角落

设备模型的主体代码在drivers/base/目录下,核心文件包括core.c、bus.c、dd.c、driver.c、class.c和platform.c。很多人第一次翻开这个目录会觉得代码量太大,但其实主线非常清晰:core.c负责设备的注册和删除,bus.c负责总线对象的建立和匹配入口,dd.c(dd是driver device的缩写)负责设备与驱动的最终绑定,platform.c则是嵌入式linux开发最常见的“平台总线”实现。

更重要的是,PCI、USB、I2C、SPI、GPIO这些子系统,本质上是建立在设备模型之上的“上层建筑”。它们各自的总线类型最终都注册进同一个bus机制里,设备注册时照样走device_add这条主线。哪怕你搞的是linux内核虚拟化方向,VirtIO驱动的热插拔逻辑和设备绑定链路,同样没有绕开这层底层抽象。所以学会设备模型,等于拿到了一把打开大半内核驱动代码的钥匙。

2. 从最小细胞kobject到kset:sysfs里的一切都靠引用计数

2.1 kobject是对象能在/sys出现的前提

设备模型最小的单元不是device,而是kobject。你可以把kobject理解为设备模型里的“细胞膜”:任何结构体想被设备模型管理、想出现在sysfs的目录树里,就必须内嵌一个struct kobject。打开include/linux/kobject.h,能看到它的核心成员:

struct kobject { const char *name; struct list_head entry; struct kobject *parent; struct kset *kset; struct kobj_type *ktype; struct sysfs_dirent *sd; struct kref kref; #ifdef CONFIG_DEBUG_KOBJECT_RELEASE struct delayed_work release; #endif };

需要注意的字段就三个:name是对象在sysfs里的名字,parent决定它挂在哪个父目录下,kref是引用计数。Linux设备模型里所有对象都通过引用计数管理生命周期,这不只是理论概念。比如device_register()成功后,内核持有对设备的一个引用,而driver绑定后又会增加引用。每次kobject_get()增加计数,每次kobject_put()减少计数,当计数降到0时,就会调用ktype里定义的release()函数释放内存。

这里有一个新手最容易踩的坑:kobject_init()和kobject_add()必须成对调用,而且release()回调绝对不能为空,否则内核报“kobject: no release function”警告,甚至直接内存泄漏。我见过不少同事在自定义结构体里随手内嵌一个kobject,结果忘了写release函数,模块卸载时系统提示kobject还在使用,只能重启解决。

2.2 kset与uevent:用户空间怎么知道设备来了

单个kobject是孤独的,需要被分组管理,这个分组容器就是kset。kset本质上是一个kobject的集合,同时承担发送uevent的任务。一个经典的例子是/sys/bus/下每个总线都有对应的kset,所有挂在同一总线上的设备对象都会加入这个集合。

uevent是设备模型向用户空间广播消息的机制。当设备注册时,kset会调用uevent_ops,拼出一组环境变量(比如ACTION=add、DEVPATH=/devices/platform/xxx、SUBSYSTEM=platform),通过kobject_uevent()发送出去。用户空间的udev或者嵌入式环境里的mdev收到这些消息后,就会在/dev下创建设备节点、加载固件或者触发用户态脚本。你平时插U盘能立刻看到/dev/sda出现,背后就是这一整套链路在工作。

这也就是为什么很多调驱动的人喜欢用udevadm monitor看设备事件。设备模型在用户态的表现不只是一个静态目录树,而是一条实时事件流。理解kset与uevent的关系,你就明白了“插上设备内核如何感知”这个问题的答案。

3. device、driver、bus三巨头:谁找谁,怎么对上眼

3.1 三个结构体里必须关注的字段

设备模型里真正的主角是三巨头,三个结构体分别在include/linux/device.h里定义。内核里叫device、device_driver和bus_type,我来把关键的字段挑出来讲。

先看struct device,注册设备时你需要初始化这几项:

  • init_name或dev_set_name()设置设备在sysfs下的名字;
  • parent指定父设备,控制sysfs目录层级;
  • bus声明它挂在哪个总线上;
  • release回调,设备释放时必定会被调用,没实现它,注册就会失败。

再看struct device_driver,驱动侧的字段要简单不少:

  • name是驱动的名字,在很多总线匹配中会被拿来和设备的name作比较;
  • bus表示这个驱动属于哪条总线;
  • probe和remove是两个最重要的回调,分别为设备成功匹配后初始化硬件、以及设备移除时清理资源。

最后是struct bus_type,它是连接设备和驱动的桥梁:

  • name是总线名称,比如platform、i2c、spi;
  • match回调负责判断驱动和设备是否匹配;
  • uevent回调在发送用户态事件时补充环境变量。

这三兄弟的关系用一句话概括:总线是媒婆,设备是待嫁的姑娘,驱动是相亲对象;match是相亲条件,probe是确定关系。总线把大家聚在一起,设备把自己“挂牌”,驱动按条件“应征”。

3.2 match匹配机制和probe流程

以嵌入式开发最常见的platform总线为例。总线在匹配设备时依次尝试三条路:首先是设备树节点的compatible字段与驱动of_match_table中的compatible字符串是否完全一致;其次是设备名字与驱动名字是否一致;最后是驱动id_table中声明的ID是否匹配。platform_match()的源码就在drivers/base/platform.c里,你可以打开看看,逻辑非常直白。

匹配成功的下一步就是probe。整个调用链大致是:总线触发bus_probe_device(),进入device_attach(),最终调到drivers/base/dd.c中的really_probe()。在really_probe里,内核先确认设备没有被占用,然后调用driver->probe(),也就是你写的那个probe函数。如果probe返回0,设备和驱动就正式绑定;如果probe返回负数,内核会判定匹配失败。

这里要解释一个很多初学linux内核的人都会困惑的点:probe失败不等于匹配失败。match只负责判断“这个驱动有没有资格管这个设备”,probe才是真正去操作硬件并返回是否成功。一上来先把match做得太严格,驱动就永远碰不到probe;反过来match太宽松,probe会被反复调用,硬件没准备好就报错,这也是很常见的问题。

3.3 EPROBE_DEFER:内核的“延迟再试”

所有probe流程里最值得单独拎出来讲的是-EPROBE_DEFER。这个返回值的意思很简单:这个设备依赖的某个资源还没准备好,请内核过一会儿再试一次。举个例子,一个触摸屏驱动通过regulator框架依赖某个电源控制芯片,而电源芯片驱动还没注册,这时触摸屏probe如果直接失败,就会永久失败。正确的做法是返回-EPROBE_DEFER,内核会把设备放入延迟队列,等电源芯片驱动注册成功后再触发一轮重新探测。

实际调试中很多人看到设备反复尝试probe,以为是内核卡死了,其实这是设备模型正常的工作方式。只要在dmesg里能看到类似probe of ... returned -517的日志,就说明设备被延迟探测了。你还可以通过/sys/kernel/debug/devices_deferred文件查看哪些设备在等待延迟探测。开发排错时,这条信息比瞎猜要可靠得多。

4. 实操侧写:用常用命令“摸”设备模型,再写一个平台驱动

4.1 sysfs实操:几个linux常用命令就够

设备模型看得见摸得着的部分就是sysfs,挂载在/sys目录。先花十分钟在任意一台嵌入式板子或者虚拟机里敲几个linux常用命令,比你读十篇文档更有用。

# 查看系统里已注册的总线 ls /sys/bus/ # 查看platform总线上的设备 ls /sys/bus/platform/devices/ # 查看按功能分类的设备 ls /sys/class/ # 查看一个设备对象的uevent内容 cat /sys/bus/platform/devices/xxx/uevent # 实时监听设备事件,热插拔实验必备 udevadm monitor

我最推荐的做法是,先打开udevadm monitor,然后手动绑定或解绑一个设备:echo "demo-device" > /sys/bus/platform/drivers/demo-driver/bind。这时屏幕上会立刻出现uevent消息,你能真实感受到设备模型在用户态呈现的样子。用cat查看uevent时,里面会打印出设备路径和子系统类型,这就是kset在注册时生成并发送的环境变量集合。

这种“用系统反推机制”的方法我一直很推荐。你不必一开始就沉浸在源码里,先从sysfs这种冰山一角开始摸,摸熟了你自然想知道这些东西是哪段代码制造的,带着问题去读源码,效率比漫无目的翻代码高很多。

4.2 最小平台驱动示例:从insmod到probe

动手写一个最小的platform驱动,代码量并不大。下面这个demo没有任何实际硬件操作,却能让你完整观察设备模型里注册、匹配到probe的过程。

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> static int demo_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; dev_info(dev, "demo probe, name = %s\n", pdev->name); return 0; } static int demo_remove(struct platform_device *pdev) { struct device *dev = &pdev->dev; dev_info(dev, "demo remove\n"); return 0; } static const struct of_device_id demo_of_match[] = { { .compatible = "vendor,demo-device" }, { } }; MODULE_DEVICE_TABLE(of, demo_of_match); static struct platform_driver demo_driver = { .probe = demo_probe, .remove = demo_remove, .driver = { .name = "demo-driver", .of_match_table = demo_of_match, }, }; module_platform_driver(demo_driver); MODULE_LICENSE("GPL");

编译用最基础的Makefile就行,注意KDIR指向你当前内核版本的构建目录:

obj-m := demo.o KDIR := /lib/modules/$(shell uname -r)/build PWD := $(shell pwd) all: make -C $(KDIR) M=$(PWD) modules clean: make -C $(KDIR) M=$(PWD) clean

但光有驱动还触发不了probe,它得先匹配到一个设备。如果板子上用设备树,就加一个节点:

demo-device { compatible = "vendor,demo-device"; reg = <0x1f000000 0x1000>; };

如果用老的板级文件方式,或者想快速在pc上验证,可以直接用platform_device_register()动态注册一个同名的平台设备。两种方式最终都会走到同一个匹配流程。加载后看dmesg,你会看到probe里打印的那句话。再去/sys/bus/platform/drivers/看,驱动目录下已经出现了设备名称的链接。

4.3 设备树、resource与probe里的“标配操作”

在实际产品里,probe不是只打印一行日志就完事的。probe中需要拿到的信息主要有三类:设备树里的属性配置、寄存器物理地址范围、中断号。这些资源的获取方式,设备模型都已经帮你封装好了。

static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; dev_info(&pdev->dev, "base = %px, irq = %d\n", base, irq); return 0; }

这里用devm_开头的接口,是设备模型里很贴心的设计。你可以把它理解为“随设备生命周期自动释放的资源管理”,probe里申请的资源,即使probe中途失败,也不用你一个个手动释放,remove时内核会自动清理。在嵌入式内核源码里,现在新代码几乎统一使用devm系列接口,手写手动释放的老接口式代码反而不常见了。这也提醒我们:写驱动时,先看看有没有现成的devm版本可用。

5. 嵌入式内核源码阅读:哪些文件、哪些函数值得啃

5.1 核心文件地图与阅读顺序建议

很多初学者面对嵌入式内核源码无从下手,以为要把整个drivers目录都读完。其实设备模型部分,主线的源码量并不算大,关键是顺序和方法。我建议按下面的路径走一遍:

drivers/base/dd.c -> really_probe, device_attach drivers/base/bus.c -> bus_probe_device, bus_add_device drivers/base/core.c -> device_add, device_initialize drivers/base/platform.c -> platform_match, platform_drv_probe

第一站应该是dd.c。理由很简单:设备模型的最终目标就是让设备和驱动碰头,probe就是这个“碰头瞬间”的动作。从really_probe()开始读,你会有目的地去问“谁调用了它”,自然会追溯到bus层,再到core层。这种从目标反推调用源的方式,比从module_init开始顺着读要高效得多。

第二站读bus.c,看总线怎么把设备组织起来,怎么发起探测。第三站读core.c里device_add()这个函数,它是所有设备注册的统一入口,前一脚物理总线驱动把设备信息填好,后一脚设备模型接管生命周期。最后一站回到platform.c,看嵌入式linux最常用的平台总线在match阶段的优先级逻辑。

5.2 版本变化和我的踩坑记录

linux内核版本迭代很快,设备模型的总体框架非常稳定,几乎十年没有动过核心设计,但细节一直在变。比如5.x以后,platform_get_resource()在许多场景下开始被platform_get_mem_or_io()这类新接口替代;设备树API也越来越多地推荐使用device_property_read_*()取代of_property_read_*(),以便统一兼容ACPI和设备树。

我自己的两次惨痛教训值得说一下。第一次是写驱动时只实现了driver侧的of_match_table,没有核对设备树里的compatible大小写,结果probe始终不进入,最后用dtc反编译设备树才发现字符串大小写不一致。第二次是probe里用request_irq却忘了在remove里free_irq,导致模块卸载后中断还注册着,一有中断就崩溃。后来用devm_request_irq替换,彻底省心。

调设备模型有个不为人注意但极其有用的开关。内核配置里打开CONFIG_DEBUG_DRIVER后,整个设备模型的注册、匹配、绑定过程会打印出非常详细的日志,基本等于给你开了透视眼。生产环境当然不会开,但开发和调试阶段,这个选项比一堆printk有用得多。

6. 设备模型常见问题与排查技巧

6.1 问题速查表

我把实际调试中常见的问题整理成一个速查表,方便你直接对照。

现象可能原因常用排查手段
驱动probe始终不进入compatible不匹配、设备树解析失败、设备未被枚举dmesg、dtc反编译设备树、查看/sys/bus/platform/devices
probe返回EPROBE_DEFER但在循环依赖的驱动未加载、资源等待超时cat /sys/kernel/debug/devices_deferred、查看依赖模块是否加载
注册设备时报sysfs目录冲突name重复、parent设置错误dmesg里的kobject错误信息、检查是否同一parent下重名
模块卸载时崩溃或内存泄漏release回调未实现、引用计数不平衡CONFIG_DEBUG_KOBJECT_RELEASE、kobject_get/put调用检查
/sys下能看到设备但/dev下没节点uevent没正确发出、udev/mdev规则缺失udevadm monitor、cat设备uevent、检查用户态服务状态

6.2 通用排查链路和我的习惯

排查设备模型问题时,我有一套固定的排查链路,分享出来供你参考。第一步永远先看dmesg,无论多自信,先确认内核打印出的真实错误。第二步看sysfs实际状态,重点检查设备有没有被枚举到总线上。第三步如果probe没执行,就去/sys/kernel/debug/devices_deferred看有没有延迟探测。第四步才是打开源码逐行分析。

这套顺序看起来简单,但它能避免99%的瞎猜。因为我见过太多人一上来就怀疑内核源码有bug,结果排查半天,最后发现是设备树少写了一个属性,或者模块根本没加载成功。

调试设备模型时,我还养成了一个习惯:每次写驱动前,先在sysfs里找到对应子系统目录结构,搞清楚这个设备应该挂在哪条总线、哪个class下,然后才写代码。设备模型的目录树和内核的代码逻辑是一一对应的,你在用户态看到的目录结构,就是内核对象注册情况的镜像。多利用这个镜像,调试速度能快不少。

最后聊一点实在的。设备模型这套东西,真正掌握的标准不是能背出多少结构体成员,而是你看到dmesg里一行消息,就能判断它正处在匹配、绑定、probe还是延迟重试的哪个环节。那种“通透了”的感觉,许多linux驱动工程师工作三五年才体会得到。但如果你从这篇文章开始,先理解三巨头关系,再去sysfs里实操验证,最后顺着源码地图把主线读一遍,这个框架可能几个星期就能建立起来。无论你是准备linux面试题,还是要做嵌入式内核源码移植,设备模型都是绕不开的核心模块。就我个人而言,这是我在linux内核里第一个真正感到“相见恨晚”的知识点——它不是一堆孤立的数据结构,而是整个驱动世界的运行逻辑。希望这篇文章,也能成为你打开这扇门的起点。

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

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

立即咨询