Linux设备驱动模型深度解析:从device到probe再到sysfs
2026/9/22 2:57:23 网站建设 项目流程

写明白一个底层机制,往往比写下十层业务逻辑更有价值。Linux 设备驱动模型就是这样一类东西:它不像进程调度、内存管理那样“显眼”,但无论是嵌入式开发、内核驱动编写,还是系统稳定性排查,你绕不开它。很多时候,你觉得驱动“莫名其妙”不工作,或者设备节点时有时无,根子都在设备模型这一层。这篇文章不堆概念,我把我从看源码到实际改驱动、调硬件过程中对设备驱动模型的理解,从头到尾拆一遍。

如果你是想搞懂内核底层的开发者,不管你是做嵌入式 Linux、内核驱动,还是上层应用想深入理解sysfsuevent、设备热插拔背后的原理,这篇文章都值得你花点时间。设备模型不是一块孤立的“知识”,它是连接内核各子系统、暴露硬件拓扑给用户态的枢纽。

1. 设备驱动模型到底在解决什么问题——三个关键词讲清楚

很多人一上来就背struct devicestruct device_driverstruct bus_type,背完还是懵,因为不知道这些东西到底解决什么问题。我们先退一步,想想内核在没有这套模型之前是什么状态。

早期内核里,写一个驱动基本是“直接干活”:驱动初始化时申请中断号、映射 IO 地址、注册字符设备、建/dev节点。听起来也没啥不行,但系统一复杂就乱了。

第一个关键词:资源冲突。同一个物理中断号可不能被八个驱动同时请求,同一段 IO 地址你映射我也映射,谁来仲裁?第二个关键词:热插拔与动态加载。USB、SD 卡这些设备都是中途插进来的,内核怎么知道该把这个新设备交给哪个驱动?第三个关键词:用户态视角。应用层ls /sys/class/或者udevadm info为什么能查出设备的层级关系?这背后总得有一个组织良好的对象模型。

设备驱动模型就是内核为回答这三个问题搭建的“中间层”和“调度室”。它不是某一个具体驱动的功能,而是驱动框架的公共服务。你可以把它理解成一套“内核内部的登记与查询系统”:所有的设备、驱动、总线都在这个系统里注册、匹配、绑定,然后向用户态暴露统一接口。

这套模型的核心对象就四个:device(设备)、device_driver(驱动)、bus_type(总线)、class(类)。你记住一句话就够了——总线上挂着设备和驱动,总线的职责是让它们“配对”,配对成功后驱动负责操作设备,设备通过 class 向用户态“抛头露面”

2. device/driver/bus/class 四件套:设备、驱动、总线、类,各自干啥

这四件套是设备模型的骨架。我建议你从这四个结构体本身入手去理解,不要跳过struct的定义直接去看 API,那样永远是浮在表面。

2.1 struct device:一个设备在内核里的“身份证”

struct device是整个模型最底层的抽象,它是“一个硬件设备”在内核中的表示。这里要特别注意,device只管“设备本身是什么”,不管“怎么操作它”。

