十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Linux设备驱动模型深度解析:从总线、设备到设备树

Linux设备驱动模型深度解析:从总线、设备到设备树 做了这么多年嵌入式 Linux 开发回头看看当初最让我头疼的不是某个外设驱动怎么调通反而是设备驱动模型这套看起来不起眼的基础框架。不少朋友跟我聊内核学习时都说寄存器操作、中断回调、环形缓冲区这些都能看懂但一看到 bus、device、driver、class 这几个概念纠缠在一起再有 device tree 和 sysfs 掺和进来整个人就懵了。但我可以负责任地说一句话设备驱动模型就是内核底层的骨架你不把它骨头的接法看清楚后面读任何驱动源码都是隔着一层纱。换句话说搞懂了设备驱动模型你才算真正把内核底层吃透了而不是天天背接口。这篇文章我想从设计思想入手把总线、设备、驱动、类这四个核心角色讲清楚再展开到嵌入式场景里几乎天天用的 platform 总线、设备树最后落到 sysfs、uevent 这些跟用户空间打交道的机制上再加上我自己在调试驱动时踩过的一堆坑。内容不追求把 struct 里的每个字段都讲完那是啃书的事我重点帮你把模型立起来把链路串通。1. 先搞懂设计思想设备驱动模型的四个核心角色1.1 为什么内核非要整出这么一套模型很多人第一次接触内核里注册一个驱动的代码心里会冒出一个疑问我写个驱动直接操作硬件不就行了为什么要绕这么大一圈子搞什么 bus、device、driver 的匹配机制这是因为内核面对的硬件环境远比我们想象中复杂。一块 SoC 上既有挂在 CPU 总线上的内部控制器比如 GPIO、UART 控制器、MMC 控制器也接出了 SPI、I2C、PCIe、USB 等物理总线每种总线上挂的设备成千上万而且很多设备支持热插拔。如果每个驱动都自己管理硬件设备各自为政那会出现三件事第一驱动代码里到处是重复的找设备逻辑第二系统无法统一管理电源、热插拔、设备生命周期第三内核层面没有一个公共的视图用户空间想知道系统里有哪些设备都无从下手。所以 Linux 内核的设计者做了一件事把设备相关的所有对象抽象成统一的模型并引入一套标准化的注册、匹配、加挂机制。这个模型不是针对某个具体驱动设计的它是一个框架所有 driver 都在这套框架里运行。这就好比一个小区的物业管理系统业主设备、车辆驱动、道路总线和车辆分类类必须统一登记管理才能做到车辆进出门岗自动抬杆、车位自动分配、异常情况自动告警而不是每家自己雇个人盯在门口。设备驱动模型的核心是四个对象bus总线、device设备、driver驱动、class类。接下来的内容基本都围绕这四者的关系展开。1.2 bus、device、driver、class 各自到底干啥先给这四个角色定性device代表一个实实在在的硬件设备描述的是有什么比如地址为 0x10000000 的 UART 控制器挂在 I2C 总线地址 0x48 上的温度传感器。driver代表一段驱动逻辑描述的是怎么操作也就是你写的那些初始化、read、write、ioctl、中断处理函数。bus是 device 和 driver 之间的中间人负责让双方互相找到对方。PCI、USB、SPI、I2C、platform 都是 bus 的具体实例。class从功能角度对设备分类比如 net网络设备、input输入设备、tty终端设备、gpio 等。它主要面向用户空间让用户不用关心设备挂在哪条总线上只需要按这是一个网卡这是一个键盘的方式去找设备。在内核代码里这几个角色分别对应 struct bus_type、struct device、struct device_driver、struct class。我们日常写驱动本质上就是在填充这些结构体把驱动挂到对应的总线上然后等内核来 match 和 probe。总线这个角色特别关键因为总线上有两个链表一个挂设备一个挂驱动。当一个新设备出现总线会遍历驱动链表逐个检查是否有驱动能处理它反过来新驱动注册时总线也会遍历设备链表看有没有设备等着它。这个配对的动作就叫 match配对成功就会触发 probe。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); // 移除回调 // 其他字段省略... };bus、device、driver、class 四者之间的关系简单来说就是bus 负责撮合 device 与 driver成功绑定后就一起工作class 是给用户空间看的分类视图和总线层面是正交的同一个设备既挂在某个总线上又属于某个 class。实际中我观察到的现象是很多入门者把 platform_bus 当成了一个设备这是理解上最容易跑偏的地方。platform_bus 是虚拟总线它本身不传输数据、不分配地址它只是内核用来把那些不好归类的设备组织起来的一根线。1.3 一次注册过程的完整链路从 device 到 driver 到 probe把模型关系看明白之后下一步就是把注册这条链路串通。我以 platform 总线为例因为嵌入式场景里大量驱动都是 platform_driver。当你调用platform_driver_register(my_driver)时内核内部实际走的是platform_driver_register内部调用driver_register(drv-driver)driver_register会把这个 driver 挂到总线的 driver 链表上然后调用bus_add_driverbus_add_driver会触发driver_attach(drv)总线会遍历自己设备链表里的每个 device调用__driver_attach__driver_attach会调用总线的match函数也就是platform_match去比较设备和驱动是否匹配如果匹配成功调用driver_probe_device最终执行到总线的probe回调进而调用到drv-probe(dev)。如果反过来先注册的是 device比如设备树里声明的节点被内核解析后生成了 platform_device内核同样会触发一次遍历device_add里调用bus_probe_device去驱动链表里找匹配的驱动匹配到之后同样进入 probe。所以无论设备先来还是驱动先来只要双方都能匹配上probe 必定会被调用。这背后靠的其实就是双向扫描的机制。理解了这一条链你就明白为什么probe函数是绝大多数驱动的入口它代表设备和驱动成功见面的那一瞬间内核正式把设备的控制权交给了驱动。很多同学在学这部分的时候喜欢硬背platform_driver_register的调用流程但我更建议你打开源码读一遍路径在drivers/base/driver.c、drivers/base/dd.c、drivers/base/platform.c这三个文件把大部分答案都写了。源码里可以看到我上面描述的关键函数driver_register、driver_attach、bus_for_each_dev、__driver_attach、driver_probe_device。读完之后你会对这套框架有原来是这么回事的感觉。2. platform 总线嵌入式内核中出场率最高的虚拟总线2.1 为什么需要一条假的总线上一节提到了 platform 总线我想单独拿一节出来专门讲因为在嵌入式 Linux 驱动里platform 驱动占了绝对的大头。SD 卡控制器、网卡控制器、LCD 控制器、音频控制器……很多 SoC 内部的设备驱动都是 platform_driver。那问题来了这些设备明明没有接在 PCI 之类的物理总线上为什么内核要给它们虚拟出一条 platform 总线关键在于枚举能力。PCI、USB 这类总线上的设备硬件层面就支持扫描枚举设备自己会报告我是谁、需要什么资源内核插上就能发现。但 SoC 内部的那些控制器不一样它们是用固定的物理地址映射在 CPU 总线上的数量也是固定的CPU 没法通过硬件扫描去发现它们只能靠代码或者设备树把它们一个一个登记出来。于是内核就把这些设备统一挂到一条虚拟总线上叫 platform_bus。这条总线的存在让这些设备也能复用前面说的 device/driver 配对机制和生命周期管理。你可以把 platform 总线理解成物业给内部车辆专门开辟的一条通道没有外来车辆那种自动识别闸机但物业照样会登记每辆车的信息车牌对了就放行。2.2 手写一个 platform 驱动核心骨架我直接给你一段最精简、能编译过的 platform 驱动骨架把这个作为参考模板反复对照理解#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_device.h #include linux/io.h #define MYDEV_NAME demo-ctrl // 设备树匹配表 static const struct of_device_id demo_of_match[] { { .compatible vendor,demo-ctrl, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_of_match); // 驱动与设备匹配成功后触发 static int demo_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; pr_info(demo-ctrl probe success\n); // 从设备树获取寄存器资源 res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; // 映射物理地址到虚拟地址 base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); // 这里可以继续注册字符设备、初始化中断等等 return 0; } static int demo_remove(struct platform_device *pdev) { pr_info(demo-ctrl remove\n); return 0; } static struct platform_driver demo_driver { .probe demo_probe, .remove demo_remove, .driver { .name MYDEV_NAME, .of_match_table demo_of_match, }, }; module_platform_driver(demo_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(demo platform driver);注意上面代码里的module_platform_driver这个宏自动帮你生成了 module_init 和 module_exit分别对应platform_driver_register和platform_driver_unregister。如果驱动编译成模块insmod 加载时就会到 platform 总线上做一次匹配。probe 函数是核心。它做的事情通常包括从 platform_device 里拿资源寄存器地址、中断号、DMA 通道等映射物理地址ioremap 或 devm_ioremap_resource注册子设备比如 miscdevice、cdev、input_device初始化硬件关看门狗、配置时钟、复位外设等把必要的数据保存起来供 remove 和其他回调使用。在 probe 里我特别说明一个习惯做法尽量用devm_*系列函数。比如devm_ioremap_resource、devm_kzalloc、devm_clk_get。它们最大的优势是谁申请、谁在脱离时自动释放也就是说即使 probe 中途失败或者设备被卸载内核会帮你把已申请的资源统一释放避免泄漏。这个在驱动热插拔频繁的嵌入式场景里特别省心。2.3 资源管理和 devm_* 系列函数既然提到 devm就多说一点。在一个成熟的驱动里probe 往往要申请很多资源内存、寄存器映射、中断、时钟、GPIO、DMA、regulator。按传统模式申请之后必须手动管理释放而且一旦 probe 中某个环节出错跳转前面的资源很容易漏掉。devmmanaged device resources的机制就是给资源加上生命周期跟随 device的语义。设备 detach 时内核会自动释放所有 devm 资源。实际上在设备驱动模型里device 这个对象本身就关联了一个资源链表devm 申请的东西都会挂到这个链表上释放的时候从头到尾回收。我自己写驱动几乎不碰非 devm 版本了除非是极老的 Linux 版本。有一个例子能说明它多省事某个模块的 probe 里申请了一个 IRQ后面初始化失败返回 -EINVAL如果不小心忘了free_irq下次再加载模块要么报 resource busy要么中断乱跳。用devm_request_irq就完全不担心这类问题。3. 设备树设备模型在嵌入式场景的必经之路3.1 设备树解决什么问题聊完 platform 总线下一个绕不开的话题就是设备树Device Tree。现在市面上主流嵌入式 Linux几乎都基于设备树管理硬件资源。设备树本质上是一种描述硬件信息的树形数据格式它告诉内核这个板子上有哪些设备、每个设备用什么地址、中断是哪个、时钟是多少,至于驱动怎么操作这些设备那是 driver 自己的事。在设备树出现之前内核维护者每支持一块开发板都要在 C 代码里静态定义一个struct platform_device数组把板级硬件信息寄存器地址、中断号、GPIO 配置写死在源码里。这导致大量板级代码塞进内核版本一多就冗余到爆炸。设备树的好处就是把硬件清单与驱动代码彻底解耦同一份驱动代码配合不同的 dts/dtsi 文件就能适配不同板卡。dts、dtsi、dtb 这三者的关系类比一下就是dtsi 是公共头文件的硬件描述dts 是具体板卡的设备树源文件dtb 是编译生成的二进制文件。启动时 bootloader 会把 dtb 加载进内存内核解析后动态生成平台设备。3.2 一个设备树节点的构成与匹配原理设备树节点的基本格式是先写成文本再编译成 dtb。我贴一段实际的设备树节点示例demo_ctrl: demo-ctrl10000000 { compatible vendor,demo-ctrl; reg 0x10000000 0x1000; interrupts 0 42 4; clocks clk 10; status okay; };compatible最重要的匹配字段。内核驱动里of_match_table写的是什么字符串就要和它一致。vendor,demo-ctrl这种格式是社区惯例前面的厂商名/前缀后面是设备名避免不同厂商的同名设备产生冲突。reg设备寄存器物理地址和长度。上面0x10000000 0x1000表示起始物理地址和映射长度。probe 里通过platform_get_resource读到的就是它。interrupts中断号、触发方式等描述。clocks设备的时钟来源依赖对应的时钟控制器。statusokay代表启用disabled代表禁用。这个属性是个大坑后面会说。设备树节点被内核解析后会生成struct device_node再进一步转换成struct platform_device这就是所谓设备树中每个 compatible 节点都有可能变成一个 platform_device的来源。具体走of_platform_default_populate这类函数完成。驱动侧匹配的原理是probe 前platform_match会依次尝试多种匹配方式其中之一就是比较设备节点的compatible属性和驱动的of_match_table。匹配成功才会走进demo_probe。所以如果你的驱动加载了但不能 probe第一件事就是检查 compatible 是否一字不差。3.3 设备树调试中的几个大坑设备树相关的调试占了我实际项目排错的很大比例。先说几个最典型的。第一个坑就是 compatible 不一致。常见场景是 dts 里写的是xxx,demo-ctrl驱动里of_match_table写的是xxx,demo_ctrl中间一个横线一个下划线不仔细看根本发现不了。我习惯的做法是先 dmesg 看内核是否警告 No dtb found 之类再把设备树导出确认实际加载的节点内容。第二个坑是status disabled。设备树中的节点如果被标记为 disabled即便 compatible 完全匹配内核也会把它当作不存在直接跳过。经常有人新加设备后怎么都不 probe查了半天代码没问题最后发现是 dtsi 从公共头文件拿来的节点默认是 disabled自己忘了在板级 dts 里覆盖成status okay。第三个坑是修改设备树后没生效。编译了 dts、生成新的 dtb、烧进去了但启动时 bootloader 加载的还是旧的 dtb。这个问题在开发板平台上特别容易出因为 dtb 存放位置可能是 boot 分区、ext4 分区根目录、或者通过 tftp 加载。排查时先确认 bootloader 日志里实际加载的 dtb 路径不要盲目以为是代码问题。设备树这块我想补充一个观点它虽然是一个独立的数据格式但它和设备驱动模型是深度耦合的。你只有把 device、driver、platform 总线的匹配流程搞明白才能理解设备树节点在什么时机变成 platform_device、什么时候触发 probe。所以不要孤立地学设备树语法一定要结合设备模型来理解。4. 设备模型如何通到用户空间sysfs 与 uevent4.1 sysfs内核设备模型对用户空间的投影前面讲的 bus、device、driver、class大部分是内核内部概念你看不到摸不着。内核为了把设备模型暴露给用户空间专门搞了一套虚拟文件系统叫 sysfs通常挂载在 /sys 下。sysfs 的目录结构和你理解的内核模型一一对应/sys/bus按总线组织设备与驱动。比如/sys/bus/platform/devices/下能看到所有 platform_device/sys/bus/platform/drivers/下能看到所有已注册的 platform 驱动。/sys/class按设备功能分类。比如/sys/class/net/、/sys/class/leds/、/sys/class/gpio/。用户程序想找哪个网卡对应哪个设备时从这里查最方便。/sys/devices按设备在系统中的拓扑关系组织是最底层的设备目录。sysfs 里最有意思的是符号链接symlink。当你把一个 device 和一个 driver 绑定成功内核会在 device 目录下创建一个driver软链接指向对应 driver 的 sysfs 目录。反过来driver 目录下也会关联它当前绑定的设备。所以在调试一个驱动时我经常直接到/sys/bus/platform/drivers/xxx/下面 ls 一下看看有没有预期的设备目录出现如果有说明绑定成功如果没有说明 match 出问题了。举个实际调试例子。某次我要确认一个 SPI 控制器驱动是否注册成功但又不想翻 dmesg直接执行ls /sys/bus/platform/drivers/spi-master/ ls /sys/bus/spi/devices/第一个命令能看到驱动是否注册成功第二个命令能看到 SPI 总线上当前有哪些设备。这些信息比 dmesg 更直观而且是可以交互验证的。4.2 uevent 与 udev/mdev 动态创建设备节点sysfs 是静态视图而 uevent 是动态事件通知机制。当一个设备被注册比如插入 U 盘、加载驱动后创建了某个设备内核会通过kobject_uevent向用户空间发送一条事件这个事件就叫 uevent。uevent 里包含什么主要是一系列环境变量比如设备名、设备路径、子系统类别、动作add/remove/change等。用户空间的 udev桌面系统或 mdev嵌入式 busybox 环境收到这个消息后会根据规则动态创建 /dev 下的设备节点或者触发固件加载、设置权限。我举一个嵌入式场景里最常见的例子设备树里配置了一个 GPIO 按键驱动用 input 子系统注册后系统启动时 Uevent 通知用户空间udev 规则检查到这个 input 设备后自动创建/dev/input/event0节点之后你在应用层 open 这个节点就能读到按键事件。如果没有 uevent/udev 这套机制要么 /dev 节点不存在要么设备路径写死、在拔插后会错乱。对于写驱动的同学我的建议是不要太纠结 uevent 的每个字段但一定要理解设备模型和用户空间设备节点之间的因果关系。很多人学驱动时会有一个困惑我明明写的是内核态驱动为什么还要依赖 udev 规则原因就在于内核只负责注册设备和产生事件不负责直接面对用户空间去创建各种节点。节点要什么权限、命名成什么、落在哪个目录这些属于策略策略放在用户空间更灵活。4.3 从热拔插流程看模型协同我把 USB 设备热插入的过程串一遍你可以看到前面概念的协同USB 控制器检测到设备插入触发中断USB core 枚举设备读取设备描述符确定设备类型和厂商 IDcore 创建一个struct device挂到 USB 总线USB 总线执行 match找到匹配的 usb_driver调用驱动 probe驱动完成初始化注册接口比如 cdc_acm 注册 ttyusb-storage 注册块设备新生成的子设备同样走 model触发 uevent用户空间 udev 根据 uevent 创建 /dev/ttyACM0 或 /dev/sda1 等节点。这里面总线、设备、驱动、类、uevent、sysfs 全都用上了。理解了一个热插拔流程基本就理解了设备驱动模型的运转方式。5. 调试、避坑与内核源码阅读指引5.1 probe 不调用怎么排查probe 不调用是内核驱动调试中出现频率最高的问题没有之一。它的排查思路可以按链路一步步来先确认驱动是否成功注册。模块加载后执行 lsmod 看模块在不在或者在/sys/bus/platform/drivers/下找驱动目录。如果驱动目录都没有说明module_init没生效或者注册失败。再确认设备是否存在。查看/sys/bus/platform/devices/下有没有对应设备节点或者/sys/firmware/devicetree/base/下有没有对应设备树节点。设备树节点不存在通常是因为 dtb 没生效、节点被 status 禁用、或者 compatible 被写错。再确认司机和设备的 match 是否成功。驱动目录下如果有设备软链接但没有 probe那就要查 match 为什么会失败。常见原因除了 compatible 不一致还有of_match_table没设置、MODULE_DEVICE_TABLE 没写导致模块热插拔时无法自动加载等。最后一个容易被人忽略的是-EPROBE_DEFER。现代内核里如果驱动 probe 时需要某个资源比如时钟、regulator、IOMMU而资源还没准备好驱动会返回 -EPROBE_DEFER内核会把这个驱动放到延迟队列等资源可用后再次尝试 probe。所以如果 dmesg 里看到类似 probe deferred 的提示不要以为驱动挂了它只是暂时没排上队。5.2 常见问题速查表我把日常开发中高频问题整理成了一张表方便你排查时快速定位现象常见原因排查方向驱动加载成功但 probe 没执行compatible 不一致 / 设备树节点 status disabled检查 of_match_table 和 dts 节点属性probe 拿到资源为空reg 属性未写 / 资源顺序不对检查平台资源定义platform_get_resource 参数insmod 报 Unknown symbol依赖的符号未导出 / 依赖模块未加载检查 Module.symvers 和依赖顺序设备树修改后启动不生效dtb 未更新 / bootloader 加载位置不对确认 dtb 实际路径先 md5 一下 dtbprobe 返回 EPROBE_DEFER时钟、电源等资源暂不可用dmesg 查 deferred 队列/dev 节点不生成udev/mdev 规则缺失 / 驱动未注册对应设备接口查 /sys/class 下是否有设备目录rmmod 后系统崩溃资源未释放 / 中断还在触发 / 并发未同步检查 remove 里是否释放中断和注销设备这表里的每一条都是我实际项目里踩过、同事踩过、或者社区里高频出现的对应的方法可以直接抄。注意驱动开发里 up 一个模块后反复 reload最怕的就是资源没释放干净所以调试时养成习惯每次 rmmod 后 dmesg 看有没有 BUG/use-after-free 的提示。5.3 内核源码阅读路径建议最后聊一下怎么看内核源码才能把设备驱动模型吸收成自己的东西。我推荐的路径是先读drivers/base/core.c里的device_add它是设备对象加入内核模型的主入口再读drivers/base/driver.c里的driver_register理解驱动加入的过程然后读drivers/base/dd.c这个文件是整个匹配和 probe 调度的核心重点看driver_probe_device和really_probe最后回到drivers/base/platform.c看 platform 总线如何实现 match 和资源获取。这几份文件读下来你对总线-设备-驱动这套模型的运转机制就会有一个整体画面。之后再去读具体驱动比如 i2c 驱动、spi 驱动、usb 驱动会发现它们完全是同一套模板的变体无非是总线类型不同、match 规则不同、数据接口不同。我当初把dd.c里really_probe的代码一行一行读完后有一种豁然开朗的感觉原来前面学的 platform、device tree、sysfs 全是围着这一个函数在转它是整个设备模型的心脏。另外学习设备驱动模型不要停留在看上面。我强烈建议你在自己电脑上搭一个 QEMU 环境用 virtio 设备做实验或者直接在开发板上尝试给一个简单的 GPIO 控制器写 platform 驱动。只有亲眼看到 probe 被调用、sysfs 里出现目录、uevent 触发节点创建这套模型才算真正被吃掉。设备驱动模型这套东西你说它难它就只是一堆结构体和回调函数你说它不难它又串起了内核里几乎所有的子系统。但只要你把总线、设备、驱动、类这四个角色以及它们之间的注册—匹配—probe链路吃透再去看那些看似高深的内核底层你会发现它们全都建立在这个最简单也最核心的框架之上。后面我会再单独写一篇关于设备树属性的详细拆解以及一个从零编写可运行字符设备驱动的完整案例感兴趣的朋友可以持续关注。
返回列表