搞嵌入式Linux驱动,只要你在设备树和驱动模型之间打过交道,就一定绕不开Platform机制。很多刚入手i.MX6ULL的朋友,第一步往往是照着手册写字符设备驱动,insmod之后看到日志打印就以为完事了,但等真正要把驱动挂到实际硬件上、想搞明白为什么probe函数会被调用、或者想自己加一个外设节点却死活匹配不上时,才意识到对Platform设备与驱动匹配机制的理解还停留在“会用”而不是“懂原理”的程度。
这篇文章不打算从Linux设备模型的历史讲起,也不去翻《Linux设备驱动程序》里那些已经被内核演化甩开的老框架,就直接用i.MX6ULL这片板上最常见的实际场景,把Platform bus上的设备、驱动、匹配规则、probe触发链路一条条捋清楚。你要是在做i.MX6ULL裸机开发、刚入手Linux驱动、或者正在被“明明设备树节点写了、驱动也加载了、probe怎么就是不调用”这类问题折磨,这篇文章应该能帮你省下好几个通宵。
1. 我为什么要专门梳理Platform机制
实话讲,最早我刚接触Linux驱动时也犯过一个错误:以为驱动开发就是写file_operations、注册字符设备、实现read/write回调,剩下的事交给应用层就完了。这套路在模块化加载、不关心硬件连接方式的情况下确实能跑通,但一旦涉及真实SoC外设,比如i.MX6ULL上的UART、ECSPI、I2C控制器,问题马上就会暴露出来——这些控制器都是芯片原厂集成在片内的,不像PCI或者USB那样有标准枚举机制,操作系统并不知道“这里有一个硬件”,除非有人显式告诉它。
Platform bus就是解决这个“告诉”的过程的。
1.1 从一次实测卡壳说起
我记得自己第一次在i.MX6ULL上写GPIO按键驱动,设备树里添加了节点,compatible也对着驱动里of_match_table写好了,但加载模块后lsmod看得到驱动,/sys/bus/platform/drivers/下面也有对应目录,probe却始终没有被调用。内核日志干干净净,什么warning都没有,我一度以为自己设备树没编译进去,查了.dtb反编译正常,节点确实存在,可probe就是不来。
后来一步一步追下去才发现,问题出在设备树节点的compatible字符串里混进了一个不可见字符,驱动里的of_match_table明明写的是"myvendor,gpio-key",设备树里却因为编辑器粘贴多了一个空格,导致匹配失败。这种问题要是没有把Platform匹配机制的原理彻底吃透,排查起来只能靠瞎猜和反复dmesg。
1.2 没有Platform的裸写驱动为什么难维护
再往前想一步,如果片内外设不经过Platform bus,而是直接在驱动里通过ioremap固定物理地址来操作硬件,会怎么样呢?比如驱动里写死了寄存器基地址0x0209C000,编译成ko之后,这个驱动就只服务这一块板卡。哪天产品做了硬件改版,把引脚和寄存器重新分配了,你就要改源码重新编译,这还不算最麻烦的。
更麻烦的问题是,如果同一段代码要支持多个内核版本、多款硬件平台,那驱动文件里就会堆满#ifdef和平台相关的条件编译,维护起来简直就是噩梦。Platform机制的价值在于:把“硬件长什么样”和“驱动怎么操作”解耦——硬件信息用设备树描述,驱动逻辑用platform_driver描述,总线负责在两者之间牵线搭桥。硬件改版优先改设备树,驱动代码尽量不动;新外设接入时查一下兼容性就清楚能不能复用现有驱动。这正是i.MX6ULL这类嵌入式系统中主流外设驱动都建立在Platform总线之上的根本原因。
2. Platform机制的三个核心角色
理解Platform bus,绕不开Linux设备模型中的三个基础对象:device(设备)、device_driver(驱动)和bus(总线)。单说Platform,它其实是在这三者之上做了语义化封装的一种虚拟总线,它的设备是platform_device,驱动的载体是platform_driver。
2.1 platform_device从哪来
在设备树普及之前,platform_device主要靠板级文件里的platform_device_register来注册。i.MX6ULL的老一些BSP(比如3.0.35时代的板级初始化文件)里经常能看到类似platform_device_register(&fec_device)这样的调用,把网卡控制器、串口控制器逐个注册到Platform总线上。
到了4.x和5.x内核,设备树全面接管硬件描述,platform_device的注册就变成了一个由内核自动完成的过程。设备树里每一个带有compatible属性的节点,都会在系统初始化时被OF核心解析成struct platform_device,然后挂到Platform总线上等待匹配。i.MX6ULL的imx6ull.dtsi里大量节点(比如ecspi1、uart1、gpio1)最终都会以platform_device形式存在。
2.2 platform_driver怎么写
平台驱动这边,struct platform_driver结构体就是驱动的身份证。它内部除了核心的probe、remove回调之外,还有id_table、driver.of_match_table等字段。写一个Platform驱动的最小骨架大致是这个样子:
static const struct of_device_id mydev_of_match[] = { { .compatible = "myvendor,mydev", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydev_of_match); static int mydev_probe(struct platform_device *pdev) { struct resource *res; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); /* 申请内存、映射寄存器、注册中断等 */ return 0; } static int mydev_remove(struct platform_device *pdev) { /* 释放资源、注销设备等 */ return 0; } static struct platform_driver mydev_driver = { .probe = mydev_probe, .remove = mydev_remove, .driver = { .name = "mydev", .of_match_table = mydev_of_match, }, }; module_platform_driver(mydev_driver);注意这里的module_platform_driver宏,它等价于在module_init里调用platform_driver_register、在module_exit里调用platform_driver_unregister。用宏的好处是省去两行样板代码,同时避免忘记配对。
2.3 总线/驱动核心做了什么
Platform bus在这里扮演的是一个居中人的角色。每当有新的platform_device注册进来,总线就会拿这个设备的信息去和所有已注册的platform_driver比对一次;反过来,每当有新的platform_driver注册进来,总线也会拿着驱动去扫描所有已注册的设备。
这个比对动作发生在platform_match函数里。它内部按照一定优先级尝试多种匹配方式,如果任何一种匹配成功,总线就会调用驱动核心的device_attach逻辑,触发probe回调,让驱动和设备正式“握手”。如果在插入模块的瞬间设备已经存在(比如设备树里早就有了节点),platform_driver_register的调用栈会直接完成匹配并调用probe——这解释了为什么我们insmod驱动时经常看到probe在insmod进程上下文中立刻被调用。
3. 匹配规则:到底按什么把设备认领走
很多新手直接看内核源码,跑到drivers/base/platform.c里去看platform_match,第一眼会有点晕,因为逻辑分支比较多。我帮你把优先级和判断条件拆开。
3.1 of_match_table是当前主角
现代内核几乎都是通过设备树来匹配的。platform_driver.driver.of_match_table指向一个struct of_device_id数组,数组中的每个条目都有一个compatible字符串和一个可选data指针。匹配发生时,内核会把设备树中当前节点的compatible属性(它是一个字符串列表,可能有多个值)与of_match_table里逐个比较。
比较的原则是:只要of_match_table中任何一个compatible和设备树节点compatible属性列表中的任意一个字符串完全相等,就算匹配成功。注意这里有个很微妙的点,设备树节点的compatible是一个数组,比如compatible = "myvendor,mydev", "generic,mydev",而驱动of_match_table里只要命中任一即可,不要求全部覆盖。
3.2 id_table负责“古老”兼容
在没有设备树或者设备节点没有compatible属性的场景下,比如ACPI固件表驱动的x86平台,或者某些用板级文件传递设备信息的旧内核,就轮到id_table登场了。struct platform_device_id是另一种匹配凭证,它主要依靠名字来匹配。
代码里platform_match会先检查是否能用of方式匹配,如果不满足,再检查platform_driver.id_table,如果id_table为NULL,就用platform_driver.driver.name和platform_device.name做字符串比较。这三种方式不是逐个尝试,而是有优先级和短路逻辑的,实际执行流程大致如下:
- 设备树匹配成功:直接返回匹配成功。
- 设备树匹配不适用(无of_node或of_match_table为空):检查id_table。
- id_table也匹配不了:退回到driver.name和设备名做比较。
不过现在的主流平台基本都走第一条路径,后两种更多是为了向前兼容历史代码。
3.3 匹配优先级的实际顺序
我记得内核4.19版本里platform_match函数实现大致是这样的逻辑:
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); /* 1. 优先尝试OF匹配 */ if (of_driver_match_device(dev, drv)) return 1; /* 2. 尝试ACPI匹配 */ if (acpi_driver_match_device(dev, drv)) return 1; /* 3. 尝试id_table匹配 */ if (pdrv->id_table) return platform_match_id(pdrv->id_table, pdev) != NULL; /* 4. 最后退回name匹配 */ return (strcmp(pdev->name, drv->name) == 0); }这个顺序的优先级是:OF匹配 > ACPI匹配 > id_table匹配 > name匹配。对于i.MX6ULL上的开发,我们通常只需要关心OF匹配就足够了,但了解整个链路的好处在于排查问题时能想明白“为什么设备树里compatible写错了,驱动反而可能在某个环节被name匹配意外兜住”。
4. 在i.MX6ULL上完整走一遍匹配流程
纸上谈兵没意思,我们来一个具体的实操梳理。假设我在i.MX6ULL上要写一个简单的LED控制器驱动,通过GPIO控制一盏灯,同时我希望这个驱动能够读取设备树里的属性来决定闪烁频率之类的参数。
4.1 从设备树里的一个节点开始
设备树的起点是imx6ull.dtsi,一般我们会把自定义外设节点放在板级dts里,比如imx6ull-myboard.dts。假设我在根节点下添加这样的节点:
/ { myled { compatible = "myvendor,myled"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_myled>; led-gpio = <&gpio5 3 GPIO_ACTIVE_LOW>; blink-period = <500>; status = "okay"; }; };注意这里的myled是一个自定义节点。因为i.MX6ULL的设备树是支持gpio-leds这种现成框架的,但为了讲清Platform流程,我们故意自己定义一个简单平台设备。compatible是这个节点能被识别为platform_device并匹配驱动的关键。
内核在设备树初始化时,会遍历根节点下的所有节点,遇到带compatible属性的节点就创建platform_device。对于简单设备(没有reg、interrupts等复杂资源),这个过程几乎是透明的,你甚至不需要任何额外的注册代码。
4.2 驱动端完整代码框架
对应上面的设备树节点,驱动端可以这样写:
#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/delay.h> struct myled_data { struct gpio_desc *led_gpio; int blink_period; }; static const struct of_device_id myled_of_match[] = { { .compatible = "myvendor,myled", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, myled_of_match); static int myled_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct myled_data *data; u32 period = 0; data = devm_kzalloc(dev, sizeof(*data), GFP_KERNEL); if (!data) return -ENOMEM; >insmod myled.ko然后查看内核日志:
dmesg | tail -20如果一切正常,你会看到类似如下的输出:
myled myled: myled probed, blink period = 500 ms platform myled: myled driver claimed同时,/sys/bus/platform/devices/myled/目录下会出现driver符号链接,指到/sys/bus/platform/drivers/myled/。如果probe没有执行,这个driver链接不会存在。
再执行ls -l /sys/bus/platform/drivers/myled/,能看到它下面挂着的设备符号链接,也是确认绑定成功的方法。
5. 常见问题与排查技巧实录
这部分才是真正的干货。我从实际踩坑经历出发,把那些经常把新手拦住的问题集中列一遍。
5.1 probe函数根本没进,怎么查
你辛辛苦苦写好了驱动,设备树节点也在,insmod之后没有报错,但/sys/bus/platform/devices/myled/下面根本没有driver链接,甚至整个设备节点都不存在,这时候从哪下手?
第一步,先确认设备树节点是否真的被内核解析成platform_device了。很多人以为dtb编进去了就等于有效,其实可能设备树语法错误或者节点被disabled。在板子上执行:
ls /sys/bus/platform/devices/ | grep myled如果没有输出,先查设备树是否编译成功。可以在boot阶段加console=ttymxc0之后看内核启动日志,搜OF: fdt:相关的打印,或者用fdtdump反编译dtb确认节点还在。我遇到过一种情况,设备树里status = "disabled"被写成"disable",内核根本不会创建这个platform_device。
第二步,确认设备树节点确实被创建为platform_device后,再看驱动是否注册成功。/sys/bus/platform/drivers/myled/目录存在说明platform_driver_register成功了。如果这里没有目录,大概率是module_platform_driver宏没生效,检查Makefile是否把编译目标正确包含进去。
第三步,确认匹配条件。把myled_of_match里的compatible和设备树节点里实际的值逐一比对,包括有没有空格、大小写、标点全角半角问题。这一步最常见,我前面讲的不可见字符案例就是在这里踩坑的。
5.2 compatible写对但还是不匹配
这种情况下,有一种可能你忽略的是:设备树节点虽然带compatible属性,但这个节点可能不是挂在platform总线上的。比如你把它放到了某个I2C总线的子节点下,那它就会被I2C核心识别为i2c_client,走的是I2C设备和驱动的匹配流程,你的platform_driver自然不可能被匹配上。同理,SPI外设子节点也属于spi bus。
如果你希望某个节点被当作platform_device处理,它必须作为平台设备出现在设备树根层级,或者它的父节点本身是一个platform设备对应的节点(比如某个mmio总线的子节点)。i.MX6ULL的imx6ull.dtsi中,soc节点下的外设控制器节点就是这种情况:它们位于simple-bus总线上,会被解析为platform_device挂在Platform总线上。
另一个可能被忽视的原因是of_match_table数组结尾没有补sentinel条目(也就是空结构体)。内核遍历of_device_id数组时,靠of_match_device判断到哪个条目时compatible为NULL就停止,如果你忘记写最后的空条目,内核可能会越界访问,匹配行为变得不可预期。我见过有人数组只写了一项,没有{ /* sentinel */ },结果驱动时灵时不灵,最后补上空条目就稳定了。
5.3 两个驱动抢同一个设备
有时候我们在设备树里建了节点,模块里也注册了两个platform_driver,它们的of_match_table都包含同一个compatible,到底谁会成功绑定设备?
答案只有一个:先注册成功的先绑定。后注册的驱动再匹配同一个设备时,设备已经被claimed,不会再触发probe。这跟设备模型的“一夫一妻”原则一致——一个platform_device同一时刻只能绑定一个platform_driver。如果两个驱动都想操作同一个设备,要么改为一个支持多重实例、一个作为前端设备,要么用miscdevice/字符设备框架让应用层选择使用哪个逻辑,而不是抢同一个platform_device。
排查这种问题时,在/sys/bus/platform/devices/myled/driver里看符号链接指向哪个驱动目录,就知道谁抢到了。
6. 一些没那么多人提的边缘经验
到了这个阶段,匹配流程你已经能跑通了,但还有几个小技巧能让你在实际项目里少走弯路。
6.1 开启内核调试信息
Linux驱动模型在drivers/base/core.c和drivers/base/dd.c里埋了不少dev_dbg级别的调试打印。如果你实在查不到问题,可以打开驱动模型的动态调试:
echo 'file drivers/base/dd.c +p' > /sys/kernel/debug/dynamic_debug/control echo 'file drivers/base/platform.c +p' > /sys/kernel/debug/dynamic_debug/control这样内核就会在设备与驱动绑定、解绑、probe成功/失败时打印详细的日志。很多时候dmesg原本干净的背后,其实是有dev_dbg日志被默认关掉了。
6.2 设备树与模块参数如何取舍
遇到需要配置的参数,比如LED闪烁周期,是写在设备树里通过s/device_property_read_u32读取,还是放在模块加载参数里用module_param读取?
我的建议很简单:只要和硬件连接方式相关、或者随板卡变化而变化的参数,放设备树;如果纯粹是运行时行为配置、与硬件细节无关,放模块参数。设备树的价值在于描述硬件,模块参数适合干活时的行为开关。混淆这两者会让后续维护非常难受,别人接手你的驱动时,看到blink-period写在模块参数里却还要分别确认每块板卡的配置,会非常头疼。
6.3 用match_data传递平台差异
of_device_id结构体里除了compatible之外,还有一个data字段。这个字段可以在不增加任何运行时判断代码的情况下,为不同芯片型号传递不同的配置参数。比如你要写一个驱动同时支持i.MX6ULL和i.MX8M Mini的某个外设,但两边寄存器基地址不同、时钟频率上限也不同,可以这样定义:
struct mydev_cfg { unsigned long base; int max_clock; }; static const struct mydev_cfg imx6ull_cfg = { .base = 0x0209C000, .max_clock = 200000000, }; static const struct mydev_cfg imx8mm_cfg = { .base = 0x30000000, .max_clock = 500000000, }; static const struct of_device_id mydev_of_match[] = { { .compatible = "myvendor,mydev-imx6ull", .data = &imx6ull_cfg }, { .compatible = "myvendor,mydev-imx8mm", .data = &imx8mm_cfg }, { /* sentinel */ } };然后在probe里用of_device_get_match_data(dev)取出对应的cfg结构体指针,不需要一堆ifdef和compatible字符串比较。这个小技巧在半导厂商的驱动里很常见,比如pinctrl、clk驱动都会用match_data来区分不同SoC版本。
6.4 设备树节点地址命名的影响
Platform设备的名称有时候会和设备树节点的节点名一致,但也不完全如此。设备树里myled@0这样的节点名如果在相同层级如果还有其他节点,设备名可能会带上地址后缀。比如myled@0x020C0000这样的名字会让sysfs路径发生变化。写脚本或程序依赖固定路径时,尽量通过compatible或driver链接来识别,不要死记设备名。
还有个细节,设备树节点的reg属性如果不设置,节点名带地址后缀纯粹是给读者看的,不影响匹配。反过来如果你在一个节点里写入了reg = <0x020C0000 0x1000>,内核会自动把这段地址资源变成platform_device的IORESOURCE_MEM资源,驱动里就可以用platform_get_resource(pdev, IORESOURCE_MEM, 0)拿到。这也就解释了一种常见的困惑:为什么有的驱动里明明没有设备树代码,却能拿到寄存器和中断。
另外还需要提一下devm_系列接口。在probe里用devm_kzalloc、devm_gpiod_get、devm_ioremap_resource这类资源管理接口,可以让设备在remove、probe失败、模块卸载时自动释放资源。我见过不少人习惯在probe里用普通的kzalloc+gpio_request,然后必须在remove里手工配对释放,稍一疏忽就泄漏。用devm系列接口后,remove函数往往只需要做关灯、同步等待等逻辑,资源释放由驱动核心自动完成,这个习惯值得尽早养成。
Platform设备与驱动匹配机制说穿了并不玄乎,它就是Linux设备模型在片上外设场景下的一次规范落地。你只要把“设备树描述硬件、驱动描述操作逻辑、总线负责撮合”这三个角色的关系刻在脑子里,再遇到probe不进、匹配失败的问题时,按部就班查设备树节点是否存在、compatible是否一致、总线上有没有别家抢走,通常很快就能定位。
我自己每次写一个新的Platform驱动都会有一个固定动作:先在sysfs里确认设备在不在,再确认driver目录有没有建起来,最后才去看dmesg。这个习惯帮我过滤掉了大量无效怀疑,也让我在团队里排查问题的时候少走弯路。希望这篇文章对你在i.MX6ULL上的驱动开发也能起到类似的作用。