struct device { struct device *parent; // 谁生了我(父设备) struct device_private *p; // 私有的、不对外的数据 struct kobject kobj; // 所有 sysfs 表现的基础 const char *init_name; // 设备在 sysfs 里的名字 struct bus_type *bus; // 挂在哪个总线上 struct device_driver *driver; // 配对成功的驱动 void *platform_data; void *driver_data; // 驱动自定义私有数据,常用 dev_t devt; // 设备号(用于创建设备节点) ... }

这个结构体里最关键的几个问题:parent表示设备在拓扑结构中的位置,比如 USB 设备挂在 USB 控制器下面;bus指向它所在的总线类型;driver一旦被赋值,就说明这个设备已经被“认领”了;devt是设备号,有了设备号,device_create()才能生成/dev节点。

还有一个非常容易踩坑的点:release回调函数struct device里有个release函数指针,它在设备引用计数归零时被调用,用来释放设备占用的内存。如果你自己动态kzalloc了一个devicedevice_register注册它,而没有初始化release,内核在注销时会直接报错并崩溃。这个我在第 8 章会再展开。

2.2 struct device_driver:驱动只是“能力的声明”

驱动对象struct device_driver同样挂在内核的对象系统里,但它本身不包含“操作函数”,它的核心是声明自己能匹配哪些设备,以及匹配成功后如何初始化/释放

struct device_driver { const char *name; struct bus_type *bus; const struct of_device_id *of_match_table; int (*probe)(struct device *dev); // 匹配成功后被调用 void (*remove)(struct device *dev); // 设备被移除时调用 const struct dev_pm_ops *pm; // 电源管理 ... }

驱动本身不干活,真正干活的是probe函数。所谓“写驱动”,本质上是填好proberemove,在probe里把硬件初始化、注册中断、建立数据通路,然后把操作接口暴露给用户态。

你可能会问:那读写函数(read/write)呢?那不叫device_driver,那是file_operations,是字符设备层的事。设备驱动模型管的是“设备与驱动匹配”这件事,数据通路是匹配成功之后注册到具体子系统里的。先有匹配,后有业务

2.3 struct bus_type:总线不是物理线,是“匹配中介”

这是最容易误解的地方。bus_type不是指 PCB 上的线,而是内核定义的一种“聚合与匹配规则”。

struct bus_type { const char *name; int (*match)(struct device *dev, struct device_driver *drv); int (*probe)(struct device *dev); int (*remove)(struct device *dev); struct device_attribute *dev_attrs; ... }

内核里最典型的就是platform_bus_type,这是一个虚拟总线,叫platform。它专门用来挂载那些不依附于 USB、PCI、I2C 等物理总线的设备——比如 SoC 内部的 UART、GPIO 控制器、以太网 MAC。你会发现,在/sys/bus/platform/devices/下面躺着大量 SoC 内部外设,这就是虚拟总线把所有“板级设备”统一管理起来的实例。

总线的match函数是配对规则的裁判。platform_bus的配对顺序我在下一章详细拆,这里你先记住:设备想要被驱动找到,必须先挂到总线上;驱动想找设备,也得先注册到同一个总线上。两头缺一头,永远配不上。

2.4 struct class:给设备“分类”,让用户态看得懂

class解决的是“用户态视角”问题。一个设备硬件上在某个总线上,但从应用层的角度看,你更关心它是一个输入设备、一个网络设备还是一个 LED,而不是它挂在哪条总线上。

struct class { const char *name; struct module *owner; ... }

class_create()会在/sys/class/下创建一个以类名命名的目录;device_create()则在这个类目录下创建一个设备子目录,并生成/dev节点。

比如你写一个 GPIO LED 驱动,通常会class_create("led_class"),然后device_create(led_class, ...),于是/dev/led出现,应用层直接 open/write。这就是设备模型向用户态“抛头露面”的标准路径。很多驱动开发者把class仅仅当成“创建设备节点的工具”,这么理解不算错,但要知道它本质是设备模型的一部分,是用户态 sidecar。

3. 设备与驱动怎么“配对”:match 机制与匹配优先级

设备模型的核心操作就是“配对”。每一次device_register()driver_register()的发生,内核都会触发一次总线扫描,看新来的这个家伙能不能和已有对象配对成功。

platform总线为例,platform_match()是配对的实际执行者。它按下面的顺序依次尝试,谁先命中算谁的:

