Linux Platform设备与驱动匹配机制详解:从设备树到probe调用
2026/9/8 6:12:28 网站建设 项目流程

1. 为什么Linux要搞出Platform这套机制

接触i.MX6ULL嵌入式Linux开发的朋友,多半都有过裸机开发的经验。裸机写驱动很简单,寄存器手册翻到对应外设章节,找到物理地址,直接操作寄存器就行。但到了Linux下,这一套就行不通了。原因有很多:Linux要管多个进程,不能让用户随便碰物理地址;Linux的驱动模型希望把设备硬件信息、驱动逻辑、总线连接方式解耦;再一个,一个SoC内部有大量没有真正“总线”概念的设备,比如GPIO控制器、UART串口、I2C控制器这些内部外设,它们既不在PCI总线上,也不在USB总线上。

这里就引出了Platform机制的核心作用——Platform是一套虚拟总线

Linux设备模型里,一个设备要和一个驱动配对,必须有总线在中间牵线搭桥。USB设备挂USB总线,PCI设备挂PCI总线,那SoC内部这些“天生焊死在芯片里”的外设挂哪里?总不能让它们无家可归。于是内核定义了platform_bus这条虚拟总线,凡是直接挂在CPU地址总线上、没有标准热插拔总线的设备,都归它管。i.MX6ULL上几乎所有的外设控制器,本质上都是platform设备。

这套机制解决了一个特别实际的问题:把设备信息和驱动逻辑分开。设备树里描述“我有什么硬件、寄存器在哪、中断号是多少”,驱动代码只写“我怎么操作这些硬件、怎么实现功能”。两者只要匹配上,内核就自动把资源传给驱动的probe函数,驱动拿到资源直接开工。硬件变了,改设备树;逻辑变了,改驱动。互不干扰,维护起来特别舒服。

对新手来说,我第一次在i.MX6ULL上写驱动时最大的困惑就是:明明我注册了driver,设备树里也有节点,为什么probe就是不执行?后来才明白,我没搞清楚匹配机制的门道。这篇文章就把Platform设备与驱动的匹配机制掰开揉碎讲清楚,包括匹配方式有哪些、优先级怎么算、probe函数什么时候被调用、匹配不上怎么排查。不管你是刚入门嵌入式驱动开发,还是被设备树折磨过的老手,这篇都能帮你把这块的知识拼图补完整。

2. Platform设备与驱动的匹配机制拆解

2.1 设备端是怎么“登记”的

要理解匹配,先得搞清楚两边各自长什么样。在i.MX6ULL这种现代嵌入式Linux开发环境下,设备信息主要有两种登记方式。

方式一是老式的platform_device结构体,在代码里手动定义资源,再用platform_device_register注册。这种方式在早期的ARM Linux驱动里很常见,但现在基本被设备树取代了。方式二是通过设备树,在.dts文件里描述硬件节点,内核启动时解析设备树,自动为每一个带有compatible属性的节点创建platform_device。这是当前的主流做法

以i.MX6ULL的一个GPIO按键为例,设备树里会写类似这样的节点:

key { compatible = "gpio-keys"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_key>; status = "okay"; };

内核在启动阶段解析到这里,发现compatible是"gpio-keys",就创建一个platform_device,挂在虚拟的platform总线上。这个device身上带着各种资源:寄存器地址(比如reg属性)、中断号(interrupts属性)、GPIO编号(gpio属性)等,这些就是设备端的“身份信息”。

在设备树之前或者某些特殊场景下,老式写法长这样:

static struct resource key_resources[] = { [0] = { .start = GPIO1_IO03, .end = GPIO1_IO03, .flags = IORESOURCE_IO, }, }; static struct platform_device key_device = { .name = "gpio-key", .id = -1, .num_resources = ARRAY_SIZE(key_resources), .resource = key_resources, }; platform_device_register(&key_device);

老式写法里没有设备树,就用resource结构体手动描述设备的寄存器地址范围、中断号等信息。现在这种写法在面试题里还常见,但实际项目中已经很少用了。你只要知道:只要有platform_device出现在总线上,无论是设备树生成还是代码注册的,内核就会尝试为它找driver

2.2 驱动端是怎么“报名”的

驱动端的结构是platform_driver,驱动开发者的主要工作之一就是填充这个结构体。i.MX6ULL驱动开发入门时,最常见的模板长这样:

