ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从字符设备到platform:Linux设备驱动模型核心解析

从字符设备到platform:Linux设备驱动模型核心解析 1. 从“能写驱动”到“看懂内核”中间隔着一整个设备驱动模型刚接触内核模块那阵子我写完一个能点亮板子上 LED 的字符设备驱动就觉得自己已经摸到 Linux 的底了。直到有一次做板级 Bring-up同事随口问我一句/sys/bus/platform/devices下面那些目录到底是谁创建的删掉一个会发生什么我当场卡壳。那一刻才意识到我之前只是“会调用 API”并不理解 Linux 设备驱动模型这套骨架在干什么。设备驱动模型Driver Model是内核 2.6 之后引入的一层统一抽象它把“总线、设备、驱动”这三样东西用一套数据结构串起来让内核知道系统里有哪些硬件、每个硬件归哪个驱动管、驱动什么时候被调用。没有它每加一种新总线、新外设内核都要写一套自己的枚举和注册逻辑代码会膨胀到无法维护。有了它无论是 PCI、USB、I2C、SPI 还是平台设备platform都走同一套“注册—匹配—probe—注销”的流程。这篇内容适合三类人一是能写简单模块但被kobject、bus_type、device_driver绕晕的嵌入式开发者二是准备内核相关面试、想把“设备驱动模型”这个高频考点讲透的人三是做系统移植、板级调试天天和/sys、/dev、dmesg打交道却说不清底层链路的工程师。我会从设计思路讲到手写可加载模块把字符设备和平台设备两条主线都跑通再补上我实际踩过的坑。看完你至少能做到看到一个设备节点能顺着 sysfs 找到它的驱动看到一条 probe 失败日志能定位到是匹配没成功还是资源申请失败。2. 设备驱动模型到底解决了什么问题为什么绕不开它2.1 没有驱动模型的世界是什么样子在设备驱动模型出现之前驱动注册基本是“各管各的”。字符设备直接register_chrdev塞一个主设备号进去网卡驱动自己维护一张链表PCI 驱动自己遍历总线扫描设备。问题非常明显设备号和资源分配容易冲突热插拔基本没法处理电源管理更是无从谈起因为系统根本不知道“谁在用这块硬件”。更麻烦的是信息不透明。你想知道某个 GPIO 被哪个驱动占了只能去翻代码想知道一个 USB 设备当前绑定了哪个驱动没有统一入口。内核社区当时的思路很明确既然所有硬件都可以归纳成“挂在某条总线上、由某个驱动服务”那就抽象出统一的 bus、device、driver 三层对象再用一套引用计数和生命周期管理把它们管起来。这套东西就是后来的 LDMLinux Device Model。我个人的理解是驱动模型不产生功能它产生的是秩序。它不负责帮你读写寄存器但它决定了你的probe函数什么时候被调用、以什么顺序调用、失败之后怎么回滚。2.2 四层抽象kobject、kset、subsystem 与 bus讲设备驱动模型绕不开kobject。它是整个模型的地基作用是提供引用计数kref、提供 sysfs 中的目录映射、支持热插拔事件上报。你可以把kobject理解成“内核对象的最小身份证”任何一个需要出现在/sys里的东西底层都挂着一个kobject。kset是一组同类kobject的集合负责批量管理比如所有块设备可以归到一个 kset 下。再往上早期有subsystem的概念新内核里逐渐弱化被bus_type、class这些更具体的类型取代。bus_type描述一条总线它最重要的两个回调就是match和probestruct bus_type { const char *name; int (*match)(struct device *dev, struct device_driver *drv); int (*probe)(struct device *dev); int (*remove)(struct device *dev); /* ... 省略电源管理相关字段 ... */ };这里有个设计上的巧思match和probe放在 bus 层而不是放在 device 或 driver 里。原因是“怎么判断设备和驱动是一对”这件事本质上属于总线协议的知识I2C 用地址匹配USB 用 VID/PID 匹配平台设备用名字字符串匹配。放在 bus 层具体总线各自实现上层流程统一。这就是典型的“模板方法”思路——流程固定细节下沉。我建议你在读内核源码时先把drivers/base/这个目录过一遍core.c、bus.c、dd.cdriver core 的匹配逻辑就在这三个文件把主干讲清楚了比零散看博客效率高得多。2.3 sysfs 目录树是怎么“长”出来的很多人第一次打开/sys会懵目录层级看着乱其实规则非常固定。/sys/devices/是真实的物理拓扑设备之间的父子关系在这里体现比如一个 I2C 控制器下面挂着的传感器在/sys/devices/里就是父子目录。而/sys/bus/、/sys/class/、/sys/block/都是符号链接视图是为了方便按不同维度查找而生成的快捷入口。举个例子你要找一个平台设备对应的驱动ls -l /sys/bus/platform/devices/ | head ls -l /sys/bus/platform/drivers/进入某个设备目录后driver是一个软链接指向它的驱动而进入某个驱动目录bind和unbind两个文件允许你手动绑定/解绑设备。这两个文件在实际调试中非常好用——当你想验证“驱动本身有没有问题还是匹配逻辑有问题”时手动 echo 一下设备名到bind如果 probe 成功说明匹配环节被卡住了。注意unbind一个正在被使用的设备比如正在挂载的存储设备会导致系统异常生产环境千万别随手操作我在测试机上就手滑解绑过根文件系统所在的设备后果是整个系统直接失去响应只能硬重启。理解了“真实拓扑 多维视图”这个设计你再看/sys就不会觉得乱了找物理归属去/sys/devices按总线找去/sys/bus按功能找去/sys/class。3. 亲手写一个字符设备驱动把模型跑通3.1 环境准备与模块骨架搭建理论说再多不如自己写一遍。环境方面我建议用一台可以随便折腾的虚拟机装好和你目标内核版本一致的linux-headers或完整源码树。判断方法很简单uname -r ls /lib/modules/$(uname -r)/build如果第二条命令报错就说明头文件没装make一定会失败报Kernel configuration is invalid或者找不到Makefile。这个坑我见过太多次很多人以为是代码问题其实是环境没配好。模块的 Makefile 标准写法如下关键点是M$(PWD)告诉内核源码树“外部模块在这里”obj-m demo_chr.o KDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) cleanobj-m表示编译成可动态加载模块.ko如果想直接编进内核改成obj-y并放进对应目录的 Makefile 里。这里有个细节$(MAKE)前面的缩进必须是Tab不是空格。Makefile 对这个极其敏感报missing separator九成是这个原因。驱动代码我按最小可运行来写包含设备号动态申请、cdev 注册、自动创建设备节点三部分#include linux/module.h #include linux/fs.h #include linux/cdev.h #include linux/device.h #include linux/slab.h #include linux/uaccess.h #define DEV_NAME demo_chr #define BUF_SIZE 256 static dev_t demo_devno; static struct cdev demo_cdev; static struct class *demo_class; static char *demo_buf; static int demo_open(struct inode *inode, struct file *filp) { filp-private_data demo_buf; pr_info(demo_chr: open\n); return 0; } static int demo_release(struct inode *inode, struct file *filp) { pr_info(demo_chr: release\n); return 0; } static ssize_t demo_read(struct file *filp, char __user *ubuf, size_t count, loff_t *ppos) { if (*ppos BUF_SIZE) return 0; if (count BUF_SIZE - *ppos) count BUF_SIZE - *ppos; if (copy_to_user(ubuf, demo_buf *ppos, count)) return -EFAULT; *ppos count; return count; } static ssize_t demo_write(struct file *filp, const char __user *ubuf, size_t count, loff_t *ppos) { if (*ppos BUF_SIZE) return -ENOSPC; if (count BUF_SIZE - *ppos) count BUF_SIZE - *ppos; if (copy_from_user(demo_buf *ppos, ubuf, count)) return -EFAULT; *ppos count; return count; } static const struct file_operations demo_fops { .owner THIS_MODULE, .open demo_open, .release demo_release, .read demo_read, .write demo_write, };file_operations是用户态系统调用进入内核后的第一站read/write/open这些用户调用最终会被 VFS 分发到这里对应的函数指针。.owner THIS_MODULE这行别省它负责引用计数防止你正在读写的时候模块被rmmod掉导致内核直接崩。3.2 从设备号到设备节点的完整注册链路初始化函数是整套流程的核心我把它拆成四步看static int __init demo_init(void) { int ret; /* 第一步动态申请设备号 */ ret alloc_chrdev_region(demo_devno, 0, 1, DEV_NAME); if (ret 0) { pr_err(alloc_chrdev_region failed: %d\n, ret); return ret; } /* 第二步准备数据缓冲区 */ demo_buf kzalloc(BUF_SIZE, GFP_KERNEL); if (!demo_buf) { ret -ENOMEM; goto err_unregister; } /* 第三步初始化并添加 cdev */ cdev_init(demo_cdev, demo_fops); demo_cdev.owner THIS_MODULE; ret cdev_add(demo_cdev, demo_devno, 1); if (ret) { pr_err(cdev_add failed: %d\n, ret); goto err_free; } /* 第四步创建设备类和设备节点 */ demo_class class_create(DEV_NAME); if (IS_ERR(demo_class)) { ret PTR_ERR(demo_class); goto err_cdev; } if (IS_ERR(device_create(demo_class, NULL, demo_devno, NULL, DEV_NAME))) { ret -ENODEV; goto err_class; } pr_info(demo_chr: registered major%d\n, MAJOR(demo_devno)); return 0; err_class: class_destroy(demo_class); err_cdev: cdev_del(demo_cdev); err_free: kfree(demo_buf); err_unregister: unregister_chrdev_region(demo_devno, 1); return ret; }这里的错误处理用goto链式回滚是内核代码的标准写法。为什么要这么写因为模块初始化失败后如果已经申请的资源不释放别人再加载同类型模块就会失败或者留下悬空的设备号。内核里没有异常机制所有回滚都得手写goto是保证“每条失败路径都释放对应资源”的最清晰方式。class_create这一句要注意版本差异老内核是class_create(owner, name)两个参数5.x 之后逐步改成单参数class_create(name)新版编译器会直接报参数过多。这也是移植老驱动时最常见的编译错误之一。3.3 实测加载与观察 sysfs 的真实变化代码写好了编译加载make sudo insmod demo_chr.ko dmesg | tail -n 5 ls -l /dev/demo_chr ls /sys/class/demo_chr/正常的话dmesg会打印你申请的动态主设备号/dev/demo_chr会自动出现。这里就要说到谁创建了这个节点——是内核的devtmpfs机制配合device_create完成的device_create会往内核发一个 uevent用户态的 udev嵌入式系统常用 mdev收到之后创建节点并设置权限。如果你发现/dev下什么都没有但class目录里能看到设备那基本是用户态那套东西没跑起来。简单读写验证echo hello driver | sudo tee /dev/demo_chr sudo cat /dev/demo_chr实测下来tee写入后cat能读到相同内容说明copy_from_user/copy_to_user都正常。如果读到的是空或者乱码优先检查ppos有没有正确递增——read和write共用同一个文件位置指针写完之后文件位置在末尾直接cat自然读到 0 字节。要么重新打开每次cat都会重新 open要么在read里处理*ppos越界返回 0 的逻辑。卸载用sudo rmmod demo_chr如果提示Module is in use说明还有进程占着这个设备节点没关闭。用lsof /dev/demo_chr查一下是谁别硬来。3.4 字符设备与设备驱动模型的关系澄清这里必须澄清一个常见混淆字符设备的注册cdev和设备驱动模型device/class是两套并行的东西。cdev_add负责把file_operations和设备号关联起来让用户态能通过设备号找到你的操作函数而device_create负责在 sysfs 里建立对象、上报 uevent、让节点自动出现。前者管“怎么读写”后者管“怎么被系统发现和管理”。所以你会看到有些不规范的驱动只做cdev_add不做device_create那就得手动mknod维护起来非常痛苦设备号变了节点就失效了。正确的做法永远是两套都做全。理解这个分工你再看别的驱动代码就一目了然凡是调了class_create/device_create的地方都是在接入设备驱动模型。4. 平台设备模型实操理解 probe 被调用的完整链路4.1 platform 总线为什么是嵌入式的主力在没有自动枚举机制的总线上比如板载的 I2C 外设、GPIO 扩展、SoC 内部控制器内核没法自己发现“这里有个设备”必须由人显式描述。平台总线platform bus就是为这种情况下设计的“伪总线”它没有真实的物理总线协议设备信息靠设备树、ACPI 或者直接注册platform_device提供。平台设备的结构体简化后长这样struct platform_device { const char *name; int id; /* -1 表示只有一个实例 */ struct device dev; u32 num_resources; struct resource *resource; /* 内存/中断等硬件资源 */ };resource是关键它把“寄存器基地址、长度、中断号”这些硬件信息从驱动里解耦出来。以前驱动里写死0x40001000换个板子就得改代码现在资源从设备侧传进来驱动在probe里用platform_get_resource取同一份驱动可以服务多个平台。这就是设备树能大行其道的底层支撑。4.2 一个最小平台驱动与 probe 调用演示为了让你亲眼看到 probe 被调用我写一个不操作真实硬件的演示驱动#include linux/module.h #include linux/platform_device.h static int demo_probe(struct platform_device *pdev) { struct resource *res; dev_info(pdev-dev, probe called, name%s id%d\n, pdev-name, pdev-id); res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (res) dev_info(pdev-dev, mem resource: %pa len%pa\n, res-start, res-end); return 0; } static void demo_remove(struct platform_device *pdev) { dev_info(pdev-dev, remove called\n); } static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name demo-plat, }, }; module_platform_driver(demo_driver);module_platform_driver是个宏展开后等价于在init里调platform_driver_register在exit里调platform_driver_unregister省掉了模板代码。用dev_info(pdev-dev, ...)而不是pr_info是个好习惯因为前者会自动在日志前缀里带上设备名log 一多的时候非常好认。配套的设备侧测试时可以用最简方式注册static struct platform_device *demo_pdev; static int __init demo_pdev_init(void) { demo_pdev platform_device_register_simple(demo-plat, -1, NULL, 0); return PTR_ERR_OR_ZERO(demo_pdev); }先加载设备模块再加载驱动模块dmesg里就能看到probe called。反过来先加载驱动再加载设备同样会触发 probe——因为平台总线在device_add和driver_register两个入口都会去尝试匹配。这一点很重要它意味着驱动的加载顺序其实不敏感很多人以为必须“先设备后驱动”其实内核两条路都走通了。4.3 匹配失败时内核到底在干什么平台总线的match函数逻辑其实非常朴素核心就是比较名字static int platform_match(struct device *dev, struct device_driver *drv) { struct platform_device *pdev to_platform_device(dev); struct platform_driver *pdrv to_platform_driver(drv); /* 设备树匹配优先 */ if (of_driver_match_device(dev, drv)) return 1; /* ACPI 匹配 */ if (acpi_driver_match_device(dev, drv)) return 1; /* 然后是 id_table 表匹配 */ if (pdrv-id_table) return platform_match_id(pdrv-id_table, pdev) ! NULL; /* 最后退化到名字字符串比较 */ return strcmp(pdev-name, drv-name) 0; }读这段代码的价值在于它明确告诉你匹配优先级。设备树匹配排在第一位所以当你同时在设备树里写了compatible又在驱动里写了.name时最终以设备树为准。我见过一个真实的坑设备树里compatible字符串多写了一个后缀驱动里of_device_id表没对应上结果就是怎么也 probe 不了dmesg里连报错都没有/sys/bus/platform/drivers/xxx/目录下也看不到设备。排查这类问题我固定用这三条命令ls /sys/bus/platform/devices/ | grep demo ls /sys/bus/platform/drivers/ | grep demo cat /sys/bus/platform/drivers/demo-plat/*/driver/uevent 2/dev/null如果设备目录存在但 drivers 目录下没有它就是匹配没成功。这时候去驱动目录里看bind和unbind之间有没有module相关的链接再对比一下名字。设备树方案下还可以直接查/sys/firmware/devicetree/base/里对应节点的compatible属性一眼就能看出字符串差异。4.4 probe 函数里该做什么不该做什么probe 被调用时说明设备和驱动已经配对了但这不代表你可以随便花时间。我的经验是probe 里只做三件事申请资源、初始化硬件到可用状态、注册对外接口。凡是耗时的操作比如等待外部芯片上电稳定、大量数据预加载都应该往后放交给工作队列或者 delayed work。原因是 probe 运行在进程上下文但整个注册流程可能持有锁阻塞太久会拖慢其它设备的枚举启动阶段尤其明显。我遇到过一块板子开机要 40 秒最后定位到一个传感器驱动在 probe 里msleep(500)三次加一次 I2C 轮询优化成异步初始化之后开机时间降到 12 秒。另外 probe 的返回值一定要认真处理。新版内核6.11 起把platform_driver的remove回调返回值从int改成了voidprobe也早已统一用int返回错误码。移植老代码时这两个签名要特别注意否则编译直接报类型不匹配。5. 常见问题与排查技巧实录5.1 匹配失败与 probe 报错速查表实际调试中问题大多集中在“匹配”和“资源”两个环节。我整理了一张速查表基本覆盖了日常八成的情况现象可能原因排查手段加载驱动后无任何日志名字没对上match 失败比对设备与驱动的.name/compatible有 probe 日志但立刻返回错误资源申请失败或硬件无响应看probe返回值与dev_err输出设备目录出现在 sysfs驱动目录没有驱动未注册成功modinfo xxx.ko查模块信息节点生成但权限不对udev/mdev 规则缺失检查/etc/udev/rules.d/或 mdev.confrmmod 报 Module in use有进程占用或引用计数未减lsof /dev/xxx检查.owner是否漏写编译报符号未定义依赖模块没导出符号或没先加载检查EXPORT_SYMBOL与模块加载顺序这张表里的每一行我都踩过。特别是“名字没对上”这一类最坑的是它完全静默——内核不会报错因为匹配失败是正常情况总线上设备那么多不可能每个都能匹配上。所以当你怀疑匹配问题时不要指望日志要主动去 sysfs 里对比。5.2 设备节点与权限的那些坑devtmpfs自动创建节点是方便但它默认权限是0600只有 root 能访问。如果你希望普通用户也能读写得靠 udev 规则。在/etc/udev/rules.d/下加一条KERNELdemo_chr, MODE0666, GROUPusers嵌入式环境用 busybox 的话走的是 mdev配置文件通常是/etc/mdev.confdemo_chr 0:0 0666这里有个我踩过的坑改完规则要重新触发udevadm trigger或者重新insmod一次不然规则不会生效。有次我改了半天规则发现没反应最后发现是节点早就存在了udev 只在设备出现那一刻执行规则。养成习惯改完规则先rm /dev/demo_chr再重新加载模块。还有一点设备号是动态申请的每次insmod主设备号可能都不一样但 udev 靠的是设备名KERNEL 字段而不是设备号所以规则不受影响。如果你的脚本里写死了设备号做mknod那才是真正的隐患换一次重启就失效。5.3 模块依赖与卸载顺序多模块协作时模块之间通过EXPORT_SYMBOL暴露符号。加载顺序上提供符号的模块必须先加载否则依赖方insmod会报Unknown symbol。反过来卸载时被依赖的模块不能先卸rmmod会直接拒绝。查看依赖关系用lsmod的输出最后一列或者更直观地modinfo demo_chr.ko | grep depends我一般会在 Makefile 里加一条测试目标按正确顺序加载所有模块避免手敲顺序出错test: sudo insmod demo_core.ko sudo insmod demo_chr.ko sudo dmesg | tail -n 10顺手提一句引用计数内核对象的生命周期全靠引用计数管理kobject_get/kobject_put必须成对出现。漏掉一个put会导致对象永远不释放表现为rmmod之后 sysfs 目录还在设备号也没归还。用kref时建议包裹一层自己的 get/put 函数方便打点计数出问题时好定位。5.4 内核调试手段的合理使用排查驱动模型问题时有几个工具非常好用。debugfs挂载后可以看到很多总线内部的匹配信息/sys/kernel/debug/devices_deferred能列出所有延迟 probe 的设备这对依赖链较长的系统特别有用ftrace可以追踪driver_probe_device的调用看清楚到底是哪个设备触发了 probe。关于“动态替换已有驱动的file_operations”这类做法我想说清楚立场内核里确实能通过修改函数指针或者符号表做拦截但这属于极其危险的操作会破坏模块的引用计数和生命周期语义稍有不慎就是内核崩溃或者难以复现的并发问题。正规做法是分层包装——在原有驱动之上加一层拦截模块通过EXPORT_SYMBOL暴露的接口转发而不是去改别人的函数指针。前者是工程后者是玩火。6. 再往深一层kobject、引用计数与热插拔事件6.1 引用计数如何决定设备对象的生死kobject的引用计数是设备驱动模型的隐形骨架。当一个device被注册内核会为它建立kobject引用计数初始化为 1用户态通过 sysfs 访问、驱动持有指针、总线挂到链表上都会让计数增加。只有计数归零release回调才会被调用对象才真正释放。这个机制解决了一类非常难缠的竞态设备正在被卸载同时用户态还在读它的 sysfs 属性。如果没有引用计数属性回调可能访问已经被释放的内存直接触发 oops。有了引用计数读操作持有一个引用卸载流程只能等它读完。我写驱动时有个自检习惯任何持有struct device *的地方都想清楚“我凭什么保证它还活着”。如果是短时间使用就完了那没事如果存到全局变量里长期持有那就必须显式 get用完 put。这条规则看起来啰嗦但能挡掉绝大多数“跑几个小时就崩”的诡异问题。6.2 uevent 与热插拔事件的落地路径device_create之后用户态能自动建节点靠的就是 uevent。内核侧调用kobject_uevent通过 netlink 广播一个消息用户态的 udev 常驻进程监听这个 socket收到后解析环境变量ACTION、DEVPATH、MAJOR、MINOR等再执行对应的规则。理解这条链路你就能解释很多现象为什么容器里经常看不到设备节点因为容器通常没有独立的 udev也没有挂载devtmpfs。为什么有的系统/dev下节点延迟出现因为 udev 处理有排队。为什么mdev -s能一次性补齐所有节点因为它遍历/sys/class重新扫描。要手动触发一次 uevent可以这样echo add /sys/class/demo_chr/demo_chr/uevent这在调试 udev 规则时特别管用不用反复rmmod/insmod。不过要注意手动触发只走用户态规则处理不会重新执行 probe。嵌入式场景里还有一种常见需求设备插入后要自动启动一个采集程序拔掉后要停掉。正规做法就是在 udev/mdev 规则里加RUN或 mdev 的$ACTION判断而不是在驱动里 fork 用户进程。驱动只管上报事件策略交给用户态这是内核开发的一条基本原则越界了代码就难维护。7. 我个人在设备驱动模型上的一些体会真正把设备驱动模型吃透靠的不是背kobject的字段而是养成一个习惯看到任何设备相关的问题先去/sys里找它的“身份证”顺着devices → bus → driver这条链看它挂在哪、被谁管。这个动作我做了一年多之后发现调试效率提升非常明显很多时候日志还没看完sysfs 就已经告诉了我答案。另外一点体会是不要急着上手写复杂驱动先把字符设备和平台设备这两个最小模型各写一遍每个都跑通加载、匹配、probe、节点生成、卸载回滚五步再去做真实硬件。这套最小模型就像练字时的基本笔画笔画写正了后面写什么字都不会歪。我见过太多人直接抄一份现成驱动改寄存器地址改到能跑就不管了结果一出问题完全不知道从哪查——因为模型这一层从来没建立起来。最后分享一个实用小技巧给每个自己写的驱动都加一个dev_dbg级别的日志开关配合动态调试echo file demo.c p /sys/kernel/debug/dynamic_debug/control按需打开。这样驱动上线时日志干净出问题时又能快速打开详细追踪比全程pr_info打印满屏日志要专业得多。
返回列表