  1. 设备树匹配of_driver_match_device()。它会比较设备树节点里的compatible字符串和驱动的of_match_table中的.compatible。这是现代 ARM/ARM64/RISC-V 平台最主流的匹配方式。
  2. ACPI 匹配acpi_driver_match_device()。在 x86 和某些服务器平台上,固件用 ACPI 表描述硬件,匹配逻辑走的是 ACPI 路径。
  3. ID 表匹配driver_match_device()会查找驱动里的id_table。比如 I2C 驱动有i2c_device_id,SPI 驱动有spi_device_id。对于 platform 驱动,platform_driver中也有id_table,里面保存的是设备的name
  4. 设备名/驱动名匹配platform_match_id()如果都没命中,内核会直接比较driver->driver.nameplatform_device->name是否一致。很多早期 platform 驱动就是这么干的,现在仍然兼容。

这个顺序非常重要。你在调试时如果发现“明明 compatible 不一致,驱动还是 probe 了”,很可能就是第 4 步的 name 匹配兜底了;反过来,你要想确认设备是通过哪种方式匹配上的,可以在probe里打印dev->driver,或者用ls /sys/bus/platform/devices/.../driver看驱动符号链接是否存在。

我当初调一个传感器驱动,DTS 里的compatible写成了"vendor,sensor-v1",驱动of_match_table里写的是"vendor,sensor-v2"。按我的预期是匹配失败,结果驱动照样 probe。查了很久才发现驱动内嵌的 platform_driver 的.name和 platform_device 的name恰好一致,走了第 4 步。你以为的设备树匹配,实际是 name 兜底匹配。这不算 bug,但确实容易让人误判。

还有一个概念叫-EPROBE_DEFER,全称是 probe defer(推迟探测)。当一个设备的 probe 依赖另一个设备(比如依赖某个 regulator、某个时钟或者某个 GPIO 控制器),而依赖对象还没就绪时,驱动返回-EPROBE_DEFER,内核不会报错,而是把这个设备扔回队列,等下次有驱动注册时再尝试。这是设备模型里最优雅的机制之一。没有它,你要自己写依赖排序,麻烦得多。

static int my_probe(struct platform_device *pdev) { struct clk *clk = devm_clk_get(&pdev->dev, "axi"); if (IS_ERR(clk)) { if (PTR_ERR(clk) == -EPROBE_DEFER) return -EPROBE_DEFER; // 告诉内核:我再等等 return PTR_ERR(clk); } ... }

4. probe 之后的资源生命周期:内核对设备的“全生命周期管理”

配对成功之后,probe被调用,驱动和设备正式“绑定”。但设备模型的故事并没有结束,它最强大的地方在于对设备资源生命周期的统一管理

我见过不少开发者写的驱动,probekzalloc分配内存,request_irq注册中断,ioremap映射 IO,然后在remove里一步步手动释放。这样做本身没错,但效率低,而且容易泄漏。设备模型提供了一套devm(managed device resources)API,让你的资源自动绑定到设备生命周期上。

struct my_dev { void __iomem *base; int irq; }; static int my_probe(struct platform_device *pdev) { struct resource *res; struct my_dev *mdev; int irq, ret; mdev = devm_kzalloc(&pdev->dev, sizeof(*mdev), GFP_KERNEL); if (!mdev) return -ENOMEM; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); mdev->base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(mdev->base)) return PTR_ERR(mdev->base); irq = platform_get_irq(pdev, 0); if (irq < 0) return irq; ret = devm_request_irq(&pdev->dev, irq, my_isr, 0, "mydev", mdev); if (ret) return ret; platform_set_drvdata(pdev, mdev); return 0; }

注意:devm_kzallocdevm_ioremap_resourcedevm_request_irq,全程看不到一次手动释放。这就是devm的威力——当设备被移除(remove被调用)或者驱动从总线上解绑之后,内核会按照“后注册的资源先释放”的顺序,自动把内存、IO 映射、中断请求、时钟、GPIO 等全部清理干净。

devm_系列 API 几乎是所有现代内核驱动的默认选择。它省下来的不仅是代码量,更是一整类“谁负责释放、什么时候释放”的 bug。我甚至见过一个驱动因为手动kfree顺序写反,导致use-after-free崩溃的案例。用devm_之后,这类问题从根上消失了。

当然,devm_不是万能药。如果资源生命周期和设备生命周期不一致——比如你要保留一块内存供另一个驱动使用——你就需要手动管理,不能用devm_kzalloc。所以理解devm_的本质比记住函数名更重要:devm_就是把这个资源登记到设备上,让设备替你做善后工作

设备模型的另一个重要生命周期节点是uevent。当设备注册或注销时,内核会向用户态发送uevent事件,udev(或者mdeveudev)收到事件后,在用户态完成设备节点的创建、权限设置、固件加载等动作。内核创建设备、用户态生成节点,这解释了一个现象:嵌入式板子上如果没跑udev,即使驱动 probe 成功,/dev下也不会有节点。你需要手动mknod,或者直接在驱动里用device_create时配合devtmpfs来自动生成。

5. kobject 与 sysfs:看不见的底层架构如何变成你能摸到的文件

说到/sys,就得把设备模型的底层地基翻出来——kobjectkset组成的“内核对象系统”。

你可以把kobject理解成一块“标签”,任何想纳入设备模型管理的对象都要内嵌一个kobject。设备有struct device里的kobj,驱动有kobj,总线也有kobjkobject负责三件事:引用计数(生命周期)、父子关系(拓扑)、sysfs 入口(可视化)。

kset则是同一类kobject的集合,你可以把它理解成一个分组容器。设备模型里的busclasssubsystem本质上就是kset或者由kset扩展开来的。

sysfs 是这个对象系统在用户态的一面镜子。你在终端里看到的一切,都是kobject树在内存中的投影:

