前几天帮同事调i.MX6ULL的驱动,现象非常典型:模块加载了,日志里却没有probe函数的打印。设备树里compatible属性的值和驱动of_match_table里的字符串,肉眼看上去一模一样,就是匹配不上。最后我在/sys/bus/platform目录下翻了半天,才发现设备树节点compatible值的末尾多了一个不可见的空格。这类问题,根子都在对Platform设备与驱动匹配机制的理解还停留在“大概知道”的层面。
本文就以i.MX6ULL平台为例,把platform总线的匹配机制、代码路径、实操套路和排错思路完整串一遍。如果你正准备入门i.MX6ULL或者其他Cortex-A系列芯片的Linux驱动开发,或者写过platform驱动但老是栽在匹配环节,这篇应该能帮你省不少时间。
1. 为什么i.MX6ULL上的外设多半绕不开platform机制
1.1 从硬件控制器的角度理解platform设备的来源
i.MX6ULL是一颗Cortex-A7内核的处理器,但芯片内部集成了大量外设控制器,比如UART、I2C、SPI(ECSPI)、SDIO、GPIO、PWM、ADC、LCDIF、ENET等。这些控制器从软件角度看,本质就是一段寄存器地址区域加上若干中断号,但在Linux设备模型里,它们需要被抽象成设备节点,挂到某一类总线上。
问题在于,这些控制器不是PCI设备,也无法枚举,它们的位置和资源在硬件设计时就固定了。Linux内核为这类“挂不到真实总线上的片上外设”设计了一条虚拟总线,就是platform总线。i.MX6ULL上几乎每一个内部外设控制器,在内核启动后都会注册成一个platform_device。
设计上通常分两步:第一步是描述硬件,传统方式是在arch/arm/mach-imx/目录下的板级文件里手动填充platform_device结构体并调用platform_device_register,这种方式在内核3.x时代很常见;第二步是现在主流的设备树方式,内核在启动过程中通过of_platform_default_populate等函数解析设备树,把节点自动转换成platform_device。设备树之所以能成为主流,核心原因是解决了硬件描述与驱动代码耦合的问题,更换板卡硬件时不用重新编译内核,只改设备树文件。
在i.MX6ULL的官方BSP里,默认就是设备树方式。你在设备树里写一个节点,内核启动后就可以在/sys/bus/platform/devices/目录下看到对应的设备目录。
1.2 platform设备与platform驱动的注册时机
刚学驱动的人很容易有一个困惑:驱动和设备到底谁先注册?probe函数什么时候调用?这里的关键是理解Linux设备模型的事件驱动机制。
当驱动注册时,总线会遍历所有已经注册的设备,逐个调用匹配函数寻找合适的设备,找到就立刻调用驱动的probe;反过来,当设备注册时,总线也会遍历所有已经注册的驱动,找到匹配项就触发probe。所以,注册顺序本身不影响最终是否能匹配上,只影响probe触发的早晚。如果两个都注册完成后还没匹配上,那就是匹配条件本身出了问题,这也是调试时的基本判断方向。
在i.MX6ULL的启动流程里,设备树解析生成platform_device的动作发生得比较早,通常在kernel_init之前。而驱动模块如果选择编译为模块(.ko),则是在系统启动后由modprobe或insmod加载。操作系统会先存在设备,再补充驱动;如果驱动编译进内核(zImage),则设备和驱动的注册顺序就不一定了,但各自注册时都会去扫描对方,所以probe依然会正常触发。
1.3 platform总线在sysfs中的组织结构
/sys/bus/platform/目录下有两个关键子目录:devices/和drivers/。devices目录下面是所有platform设备的软链接,drivers目录下面是所有platform驱动的软链接。当驱动和设备匹配成功后,设备目录下会多出一个driver链接,指向对应的驱动目录;同时驱动目录下也会出现设备的链接。这个双向链接是判断绑定关系最直观的依据。
我在调试时几乎离不开这个目录。设备有没有注册、驱动有没有加载、绑定关系是否建立,ls一下马上就知道。这篇文章后续的排错环节,很多操作也是围绕这个目录展开的。
2. platform_match的执行顺序:驱动和设备到底怎么“对上眼”
2.1 内核源码里platform_match的完整逻辑
platform_driver和platform_device的匹配入口是platform_match函数,定义在drivers/base/platform.c中。以Linux 5.x/6.x内核为例,核心逻辑如下:
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); /* When driver_override is set, only bind to the matching driver */ if (pdev->driver_override) return !strcmp(pdev->driver_override, drv->name); /* Attempt an OF style match first */ if (of_driver_match_device(dev, drv)) return 1; /* Then try ACPI style match */ if (acpi_driver_match_device(dev, drv)) return 1; /* Then try to match against the id table */ if (pdrv->id_table) return platform_match_id(pdev, pdrv->id_table) != NULL; /* fall-back to driver name match */ return (strcmp(pdev->name, drv->name) == 0); }这五步的优先级是硬编码的,从上到下依次执行,命中即返回。理解这个顺序,对调试很多匹配问题非常有帮助。
2.2 五种匹配方式的适用场景
先看driver_override,这是留给调试和特殊场景用的“后门”。如果设备节点的driver_override属性被设置,那么只和这个字段指定的驱动名进行字符串比较,其他匹配方式全部跳过。我一般用它来强制绑定某个驱动,或者在设备树不便于修改的板子上暂时切换驱动,后面排错章节会详细演示。
接着是of_driver_match_device,这是i.MX6ULL设备树平台最常用的匹配路径。它做的事可以理解为:取出device_node的compatible属性,和驱动的of_device_id数组中每个成员的compatible字段逐一比较,只要有任何一个字符串相等,就返回匹配。注意,compatible比较要求完全相等,包括厂商前缀和大小写,一个字符不对都不行。
然后是acpi_driver_match_device,x86平台和部分ARM服务器平台会走ACPI路径,i.MX6ULL这种典型嵌入式平台几乎不用,但代码逻辑存在,不影响什么。
再然后是platform_match_id,这是给没有设备树的老式驱动用的。驱动可以定义一个platform_device_id数组:
static const struct platform_device_id mybeep_id_table[] = { { "mybeep", 0 }, { } };platform_match_id会拿设备的name字段和id_table里的name做比较。那什么时候会走上这条路呢?最常见的就是没有设备树、设备通过platform_device_register注册的场景,设备名字就是在platform_device结构体里指定的那个字符串。
最后一步是退化匹配,直接把pdev->name和drv->driver.name做字符串比较。这种写法在很老的驱动里能看到,比如:
static struct platform_driver mybeep_driver = { .driver = { .name = "mybeep", }, .probe = mybeep_probe, .remove = mybeep_remove, };如果设备树里的节点没有compatible属性,且设备节点名为mybeep,驱动名也叫mybeep,走这一步就能匹配上。但这属于“祖传写法”,在新代码里不推荐依赖它。一方面它太隐晦,可读性差;另一方面设备树节点的name字段通常包含总线前缀或单元地址,和驱动名不一定对得上。规范做法是使用of_match_table或id_table。
2.3 设备树compatible与of_match_table的对应逻辑
static const struct of_device_id mybeep_of_match[] = { { .compatible = "myvendor,mybeep", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mybeep_of_match);关于MODULE_DEVICE_TABLE,很多人以为它只是给用户态工具看的,其实它还有一层重要含义:它会在编译时生成模块别名,把of_device_id里的compatible字符串编码进.modinfo段。这样modprobe在加载模块时可以通过内核发送的uevent事件自动匹配设备。驱动编译为模块后,用modinfo检查能看到类似alias=of:NTCmyvendor,mybeep的信息,有这个信息才说明模块别名生成正确。
我在写platform驱动时的习惯是:只要面对的是设备树平台,of_match_table一定写,id_table可以不写,name退化匹配基本不考虑。这是最清晰、最不容易出错的路径。
3. 在i.MX6ULL上实操:从设备树到probe触发的完整过程
3.1 一个最简单的beep硬件设备与设备树描述
以一块i.MX6ULL开发板上的蜂鸣器为例。蜂鸣器的控制引脚通常接到某个GPIO上,比如GPIO5_IO01(具体引脚以你板子原理图为准)。硬件上无非是给GPIO输出高电平就响,输出低电平就停。
设备树里我习惯这样描述:
/ { mybeep { compatible = "myvendor,mybeep"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_mybeep>; mybeep-gpios = <&gpio5 1 GPIO_ACTIVE_HIGH>; status = "okay"; }; };引脚复用配置放在iomuxc节点下:
&iomuxc { pinctrl_mybeep: mybeepgrp { fsl,pins = < MX6ULL_PAD_SNVS_TAMPER1__GPIO5_IO01 0x17059 >; }; };MX6ULL_PAD_SNVS_TAMPER1__GPIO5_IO01这个宏由SDK提供,位于imx6ull-pinfunc.h头文件,具体引脚对应的宏名以你的BSP为准。0x17059是引脚配置值,包含了上下拉、驱动能力、速度等设置。这里不展开讲怎么算,直接用SDK推荐的配置值就可以。
3.2 platform驱动代码骨架与加载后的sysfs变化
驱动的代码结构如下:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/gpio/consumer.h> static int mybeep_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct gpio_desc *desc; desc = devm_gpiod_get(dev, "mybeep", GPIOD_OUT_LOW); if (IS_ERR(desc)) { dev_err(dev, "failed to get mybeep gpio: %ld\n", PTR_ERR(desc)); return PTR_ERR(desc); } dev_info(dev, "mybeep probe ok, beep on\n"); gpiod_set_value(desc, 1); return 0; } static void mybeep_remove(struct platform_device *pdev) { struct device *dev = &pdev->dev; dev_info(dev, "mybeep removed\n"); } static const struct of_device_id mybeep_of_match[] = { { .compatible = "myvendor,mybeep", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mybeep_of_match); static struct platform_driver mybeep_driver = { .probe = mybeep_probe, .remove = mybeep_remove, .driver = { .name = "mybeep", .of_match_table = mybeep_of_match, }, }; module_platform_driver(mybeep_driver); MODULE_LICENSE("GPL"); MODULE_DESCRIPTION("i.MX6ULL beep platform driver");注意编译镜像时,设备树源文件(dts)要加入编译列表,如果手动用fdtdump或dtc工具查看dtb,确认节点确实存在再加载驱动模块。模块加载命令:
insmod mybeep.ko加载后先看dmesg:
dmesg | tail -20正常情况下会看到“mybeep probe ok”。再检查sysfs:
ls -l /sys/bus/platform/devices/mybeep/driver lrwxrwxrwx 1 root root 0 Jan 1 00:00 /sys/bus/platform/devices/mybeep/driver -> ../../../bus/platform/drivers/mybeep这个尾部箭头指向mybeep驱动,说明设备与驱动已经绑定,probe成功执行。
3.3 驱动、设备和probe的对应关系
我见过不少初学者在probe里写了一堆初始化,但完全没搞懂probe为什么能拿到pdev参数。platform_driver的probe回调签名为int (*probe)(struct platform_device *),这个参数就是匹配上的那个platform_device。probe里通过&pdev->dev拿到struct device指针,之后就可以用devm系列API、device_property系列API访问设备树属性、GPIO、中断等资源。
probe的本质是“驱动被允许操作设备”的入口,不是驱动模块加载入口。驱动的模块加载入口其实是由module_platform_driver宏展开时的module_init函数完成的,它负责调用platform_driver_register,最终在总线匹配成功后触发probe。理解这个层次,排查问题思路就会清晰很多。
4. probe没调用怎么排查:我在i.MX6ULL上的完整debug链路
4.1 一次典型的匹配失败复现
假设驱动加载后dmesg没有probe日志,先从最基础的确认开始。第一步确认设备树节点是否被内核解析:
ls /proc/device-tree/mybeep/ cat /proc/device-tree/mybeep/compatible/proc/device-tree是设备树在内存中的展开形式,节点存在就说明设备树里确实有这个节点,而且dtb已经正确加载。cat compatible时建议用xxd或od查看十六进制,因为compatible值在设备树里是字符串数组,用cat可能看不到结尾空格或不可见字符。我当初遇到的那个多一个空格的坑,就是靠xxd查出来的:
xxd /proc/device-tree/mybeep/compatible 00000000: 6d79 7665 6e64 6f72 2c6d 7962 6565 700a myvendor,mybeep.看到末尾的0x0a了吗,当时我们设备树里字符串后面多了一个换行符。compatible的字符串比较是逐字节进行的,一个多余字符就会导致匹配失败。如果cat显示正常、情况又非常诡异,一定要用xxd确认原始字节。
4.2 确认platform_device与platform_driver两端的注册情况
设备树节点解析成功后,内核还未必生成了platform_device。用下面的命令看设备端:
ls /sys/bus/platform/devices/如果mybeep目录不存在,说明设备没有注册成功,问题出在设备树解析阶段,常见原因包括节点语法错误、status属性为disabled、父节点状态不对。如果目录存在,再查看驱动注册情况:
ls /sys/bus/platform/drivers/mybeep/如果这个目录不存在,说明驱动模块没有注册成功,常见原因包括platform_driver结构体初始化错误、module_platform_driver宏使用不当、模块加载失败。可以先用modprobe或insmod看返回信息,再用dmesg查模块加载阶段是否有报错。
如果两边都存在,却没有绑定链接,就要看驱动目录下的uevent或者设备的uevent:
cat /sys/bus/platform/drivers/mybeep/uevent cat /sys/bus/platform/devices/mybeep/ueventuevent文件会显示模块名和MODALIAS信息。如果驱动的MODALIAS里有of:N...T...Cmyvendor,mybeep,设备的MODALIAS也是Cmyvendor,mybeep,那匹配理论上应该成立。这里经常出现的问题是大小写不一致、compatible缺少厂商前缀、或者of_match_table结尾忘了写sentinel空条目。
4.3 对比compatible字符串、检查of_match_table的常见错误
of_match_table的检查重点有三个。第一,of_device_id数组必须以空结构体结束,也就是sentinel,否则内核遍历数组时会越界或漏匹配。第二,compatible字符串必须完整,按惯例包含“厂商名,设备名”两部分,比如“myvendor,mybeep”,不少人在设备树里少写了厂商前缀,驱动里写了,两个字符串当然不相等。第三,驱动结构体中of_match_table字段是否真的赋值给了.driver的成员,而不是赋给了platform_driver的顶层字段。
有时候代码写成了:
static struct platform_driver mybeep_driver = { .of_match_table = mybeep_of_match, ... };这是错的,of_match_table必须放在.driver子结构体里。这个问题编译不会报错,但驱动加载后平台总线完全不知道匹配表的存在,只能退回去走name匹配,自然匹配不上。
4.4 用driver_override强制绑定与手动bind/unbind验证
为了快速验证“到底是不是匹配逻辑的问题”,可以手动触发绑定。设备已经有了,驱动也注册了,直接写sysfs:
# 先解除可能的旧绑定 echo mybeep > /sys/bus/platform/drivers/mybeep/unbind 2>/dev/null # 强制指定驱动 echo my_beep > /sys/bus/platform/devices/mybeep/driver_override echo mybeep > /sys/bus/platform/drivers/my_beep/bind注意driver_override里写的是驱动名,即.driver.name的值,bind里写的是设备名。如果这样手动绑定时probe能执行,说明驱动本身没问题,问题一定出在自动匹配机制的某个环节。如果手动绑定也失败,驱动代码本身要回炉检查,重点看probe里有没有返回错误码。
还要强调一种容易误导的现象:probe函数被调用了,但设备状态仍然显示not bound。这时要区分probe根本没执行和probe执行后返回错误。第一种对应匹配失败,第二种对应初始化失败。初始化失败时,dmesg里通常有probe函数的dev_err输出,sysfs下设备的driver链接也会消失。这种情况需要用driver_override加上echo bind的方式复现,观察内核打印的具体strace或错误码。
4.5 一张表总结probe没调用的常见原因
| 现象 | 可能原因 | 验证方法 |
|---|---|---|
| /proc/device-tree下无节点 | 设备树未编译/节点被裁剪/status为disabled | 检查dts编译列表、dtb内容、status属性 |
| /sys/bus/platform/devices下无设备 | 节点存在但未生成platform_device | 检查节点语法、父节点状态、of_platform_create |
| /sys/bus/platform/drivers下无驱动 | 驱动模块加载失败/驱动未注册 | dmesg查看模块加载日志 |
| 两边都存在但无driver链接 | compatible不匹配/of_match_table错误 | xxd对比compatible字符串,检查of_match_table的sentinel和字段位置 |
| probe执行但设备报错 | 驱动初始化失败或资源获取失败 | 查看dmesg中probe内错误日志,检查GPIO等资源是否冲突 |
5. 容易被忽略的细节:module_platform_driver宏、remove回调与资源管理习惯
5.1 module_platform_driver宏到底展开了什么
module_platform_driver是一个宏,不是函数。它把驱动的注册和注销包装成标准的模块入口函数:
static int __init mybeep_driver_init(void) { return platform_driver_register(&mybeep_driver); } module_init(mybeep_driver_init); static void __exit mybeep_driver_exit(void) { platform_driver_unregister(&mybeep_driver); } module_exit(mybeep_driver_exit);使用这个宏的好处是省去手写入口函数,避免注册和注销逻辑不对称。但这也带来一个理解上的盲区:很多新人以为probe函数是模块加载入口,实际上模块加载入口是宏展开出来的mybeep_driver_init,它做了teamplate注册工作后立即返回。platform_driver_register内部会同步扫描总线上的设备,如果找到匹配项,调用匹配逻辑后再调用probe。
手动载入驱动有时会遇到probe在注册期间就同步执行的情况。如果probe里做了耗时操作,modprobe命令就会卡住一会儿,这不是系统异常,而是probe同步调用的表现。知道了这一点,就不会误以为系统死机了。
5.2 remove回调签名变化与不同内核版本的兼容处理
i.MX6ULL的老BSP,比如NXP官方4.1.15和5.4内核,platform_driver的remove回调签名是:
static int mybeep_remove(struct platform_device *pdev)但在Linux 6.1之后,内核社区把remove的返回值改成了void,引入了一个过渡阶段:新代码用remove_new成员保存void返回类型的回调。比如:
static void mybeep_remove(struct platform_device *pdev) { ... } static struct platform_driver mybeep_driver = { .probe = mybeep_probe, .remove_new = mybeep_remove, ... };如果你在6.1以上内核里直接写.remove = mybeep_remove(旧式int返回版本),编译会收到warning甚至报错。如果你的驱动需要同时兼容老内核和新内核,可以使用内核提供的宏或者条件编译处理。这不是i.MX6ULL专有问题,但很多用老SDK的工程师升级内核时都会撞上。
5.3 资源管理:为什么推荐devm_platform_ioremap_resource与devm_gpiod_get
在probe里获取硬件资源时,我强烈建议使用devm系列API,这类API把资源的申请和释放绑定到了struct device的生命周期。驱动卸载时,probe里用devm_获取的资源会自动释放,不需要在remove里逐个手动释放。少了release步骤,不仅代码简洁,还减少了内存泄漏和资源泄漏的风险。
对于寄存器地址映射,老式写法是:
res = platform_get_resource(pdev, IORESOURCE_MEM, 0); regs = devm_ioremap_resource(&pdev->dev, res);或者直接一步到位:
regs = devm_platform_ioremap_resource(pdev, 0);devm_platform_ioremap_resource内部会做platform_get_resource、devm_request_mem_region、devm_ioremap三件事,返回映射后的虚拟地址。如果资源不存在或已被占用,返回ERR_PTR错误。
GPIO的获取同理,用devm_gpiod_get系列。设备树属性名mybeep-gpios会被转换成con_id“mybeep”,这正好对应我们设备树里的写法。devm_gpiod_get返回struct gpio_desc指针,后续可以使用gpiod_set_value、gpiod_get_value等操作函数。
5.4 关于“什么情况下才应该写platform驱动”的思考
最后聊一个偏设计的话题。这个细节是我带新人时总被问到的:是不是驱动都必须写成platform驱动?
并没有这个要求。platform驱动适合描述“挂在CPU总线上的外设控制器”这类实体硬件。如果设备树里能用一个物理节点描述它,probe后需要访问寄存器或GPIO等硬件资源,就适合platform驱动。但如果只是一个纯软件逻辑,比如一个内核线程配合procfs提供调试接口,没有对应的实体硬件,那写成platform驱动更多是套了一套框架,反而显得绕。
在i.MX6ULL上,像beep这种简单GPIO输出设备,其实还可以考虑用内核自带的led-class框架或者pwm-leds驱动,不需要自己写platform驱动。自己写platform驱动更适合那些需要访问私有寄存器、处理特定中断、做硬件状态管理的设备。选型时先把内核现成子系统找一遍,能复用就复用,这才是驱动开发的省力之道。
我在实际调试中养成了一个习惯:遇到匹配问题,先看/sys/bus/platform下设备与驱动两端是否存在,再对比/proc/device-tree里的compatible原始字节,最后才去翻源码。这套流程跑下来,大部分platform驱动匹配问题都能定位到根因。希望这篇文章能帮你把i.MX6ULL上的platform机制这块拼图补完整。