static const struct of_device_id gpio_key_of_match[] = { { .compatible = "gpio-keys" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, gpio_key_of_match); static struct platform_driver gpio_key_driver = { .probe = gpio_key_probe, .remove = gpio_key_remove, .driver = { .name = "gpio-key", .of_match_table = gpio_key_of_match, }, }; module_platform_driver(gpio_key_driver);

这个结构体最核心的成员是driver.name、id_table和of_match_table。这三个字段决定了driver能不能和设备配上对。probe和remove是回调函数:匹配成功时内核调用probe,驱动在这里完成硬件初始化;设备移除或驱动卸载时调用remove,做清理工作。

module_platform_driver是个便捷宏,展开以后就是module_init加上platform_driver_register,module_exit加上platform_driver_unregister。新手阶段建议先用这个宏,等理解了生命周期再手写init和exit也不迟。

2.3 匹配的完整流程:三种方式与优先级

现在我来讲最关键的部分——platform总线是如何执行匹配的。内核里有个函数叫platform_match,每次总线上新增设备,或者新增驱动,总线都会调用它检查是否有配对成功的。

匹配顺序是固定的:

  1. of_match_table匹配:driver里通过of_match_table指定了compatible字符串列表,内核拿设备树节点的compatible属性逐一比较。比如设备树里compatible = "gpio-keys",driver的of_match_table里也有"gpio-keys",那就算匹配上。这是现代设备树模式下最常用的匹配方式,也是我最推荐的。

  2. id_table匹配:如果of_match_table匹配失败,或者驱动根本没填of_match_table,内核会看driver的id_table。id_table是platform_device_id数组,比较的是driver名字和设备name字段。不过这个方式主要用在老式platform_device场景,设备树时代用得不多了。

  3. name匹配:如果id_table也是空的,最后内核尝试拿driver.driver.name和设备的名字做比较。同样是比较字符串,优先级最低。

简单来说,匹配就是一个字符串比较的过程。设备树出生就带着compatible属性,相当于给自己起了名字、贴了标签;驱动这边也列了一份自己认识的标签清单。两边有一致,就算“牵手成功”。

提示:同一个设备可能同时满足多种匹配方式,但内核按上面顺序执行,先到先得。实际开发中别混用,选一种方式写清楚即可。

2.4 匹配成功后发生了什么

匹配成功不是结束,只是开始。之后内核会调用driver的probe函数。probe是驱动开发者的主战场,i.MX6ULL下几乎每一个驱动的主要逻辑都在probe里:

static int gpio_key_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct resource *res; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(dev, "failed to get resource\n"); return -ENXIO; } /* 以i.MX6ULL的GPIO控制器为例 */ base = devm_ioremap(dev, res->start, resource_size(res)); if (!base) { dev_err(dev, "failed to ioremap\n"); return -ENOMEM; } /* 申请中断、注册字符设备、创建类、创建设备节点 */ ... return 0; }

这里你注意,devm_ioremap是受设备管理的映射函数,内核会跟踪这个ioremap,设备注销时自动释放。这类带devm前缀的函数是驱动开发的“懒人福利”,能省掉大量错误处理代码。

同时值得注意的是,probe执行时拿到的pdev参数,就是匹配成功的那个platform_device。通过platform_get_resource、device_property_read_u32之类的辅助函数,驱动可以拿到寄存器地址、中断号、DMA通道等硬件资源。这套机制的好处在于:驱动代码不写死任何地址和中断号,全部从设备树动态获取,换了板子只需要改dts,driver不用动。

3. i.MX6ULL上从零写一个Platform驱动

3.1 环境准备与开发板选型

写这篇文章的前提是你手里有一块i.MX6ULL的开发板。市面上的核心板、底板方案有很多,NXP官方EVK或者是国内主流开发板都可以。i.MX6ULL是Cortex-A7单核(部分型号双核)的处理器,主频800MHz,特点是性价比高、外设丰富,非常适合做嵌入式Linux学习的平台。

开发环境一般是Ubuntu虚拟机里装交叉编译工具链,比如arm-linux-gnueabihf-gcc。烧录和启动用SD卡或者EMMC都行。内核版本建议4.1.15或者更新都无所谓,Platform机制在2.6以后就基本定型了,各版本差别不大。你只要保证内核源码能编译通过、能启动到文件系统,就能跟着实操。

3.2 设备树节点的编写思路

