Linux内核struct device深度解析:从设计原理到驱动开发实战

发布时间:2026/10/12 23:35:47
Linux内核struct device深度解析:从设计原理到驱动开发实战
1. 设备模型的地基为什么每个驱动开发者都绕不开 struct deviceLinux 内核里有一个结构体几乎每个写驱动的人都跟它打过照面但真正把它吃透的人并不多那就是struct device。你可以把它理解成内核对一个设备这件事的抽象身份证——不管是挂在 I2C 总线上的温湿度传感器、走 PCIe 通道的网卡、还是 USB 口插进来的键盘在内核眼里最终都会被表示成一个struct device实例。它不负责具体怎么读写寄存器那是各子系统驱动的事它负责的是这个设备是谁、挂在哪条总线上、属于哪个驱动、跟谁有父子关系、电源怎么管、什么时候释放这一整套通用逻辑。我刚开始接触内核驱动的时候最容易犯的错就是把struct device和具体子系统的设备结构体搞混。比如写 I2C 驱动时用的是struct i2c_client写平台设备时用的是struct platform_device写 SPI 时是struct spi_device。这些结构体看起来各不相同但它们内部都内嵌了一个struct device而且这个内嵌成员必须是第一个字段。这个设计不是随便定的而是内核实现继承和向上转型的关键手法——拿到一个struct i2c_client *的指针直接强转成struct device *就能访问通用设备层因为偏移量是零。理解这一点是理解整个 Linux 设备模型device model的起点。这篇文章我打算从实战角度把struct device拆开讲透。适合谁看如果你已经能写一个最简单的字符设备驱动但对device_register、device_create、dev_set_drvdata、引用计数kobject这些东西还是一知半解那这篇就是写给你的。如果你正在调试驱动 probe 不触发设备节点没生成rmmod 之后内核报 use-after-free这类问题那更要往下看因为这些问题十有八九都跟struct device的生命周期和字段理解不到位有关。我会先讲整体设计思路再逐字段拆解核心成员然后给出一套可复现的实操流程最后把我踩过的坑整理成排查表。2. 整体设计思路内核为什么要造出 device 这一层抽象2.1 从各管各的到统一模型设备模型解决的真实痛点在设备模型出现之前内核里的驱动基本是各写各的。网卡驱动自己维护一套设备列表块设备驱动自己维护一套字符设备又一套。带来的问题是电源管理没法统一做因为内核不知道当前有哪些设备、它们的父子关系是什么热插拔没法优雅处理因为没有一个统一的设备注册/注销入口sysfs 也没法自动生成因为没人知道设备之间的层次结构。设备模型就是为了解决这三个问题而生的统一表示、统一生命周期、统一用户空间视图。struct device就是这套模型的原子单位。它通过内嵌的struct kobject接入 sysfs 和引用计数体系通过struct bus_type *bus接入总线匹配机制通过struct device_driver *driver关联到具体驱动通过struct device *parent建立树状层次。你可以把这几个字段想象成一张关系网kobject 负责我在文件系统里长什么样、什么时候能被销毁bus 负责谁来给我找驱动driver 负责找到之后谁来操作我parent 负责我在设备树里挂在哪根枝上。2.2 内嵌而非继承C 语言里模拟面向对象的经典手法前面提到子系统的设备结构体都把struct device放在第一个字段这是内核里非常经典的伪继承技巧。我举个具体例子struct i2c_client的定义大致是这样的struct i2c_client { unsigned short flags; unsigned short addr; char name[I2C_NAME_SIZE]; struct i2c_adapter *adapter; struct device dev; /* 内嵌的通用设备 */ int irq; /* ... */ };注意dev不一定在第一个位置但内核提供了to_i2c_client(d)这样的宏通过container_of反推出外层结构体的地址。所以真正的机制是双向的向上转型i2c_client→device靠偏移计算向下转型device→i2c_client靠container_of。为什么这么设计因为 C 没有继承但内核需要通用代码操作通用部分、具体代码操作具体部分的能力。总线层的probe函数拿到的是struct device *它只关心通用字段而具体驱动的probe拿到的是struct i2c_client *它需要addr、adapter这些具体信息。两者通过转型宏无缝衔接。提示自己写子系统封装时如果也想用这套模式务必保证struct device是外层结构体的第一个成员或者至少用container_of时偏移计算正确。我见过有人把dev放在中间还手动做指针加减结果编译器对齐一改就崩得不偿失。2.3 生命周期与引用计数kobject 才是幕后老板很多人以为struct device的生死由device_register/device_unregister决定其实真正管命的是它内嵌的struct kobject。kobject有一套引用计数kref机制kobject_get加一kobject_put减一减到零才真正调用 release 回调释放内存。struct device的kobj字段就是这个 kobject。为什么这么绕因为设备可能同时被多个地方引用sysfs 里有一个目录项引用它驱动 core 的链表引用它某个正在执行的 probe 也可能临时持有它。如果谁想释放就释放必然出现 use-after-free。引用计数保证了最后一个放手的人才负责销毁。这里有个非常关键的坑device_register成功之后如果你直接kfree掉自己分配的设备结构体那就是典型的错误。正确做法是调用put_device(dev)让引用计数归零后由device_release回调去释放。而device_release默认只会释放struct device本身如果你外层还包了i2c_client这种结构体就必须自己提供 release 回调在里面用container_of拿到外层再释放。这个细节我在第 4 节会展开讲。3. 核心字段逐个拆解哪些成员你必须搞懂3.1 身份标识三件套init_name、kobj 与 devtstruct device里跟我叫什么、我在哪相关的字段主要有三个。init_name是设备初始化时用的名字注册进设备模型后这个名字会被 kobject 接管之后你再去读init_name可能已经是 NULL 了所以别在注册后依赖它。kobj前面说过是接入 sysfs 和引用计数的入口它的name字段才是注册后真正的设备名。devt是设备号类型是dev_t由主设备号和次设备号组成主要给字符设备和块设备用用来在/dev下生成节点。我实测过一个容易混淆的点dev_set_name和直接赋值init_name的区别。dev_set_name会走 kobject 的命名逻辑支持格式化字符串而且注册后名字是稳定的直接写init_name只在注册前有效。所以规范做法是分配设备结构体后立刻dev_set_name(dev, mydev%d, id)而不是手动strcpy。后者在名字带格式或者需要唯一性时会出问题。3.2 拓扑关系parent、bus 与 driver 如何串起匹配流程parent字段决定了设备在 sysfs 里的目录层次。比如一个挂在 I2C 适配器下的传感器它的 parent 就是那个适配器对应的设备于是 sysfs 里就呈现为/sys/bus/i2c/devices/.../下面嵌套的层次。这个层次不只是好看电源管理会沿着 parent 链向上传播父设备挂起时子设备也要跟着处理。bus字段指向设备所属的总线类型比如i2c_bus_type、platform_bus_type。设备注册到总线后总线 core 会拿设备和总线上已注册的驱动做匹配。匹配规则由总线的match回调决定常见的是比对of_device_id、acpi_device_id或者name字段。匹配成功后调用驱动的probe。driver字段则在匹配成功后由总线 core 填上指向那个驱动。所以一个设备从注册到 probe 的完整链路是device_register→ 加入 bus 的设备链表 → 触发bus_probe_device→ 遍历驱动链表做 match → 命中后绑定 driver 并调 probe。注意如果你发现设备注册了但 probe 死活不触发先别怀疑驱动代码按这个顺序查——bus 字段对不对、match 规则能不能命中、驱动是不是已经注册到同一条 bus 上。我遇到过有人把平台设备注册到了 I2C 总线上match 永远不成功查了半天。3.3 驱动私有数据drvdata 的正确用法与陷阱drvdata是驱动存放自己私有数据的指针配套的 API 是dev_set_drvdata和dev_get_drvdata。它的典型用法是在 probe 里把驱动自己分配的一个上下文结构体挂上去之后在中断处理、文件操作、电源回调里通过dev_get_drvdata取回来。为什么不用全局变量因为一个驱动可能同时服务多个同类型设备每个设备都需要独立的上下文全局变量会互相踩。这里有个经典陷阱drvdata的生命周期必须和设备的生命周期对齐。如果你在 probe 里kmalloc了上下文挂上去那在remove回调里必须释放否则设备注销后内存泄漏。更隐蔽的是如果设备被引用计数延迟释放而你在 remove 里就把 drvdata 释放了后续还有代码通过dev_get_drvdata访问就是 use-after-free。稳妥的做法是把上下文的释放放到 release 回调里或者确保 remove 之后没有任何路径还能拿到这个 device。3.4 电源管理与回调pm_domain、links 与 releasestruct device里跟电源相关的字段不少pm_domain指向电源域links维护设备间的电源依赖关系还有power子结构体记录运行时电源状态。对大多数简单驱动来说你不需要直接操作这些但要知道它们的存在因为dev_pm_ops里的suspend/resume回调就是通过设备模型统一调度的。如果你的设备有电源依赖比如必须先给父设备上电我才能工作那就要正确设置 parent让电源管理按层次顺序处理。release回调是设备释放时的最后一道关卡函数指针类型是void (*release)(struct device *dev)。前面反复强调过引用计数归零后调用的就是它。默认的device_release只释放struct device本身如果你外层包了别的结构体必须自己实现 release 并在里面释放外层。我建议的做法是分配时用kzalloc分配外层结构体注册前设置dev-release my_release在my_release里用container_of拿到外层再kfree。这样无论谁最后 put内存都能正确回收。4. 实操过程从零注册一个设备并观察它的完整生命周期4.1 环境准备与最小可复现模块框架要动手验证struct device的行为最直接的方式是写一个内核模块手动注册一个平台设备或者直接注册一个裸设备。我下面给的是一个基于平台设备的完整示例因为它最贴近日常开发而且不需要真实硬件就能跑。环境上你需要一台能编译内核模块的 Linux 机器装好对应内核版本的linux-headers然后准备一个 Makefile。obj-m devdemo.o KDIR : /lib/modules/$(shell uname -r)/build all: make -C $(KDIR) M$(PWD) modules clean: make -C $(KDIR) M$(PWD) modules clean模块代码的核心思路是定义一个平台设备注册它观察 sysfs 里生成了什么再注销它观察释放顺序。为了看到 release 回调被调用我会在 release 里打印日志。4.2 注册流程代码逐行解析与参数说明#include linux/module.h #include linux/platform_device.h #include linux/slab.h struct my_dev_ctx { int id; char tag[32]; }; static void my_dev_release(struct device *dev) { struct my_dev_ctx *ctx dev_get_drvdata(dev); pr_info(devdemo: release called, tag%s\n, ctx ? ctx-tag : null); kfree(ctx); } static struct platform_device *pdev; static int __init devdemo_init(void) { struct my_dev_ctx *ctx; int ret; pdev platform_device_alloc(devdemo, 0); if (!pdev) return -ENOMEM; ctx kzalloc(sizeof(*ctx), GFP_KERNEL); if (!ctx) { platform_device_put(pdev); return -ENOMEM; } ctx-id 0; snprintf(ctx-tag, sizeof(ctx-tag), demo-instance-0); /* 关键设置 release 回调否则注销时会告警 */ pdev-dev.release my_dev_release; dev_set_drvdata(pdev-dev, ctx); dev_set_name(pdev-dev, devdemo%d, 0); ret platform_device_add(pdev); if (ret) { pr_err(devdemo: add failed %d\n, ret); platform_device_put(pdev); return ret; } pr_info(devdemo: registered, check /sys/devices/platform/devdemo0\n); return 0; } static void __exit devdemo_exit(void) { platform_device_unregister(pdev); pr_info(devdemo: unregistered\n); } module_init(devdemo_init); module_exit(devdemo_exit); MODULE_LICENSE(GPL);逐行说几个关键点。platform_device_alloc会分配一个struct platform_device它内部内嵌了struct device并且已经帮你把dev.release设成了默认的平台设备 release 回调。但因为我们想自己管理drvdata的释放所以覆盖成了my_dev_release。dev_set_drvdata把上下文挂到dev-driver_data上之后 release 里用dev_get_drvdata取回来释放。dev_set_name设置设备名注意这里用了格式化保证名字唯一。platform_device_add才是真正把设备注册进设备模型、生成 sysfs 目录、触发总线匹配的动作。4.3 观察 sysfs 与引用计数变化模块insmod之后你可以去/sys/devices/platform/devdemo0/下面看。会看到uevent、driver、subsystem、modalias等文件。subsystem是个符号链接指向/sys/bus/platform这就是bus字段在用户空间的体现。driver链接此时是空的或者不存在因为我们没有注册匹配的驱动所以driver字段还没被填上。引用计数的观察稍微麻烦一点因为内核不直接暴露 kref 的值。但你可以通过行为间接验证在 release 回调里打印日志然后rmmod看日志是否出现。如果没出现说明有地方没 put 干净引用计数没归零。我实测下来只要platform_device_add成功platform_device_unregister就会触发一次 put正常情况下 release 会立刻被调用。如果你在别处get_device过却没 putrelease 就会延迟这时候rmmod之后lsmod看不到模块但内存其实还没释放属于隐蔽泄漏。提示调试引用计数问题时可以在关键路径上临时加pr_info打印kobject的kref值需要包含内部头文件仅调试用。生产代码里不要这么干但排查阶段非常有效。4.4 注销顺序与释放回调的验证注销时最容易出问题的就是释放顺序。正确的顺序是先确保没有任何外部引用比如中断已经释放、文件已经关闭、sysfs 属性已经移除再调用platform_device_unregister。这个函数内部会做device_del和put_devicedevice_del负责从 sysfs 和总线链表里摘除put_device负责减引用计数。如果计数归零release 被调用你在里面释放 drvdata。我踩过的一个坑是在 release 回调里又去访问了dev-bus或者dev-driver结果发现是 NULL 或者野指针。原因是device_del阶段已经把这些关联断开了。所以 release 回调里只能访问你自己挂上去的 drvdata别碰设备模型的其他字段。这个约束在写复杂驱动时特别重要因为很多人习惯在 release 里做清理关联的动作但那时候关联早就没了。5. 常见问题与排查技巧实录5.1 设备注册失败与 probe 不触发的排查路径设备注册失败最常见的原因是名字冲突或者内存分配失败。device_register返回非零时先看返回值-EEXIST说明 sysfs 里已经有同名设备-ENOMEM是内存问题。名字冲突在动态编号场景下很常见比如你固定用devdemo0但上次模块没卸载干净就会撞名。解决办法是用ida或者idr做动态编号或者注册前先检查。probe 不触发的原因就更多了。我整理了一个排查顺序第一确认设备真的注册成功了/sys/bus/bus/devices/下面能看到第二确认驱动也注册到了同一条 bus 上/sys/bus/bus/drivers/下面能看到第三确认 match 规则能命中平台设备看name是否完全一致设备树设备看compatible是否匹配第四确认驱动的probe函数指针不为空。这四步走完九成问题都能定位。5.2 引用计数泄漏导致的模块无法卸载模块rmmod报 Module is in use 或者卸载后内核报 use-after-free基本都是引用计数没管好。常见场景有三个一是device_register之后又手动get_device了但没配对put_device二是 sysfs 属性文件创建了但没在 remove 里移除属性文件本身持有设备引用三是中断处理里拿了设备引用但中断没释放。排查方法是逐个检查所有get_device/kobject_get的调用点确保每个都有对应的 put。我遇到过一个特别隐蔽的在probe里创建了一个sysfs属性组但remove里只移除了属性组没释放属性组内存结果属性组的 kobject 还持有设备引用导致设备永远不释放。后来改成用devm_系列 API 自动管理问题就消失了。所以我的建议是能用devm_就用devm_它会在设备释放时自动清理省掉大量手动 put 的麻烦。5.3 drvdata 访问越界与生命周期错配dev_get_drvdata返回 NULL 或者野指针通常是因为访问时机不对。比如在probe之前就访问那时候 drvdata 还没设置或者在remove之后访问那时候已经释放了。还有一种情况是多个驱动共用同一个设备各自都dev_set_drvdata后设置的覆盖前面的导致先设置的驱动取到别人的数据。这种设计本身就是错的一个设备的 drvdata 应该只有一个所有者。生命周期错配的典型表现是设备还在但 drvdata 指向的内存已经被释放。这通常发生在remove里释放了 drvdata但设备因为引用计数没归零还没真正销毁后续某个回调又去取 drvdata。解决办法是把 drvdata 的释放统一放到 release 回调里保证设备活着数据就活着设备销毁数据才销毁。5.4 常见问题速查表现象可能原因排查方法解决思路设备注册返回 -EEXISTsysfs 同名设备已存在检查/sys/devices/...下是否有同名用动态编号或先清理旧设备probe 不触发bus 不匹配或 match 失败检查 bus 字段和 match 规则修正 bus 或 name/compatiblermmod 报模块占用引用计数未归零检查所有 get_device 调用点配对 put_device 或用 devm_drvdata 为 NULL访问时机早于 probe确认访问发生在 probe 之后调整访问时机或加判空release 不调用有未释放的引用检查 sysfs 属性和中断移除属性、释放中断内核报 use-after-freedrvdata 提前释放检查 remove 里的释放逻辑移到 release 回调里释放注意这张表里的解决思路都是通用方向具体到你的驱动还要结合实际情况。比如动态编号虽然能避免冲突但如果你需要固定的设备名给用户空间用那就得换思路比如用device_create配合类来生成稳定的/dev节点。6. 进阶话题device 与 device_driver、class 的协作关系6.1 device 与 device_driver 的绑定与解绑struct device和struct device_driver是一对搭档。设备注册后等待驱动驱动注册后等待设备两者通过 bus 的 match 回调牵线。绑定成功后dev-driver指向驱动驱动的设备链表里也加入了这个设备。解绑发生在驱动卸载或者设备注销时会调用驱动的remove回调。这里有个细节remove回调执行时dev-driver还是有效的但执行完之后就会被清空。所以如果你在 remove 里需要访问驱动私有数据用dev_get_drvdata拿设备级的用dev-driver拿驱动级的都还来得及。绑定的过程还涉及probe的返回值处理。如果probe返回负数绑定会失败设备会进入未绑定状态等待下次匹配。这个机制常被用来实现驱动暂时不想接管这个设备的逻辑比如资源还没准备好时返回-EPROBE_DEFER内核会稍后重试。-EPROBE_DEFER是个非常实用的技巧尤其在设备有依赖关系时比手动轮询优雅得多。6.2 class 与 device_create自动生成设备节点的正确姿势如果你写的是字符设备驱动最终目标往往是在/dev下生成一个节点。这就要用到class和device_create。class是一类设备的抽象比如tty、input、block它在/sys/class/下建目录。device_create会在指定 class 下创建一个设备并自动在/dev下生成节点需要 udev 或 mdev 配合。相比手动mknod这种方式的好处是节点名、权限、主次设备号都由内核统一管理热插拔时也能自动处理。device_create内部会分配一个struct device并注册所以它同样遵循前面讲的引用计数和 release 规则。注销时用device_destroy它会做device_unregister并释放。我建议把class_create和device_create的返回值都检查一遍尤其是device_create失败时往往是因为设备号耗尽或者名字冲突早发现早处理。6.3 设备树与 ACPI 场景下 device 的生成差异在现代嵌入式系统里设备大多不是代码里静态定义的而是从设备树Device Tree或者 ACPI 表里解析出来的。设备树场景下内核启动时会遍历设备树节点为每个有compatible属性的节点创建对应的struct device然后注册到相应的总线。这时候你不需要手动platform_device_alloc内核帮你做了。你只需要写驱动用of_device_id表声明支持的compatible匹配上之后 probe 就会被调用。ACPI 场景类似只是匹配规则换成了acpi_device_id。这两种场景下struct device的of_node或fwnode字段会被填上驱动可以通过of_property_read_u32之类的 API 读取设备树里的参数。理解这个差异很重要因为很多新手在设备树系统里还在手动注册平台设备结果跟内核自动创建的设备撞车或者驱动永远匹配不上。记住一句话设备树系统里设备由内核创建驱动只负责匹配和操作。7. 我个人的几条实战心得写驱动这些年关于struct device我最大的体会是把它当成一个有生命周期的对象而不是一个静态的数据结构。很多人出问题都是因为只关注字段怎么填忽略了它什么时候被创建、什么时候被引用、什么时候被销毁。引用计数、release 回调、drvdata 生命周期这三件事只要有一件没理顺就会在某个不经意的时刻炸出来。第二条心得是能用框架 API 就别自己造轮子。devm_系列、device_create、platform_device_register_simple这些封装好的接口背后都处理了大量边界情况。我早期喜欢手动管理一切觉得可控后来发现可控的代价是无穷无尽的调试。现在我的原则是除非有明确理由否则优先用高层 API。第三条是调试设备模型问题时sysfs 是你最好的朋友。/sys/devices/、/sys/bus/、/sys/class/这三个目录能告诉你设备注册到哪了、匹配到哪个驱动了、属于哪个类。遇到问题先别急着改代码先去 sysfs 里看一眼实际状态往往一眼就能看出问题所在。这个习惯帮我省下了大量猜测的时间。最后分享一个小技巧如果你不确定某个设备的生命周期行为可以写一个最小模块只做注册和注销在 release 里打印日志然后反复insmod/rmmod观察。这种最小复现的方法比在复杂驱动里调试高效得多因为变量少问题暴露得直接。等你把最小模块的行为摸透了再回到真实驱动里对照基本就能定位到问题。