Linux platform总线匹配机制:从设备树到probe的全链路解析 📅 发布时间:2026/9/7 18:48:05 👁 浏览次数: 写驱动这些年最绕不开的一个坎就是设备与驱动的“对接”。尤其在i.MX6ULL这种嵌入式SoC上做Linux驱动开发你写好了platform_driver也写好了设备树节点结果insmod之后probe函数就是不动日志里连个屁都没有。这种现象十有八九就出在Platform设备与驱动的匹配机制上。今天不整虚的把这个匹配链路的底层逻辑、代码路径和实际遇到的问题一次说透。这篇文章适合两类人一是刚入门嵌入式Linux驱动开发、对着设备树和platform_driver一脸懵的新手二是已经能写字符设备驱动但遇到“probe不执行”“设备注册不上”这类问题想搞清楚内核到底按什么逻辑“发牌”的人。看完你至少能明白一个设备节点从设备树变成platform_device再到和platform_driver“对上眼”中间到底发生了什么以及出了问题该去哪查。1. 为什么非要有Platform总线从一场“硬件信息大搬家”说起1.1 老式驱动的尴尬设备信息被焊死在代码里先回到Linux 2.6时代看看没有Platform总线时驱动是怎么写的。那时候大家普遍直接用ioremap硬编码地址比如你给i.MX6ULL写个GPIO按键驱动代码里大概会这么干#define GPIO1_GDIR_BASE (0x020A0040) #define GPIO1_DR_BASE (0x020A0000) static int __init my_key_init(void) { void __iomem *gpio1_gdir, *gpio1_dr; gpio1_gdir ioremap(GPIO1_GDIR_BASE, 4); gpio1_dr ioremap(GPIO1_DR_BASE, 4); /* 配置为输入 */ writel(readl(gpio1_gdir) ~(1 18), gpio1_gdir); ... }这套写法在特定板子上跑没问题但你把这驱动拿去别的板子只要引脚变了、寄存器地址换了就必须改源码重新编译。驱动代码被“硬件配置信息”浇铸死了完全没法复用。更麻烦的是中断号。你要是把中断号直接以常量形式写在驱动里换个板子中断号就对不上了。我当时维护过一批不同型号的开发板相关代码光是维护每个板子不同的寄存器偏移就快崩溃了。硬编码的驱动逻辑本质上等于把硬件设备的“身份证”和“工作手册”焊死在了一起这显然不是Linux这种讲究“一切皆文件、分层解耦”的系统该有的设计。1.2 Platform总线的“中间人”设计Platform总线就是为了解决这个问题而生的。它的核心思想是设备信息资源、地址、中断号由平台层来管理驱动程序只负责处理业务逻辑两者之间通过Platform总线完成对接。你可以把Platform总线想成房产中介platform_device就是“房源信息”。它告诉内核我这儿有一个外设寄存器在哪个地址中断线是哪根名字叫什么。它不管自己是被谁驱动的只要有合适的“租客”来认领就行。platform_driver就是“求租者”。它说我能驱动名叫“xxx-key”的设备我需要的资源是一个GPIO中断和一个寄存器区域。它不需要关心设备到底挂在哪个总线地址上只要匹配成功内核会把资源通过参数传给它。Platform总线则是中介它不停核对“房源”和“求租者”的匹配条件一旦对应上就安排双方“签约”这个签约动作在内核里就是调用probe函数。这个设计的最大好处是解耦。驱动代码里不再出现任何具体的物理地址和中断号取而代之的是ioremap某个resource区域、注册设备树里描述的中断号。换硬件平台时只需要改设备树或者旧的board文件驱动源码可以原封不动地编译、运行。这就是为什么我在i.MX6ULL上写驱动时只要设备树节点和驱动里的compatible配好同样的驱动就能在多个引脚配置不同的板子上跑完全不用改C代码。2. 匹配链条上的三个关键角色2.1 platform_device如何诞生在i.MX6ULL这类现代嵌入式平台上大多数硬件设备节点都写在设备树Device Tree里。设备树dts文件描述了一个“虚拟的硬件设备列表”内核启动后会解析这个列表动态地创建一大堆platform_device。这里的关键点是不是所有设备树节点都会变成platform_device只有满足特定条件的节点才会被展开。正常情况下设备树根节点下的子节点如果一个节点带有compatible属性且它的父节点不是一个真正的总线比如I2C控制器节点下的设备是i2c_client不是platform_device那么它就会被内核用of_platform_populate机制转成platform_device。拿i.MX6ULL的设备树举例你在根节点或者某个soc节点下加一个自定义外设节点/ { mykey { compatible myvendor,mykey; reg 0x020A0000 0x10; interrupts GIC_SPI 32 IRQ_TYPE_EDGE_RISING; }; };内核启动时会识别到compatible属性分析这个节点的寄存器和中断信息最后创建出一个struct platform_device注册到Platform总线上去。此时这个设备在/sys/bus/platform/devices/下就有了一个名字你可以在板子上ls /sys/bus/platform/devices/看到它。有个细节得注意如果节点里的status被设为disabled或者节点挂在某个未使能的控制器下面那它就不会被展开成platform_device。我在调试时就遇到过设备树里写好了节点启动后/sys下死活找不到对应设备最后排查发现是status忘改成okay了。这个后面会细说。2.2 platform_driver如何注册驱动侧相对直接。一个标准的platform_driver长这样static const struct of_device_id mykey_of_match[] { { .compatible myvendor,mykey }, { /* sentinel */ } }; static struct platform_driver mykey_driver { .driver { .name mykey, .of_match_table mykey_of_match, }, .probe mykey_probe, .remove mykey_remove, }; module_platform_driver(mykey_driver);module_platform_driver是个精巧的宏它等价于module_init(mykey_driver_init); module_exit(mykey_driver_exit);而mykey_driver_init内部调用了platform_driver_register(mykey_driver)这个函数进一步会调用driver_register把驱动加入内核的驱动链表并触发一次总线的匹配。于是驱动和设备的“红线”就通过这个入口搭上了。一旦有设备插入总线总线就会遍历驱动链表让每个driver去跟新设备做匹配测试。反过来驱动注册时也会遍历总线上的所有设备寻找可以“牵手”的对象。这里容易忽略的一点是.driver.name到底有没有用。在老版本内核没有设备树的时候驱动完全靠这个name去匹配设备。而在设备树时代优先级最高的是of_match_table里的compatible匹配name字段更多是一种兜底机制以及用于/sys目录下显示驱动名。2.3 总线match一次“相亲”的完整流程Platform总线真正干活的核心函数是platform_match它按优先级做了如下几步of_driver_match_device(dev, drv)如果设备和驱动都有设备树信息就优先用设备树compatible匹配。这个函数会拿出设备树节点的compatible和驱动of_match_table里每一项的compatible做字符串比较只要有一个相等就算匹配通过。acpi_driver_match_device如果设备是ACPI设备尝试ACPI匹配。咱们嵌入式平台基本用不上直接跳过。platform_match_id如果驱动提供了id_table就尝试在id_table里查找与设备name相同的条目。strcmp(dev_name(dev), drv-driver.name)最原始的兜底方案设备和驱动的name字符串直接比较。整个匹配过程像极了一场相亲先看设备树compatible这个“生辰八字”对上了直接成功对不上再看驱动自己写的id_table还不行就用驱动注册时给的名字做最后尝试。前三种都失败那这门亲事就黄了。一旦匹配成功总线会调用drv-probe(dev)把platform_device结构体交给driver去处理。probe里就能通过platform_get_resource等函数拿到寄存器地址、中断号等资源然后开始真正的硬件初始化。3. 设备树模式下Platform匹配的核心compatible的“双向奔赴”3.1 compatible三件套厂商、型号不能乱写设备树里的compatible是一个字符串数组最标准的设计是“厂商,型号”格式比如fsl,imx6ull-gpio、myvendor,mykey。中间的逗号和前面的厂商名不是随便写的它是整个匹配机制里的“姓氏”用来区分不同厂商同型号的设备。驱动侧的of_device_id里compatible字符串必须和设备树节点里的完全一致包括大小写、下划线、逗号一个字符都不能差。static const struct of_device_id mykey_of_match[] { { .compatible myvendor,mykey }, { /* sentinel */ } };设备树侧mykey { compatible myvendor,mykey; };这里有一个容易踩的坑compatible是可以带多个备选值的。比如compatible myvendor,mykey-v2, myvendor,mykey;意思是优先使用”myvendor,mykey-v2“如果内核驱动不认识这个新名字就退回尝试”myvendor,mykey“。我在实际项目里为了兼容硬件改版就用过这种写法——旧驱动不用改新版硬件用新的compatible标识内核会自动匹配到旧驱动上。这个设计在维护多版本硬件时非常管用。3.2 从compatible到of_match_table的匹配底层逻辑明白了字符串要求我们再深入一点看它内部怎么找的。内核的匹配函数调用链大致是platform_match() - of_driver_match_device() - of_match_device() - of_match_node(drv-of_match_table, dev-of_node)of_match_node会遍历驱动of_match_table里定义的每个条目用每个条目的compatible跟设备树节点compatible做逐一比较。注意这个比较不是简单的一对一它会把设备树节点compatible数组里的所有字符串都拿来比较只要驱动里的compatible命中其中一个就算匹配成功。我对这个机制的理解是设备树节点像是一个带着多张身份证的人驱动只要认领其中一张身份证就能带走他。这种设计给了硬件平台很大的灵活性一个驱动可以同时兼容多种型号的设备而一个设备也能被多个版本的驱动兼容。还有个点是.data字段。of_match_table每一项里都可以附带一个.data指针匹配成功后probe可以通过of_device_get_match_data(dev)拿回这个指针。这招我在驱动一个系列芯片时经常用——同一个驱动函数通过data区分芯片版本省掉了一堆 if/else 判断。static const struct of_device_id mykey_of_match[] { { .compatible myvendor,mykey, .data (void *)1 }, { .compatible myvendor,mykey-v2, .data (void *)2 }, { /* sentinel */ } }; static int mykey_probe(struct platform_device *pdev) { int version (int)of_device_get_match_data(pdev-dev); dev_info(pdev-dev, chip version: %d\n, version); ... }3.3 为什么probe就是进不去我在实际调试中总结的排查路径这个坑是嵌入式驱动开发里最常见的没有之一。我系统性总结过probe不执行的排查路径照着这个思路走90%的问题能定位到首先确认设备树节点是否真的被展开成platform_device。方法很简单启动后执行ls /sys/bus/platform/devices/看看你要找的设备名在不在。如果名字都不在说明设备树节点根本没被解析问题出在设备树输入compatible有没有写错status是不是disabled节点是不是放在了一个不支持展开的父节点下其次确认驱动是否真的注册成功。执行cat /sys/bus/platform/drivers/mykey/这个目录下如果出现了设备的符号链接说明匹配已经成功probe应该已经被调用过。如果驱动目录里空空的说明匹配没成。最后确认匹配条件是否满足。我碰到最多的情况就是compatible字符串不一致。驱动里写的myvendor,mykey设备树里写的是myvendor,mykey1字母数字差一位内核不会给你报错它只会安静地跳过匹配然后你就在那里干瞪眼。这个排查顺序是从“设备有没有来”到“驱动有没有到”最后再查“为什么没对上眼”逻辑清晰效率最高。除了上面这些还一个很隐蔽的坑probe返回错误码。驱动注册成功后就算匹配上了如果probe执行过程中返回了负数比如-ENOMEM或者-EINVAL内核会认为驱动绑定失败于是设备会回到“未绑定”状态。这种场景下dmesg会打出一堆错误信息你要留意看probe里有没有dev_err的输出。4. 手把手在i.MX6ULL上用Platform机制写一个按键驱动实例4.1 硬件资源与驱动需求梳理纸上谈兵不如直接跑代码。以i.MX6ULL开发板为例我手头这块板子的一个GPIO按键接在GPIO1_IO18上。按键按下时GPIO电平由高变低触发一个下降沿中断。现在要写一个Platform驱动实现功能设备树节点描述按键的中断号和按键名驱动通过Platform匹配到设备后在probe里注册一个中断处理函数按键按下时在中断底半部上报一个简单的按键事件。这个例子覆盖面广既用到了设备树与驱动的compatible匹配也用到了platform_get_irq获取硬件中断资源还有完整的probe/remove生命周期。做一遍就能把Platform机制的核心链路串起来。4.2 设备树节点设计与确认首先写设备树。i.MX6ULL的中断控制器是GICGPIO1的中断号从32开始编号。GPIO1_IO18这个引脚对应的中断号在不同板子里可能会有差异我这里以某块实际板子的配置为例/ { mykey { compatible myvendor,mykey; pinctrl-names default; pinctrl-0 pinctrl_key; interrupt-parent gpio1; interrupts 18 IRQ_TYPE_EDGE_FALLING; status okay; }; }; iomuxc { pinctrl_key: keygrp { fsl,pins MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x80000000 ; }; };这个节点的含义是中断父节点是gpio1中断索引为18下降沿触发。内核会根据interrupt-parent和interrupts属性计算出最终的中断号并注册到中断框架里。需要注意如果把status去掉默认是okay可以正常展开。但如果你在别的节点里见过status disabled又想使能它记得改回来。板子启动后先在板子上执行ls /sys/bus/platform/devices/你会看到mykey这个名字出现在列表里具体名字取决于设备树节点名这就说明设备已经被正确展开。4.3 驱动代码实现probe里拿到中断号驱动侧代码如下注释已经写得比较详细#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/of_device.h #include linux/interrupt.h #include linux/gpio/consumer.h #include linux/of_gpio.h static irqreturn_t mykey_isr(int irq, void *data) { struct device *dev data; dev_dbg(dev, key pressed, irq %d\n, irq); /* 实际项目中这里通常会上报input事件或唤醒工作队列 */ return IRQ_HANDLED; } static int mykey_probe(struct platform_device *pdev) { struct device *dev pdev-dev; int irq, ret; irq platform_get_irq(pdev, 0); if (irq 0) { dev_err(dev, failed to get irq: %d\n, irq); return irq; } dev_info(dev, got irq %d\n, irq); ret devm_request_irq(dev, irq, mykey_isr, IRQF_TRIGGER_FALLING, mykey, dev); if (ret) { dev_err(dev, failed to request irq: %d\n, ret); return ret; } return 0; } static int mykey_remove(struct platform_device *pdev) { dev_info(pdev-dev, mykey removed\n); return 0; } static const struct of_device_id mykey_of_match[] { { .compatible myvendor,mykey }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mykey_of_match); static struct platform_driver mykey_driver { .probe mykey_probe, .remove mykey_remove, .driver { .name mykey, .of_match_table mykey_of_match, }, }; module_platform_driver(mykey_driver); MODULE_LICENSE(GPL); MODULE_DESCRIPTION(i.MX6ULL platform key driver example);代码逻辑很清晰probe里用platform_get_irq取设备树里配置的中断号然后注册中断处理函数。中断号来源于设备树驱动本身不关心具体的GPIO编号怎么映射这就把硬件信息和驱动逻辑彻底分开了。4.4 编译、加载与现象验证驱动写好了得有个Makefile才能编。这里给出一个标准的内核模块Makefileobj-m : mykey.o KDIR : /path/to/your/kernel ARCH : arm CROSS_COMPILE : arm-linux-gnueabihf- all: $(MAKE) ARCH$(ARCH) CROSS_COMPILE$(CROSS_COMPILE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean把内核源码路径填进KDIR交叉编译工具链换成你自己用的。编译完后会生成mykey.ko。把内核镜像和设备树一起烧到板子上或者用tftp加载启动后执行insmod mykey.ko dmesg | tail正常情况下dmesg里有mykey mykey: got irq 126这说明probe已经跑起来了而且拿到了中断号。再去看看驱动目录下的绑定情况ls -l /sys/bus/platform/drivers/mykey/会看到mykey - ../../../devices/soc/.../mykey这样的符号链接证明设备与驱动的匹配已经成功且处于绑定状态。按下按键后dmesg里会打印key pressed的信息。整个Platform匹配链路就跑通了。5. 常见问题与排查技巧实录5.1 probe不执行排查速查表把这个表存下来以后遇到probe不执行可以照着查现象可能原因检查方法/sys下都没有设备目录设备树节点未展开检查compatible、status、父节点结构设备目录存在但驱动目录为空匹配失败检查compatible字符串是否一致驱动目录下无绑定符号链接匹配成功但probe返回错误查看dmesg里probe的dev_err信息设备树更新后仍然没变化没用新的dtb启动确认uboot加载的dtb路径驱动注册报错内核开启了module签名检查CONFIG_MODULE_SIG关闭或签名模块这里再强调一个容易被忽略的点如果修改了设备树记得确认uboot实际加载的dtb文件路径。我见过好几个人改的是arch/arm/boot/dts/imx6ull.dts编译成了imx6ull.dtb但uboot环境变量里设的fdt_file指向的是另一个dtb结果改了半天没反应。排查这种问题优先在板子上执行cat /proc/device-tree/mykey/compatible如果输出为空那就是设备树没生效如果输出是你写的compatible那说明设备树已经生效了问题大概率出在驱动侧。5.2 resource获取不到platform_get_resource的坑很多新手在probe里用platform_get_resource拿寄存器地址结果拿到的是NULL然后就直接崩溃或者返回错误。常见原因有两个一是设备树节点里根本没写reg属性resource框架自然无内容可取二是节点里虽然有reg但用了#address-cells和#size-cells后偏移算错了。更常见的坑还在中断上。platform_get_irq(pdev, 0)拿中断时设备树节点里的interrupt-parent如果指定的不是GIC而是GPIO控制器那么拿到的中断号实际上是个“GPIO软中断号”此时直接拿去request_irq也能工作但语义上已经绕了一层。如果中断号返回负数比如-ENXIO通常是设备树里interrupts属性没有写对。这类问题排查方法很直接在probe里把resource的值打印出来再对照设备树里的预期值逐项核对。5.3 同一节点被多个驱动同时匹配的竞争场景有一种特殊情况如果你的系统里存在两个platform_driver它们的of_match_table里都写了同一个compatible那么这两个驱动会“竞争”同一个设备。这种情况下匹配结果是不确定的取决于驱动的注册顺序以及是否设置了driver_override属性。通常系统里不会出现这种“一个设备打算给两个驱动用”的场景。真碰到了我建议从设计上反思一下是否需要拆分设备树节点让两个物理功能分别挂在不同节点下而不是在同一个节点上打架。强行让两个驱动抢一个设备后面维护起来很痛苦。5.4 我踩过的几个隐蔽坑再说几个只有实际做项目才会踩到的隐蔽问题。第一个是驱动编译进内核而不是模块时的行为差异。如果你把驱动编进内核它会在内核启动早期就开始注册而此时设备树可能还没来得及完全展开。虽然Platform总线机制能处理“驱动先于设备注册”的情况总线会轮询匹配但在initcall层级上驱动注册的时机和设备创建的时机如果交错还是可能出现你想不到的竞态。建议调试阶段全部用模块方式验证无误后再编进内核。第二个是probe里的资源释放问题。用devm_系列函数比如devm_request_irq、devm_ioremap申请的资源内核会在设备与驱动解绑时自动释放。但如果你混用了非devm的API比如request_irq配合devm_ioremapremove里忘记释放中断下次重新加载模块时就会报“IRQ handler type mismatch”之类的问题。我的习惯是probe里能devm就devm不要混用。第三个是关于MODULE_DEVICE_TABLE的。这个宏生成一个符号表功能是给热插拔模块管理系统提供设备匹配信息。对嵌入式平台来说它最大的作用是当驱动以模块方式加载时modprobe能自动识别模块和设备是否匹配而不需要手动insmod。如果你编译的是模块建议不要省这一行。几个心得体会Platform设备与驱动匹配机制说穿了就是内核把“硬件配置信息”和“硬件驱动逻辑”做了一次干净的业务拆分。设备树描述“我有什么”驱动描述“我会驱动什么”Platform总线负责把两者撮合在一起互相交付资源再交给probe去点亮真正的硬件。这几年我写i.MX6ULL相关驱动感受最深的一点是驱动开发的大部分时间其实并不花在写代码上而是在跟“设备树对不对、匹配为什么失败、资源为什么取不到”较劲。把Platform匹配机制理解透了就等于拿到了解锁这些问题的万能钥匙很多让人抓狂的 hello world 级别的驱动问题基本看一眼设备树和/sys目录就能猜出七七八八。如果你刚开始接触这套机制不妨像我一样先在开发板上找一个自带的外设节点比如GPIO按键、LED灯手动改一改它的compatible再看看匹配行为怎么变化。这比读十篇原理文章都管用。理论看百遍不如自己动手把probe搞挂一次再亲手把它修好。