以驱动一个LED灯为例,这是i.MX6ULL驱动开发里最典型的入门项目。假设LED接在GPIO1_IO03上,高电平点亮。设备树里新建一个led节点:

gpioled { compatible = "mcu-led"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_led>; led-gpios = <&gpio1 3 GPIO_ACTIVE_HIGH>; status = "okay"; };

注意几个要点。compatible属性是自己定义的,比如"mcu-led",但必须是唯一的,不能跟内核已有的驱动冲突。led-gpios是自定义属性名,名字可以随便起,但GPIO的引用格式是固定的,<&gpio1 3 GPIO_ACTIVE_HIGH>表示GPIO1组的第3号引脚,高电平有效。pinctrl-0引用了pinmux配置节点,这个一定要在iomuxc节点下提前定义好,否则引脚复用不对,GPIO功能起不来。

pinctrl子节点需要在iomuxc节点下补全,一般长这样:

&iomuxc { pinctrl_led: ledgrp { fsl,pins = < MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10b0 >; }; };

这个配置告诉内核:把GPIO1_IO03这个引脚复用为GPIO功能,同时配置上下拉和驱动能力。宏定义MX6UL_PAD_GPIO1_IO03__GPIO1_IO03在内核的imx6ul-pinfunc.h里已经定义好了,直接用即可。0x10b0是pad控制寄存器值,不同板子取值略有区别,一般参考原厂BSP里的默认值就行。

3.3 驱动代码的完整实现

对应上面的设备树节点,写一个完整的platform驱动。先定义of_match_table:

