Linux platform总线匹配机制:从设备树到probe的全链路解析
2026/9/6 12:13:12 网站建设 项目流程

写驱动这些年,最绕不开的一个坎就是设备与驱动的“对接”。尤其在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,它按优先级做了如下几步:

  1. of_driver_match_device(dev, drv):如果设备和驱动都有设备树信息,就优先用设备树compatible匹配。这个函数会拿出设备树节点的compatible和驱动of_match_table里每一项的compatible做字符串比较,只要有一个相等就算匹配通过。
  2. acpi_driver_match_device:如果设备是ACPI设备,尝试ACPI匹配。咱们嵌入式平台基本用不上,直接跳过。
  3. platform_match_id:如果驱动提供了id_table,就尝试在id_table里查找与设备name相同的条目。
  4. 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的中断控制器是GIC,GPIO1的中断号从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-parentinterrupts属性计算出最终的中断号,并注册到中断框架里。

需要注意:如果把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才能编。这里给出一个标准的内核模块Makefile:

obj-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_irqdevm_ioremap)申请的资源,内核会在设备与驱动解绑时自动释放。但如果你混用了非devm的API,比如request_irq配合devm_ioremap,remove里忘记释放中断,下次重新加载模块时就会报“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搞挂一次,再亲手把它修好。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询