  • /sys/devices/:以物理拓扑方式组织的所有设备,这是最真实的视图
  • /sys/bus/:按总线分组,每个bus下有devices/drivers/两个目录
  • /sys/class/:按功能类型分组,比如netinputgpioleds,方便应用层扫描
  • /sys/block/:块设备专用视图

你打开一个设备目录,里面会有大量属性文件。这些文件背后就是device_attribute(或者driver_attribute)在驱动里对应的show()store()函数。在 sysfs 里cat一个文件,等于内核执行了一次show()函数;echo 1 > file,等于内核调用了一次store()函数

举个例子,如果你想在 sysfs 里暴露一个可读写的寄存器:

static ssize_t reg_show(struct device *dev, struct device_attribute *attr, char *buf) { struct my_dev *mdev = dev_get_drvdata(dev); u32 val = readl(mdev->base + REG_OFFSET); return sysfs_emit(buf, "0x%08x\n", val); } static ssize_t reg_store(struct device *dev, struct device_attribute *attr, const char *buf, size_t count) { struct my_dev *mdev = dev_get_drvdata(dev); u32 val; if (kstrtou32(buf, 0, &val)) return -EINVAL; writel(val, mdev->base + REG_OFFSET); return count; } static DEVICE_ATTR_RO(reg_show); static DEVICE_ATTR_WO(reg_store);

然后在probe里用device_create_file()注册属性文件,或者在驱动里用一个宏表一次性创建多个属性文件。对于底层调试来说,这是最直接的“人机接口”——不用写应用层工具,直接 shell 里读写寄存器,非常方便。

还有一个细节值得提:/sys/bus/platform/drivers/xxx/下面有一个bind和一个unbind文件。你可以手动把一个设备从驱动上解绑,或者强制绑定另一个驱动。这在调试阶段极其有用:比如某个驱动probe时中断申请失败,你可以echo <device-name> > /sys/bus/platform/drivers/xxx/unbind,修改参数后再 bind 回去,不用反复卸载加载模块。

ls /sys/bus/platform/drivers/mydev/ echo mydev.0 > /sys/bus/platform/drivers/mydev/unbind echo mydev.0 > /sys/bus/platform/drivers/mydev/bind

这套“对象系统 + 文件系统”的配合,让内核里最复杂的结构在你面前变成了一棵可以自由浏览、操作的目录树。可以说 sysfs 是开发者理解设备模型最趁手的地图。

6. 设备树入局后,驱动模型发生了什么变化

聊设备模型不可能绕开设备树。设备树(Device Tree,DT)对于驱动模型来说,最大的变化是:设备的描述从 C 语言代码里挪到了 DTS 文件里

在设备树之前,内核里每个板子都会写一堆platform_device静态定义来描述板载硬件,代码冗余、依赖硬编码地址、不同板子无法复用。设备树引入后,硬件信息变成数据——compatiblereginterruptsclocksgpios等属性在 DTS 里声明,内核启动时把这些节点解析成platform_device或者i2c_clientspi_device等具体总线设备。

这就引出了驱动开发者要掌握的另一个匹配表——of_match_table

static const struct of_device_id my_of_match[] = { { .compatible = "vendor,mydev-v2", }, { .compatible = "vendor,mydev-v1", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_of_match); static struct platform_driver my_driver = { .probe = my_probe, .remove = my_remove, .driver = { .name = "mydev", .of_match_table = my_of_match, }, }; module_platform_driver(my_driver);

大概在compatible这块有三个容易出问题的点:

一个是of_match_table结尾必须要有哨兵条目,也就是{ /* sentinel */ }。很多人抄代码漏掉这个空条目,导致驱动加载时越界读取,probe莫名异常,甚至内核 panic。

另一个是MODULE_DEVICE_TABLE。这个宏的作用是让modinfo能查看驱动支持的 compatible 列表,同时让内核在模块热插拔时能根据设备树节点自动加载对应模块。不写这个宏,如果驱动是编译成模块的,很容易出现“设备树节点在,驱动也在,却没人 probe”的现象——因为驱动压根没被自动加载。你手动modprobe才有效,但一重启又不行了。

还有一个是compatible的命名规范,一般建议使用"厂商名,设备型号"的形式,比如"fsl,imx6ull-uart"。如果你在 DTS 里写的是全小写字母,在驱动里写的是带大写字母,字符匹配失败,probe不执行,但 dmesg 里往往没有明确报错。排查这类问题要靠of_device_is_compatible()或直接在probe前打印调试信息。

设备树还引入了reginterrupts的属性解析方式。对于一个 platform 设备,platform_get_resource()会根据索引获取内存区域或中断号,而不需要像老式驱动那样从静态定义里硬读地址。资源分离让同一份驱动源码支持多个不同基地址的设备节点,这正是设备树设计的初衷——驱动程序只关心“这个我适配的设备”,不关心“它具体在哪个地址”

我想特别强调一点:设备树并不是只有 ARM 在用,RISC-V、x86(ACPI 不可用或缺失时)也会用扁平设备树。设备树本身就是设备模型在这类嵌入式平台上的“描述组织方式”。理解了设备和驱动模型,再看 DTS 里那些&uart1 { status = "okay"; };的片段,你就知道那其实是在修改一个device节点的一些属性,最终影响的是设备能否被创建、能否被匹配。

7. 手写一个 platform 驱动:从零看完整链路

理论说再多,不如手写一遍。我准备用一个最小的 platform 设备驱动,走通“DTS 描述 → 设备创建 → 总线匹配 → probe → sysfs 暴露 → 用户态访问”这条完整链路。

第一步:DTS 中描述设备

// arch/arm/boot/dts/myboard.dts &iomuxc { mydev { compatible = "vendor,mydev"; reg = <0x02200000 0x1000>; interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>; }; };

这段描述告诉内核:在某个总线上挂了一个设备,厂商是vendor,型号是mydev,它的寄存器基地址在0x02200000,长度是0x1000,中断号是GIC_SPI 42(在 ARM GIC 中断控制器上)。

第二步:写驱动骨架

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/interrupt.h> #include <linux/io.h> #define REG_DATA 0x00 struct mydev_data { void __iomem *base; unsigned int irq; }; static irqreturn_t mydev_isr(int irq, void *dev_id) { struct mydev_data *data = dev_id; u32 status = readl(data->base + REG_DATA); pr_info("mydev: interrupt, status=0x%08x\n", status); return IRQ_HANDLED; } static int mydev_probe(struct platform_device *pdev) { struct resource *res; struct mydev_data *data; int ret; data = devm_kzalloc(&pdev->dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); >insmod mydev.ko ls /sys/bus/platform/devices/ | grep mydev ls /sys/bus/platform/drivers/mydev/ cat /proc/interrupts | grep mydev

如果一切正常,你会看到设备枚举成功,中断号被分配。

这里我要特别推荐module_platform_driver()这个宏,它本质上是:

module_init(mydev_driver_init); module_exit(mydev_driver_exit);

自动帮你生成了platform_driver_register/platform_driver_unregister的包裹函数。这样写出来的驱动结构极其清晰:probe做初始化,remove做清理,剩下的是匹配表信息。

在这个基础上,你还可以利用DEVICE_ATTR加几个属性文件,然后在 shell 里直接读写寄存器验证硬件。从设备模型的角度看,一个驱动做完 probe、注册好资源、暴露 sysfs 属性,就已经是一个“合格”的驱动了。至于字符设备、网络子系统、输入子系统之类的业务层,都是在 probe 之后往具体框架里注册的结果。

8. 调试设备模型时我踩过的坑与排查路径

最后一部分我写点实战里最常遇到的问题。设备模型的好处是高度结构化,所以排查问题的路径也比较固定。

坑一:驱动加载了,probe 却不执行

这是最典型的“设备模型问题”。排查时按下面的链路走:

  1. 先确认设备确实被枚举出来了:ls /sys/bus/platform/devices/ | grep xxx。没有设备,说明 DTS 没生效,检查 DTS 语法、编译的 dtb 是否真的加载了,compatible字符串有没有拼错。
  2. 确认驱动注册成功:ls /sys/bus/platform/drivers/xxx/。没有驱动目录,检查模块是否加载成功,platform_driver_register是否真的执行。
  3. 确认匹配条件满足:cat /sys/bus/platform/devices/xxx/uevent,看OF_NAMEOF_COMPATIBLE和驱动modinfo显示的匹配表是否一致。
  4. dmesg里搜platform或者驱动的名字。如果设备确实尝试过匹配,但驱动返回了-EPROBE_DEFER,你不会看到错误,只能看到probe defer的信息。

坑二:设备节点时有时无

如果你没有跑udev,或者跑的是精简版mdev,经常遇到内核明明已经注册了设备,但/dev下没节点。排查思路是看/sys/class/你的类名/下面有没有对应的设备目录。有目录但/dev没有,那是devtmpfsudev的问题;连/sys/class下都没有,那是你的class_create+device_create没调用成功。

坑三:release回调没实现导致 panic

前面提过,这里展开讲。如果你自己kzalloc了一个struct device,然后注册到总线,最后注销时,内核会调用device->release来释放这块内存。平台总线上的平台设备一般由内核框架统一管理,但你自己device_register一个裸的device时,必须初始化release

static void mydev_release(struct device *dev) { kfree(dev); } static int create_my_device(void) { struct device *dev = kzalloc(sizeof(*dev), GFP_KERNEL); dev->release = mydev_release; dev->bus = &platform_bus_type; dev_set_name(dev, "mydev.0"); return device_register(dev); }

我当初第一次写类似代码时忘了给release赋值,device_unregister时内核直接报BUG: unable to handle kernel NULL pointer dereference,然后整个系统 panic。这一坑在中级内核开发者中极其常见。

坑四:属性文件的读写返回值问题

show()函数返回的值必须是你实际写入buf的字节数,不能多不能少。echostore()返回count。如果你在store()里做了一次strncmp匹配后忘记return count,shell 会一直报echo: write error。新版内核还提供了sysfs_emit()之类的安全接口,推荐优先使用。

坑五:probe里用了msleep()慢启动

设备模型的匹配和 probe 是在内核线程里串行执行的。如果你在probe里加了一个大延时,系统启动时间会肉眼可见地变长。排查慢启动时,initcall_debug是个好帮手,打开后能打印每个 initcall 的耗时,但 platform 驱动的 probe 发生得更早,你可以用ftraceprobe事件追踪。

echo 1 > /sys/kernel/debug/tracing/events/initcall/enable cat /sys/kernel/debug/tracing/trace

坑六:驱动编成模块但没自动加载

很多板子把驱动编成.ko放在根文件系统里,但没有跑depmod,没有把模块路径加到/etc/modules-load.d/,也没有配置modprobe的别名。设备树里明明有兼容节点,驱动模块就是不被自动加载。如果你用的 buildroot,建议在 rootfs 的/etc/modules里加模块名,或者干脆把驱动编进内核——对产品发布来说,编进内核更省心,更新也少。

设备模型这个抽象层,强就强在它把“设备发现”“驱动匹配”“资源生命周期”“用户态可视化”全部统一到一个框架里。花时间把devicedevice_driverbus_typeclass这四件事想透,再看具体子系统的驱动代码,你会发现所有套路基本一致:总线上有设备有驱动,匹配之后probe,然后注册业务接口。这个过程熟悉之后,内核那些看似复杂的子系统源码,你读起来会轻松太多。

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

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

立即咨询