Linux设备驱动模型深度解析:从device到probe再到sysfs
写明白一个底层机制往往比写下十层业务逻辑更有价值。Linux 设备驱动模型就是这样一类东西它不像进程调度、内存管理那样“显眼”但无论是嵌入式开发、内核驱动编写还是系统稳定性排查你绕不开它。很多时候你觉得驱动“莫名其妙”不工作或者设备节点时有时无根子都在设备模型这一层。这篇文章不堆概念我把我从看源码到实际改驱动、调硬件过程中对设备驱动模型的理解从头到尾拆一遍。如果你是想搞懂内核底层的开发者不管你是做嵌入式 Linux、内核驱动还是上层应用想深入理解sysfs、uevent、设备热插拔背后的原理这篇文章都值得你花点时间。设备模型不是一块孤立的“知识”它是连接内核各子系统、暴露硬件拓扑给用户态的枢纽。1. 设备驱动模型到底在解决什么问题——三个关键词讲清楚很多人一上来就背struct device、struct device_driver、struct 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了一个device并device_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函数。所谓“写驱动”本质上是填好probe和remove在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()是配对的实际执行者。它按下面的顺序依次尝试谁先命中算谁的设备树匹配of_driver_match_device()。它会比较设备树节点里的compatible字符串和驱动的of_match_table中的.compatible。这是现代 ARM/ARM64/RISC-V 平台最主流的匹配方式。ACPI 匹配acpi_driver_match_device()。在 x86 和某些服务器平台上固件用 ACPI 表描述硬件匹配逻辑走的是 ACPI 路径。ID 表匹配driver_match_device()会查找驱动里的id_table。比如 I2C 驱动有i2c_device_idSPI 驱动有spi_device_id。对于 platform 驱动platform_driver中也有id_table里面保存的是设备的name。设备名/驱动名匹配platform_match_id()如果都没命中内核会直接比较driver-driver.name和platform_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被调用驱动和设备正式“绑定”。但设备模型的故事并没有结束它最强大的地方在于对设备资源生命周期的统一管理。我见过不少开发者写的驱动probe里kzalloc分配内存request_irq注册中断ioremap映射 IO然后在remove里一步步手动释放。这样做本身没错但效率低而且容易泄漏。设备模型提供了一套devmmanaged device resourcesAPI让你的资源自动绑定到设备生命周期上。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_kzalloc、devm_ioremap_resource、devm_request_irq全程看不到一次手动释放。这就是devm的威力——当设备被移除remove被调用或者驱动从总线上解绑之后内核会按照“后注册的资源先释放”的顺序自动把内存、IO 映射、中断请求、时钟、GPIO 等全部清理干净。devm_系列 API 几乎是所有现代内核驱动的默认选择。它省下来的不仅是代码量更是一整类“谁负责释放、什么时候释放”的 bug。我甚至见过一个驱动因为手动kfree顺序写反导致use-after-free崩溃的案例。用devm_之后这类问题从根上消失了。当然devm_不是万能药。如果资源生命周期和设备生命周期不一致——比如你要保留一块内存供另一个驱动使用——你就需要手动管理不能用devm_kzalloc。所以理解devm_的本质比记住函数名更重要devm_就是把这个资源登记到设备上让设备替你做善后工作。设备模型的另一个重要生命周期节点是uevent。当设备注册或注销时内核会向用户态发送uevent事件udev或者mdev、eudev收到事件后在用户态完成设备节点的创建、权限设置、固件加载等动作。内核创建设备、用户态生成节点这解释了一个现象嵌入式板子上如果没跑udev即使驱动 probe 成功/dev下也不会有节点。你需要手动mknod或者直接在驱动里用device_create时配合devtmpfs来自动生成。5. kobject 与 sysfs看不见的底层架构如何变成你能摸到的文件说到/sys就得把设备模型的底层地基翻出来——kobject和kset组成的“内核对象系统”。你可以把kobject理解成一块“标签”任何想纳入设备模型管理的对象都要内嵌一个kobject。设备有struct device里的kobj驱动有kobj总线也有kobj。kobject负责三件事引用计数生命周期、父子关系拓扑、sysfs 入口可视化。kset则是同一类kobject的集合你可以把它理解成一个分组容器。设备模型里的bus、class、subsystem本质上就是kset或者由kset扩展开来的。sysfs 是这个对象系统在用户态的一面镜子。你在终端里看到的一切都是kobject树在内存中的投影/sys/devices/以物理拓扑方式组织的所有设备这是最真实的视图/sys/bus/按总线分组每个bus下有devices/和drivers/两个目录/sys/class/按功能类型分组比如net、input、gpio、leds方便应用层扫描/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 TreeDT对于驱动模型来说最大的变化是设备的描述从 C 语言代码里挪到了 DTS 文件里。在设备树之前内核里每个板子都会写一堆platform_device静态定义来描述板载硬件代码冗余、依赖硬编码地址、不同板子无法复用。设备树引入后硬件信息变成数据——compatible、reg、interrupts、clocks、gpios等属性在 DTS 里声明内核启动时把这些节点解析成platform_device或者i2c_client、spi_device等具体总线设备。这就引出了驱动开发者要掌握的另一个匹配表——of_match_tablestatic 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前打印调试信息。设备树还引入了reg和interrupts的属性解析方式。对于一个 platform 设备platform_get_resource()会根据索引获取内存区域或中断号而不需要像老式驱动那样从静态定义里硬读地址。资源分离让同一份驱动源码支持多个不同基地址的设备节点这正是设备树设计的初衷——驱动程序只关心“这个我适配的设备”不关心“它具体在哪个地址”。我想特别强调一点设备树并不是只有 ARM 在用RISC-V、x86ACPI 不可用或缺失时也会用扁平设备树。设备树本身就是设备模型在这类嵌入式平台上的“描述组织方式”。理解了设备和驱动模型再看 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, status0x%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 却不执行这是最典型的“设备模型问题”。排查时按下面的链路走先确认设备确实被枚举出来了ls /sys/bus/platform/devices/ | grep xxx。没有设备说明 DTS 没生效检查 DTS 语法、编译的 dtb 是否真的加载了compatible字符串有没有拼错。确认驱动注册成功ls /sys/bus/platform/drivers/xxx/。没有驱动目录检查模块是否加载成功platform_driver_register是否真的执行。确认匹配条件满足cat /sys/bus/platform/devices/xxx/uevent看OF_NAME、OF_COMPATIBLE和驱动modinfo显示的匹配表是否一致。dmesg里搜platform或者驱动的名字。如果设备确实尝试过匹配但驱动返回了-EPROBE_DEFER你不会看到错误只能看到probe defer的信息。坑二设备节点时有时无如果你没有跑udev或者跑的是精简版mdev经常遇到内核明明已经注册了设备但/dev下没节点。排查思路是看/sys/class/你的类名/下面有没有对应的设备目录。有目录但/dev没有那是devtmpfs或udev的问题连/sys/class下都没有那是你的class_createdevice_create没调用成功。坑三release回调没实现导致 panic前面提过这里展开讲。如果你自己kzalloc了一个struct device然后注册到总线最后注销时内核会调用device-release来释放这块内存。平台总线上的平台设备一般由内核框架统一管理但你自己device_register一个裸的device时必须初始化releasestatic 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的字节数不能多不能少。echo时store()返回count。如果你在store()里做了一次strncmp匹配后忘记return countshell 会一直报echo: write error。新版内核还提供了sysfs_emit()之类的安全接口推荐优先使用。坑五probe里用了msleep()慢启动设备模型的匹配和 probe 是在内核线程里串行执行的。如果你在probe里加了一个大延时系统启动时间会肉眼可见地变长。排查慢启动时initcall_debug是个好帮手打开后能打印每个 initcall 的耗时但 platform 驱动的 probe 发生得更早你可以用ftrace的probe事件追踪。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里加模块名或者干脆把驱动编进内核——对产品发布来说编进内核更省心更新也少。设备模型这个抽象层强就强在它把“设备发现”“驱动匹配”“资源生命周期”“用户态可视化”全部统一到一个框架里。花时间把device、device_driver、bus_type、class这四件事想透再看具体子系统的驱动代码你会发现所有套路基本一致总线上有设备有驱动匹配之后probe然后注册业务接口。这个过程熟悉之后内核那些看似复杂的子系统源码你读起来会轻松太多。