1. 先把Platform机制是什么说清楚
1.1 为什么CPU内置外设需要一条"虚拟总线"
刚开始在i.MX6ULL上做Linux驱动时,我其实对Platform有很强的抵触情绪:裸机时代我写个LED驱动,直接操作GPIO寄存器就完事了,到了Linux底下怎么就非得搞什么device、driver、bus这一套,绕来绕去的,一度觉得是在浪费时间。
但真正把Linux的设备模型吃透之后,我现在的看法完全不同了。i.MX6ULL这颗Cortex-A7内核芯片上,GPIO、UART、SPI、I2C、ECSPI、SDIO这些外设全都挂在SoC内部总线上,它们不是像PCI或者USB那样可以热插拔、枚举出真实硬件的总线设备。系统怎么知道有哪些设备?中断控制器接了哪些中断源?引脚复用成什么功能?这些信息在纯软件层面根本看不到,必须有人告诉你。在旧内核里,这个信息靠arch/arm/mach-xxx目录下大量的board-xxx.c文件写死,代码又臭又长,换一块板子就要改内核,维护成本极高。
Linux的做法是把设备、驱动、总线三者抽象成一套模型:总线负责匹配设备和驱动,设备描述"硬件上有什么",驱动描述"软件上怎么操作"。对于挂在CPU内部总线上的这些外设,内核就虚拟了一条总线叫platform_bus,也就是Imx6ull驱动开发里天天碰到的Platform机制,中文常叫平台设备与驱动匹配。所以你可以把platform_bus理解为一条看不见摸不着、但所有CPU内置外设都要走的"注册通道",它解决的核心问题只有一个:让驱动代码和设备描述解耦。
1.2 i.MX6ULL上的实际场景:从裸机到Linux的思维转换
如果你是像我一样从裸机RTOS转过来的,最容易卡住的就是"设备"这个概念。裸机开发时,点亮LED的代码无非三步:使能GPIO时钟、配置引脚复用为GPIO模式、操作数据寄存器。这没有问题,因为你知道电路板上LED接在哪个引脚。但Linux驱动是通用代码,同一个驱动文件可能要跑在十几种板子上,有的板子LED接GPIO1_IO03,有的接GPIO5_IO01,你不能把这个信息硬编码在驱动里。
Platform机制就是为这个场景准备的:板级硬件信息(引脚号、时钟频率、中断号、DMA通道等)放进设备树,驱动里只写操作逻辑,通过api去读取设备树里传进来的参数。设备树负责回答"板子上有什么、接在哪里",驱动负责回答"这个硬件怎么用",platform_bus在中间做媒人。这个分工一旦想明白,Linux驱动开发的整个思维框架就立住了一半。
我在i.MX6ULL上做第一款Platform驱动时,硬件上只是点了个LED,但程序从device、driver到match的完整链路都走了一遍,跑通之后回头看,整个设备模型的地基算是打牢了,后面再写按键、中断、I2C驱动,基本就是在这个框架里填肉。
2. 匹配机制的三个关键要素
2.1 设备树节点:compatible就是你的"身份证"
设备树(Device Tree)是Platform机制里设备的出场凭证。在i.MX6ULL的板级设备树文件(通常是imx6ull-14x14-evk.dts)里,一个典型的设备节点大概长这样:
led { compatible = "myvendor,board-led"; reg = <0x020c406c>; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_led>; led-gpio = <&gpio5 3 GPIO_ACTIVE_LOW>; status = "okay"; };兼容属性,也就是compatible,是匹配的关键身份证。它遵循"厂商名,设备名"的命名约定,例如"myvendor,board-led",这个字符串会同时出现在设备树节点和驱动里,匹配时双方对上了,内核就认为"这个驱动认识这个设备"。
刚开始我犯过一个低级错误:设备树里写的是"myvendor,board-led",驱动里of_match_table写的是"myvendor,board_led",一个短横线一个下划线,结果驱动probe就是不执行,查了半天才在dmesg里发现驱动没匹配上。这种细节一定要小心,设备树里的compatible字符串和驱动里的完全一致,差一个字符都不行。
设备树节点的作用和以前板级文件里的platform_device结构体是一样的,只不过从C代码换成了描述性文本。它描述的核心内容包括:设备挂在哪个总线上、使用哪些寄存器地址和长度、有哪些中断、引脚的复用关系、GPIO编号等。这些东西最终会被内核的device_node结构体保存下来,供驱动解析使用。
2.2 platform_driver与of_match_table
设备树把"设备"定义好了,接下来就是"驱动"这边。在驱动代码里,核心是一个platform_driver结构体:
static const struct of_device_id led_of_match[] = { { .compatible = "myvendor,board-led" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_of_match); static struct platform_driver led_driver = { .probe = led_probe, .remove = led_remove, .driver = { .name = "board-led", .of_match_table = led_of_match, }, }; module_platform_driver(led_driver);这里of_match_table就是刚才说的身份证比对库,内核会遍历它,看有没有和设备树节点compatible值匹配的项。两个都要对应起来,设备树那边是"我想要谁",驱动这边是"我能匹配谁",一旦相等,总线就会调用驱动的probe函数。
一个驱动也可以写多个of_device_id条目,表示支持多种设备,比如:
static const struct of_device_id led_of_match[] = { { .compatible = "myvendor,board-led" }, { .compatible = "myvendor,board-led-v2" }, { /* sentinel */ } };这样同一份驱动代码,可以同时适配V1和V2两个版本的硬件,硬件改版时只需要在设备树里换compatible字符串,驱动文件一行都不用动。
2.3 匹配优先级:不止compatible这一条路
很多人以为Platform驱动匹配只靠compatible,实际上内核的匹配顺序是有一套完整优先级的。在驱动注册或者设备注册的时候,内核会按顺序尝试以下几种匹配方式:
| 匹配方式 | 说明 | 使用场景 |
|---|---|---|
| 设备树of_match_table | 比较设备节点compatible和驱动表中compatible | 设备树普及后最常用 |
| ACPI匹配 | 比较ACPI表里的HID/CID | x86平台、UEFI环境 |
| id_table匹配 | 比较platform_device的name和id_table中的name | 传统板级文件平台 |
| 驱动名与设备名匹配 | 比较platform_driver.driver.name和platform_device.name | 早期platform设备 |
| 私有无匹配机制 | 设备树节点里带driver_override字段 | 特殊情况强制指定 |
在i.MX6ULL这种嵌入式平台上,绝大多数情况走的是第一种方式。等内核的设备树支持完善之后,ACPI基本不用管,id_table那套则是老平台的产物,现在写驱动优先写of_match_table就可以了。
理解了优先级,你就能解释很多奇怪现象。比如驱动明明注册了,设备树里compatible也对,但probe没执行,那有可能是有另一个驱动先按id_table或设备名抢占了匹配。这种"半路杀出个程咬金"的case,我后续会专门展开说。
3. 匹配代码解析与probe触发流程
3.1 从内核启动到probe执行的完整链路
很多人写过platform驱动,但整个匹配的流程细节未必清楚。我画了一条逻辑链路帮助理解,梳理完你会发现它其实是层层递进的关系:
内核启动 -> 解压设备树DTB -> 解析device_node树 -> 遍历节点创建platform_device -> 注册到platform_bus -> 注册platform_driver -> 总线match检查 -> probe成功 -> 驱动接管设备在i.MX6ULL上,设备树的解析发生在内核启动早期,由start_kernel调用setup_arch,再走到unflatten_device_tree,把所有设备树节点展开成struct device_node的树状结构。之后内核会调用of_platform_default_populate_init遍历根节点下所有带compatible属性的节点,为它们创建platform_device。
我插一句,这个过程是存在时序的。如果你把驱动编译成模块(.ko),insmod加载时驱动才注册,这时候设备树早已解析完成,platform_device早就挂在总线上了;如果你把驱动编进内核(built-in),那么驱动的注册时机由驱动的initcall层级决定。i.MX6ULL默认的启动流程里,绝大部分设备驱动是built-in的,系统起来时驱动和设备在总线上"相遇"并完成匹配。理解了这一点,你调试的时候就会知道什么时候该等设备,什么时候该等驱动。
3.2 一次匹配,驱动的executive入口怎么选
真正走到匹配环节,内核会调用platform_match函数,它是platform_bus这个"媒婆"的看家本领。这个函数匹配逻辑的核心如下(基于内核源码逻辑):
static int platform_match(struct device *dev, struct device_driver *drv) { struct platform_device *pdev = to_platform_device(dev); struct platform_driver *pdrv = to_platform_driver(drv); /* 用id_table匹配 */ if (pdrv->id_table) if (platform_match_id(pdev, pdrv->id_table) != NULL) return 1; /* 用设备树匹配 */ if (of_driver_match_device(dev, drv)) return 1; /* 用acpi匹配 */ if (acpi_driver_match_device(dev, drv)) return 1; /* 用驱动名和设备名匹配 */ if (strcmp(pdev->name, drv->name) == 0) return 1; return 0; }一旦platform_match返回1,内核会调用really_probe,进而调用driver的probe函数。注意,probe函数的参数是struct platform_device指针,你可以通过这个指针拿到设备树里的各种资源。
如果你是从应用层转到内核开发,这里有个特别值得体会的地方:以前的裸机程序是"我要操作某个寄存器",驱动开发是"系统告诉我有一个设备,你初始化并服务它"。控制流反过来了。probe返回0表示成功,返回负数表示失败,失败时内核可能会尝试下一个驱动,或者把设备挂进deferred probe列表等待时机重试。
3.3 实战:完整写一个i.MX6ULL的platform驱动框架
理论说了这么多,不落地都是空的。我在i.MX6ULL上写过一个最简但五脏俱全的Platform驱动,功能是操控一个LED,代码框架分享给大家:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_gpio.h> #include <linux/gpio/consumer.h> #include <linux/gpio.h> struct led_data { struct gpio_desc *led_gpio; }; static int led_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct led_data *data; data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; /* 从设备树获取GPIO,语义化获取,不用关心具体引脚号 */ >led { compatible = "myvendor,board-led"; led-gpios = <&gpio5 3 GPIO_ACTIVE_LOW>; status = "okay"; };编译的方法和其它内核模块一样,写个Makefile放内核源码树下或使用内核模块构建环境:
obj-m := led_platform.o KDIR := /path/to/linux-imx all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean模块编出来之后,如果是模块方式,先确保设备树里已经有了那个节点(i.MX6ULL上一般改完设备树重新编译DTB烧进去),然后在板子上执行:
insmod led_platform.ko这时候dmesg里如果能看到"led probe success",说明设备树→platform_device→platform_driver→probe这条路彻底通了。
用devm_gpiod_get系列接口而不是老式gpio_request加of_get_named_gpio的好处是资源生命周期全由内核托管,probe失败或者remove时不用手动一堆清理操作,可以减少很多裸奔内存泄漏的隐患,这是我在i.MX6ULL项目里明显体会到的差别。
4. 常见问题排查手册:probe不执行怎么办
4.1 排查清单与定位方法
我见过太多初学者卡在"驱动编译进内核,也注册到总线了,但probe就是不被调用",其实这类问题95%都能用下面这张清单定位:
| 排查项 | 检查方法 | 常见原因 |
|---|---|---|
| 设备树节点是否存在 | /proc/device-tree/或/sys/firmware/devicetree/base/ | DTB没更新,节点没编译进去 |
| compatible是否一致 | 对比设备树和of_match_table字符串 | 拼写差异、符号差异 |
| 设备树status状态 | 查看status属性是否为okay | disabled节点不参与匹配 |
| 驱动是否注册成功 | ls /sys/bus/platform/drivers/xxx | 驱动编译进了别的模块 |
| 是否被deferred probe | dmesg搜索deferred | 依赖的外设还没就绪 |
| 是否有其它驱动占用 | 查看/sys/bus/platform/devices/ | 驱动名和设备名碰撞 |
排查的时候我习惯按三个步骤走:第一步在板子上cat /proc/device-tree/led/compatible,确认设备树里字符串到底对不对;第二步到/sys/bus/platform/drivers/下面ls,看驱动有没有注册进来;第三步dmesg搜"platform"关键词,看匹配过程有没有报错。这三板斧下来,大部分问题都有了方向。
4.2 延迟探测deferred probe是怎么回事
真正让人头疼的是deferred probe。我有个很典型的经历:在i.MX6ULL上做GPIO按键驱动,设备树里设备引用了某个GPIO控制器,而Probe里的devm_gpiod_get需要GPIO控制器先完成probe,但设备树节点的创建顺序未必保证GPIO控制器在前。这时候驱动程序probe会返回-EPROBE_DEFER,告诉内核"这次不行,但别放弃我,等会再试"。
如果dmesg里看到"probe of xxx returned -EPROBE_DEFER"这种提示,别慌,这是内核在排队重试。系统会在所有设备注册完之后,对那些返回-EPROBE_DEFER的驱动重新probe。但如果依赖永远不满足,比如GPIO控制器驱动根本没编译进内核,那设备就会一直卡在deferred状态,看起来就像probe永远不被调用。
排查deferred probe有一个很实用的命令思路:
cat /sys/kernel/debug/devices_deferred cat /sys/kernel/debug/device_pm/deferred_probe_pending_list.sh # 或看内核相关debug节点这个文件会列出所有正在等待的设备和原因,一眼就能看出是谁在等谁。
4.3 查看sysfs和调试信息的实战技巧
sysfs是Linux内核设备模型的现实投影,调试Platform驱动时非常有用。设备匹配成功后,你可以看到:
ls /sys/bus/platform/devices/ ls /sys/bus/platform/devices/led/ ls /sys/bus/platform/drivers/board-led/后面这条链路可以看到设备和驱动之间建立了链接。我个人调试的时候有一个习惯:写驱动时在probe里故意加上dev_info和dev_err打印,信息打全一点,方便在dmesg里追踪。比如打印pdev->name、of_node的全路径、拿到的GPIO编号等,这些信息在调试时能省下大量猜时间。
另外,强烈建议开启内核的动态调试:
echo "file drivers/base/platform.c +p" > /sys/kernel/debug/dynamic_debug/control然后dmesg里就能看到platform_match、really_probe这些核心函数是否被调用,以及在哪个环节返回的失败。这个手段我用了很多次,比自己瞎改代码重新编译省力太多了。
5. 进阶经验与避坑指南
5.1 多个同类设备如何匹配同一个驱动
实际项目中,板子上往往不止一个LED,比如电源灯、状态灯、网络灯都是同一颗芯片控制的。这时候你就需要多个设备树节点对应同一个驱动。做法很简单:设备树里写多个compatible相同的节点,驱动中的of_match_table保持不变,内核会为每个节点创建一个platform_device,然后逐个匹配同一个platform_driver,probe会被调用多次。
但这里有个坑必须注意:probe里的devm_gpiod_get如果写死了设备树属性名"led",多个节点都能用,没有问题;但如果你用platform_get_resource之类的方式拿到固定的寄存器地址或内存区,多个设备之间就可能产生资源冲突。更好的做法是在设备树里用reg属性区分:
led1 { compatible = "myvendor,board-led"; reg = <0x0>; led-gpios = <&gpio5 3 GPIO_ACTIVE_LOW>; }; led2 { compatible = "myvendor,board-led"; reg = <0x1>; led-gpios = <&gpio5 4 GPIO_ACTIVE_LOW>; };然后在probe里读reg值判断是第几个设备:
u32 id; struct resource *res; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (res) id = res->start; dev_info(dev, "this is led%u\n", id);当然,设备树里同一个根节点下不允许有同名子节点,所以命名要么用led@0、led@1,要么用led-red、led-green这种方式,我推荐前者,元素更规整。
5.2 设备树修改后一定要reboot
这个经验说出来像废话,但确实是我最想写进避坑清单的一条。在i.MX6ULL上调试Platform驱动时,我发生过两次"灵异事件":改了设备树,重新编译DTB,烧到板子上,dmesg里的设备还是老样子,compatible还是旧值。折腾了很久才发现,用的是板卡开发环境里缓存的旧DTB,或者u-boot环境变量uimage里指定的设备树路径根本没指向我新烧的位置。
后来我给自己定了个规矩:每次烧完设备树,在板子上第一条命令就是cat /proc/device-tree/led/compatible,确认当前生效的设备树就是自己刚改的版本。很多调试问题,根源其实是"你以为你改的东西已经生效了",但实际没有。内核模块也一样,insmod之前先rmmod旧模块,或者干脆重新启动系统,避免模块版本残留、符号冲突带来的干扰。
5.3 从Platform机制延伸到其它总线
把Platform机制彻底搞懂之后,你会发现它其实是理解其它Linux驱动总线的钥匙。i.MX6ULL上还有i2c总线、spi总线、regmap框架,它们的匹配逻辑和Platform一脉相承,都是device、driver、bus三件套,只不过各自的总线类型不同,匹配时的id_table和probe参数会有差异。
比如I2C设备驱动,同样有i2c_device_id、of_match_table,probe参数是struct i2c_client,从设备树获取的也是compatible属性;SPI驱动则对应struct spi_device。所以我在带人的时候经常说:先把Platform机制啃透,再去碰I2C/SPI/regmap,一通百通。
最后再分享一个小技巧。调试匹配问题时,尽量不要用printk裸打,养成用dev_dbg/dev_info的习惯,日志里自带上层设备和驱动名,一眼就知道是谁在打日志。然后把内核日志级别调成8再开动态调试,整个匹配过程的每一步都清清楚楚。这是我踩了无数次坑之后养成的习惯,对排查"probe不执行"这类问题特别管用。