
1. 一次让我彻底想明白这四个概念的调试现场接触 Linux 的嵌入式驱动开发你迟早会遇到这么一天同事给了你一份设备树补丁说“把触摸方向改一下”你改了touchscreen-inverted-y编了内核烧进板子重启后发现触摸完全没有反应。好不容易在源码里加了几个printk得到的却是probe函数压根没有被调用。我就在这么一次 RK 平台的 bring-up 现场卡了一整天。当时我的第一反应是“驱动是不是没编进去”反复确认模块已经通过insmod/ 内建方式加载/sys/bus/platform/drivers/下面也确实看得到驱动的名字。可dmesg里就是没有那个驱动的probe日志。最后在另一个工程师的提示下我去查了/proc/device-tree发现我改错的不是设备树里被驱动绑定的节点而是另一个同名的status被置为disabled的节点。那一刻才意识到我对设备树、platform、device、driver这四个词的理解一直是碎片化的根本没有串成一条完整的链路。这篇东西不是教科书复读而是一个工程师在吃了几次亏之后把这条链路用最直白的话重新梳理了一遍。如果你也是那种“能看懂驱动代码但说不清设备树节点和驱动里的of_match_table到底怎么连起来”的人这篇应该能帮上忙。文章会覆盖四个概念各自的职责、设备树从文本到运行时结构的过程、platform 总线的匹配顺序、probe被触发的完整流程以及一个 RK3568 触摸屏改造的真实案例。先有一个心理预期理解这套关系不在于背多少结构体而在于你拿到一个“设备不工作”的现场时能迅速判断问题出在“树里没这个节点”“节点没转成 device”“device 找不到 driver”还是“driver 匹配了但 probe 里资源解析失败”。2. device 与 driver 在 Linux 里到底对应谁2.1struct device和struct device_driver的最小概念Linux 的设备模型把“硬件”和“软件操作硬件的方法”拆成了两组最基本的核心结构struct device和struct device_driver。前者描述“有一个硬件实体在这里”后者描述“有一份代码能操作某类硬件”。两者都挂在一个“总线”上由总线负责把它们做匹配。struct device里记录的不只是名字还包括电源状态、引脚、DMA 掩码、NUMA 节点、以及指向父设备的指针等。它更像一个“挂在系统里的注册信息”不一定要求这个硬件真实存在虚拟设备也会用它。struct device_driver则包含操作这个设备所需要的核心函数指针以及用于匹配的of_match_table、id_table之类的信息。实际驱动开发里我们接触更多的是两个基于它们扩展出来的平台专用结构。struct platform_device { const char *name; int id; struct device dev; u32 num_resources; struct resource *resource; const struct platform_device_id *id_entry; ... }; struct platform_driver { int (*probe)(struct platform_device *); int (*remove)(struct platform_device *); void (*shutdown)(struct platform_device *); struct device_driver driver; const struct platform_device_id *id_table; };可以那么理解platform_device是“在 platform 总线视角下看到的 device”platform_driver是“专门针对这类 device 写的 driver”。内核源码里大量驱动函数的入参都是struct platform_device *而不是struct device *因为这个结构体里已经携带了resource寄存器地址、中断号等平台相关信息probe阶段可以直接从这个入参里取资源。2.2 总线是如何成为中间的撮合者的“总线”在概念上可以对应物理总线I2C、SPI、PCI但 platform 总线是一个虚拟总线专门用来承载“没有可枚举总线的设备”。很多 SoC 内部的控制器比如 GPIO、pinctrl、DMA、以太网 MAC它们并不挂在一个可以扫描发现硬件的物理总线上所以内核捏造了一条 platform 总线来管理它们。总线的作用不只是“挂着”更核心的是提供一个match函数。每当有新的 device 或者新的 driver 注册到总线总线的match就会被调用用来判断这对 device/driver 能不能“看对眼”。platform_bus_type里的match回调是理解 platform 设备模型的关键入口我会在后面的章节单独拆开讲。2.3 用一套生活化的映射记住这套关系如果觉得结构体有点遥远可以把这套概念映射到现实生活里一个很常见的场景USB 接口的打印机。device 那台打印机本体它插在 USB 口上系统能看到“这里有一个 USB 设备”。driver 打印机驱动程序它知道如何给这台打印机发送数据、设置纸张尺寸。bus USB 控制器那一套拦路逻辑它负责发现“插进来一台打印机”并负责去驱动列表里寻找“有没有谁能处理这台打印机”。设备树 买打印机时包装箱里的说明说上面写了这台打印机的型号compatible、它占用什么资源USB 地址。如果没有总线你必须手动告诉驱动“打印机来了你去操作它”。有了总线之后打印机的插入动作会触发总线去遍历驱动一旦发现compatible或者 VID/PID 匹配就自动调用驱动的probe。设备树做的事情就是在没有“热插拔发现机制”的平台上提前把这个“打印机说明纸”以树状结构写死。设备模型本身并不只有平台总线这一种I2C、SPI、PCI、USB 都有各自的总线与配对逻辑但 platform 是我们在 SoC 平台开发中打交道最多的。所以理解了 platform 总线再去理解其他总线就是约等于换个match实现的问题。3. 设备树文件的角色硬件档案与拓扑图纸3.1 为什么需要在代码之外再维护一份设备树很早以前Linux 内核里大量硬件信息是通过board-xxx.c或者mach-xxx.c这类 C 文件硬编码在代码里的。换一块板子或者板子上某个外设的中断号变了就得重新编译内核甚至改代码。设备树的引入改变了这种做法硬件“长什么样、寄存器在哪、中断是什么、引脚怎么复用”全部抽离成一份文本描述文件内核启动时再解析这份文件动态生成设备节点。设备树这个名字里的“树”指的是它的结构形式。从一个根节点开始向下派生出 CPU、内存、soc 控制器、I2C 总线、I2C 总线下的外设等节点。节点之间是严格的父子层级关系这个结构对应了硬件拓扑。一个典型的设备树节点看起来是这样i2c0 { touchscreen40 { compatible goodix,gt911; reg 0x40; interrupt-parent gpio0; interrupts RK_PA5 IRQ_TYPE_LEVEL_LOW; touchscreen-inverted-x 0; touchscreen-inverted-y 1; touchscreen-swapped-x-y 0; reset-gpios gpio0 RK_PB2 GPIO_ACTIVE_LOW; }; };这里compatible是给驱动的“身份证”reg是 I2C 设备地址interrupt-parent和interrupts告诉内核中断控制器编号和中断触发方式reset-gpios给驱动一个复位引脚的操作入口。这些内容最终会成为驱动probe阶段能读到的运行时配置。3.2 从.dts到内核device_node的完整路径设备树文件并不是直接被内核运行的它需要经过一个编译和解析的过程。完整链路如下编写 .dts / .dtsi.dtsi是 SoC 级别的共用文件.dts是具体板卡的板级文件通过#include把 SoC 定义包含进来板级文件里再做引脚复用、外设启用、参数覆盖。DTC 编译内核构建时用设备树编译器DTC把.dts编译成.dtb。DTC 输出的是一种扁平二进制格式不依赖特定内存布局。Bootloader 传递U-Boot 等引导程序会把.dtb放到内存指定地址然后在启动内核时通过寄存器传递该地址。内核 unflatten内核在启动早期调用unflatten_device_tree()把扁平二进制的 DTB 解析成一颗由struct device_node组成的运行时树结构。驱动绑定后续设备模型初始化阶段会遍历这颗device_node树根据节点的compatible等信息创建对应的platform_device再交由平台总线进行驱动匹配。第 4 步和第 5 步正是大部分人理解上的盲区。普通开发者往往只看到“设备树文本”和“驱动代码”两张皮却不知道中间还隔着device_node与platform_device的转换层。3.3device_node与platform_device的衔接点struct device_node是设备树解析后的结果它代表的是“设备树里的一棵树节点”。驱动模型最终要操作的是struct platform_device。这个转换发生在设备模型初始化时内核会把树中的节点根据父节点是否带compatible、以及是否匹配simple-bus等机制批量“填充”成平台设备。要注意的是并不是所有设备树节点都会变成platform_device。只有满足一定条件的节点才会被填充。比如挂载在 I2C 总线节点的子节点最终会变成struct i2c_client而不是struct platform_device挂在 SPI 总线下的节点会变成struct spi_device。这个细节非常重要因为它决定了驱动应该写platform_driver、i2c_driver还是spi_driver。很多人写驱动时把结构体选错根源就在于对这一步转换关系不清楚。设备树解析和填充完毕之后device_node并不消失它依然保存着设备树原始属性允许驱动在probe阶段通过of_*接口去查询属性。可以从这个角度看device_node是设备树的“果树本体”platform_device是被“嫁接”到设备模型里的“食用果实”两个结构体通过platform_device.dev.of_node指针互相指向。4. platform 总线的匹配顺序与“看对眼”规则4.1 双向注册谁来等谁先理清一个最常见的困惑设备树、platform、device、driver 谁先谁后。在设备模型初始化过程中设备和驱动的注册不是严格顺序的内核允许它们以任意顺序到来。比如驱动可以早注册板子上的设备节点晚一些才被填充成 platform_device也可以反过来。总线机制保证“后到的一方”会主动去跟“先到的一方”做配对。系统启动时platform 驱动通常是在arch_initcall、subsys_initcall等阶段注册的platform 设备则在of_platform_default_populate()里大批量填充两者相遇后马上进入匹配流程。如果你是内核模块动态加载insmod动作本质上就是在某个时间点往总线上注册一个驱动总线会立即遍历现有设备看看有没有能配对的。4.2platform_match的完整优先级drivers/base/platform.c里的platform_match函数体现了什么是“门当户对”。匹配顺序约等于下面五步从上到下依次尝试driver_override 强制匹配平台设备上可以设置driver_override字段强制某个驱动绑定这个设备这通常用于调试或特殊场景OF 匹配设备树方式如果驱动设置了of_match_table则将设备节点的compatible属性与表中每个.compatible字符串比较。这是现代设备树环境下最常用的一条路径ACPI 匹配在支持 ACPI 的平台上通过_HID、_CID等表项匹配主要用在 x86 平台id_table 传统匹配驱动如果提供了platform_device_id表则与platform_device-name做比较。很多不用设备树的老驱动或者需要区分设备版本的驱动仍然使用这个表name 直接比较最后退回到platform_device-name与platform_driver-driver.name的字符串比较。上面第 2 步是设备树驱动最核心的匹配路径。一个驱动只要在声明.driver.of_match_table rockchip_rk3568_dt_match并且设备树节点里有对应的compatible vendor,device-name内核就会认为这个设备找到了驱动。4.3 一个驱动侧匹配表的例子以常见的 SoC pinctrl 驱动为例它的匹配表通常写成这样static const struct of_device_id rk3568_pinctrl_dt_match[] { { .compatible rockchip,rk3568-pinctrl, .data rk3568_pinctrl_data }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, rk3568_pinctrl_dt_match); static struct platform_driver rk3568_pinctrl_driver { .driver { .name rk3568-pinctrl, .of_match_table rk3568_pinctrl_dt_match, .pm rk3568_pinctrl_pm_ops, }, .probe rk3568_pinctrl_probe, .remove rk3568_pinctrl_remove, };MODULE_DEVICE_TABLE(of, ...)这条宏并不是运行时必需的但非常重要它会在编译时生成一个符号表供模块加载工具做自动别名同时也可以让模块在安装时通过类似modinfo查询驱动支持的 compatible 字符串。对应设备树侧SoC 的 dtsi 里一般会写pinctrl: pinctrlfdc00000 { compatible rockchip,rk3568-pinctrl; reg 0x0 0xfdc00000 0x0 0x10000; rockchip,grf grf; #address-cells 2; #size-cells 2; ranges; ... };当内核在填充设备树节点时看到这里有一个compatible rockchip,rk3568-pinctrl的节点会尝试创建一个platform_device。而platform_driver_register(rk3568_pinctrl_driver)注册时platform 总线调用of_driver_match_device()把设备树里的 compatible 与驱动表里的 compatible 一一比较。一旦相同立即进入probe。4.4 匹配过程中最容易看漏的一点很多人以为compatible是“约等于字符串比较”这么简单但实际匹配时还牵涉到.data字段。of_device_id里的data成员可以携带一个指向设备特定数据的指针probe里可以通过of_device_get_match_data()取出来。数据多的平台驱动常常依靠这个指针来区分不同 SoC 型号从而在同一个驱动里处理多款芯片。也就是说即使出现“多个 compatible 都匹配同一驱动”的情况数据入口也可以不一样。同理如果设备树里有多个节点 compatible 都写了同一个字符串那么内核会生成多个platform_device每个都会走一次匹配、调用一次probe。这解释了为什么有些驱动在系统里会有多个 probe 实例比如同一个驱动管两个 GPIO 控制器。5. 从匹配到 probe驱动真正开始接管设备资源5.1 一条完整的调用链设备树 JSON好吧是 DTB被解析platform_device 被填充总线又把 device 和 driver 撮合成功接下来内核就会触发probe。完整调用链大致是platform_driver_register() - driver_register() - bus_add_driver() - driver_attach() - __driver_attach() - driver_match_device() // 调用 platform_match - driver_probe_device() - really_probe() - drv-probe(dev) // 最终进入 platform_driver.probe上面的really_probe是驱动模型里真正执行驱动初始化函数的地方它还会做不少边框工作比如初始化电源管理、处理设备链接、调用dev_pm_domain_attach等。对于普通驱动开发者把这条链记到driver_probe_device这一层就够用了。5.2 probe 里最常用的资源获取方式probe的入参是struct platform_device *pdev设备树里解析出来的资源信息最终会变成platform_device的resource数组和 device 的of_node属性。在probe里取设备树信息基本逃不出以下几组接口。目的常用接口对应设备树属性获取寄存器物理地址platform_get_resource(pdev, IORESOURCE_MEM, 0)reg ...映射寄存器到虚拟地址devm_platform_ioremap_resource(pdev, 0)reg ...获取中断号platform_get_irq(pdev, 0)interrupts .../interrupts-extended获取时钟devm_clk_get(pdev-dev, name)clocks ...获取 GPIOdevm_gpiod_get(pdev-dev, reset, GPIOD_OUT_LOW)reset-gpios ...读取自定义属性device_property_read_u32(pdev-dev, prop, val)prop 0x1234获取匹配到的 compatible 数据of_device_get_match_data(pdev-dev)匹配表.data例如一个 I2C 控制器驱动在 probe 里通常要做这些事用platform_get_resource()取到寄存器基地址用devm_ioremap_resource()完成物理地址到虚拟地址的映射并申请资源用platform_get_irq()获取中断号然后调用devm_request_irq()注册中断处理函数用device_property_read_u32()读取 dtsi 里配置的自定义参数初始化 NVIC、DMA、时钟等最后调用i2c_add_numbered_adapter()把控制器注册进 I2C 子系统。反过来如果probe执行到中间某一步失败驱动模型会把已经分配的资源释放掉。用devm_前缀的函数能自动管理资源释放不必在错误路径里手写一长串goto err_这也是现在驱动里几乎全员devm_的原因。5.3 devm 机制是如何保证“不用自己清理”的devm_的名字来源于“managed device resources”。当你在 probe 里调用devm_ioremap_resource()时申请到的映射并不由你自己记录而是挂到struct device的“资源管理链表”上。万一 probe 失败或者设备被移除内核会统一释放这块管理链表上的所有资源。从这个角度看dev_前缀本质上是一种“作用域即生命周期”的 RAII 风格设计。我用过不少老驱动里面能看到大段err_free_region:、err_iounmap:这类标签就是手动释放资源的历史写法。新代码里用devm_之后错误路径只需要一行return ret;。两者都能工作但可维护性差距很大。建议大家写新的 platform 驱动时默认全部使用devm_系列接口。6. RK3568 触摸屏改横屏案例这套关系如何落到实处6.1 触摸方向为什么要靠设备树控制在很多应用场景里屏幕的物理安装方向可能与默认驱动方向不一致。比如同样的 7 寸屏外壳允许横放或竖放触摸屏的 X/Y 坐标就需要对应旋转。修改方案有两种一种是在用户空间通过xinput/libinput做坐标变换矩阵另一种是在驱动层面直接通过设备树告诉驱动“你的 X 轴反向”或者“你的 X/Y 轴交换”。对嵌入式 Linux 来说直接在设备树里配置驱动行为是最干净的做法。内核里的触摸驱动基本都通过touchscreen_parse_properties()解析一组标准属性这些属性定义在设备树规范的触摸屏绑定文档中touchscreen-inverted-xX 轴反向touchscreen-inverted-yY 轴反向touchscreen-swapped-x-y交换 X/Y 轴。如果一块屏幕原本是竖屏安装要改成横屏通常需要让驱动知道触摸坐标系旋转 90 度。比如原来触摸识别到的“从左上往右下”的滑动在横屏时应该表现为“从左下往右上”具体要设置哪个属性取决于屏幕安装角度。做工程时需要先在板上做实验而不是凭猜。下面是一个典型的 RK3568 板卡上 I2C 触摸屏节点配置i2c0 { status okay; touchscreen40 { compatible goodix,gt911; reg 0x40; interrupt-parent gpio0; interrupts RK_PA5 IRQ_TYPE_LEVEL_LOW; reset-gpios gpio0 RK_PB2 GPIO_ACTIVE_LOW; touchscreen-inverted-x 0; touchscreen-inverted-y 0; touchscreen-swapped-x-y 0; }; };假如触摸芯片通过 I2C0 挂载节点地址是 0x40复位脚是 GPIO0_B2中断脚是 GPIO0_A5。有了这些信息驱动probe里通过reset-gpios初始化复位时序通过interrupts注册触摸中断再通过三个touchscreen-*属性决定坐标变换。6.2 改了设备树却不生效的现场排查这个案例最有价值的不是“把两个属性改成 1”这种结论而是当它不生效时你应该怎么查。设备树、platform、device、driver 四个环节都可能出问题排查顺序应该按成本从低到高排列。先说最常见的问题设备树确实改了但编出来的 DTB 没有更新。很多板卡有独立的 boot 分区烧写内核 Image 并不等于更新了 DTB你需要单独确认resource.img或boot.img里是否包含新编译的.dtb。启动后查看cat /proc/device-tree/i2c0/touchscreen40/touchscreen-inverted-y这个命令读出来的是字节数据如果显示01或者十六进制0x01说明设备树已经生效。如果找不到这个节点说明 DTB 根本没进来别急着怀疑驱动。再查平台设备是否生成ls /sys/bus/platform/devices/如果设备树节点在 I2C 控制器的子节点下它不会出现在 platform 设备列表里而会变成 I2C client应该去查i2c总线的设备列表。查看/sys/bus/i2c/devices/下有没有0-0040这样的条目。如果没有说明 device 都没有生成问题在 I2C 控制器或者 node 匹配逻辑上。接着查驱动是否匹配ls /sys/bus/i2c/drivers/goodix/如果驱动目录存在且0-0040已经出现在目录里的软链接中说明匹配成功probe应该已经被调用。如果设备在/sys/bus/i2c/devices/里存在但驱动的目录里没有说明要么驱动没加载要么compatible对不上要么这个驱动不支持这个芯片型号。这时候再看dmesg里有没有i2c相关的报错比如地址 NAK、probe失败日志。6.3 从现象反推问题的判断矩阵现象最可能原因下一步排查动作/proc/device-tree里找不到改过的属性DTB 没更新或被旧 boot 参数覆盖重新编译 dtbs确认分区烧写设备树属性存在但 sysfs 里没有设备I2C 控制器节点 status 不对或芯片地址不匹配检查控制器节点与 reg 地址sysfs 有设备但驱动没有绑定compatible 不匹配或驱动模块未加载查看compatible字符串是否完全一致检查模块驱动绑定了但触摸无反应中断/复位 GPIO 配置错误或坐标属性没读到检查中断触发方式、GPIO 引脚复用、属性读值触摸有反应但方向不对坐标属性组合错误按屏幕物理方向设置 inverted/swap 组合上面这个表就是“设备树 → device → driver → probe → 功能正常”五层排障的基本逻辑。每一层的问题都有对应的观察点不要一上来就疯狂在驱动代码里加 printk那样效率很低。7. 从“知道概念”到“会查问题”的几条经验7.1 设备树优先驱动次要很多刚接触设备树的人有个坏习惯改功能先改驱动。实际上如果设备树能表达的事情就应该先确认设备树表达正确。一个设备能不能被内核识别首先取决于它的compatible属性与驱动表是否一致其次才是驱动代码逻辑。遇到“设备节点明明存在驱动却没有任何反应”先去验证节点是否被填充成了正确的总线设备再去怀疑驱动本身。7.2 善用 sysfs 这只“照妖镜”/sys下的信息往往比dmesg更直接。设备模型里每个 device 和 driver 都能在 sysfs 里找到一个目录目录之间的软链接关系直接反应了绑定状态。比如/sys/bus/platform/devices/xxx/driver这个软链接是否存在是判断“这个设备有没有绑定驱动”的最快方式。手动调试时bind和unbind文件也很有用可以在不重启的情况下强制解绑和重新绑定echo fdc00000.pinctrl /sys/bus/platform/drivers/rk3568-pinctrl/unbind echo fdc00000.pinctrl /sys/bus/platform/drivers/rk3568-pinctrl/bind有时驱动在 probe 失败后资源没有完全释放靠这种手动重新绑定就能快速验证问题是否可恢复。7.3.dtsi里的 arm 架构细节不要忽略设备树玩久了你会发现有些节点并不是单纯因为compatible才会被填充。比如 SoC 内部的很多总线节点compatible写成simple-bus它会在遍历时直接把子节点也填成 platform 设备。但如果某个父节点没有simple-bus又没有匹配到对应的总线驱动它的子节点就可能不会被自动填充。这也是为什么你在 dtsi 里加了一个外设节点却没看到对应设备出现的原因之一。最稳妥的做法是在 dts 里新增节点时先确认父节点的 compatible 是否支持子节点填充。如果父节点是 I2C/SPI 控制器子节点走 I2C/SPI 的客户端创建路径如果父节点是普通的 soc 总线节点则要求父节点 compatible 为simple-bus或者有对应的驱动能调用of_platform_populate()。7.4 调试工具比想象中少但足够用排查这套链路最常用的工具组合也就是几样/proc/device-tree看设备树是否生效、/sys/bus/*/devices/看设备是否存在、/sys/bus/*/drivers/看驱动是否加载、dmesg看 probe 报错、以及clk/gpio/pinctrl相关的 debugfs 接口。工具虽少但足够定位 95% 以上的问题。我自己的习惯是在驱动probe入口第一行就打印一条dev_info(pdev-dev, %s enter\n, __func__)然后在probe失败返回前打印一条带错误码的日志。这两条日志配合strace级别的耐心基本能解决所有驱动入门阶段的疑难杂症。别嫌日志丑调试效率优先。最后说一点体会把 device、driver、bus、device tree 的关系想清楚之后很多开发问题其实都不是“驱动不会写”而是对“写好的驱动怎么被内核拉起来”这一层缺乏完整认知。先把链路打通写驱动就会从“照着模板改”变成“知道自己每一步在跟谁打交道”。