static const struct of_device_id led_of_match[] = { { .compatible = "mcu-led" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_of_match);

这段代码的作用是导出一张匹配表。MODULE_DEVICE_TABLE这个宏会生成段信息,方便模块化加载时内核自动匹配。即使平时不写它也跑得起来,但正规写驱动一定要带上。

然后是probe函数,实现LED的初始化:

static int led_probe(struct platform_device *pdev) { struct device_node *np = pdev->dev.of_node; struct device *dev = &pdev->dev; enum of_gpio_flags flags; int ret; led_desc.gpio_num = of_get_named_gpio_flags(np, "led-gpios", 0, &flags); if (!gpio_is_valid(led_desc.gpio_num)) { dev_err(dev, "failed to get gpio\n"); return -EINVAL; } ret = devm_gpio_request_one(dev, led_desc.gpio_num, GPIOF_OUT_INIT_LOW, "mcu-led"); if (ret < 0) { dev_err(dev, "failed to request gpio %d\n", led_desc.gpio_num); return ret; } led_desc.dev = dev; return 0; }

这里用了of_get_named_gpio_flags配合设备树获取GPIO号。如果你用的是新版本内核,也可以用gpiod_get系列API,gpiod是更现代化的GPIO接口,操作抽象更好。但从学习角度,gpio_函数族在传统驱动代码里存量巨大,看懂很有必要。devm_gpio_request_one的好处仍然是自动释放,不用自己在remove里写gpio_free。

接着是完整的平台驱动结构:

static struct platform_driver led_driver = { .probe = led_probe, .remove = led_remove, .driver = { .name = "mcu-led", .of_match_table = led_of_match, }, }; module_platform_driver(led_driver); MODULE_LICENSE("GPL");

probe里还可以加一些逻辑:创建字符设备、注册miscdevice、创建/sys/class下的类等。LED比较轻量,很多人直接用miscdevice,省去主设备号分配和类创建的繁琐步骤。如果你要做的驱动比较复杂,那还是走传统的cdev + class + device_create的流程。

3.4 编译与验证的心得

把驱动编译成模块,可以用内核的module编译方式。假设源码放在drivers/led/目录下,Makefile写一行:

obj-m += led.o

然后在内核源码根目录执行:

make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- modules

编译出的led.ko拷贝到开发板文件系统里,用insmod加载。如果一切正常,dmesg里会看到probe被调用的日志。如果加载后没有任何反应,先用cat /sys/kernel/debug/devices_debug或者查看/sys/bus/platform/devices目录,确认设备有没有被正确枚举出来。

验证驱动正常运行,就看probe里有没有报错,以及对应功能是否生效。LED驱动的话,可以手动echo控制GPIO,或者写个测试程序操作设备节点。如果设备节点已经创建,比如/dev/mcu_led,说明probe执行成功了。

4. 匹配不上是常态:排查与调试实战

4.1 检查设备树解析是否正常

i.MX6ULL驱动开发里,匹配失败最常见的原因就是设备树没解析成功。排查第一步,先确认设备节点有没有被内核创建出来。启动后进入文件系统执行:

ls /sys/bus/platform/devices/

你会看到一堆设备名字,比如1e40000.serial、20ac000.adc之类的。前缀的十六进制数字是寄存器地址,后面的字符串是设备名。如果你写的dts节点被内核解析了,这里会多出来一个对应项。设备树节点的名字一般不会直接显示compatible,而是显示节点的name或者unit-name。如果你找不到自己定义的节点,说明dts根本没编进去,或者解析阶段出了问题。

排查dts问题,看编译是否成功。在u-boot或者内核启动日志里也能看到设备树相关提示。确认dtb已经烧录到启动分区后,还可以在u-boot环境里打印设备树内容:

fdt print /gpioled

这条命令能查看dtb里是否存在这个节点。如果节点存在,再看compatible、status、reg这些属性有没有写错。status="disabled"是最坑的情况——节点解析了,但设备不会生成,匹配自然不会发生。

4.2 驱动侧匹配表的细节坑

如果设备节点存在,驱动也加载了,但还是不进probe,问题多半出在匹配表上。

第一个坑是compatible字符串不一致。设备树里写的是"mcu-led",of_match_table里写的是"mcu_led",肉眼几乎看不出来,但内核比较字符串严格区分字符和大小写,对不上就匹配失败。我排过很多次这类问题,解决办法就是仔细核对,或者直接在驱动里打印of_match_table的内容。

第二个坑是忘写MODULE_DEVICE_TABLE。如果你的驱动是模块加载方式,这个宏不写,某些情况下内核可能无法正确生成模块的alias信息,导致自动加载失败。手动insmod一般没问题,但用udev自动加载时就会出问题。

第三个坑在of_match_table没有以空结构体结尾。设备树匹配表和id_table数组都必须有终止项。没有终止项,内核遍历时就会越界,轻则匹配不到,重则内核panic。我见过新手在这里调了一整天,最后发现是一个分号的问题。

注意:在驱动里调试匹配问题,最快的方法是加打印。platform_match执行时会遍历所有可能,你在probe入口加一条dev_info,在remove里也加一条,日志一打,状态一目了然。如果还是不行,还可以用在of_match_table中打印.name字段,确认驱动确实遍历到了这个节点。

4.3 模块加载顺序与依赖问题

还有一个容易忽略的点:在某些情况下,平台驱动和设备虽然存在,但加载顺序不对,导致probe暂时没被调用。比如驱动依赖另一个驱动初始化的资源,如果被依赖的驱动还没加载,probe里的资源申请就会失败,返回错误码。这时候平台总线不会自动重试probe,除了依赖的驱动加载完成后手动触发重试,很多时候我们只能重新insmod一次。

还有一种情况:驱动编成模块,设备树里对应节点的compatible已经被另一个驱动匹配占用了。系统里可能存在两个可以匹配同一设备的驱动,内核按驱动注册顺序、或者其他优先级选择,另一个先注册的驱动优先拿到了probe机会。如果那个驱动probe失败,资源被占用,你的驱动也就没戏了。排掉这种问题,要么确认加载顺序,要么修改compatible字符串让设备只匹配你的驱动。

4.4 使用devicetree的调试手段汇总

我把日常排查匹配问题的手段整理成一个速查表,方便你对照使用:

排查步骤命令/方法预期结果
确认设备节点是否生成ls /sys/bus/platform/devices/能看到对应节点
查看设备节点的compatible属性cat /sys/bus/platform/devices/xxx/of_node/compatible与驱动of_match_table一致
确认设备是否处于可用状态cat /sys/bus/platform/devices/xxx/status应为okay
查看驱动是否注册ls /sys/bus/platform/drivers/能看到驱动目录
检查驱动与设备是否已绑定ls -l /sys/bus/platform/drivers/xxx/有设备名软链接则已绑定
查看dmesg中的probe日志dmesg | grep -i led能看到probe相关输出
在probe入口主动打印dev_info(&pdev->dev, "probe start\n")日志出现则说明probe已调用

这套表我用了很多年,绝大多数匹配问题都能快速定位。核心思路就一句话:先确认设备存在,再确认驱动注册,最后核对匹配条件。按这个顺序排查,不会像无头苍蝇一样乱撞。

4.5 一个真实的bug复盘

最后分享一个我实际踩过的坑。有次我在i.MX6ULL上写一个SPI设备的platform驱动,设备树里节点写得好好的,compatible也一致,驱动insmod也不报错,但probe就是不进。折腾了半天,最后发现问题是:这个SPI设备节点写在了spi总线的子节点下面,而我又错误地给这个节点写了compatible,导致它被注册成了platform设备而不是spi设备,总线上根本没有这个设备的“家”,自然匹配不上。

正确做法是,挂在SPI总线下的设备节点,应该由spi驱动模型来匹配,不应该走platform机制。这个问题的本质是设备挂在哪个总线上,就要用哪个总线的驱动模型。挂在i2c总线下的用i2c_driver,挂在spi总线下的用spi_driver,只有直接挂在SoC内存地址空间上的内部外设才走platform。

这件事给我的教训是:写platform驱动前,先想清楚这个设备到底属于哪条总线。platform不是万能的,它只负责那些没有标准总线的设备。理解清楚这一点,驱动开发的思路会清晰很多。

5. 关于匹配机制的几个扩展思考

5.1 新旧接口的取舍与兼容

讲完Platform匹配机制,我想顺便聊一聊驱动开发中常见的接口混杂问题。这几年内核社区一直在推进设备驱动模型往更抽象的方向演进,比如GPIO接口从gpio_request/gpio_set_value往gpiod_get/gpiod_set_value迁移,时钟接口也有类似趋势。i.MX6ULL上的老BSP代码很多还在用老接口,网上教程也多是老代码。新手跟着老教程学,能跑通但总觉得别扭;直接上新的gpiod接口,又发现老内核版本支持不完整。

我的建议是:学习阶段两种都看,写代码优先用新接口。新接口的好处不仅仅是API更规范,更重要的是它对设备树的支持更自然,比如gpiod_get(dev, "led", GPIOD_OUT_LOW)直接用dev就可以拿到GPIO描述符,不需要手动处理GPIO号。如果板子换了个引脚,dts改一下就行,驱动代码一行不用动。这跟你用of_get_named_gpio_flags拿号再手动request是一个效果,但代码量少一半,出错面小了。

5.2 驱动编译为模块与编入内核的区别

还有一个影响匹配行为的因素需要提一嘴:驱动是以模块方式加载,还是编入内核。编入内核的驱动在设备树解析后、device创建时就会参与匹配,也就是说同步匹配,设备一存在就尝试匹配;而模块方式加载的驱动,加载完成后才会去扫描总线上的既有设备,属于异步匹配。

在i.MX6ULL的实际开发中,我一般前期都用模块方式,修改驱动后只要重新编译ko,拷贝到板子insmod就行,不用重新打包内核镜像,调试速度快很多。等驱动稳定了,再考虑编入内核,省去启动后手动加载模块这一步,也让系统更接近产品形态。这个流程对匹配机制本身没有影响,但调试效率差别巨大,值得养成习惯。

5.3 从匹配机制延伸到整个驱动模型

把Platform匹配机制搞明白之后,你会发现Linux整个设备驱动模型就是一套“注册-匹配-回调”的框架。I2C驱动、SPI驱动、USB驱动、PCI驱动,套路都是一样的:设备侧注册device,驱动侧注册driver,总线上有match函数,匹配成功后调probe。

理解了这套抽象,你再去看那些体量很大的内核驱动,比如网卡驱动、触摸屏驱动、显示控制器驱动,会发现虽然probe函数里上千行代码眼花缭乱,但骨架永远是:取资源、初始化硬件、注册子系统、创建设备文件。匹配机制只是大门,推开之后里面的房间才是真正干活的地方。而platform这套机制,就是Linux驱动开发入门的第一把钥匙。我当年在i.MX6ULL上调试第一个platform驱动时,也是从匹配不上开始的,那时候不懂设备树、不懂of_match_table、甚至连/sys/bus/platform目录都不知道去看一眼。后来踩过的坑多了,把匹配流程的几个细节搞透了,之后换到其它SoC平台、换到其它总线模型,都能很快上手。如果你也在驱动开发刚开始的阶段被probe不调用折磨,希望这篇文章能帮你少走几个弯路。

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

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

立即咨询