1. 先从最常见的故障说起:probe函数为什么没被调用
做i.MX6ULL驱动开发的朋友,大概率都经历过这样的场景:设备树里明明已经加了节点,驱动模块也通过insmod加载成功了,但驱动里的probe函数就是死活不执行。打印信息更是一脸茫然——好像什么错误都没有,又好像什么都没发生。
这个问题的根源,九成以上出在Platform设备与驱动的匹配机制上。
在早期Linux内核版本里,设备与驱动之间靠“名字”和“ID”这种比较原始的方式互相寻找。到了设备树时代,情况完全不同了:设备侧变成了设备树节点,驱动侧则通过of_match_table去声明自己能匹配哪些设备。如果两侧的“暗号”对不上,内核就认为“这个驱动跟这个设备没有关系”,于是probe自然不会被调用。
这篇文章就围绕i.MX6ULL这颗非常经典的NXP处理器,把Platform设备与驱动的匹配机制彻底讲明白。从最底层的bus_type挂接,到设备树节点的“翻译”过程,再到驱动侧的compatible匹配规则,最后用实际调试经验回答“probe不执行”这类高频问题。无论你是刚入门嵌入式Linux驱动开发的新手,还是被设备树折腾过几轮的进阶选手,这篇内容都能帮你在思路上理顺很多事。
一个需要先明确的认知是:Platform设备与驱动匹配机制并不是i.MX6ULL独有的,而是整个Linux内核设备模型里通用的一条主线。只是i.MX6ULL作为一款学习型SoC,资料多、代码简单、设备树结构清晰,非常适合用来拆解这个机制。在这颗芯片上把匹配机制吃透,换到任何其他平台,你都会发现套路完全一致。
2. 理解Platform总线:它到底在做一件什么事
2.1 为什么会有Platform总线这种“非总线”的存在
USB设备挂在USB总线上,I2C设备挂在I2C总线上,这些物理总线都有明确的硬件规范。但有一类设备比较特殊,它们没有硬件意义上的总线,比如GPIO控制器、UART控制器、DMA控制器,它们直接挂在CPU的内存地址总线上,通过寄存器映射来操作。
这类设备如果走传统总线模型,根本没有合适的父总线可以挂。Linux内核的解法很直接:虚拟出一条总线,叫Platform总线。凡是“直接挂在CPU总线上、通过寄存器访问的设备”,都可以抽象为Platform设备;对应的驱动就是Platform驱动。
这不是凭空设计的,而是内核设备模型统一管理的需要。设备模型要求一切设备都有bus、driver、device三个主要角色,通过bus把device和driver连接起来。有了Platform总线之后,那些没有物理总线的设备也能纳入这套体系,统一管理电源、热插拔、休眠唤醒等机制。
2.2 i.MX6ULL设备树环境下,Platform设备是谁创建出来的
这里有个关键问题:i.MX6ULL的设备树里写了一个节点,比如:
&ecspi1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_ecspi1>; status = "okay"; };这个节点是如何变成内核里的platform_device的?
整个过程发生在内核启动阶段。设备树源文件(.dts)经过编译变成设备树二进制文件(.dtb),内核在启动早期解析这个dtb,把每一段节点转换成device_node结构。随后,内核遍历这些device_node,如果节点的compatible属性满足特定条件(比如不会匹配到I2C、SPI这类特定总线驱动),就会调用of_platform_bus_probe或of_platform_populate来创建platform_device。
platform_device的核心成员是dev,而dev内部有一个关键的of_node指针,指向对应的device_node。这个指针极其重要,它是后续匹配时驱动侧读取设备属性的入口。
2.3 Platform设备模型涉及的数据结构
说到Platform总线的实现,绕不开这几组关键的结构体:
| 结构体 | 作用 | 说明 |
|---|---|---|
platform_device | 描述一个平台设备 | 包含资源(resource)和dev子结构 |
platform_driver | 描述一个平台驱动 | 包含probe、remove、shutdown等回调函数 |
bus_type | 描述一种总线类型 | platform总线的实例是platform_bus_type |
device_driver | 驱动的通用父结构 | 包含of_match_table、probe等 |
of_device_id | 设备树匹配表项 | 包含compatible、type、name等字段 |
platform_driver结构体里有一个driver成员,类型是device_driver,这个嵌套关系是很多人一开始容易搞混的地方。简单理解:platform_driver是“扩展版”,device_driver是“通用版”,匹配机制中真正被总线调用的其实是device_driver里的配置和回调。
3. 设备侧和驱动侧如何“对上暗号”:从compatible说起
3.1 设备侧的compatible:设备树节点里的身份证
设备树中的任何一个节点,都可以通过compatible属性来声明自己是什么类型的设备。这个属性通常是一个字符串或字符串列表,推荐写法是“厂商,型号”的格式。
以i.MX6ULL为例,在设备树中经常看到这样的节点:
gpio_leds { compatible = "gpio-leds"; status = "okay"; };这个节点的compatible是gpio-leds,含义就是:我这个人(设备)的名字叫gpio-leds,谁认识这个名字,谁就可以来管我。
需要注意:compatible字符串必须与驱动里of_match_table中声明的一致,差一个字符都会匹配失败。这属于设备树使用里最典型的“字符串匹配陷阱”。
3.2 驱动侧的of_match_table:驱动声称自己能驱动的设备
驱动一侧,通过of_match_table来声明自己支持哪些compatible。这个表是一个of_device_id数组,以空结构体结尾。
典型写法:
static const struct of_device_id my_led_of_match[] = { { .compatible = "gpio-leds", }, { /* 以空结构体结尾 */ } }; static struct platform_driver my_led_driver = { .probe = my_led_probe, .remove = my_led_remove, .driver = { .name = "my_led", .of_match_table = my_led_of_match, }, };驱动加载时,内核会把my_led_of_match数组里的每一个compatible值与设备树节点里的compatible值逐一比对。只要有一个字符串相等,就认为匹配成功。
3.3 id_table:非设备树时代的遗产
嵌入式Linux开发老手可能还记得,在没有设备树之前,驱动匹配主要靠platform_driver里的id_table,里面存放platform_device_id,包含name字段。比如:
static const struct platform_device_id my_led_id_table[] = { { .name = "my_led", }, { } };这个机制在旧内核(2.6时代)很常见,在现代内核里的arm平台基本被设备树取代。但内核为了兼容旧代码,platform_match函数里依然保留了先用id_table匹配的逻辑。
这里有一个很容易踩的坑:有些内核版本中,如果驱动只设置了.driver.name,不设置of_match_table,那么在设备树节点没有匹配到的时候,内核有可能通过driver.name和device.name去匹配。但这要求设备侧也有一个对应的name,而设备树节点本身没有硬性的name属性,往往匹配不上。所以务实一点,现代驱动里不要依赖这个name匹配方式,老老实实写of_match_table。
4. 匹配的完整流程追踪:从bus_type到probe
4.1 platform_match函数的核心逻辑
platform_bus_type的定义在drivers/base/platform.c里,核心函数是platform_match。这个函数的调用关系是:设备模型在注册device或driver时,会通过总线类型的.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步:如果设备树提供了compatible,用它匹配of_match_table */ if (of_driver_match_device(dev, drv)) return 1; /* 第2步:用platform_device_id表匹配 */ if (pdrv->id_table) return platform_match_id(pdrv->id_table, pdev) != NULL; /* 第3步:用driver.name和device.name匹配 */ return (strcmp(pdev->name, drv->name) == 0); }第一步是纯设备树匹配,也是现代arm平台最常用的路径。of_driver_match_device内部先通过dev->of_node拿到设备树节点,然后遍历驱动的of_match_table,如果of_device_id里的compatible与节点的compatible完全一致,匹配成功。
第二步是传统ID匹配。如果没有of_match_table,而驱动又提供了id_table,那么会遍历id_table,每个条目的name字段与设备的名字做比较。
最后一步是兜底逻辑:比较name字符串。对于纯设备树环境,大部分情况下走不到这一步。
4.2 probe被调用的完整路径
理解了platform_match,我们再把probe的触发路径串起来。内核里的调用链大致如下:
platform_driver_register()注册驱动。- 驱动模型把驱动挂到
platform_bus_type的驱动链表上。 - 总线驱动的
device_attach()开始扫描总线上每一个device,逐一调用bus->match()。 - 对每个device,
really_probe()被调用,设备模型先检查设备是否已经被某个驱动绑定,然后再次检查驱动是否具备probe能力。 - 如果驱动匹配上了,
really_probe()就调用drv->probe(dev),这里的drv->probe在平台驱动里最终指向的就是platform_driver的probe成员。
反过来也一样,先注册驱动、后创建设备树节点(比如模块热插拔场景),也会触发同样的匹配过程。所以无论先后,只要设备树解析完成、驱动注册完成,内核就会尝试匹配。
4.3 probe调用前的资源准备工作
在很多驱动源码里,probe函数开头的第一件事往往是获取设备资源,比如:
static int my_led_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(base)) return PTR_ERR(base); ... }这里要理解:pdev通过匹配流程和device_node关联起来了,platform_get_resource才能从设备树节点的reg属性里提取出内存地址资源。这也从另一个侧面说明了匹配成功的重要性——匹配成功之后,设备节点和驱动才算正式“绑定”关系确立,资源获取接口才能正常工作。
5. 设备树节点的常见写法与匹配细节:在i.MX6ULL上的实操示例
5.1 一个完整的实际设备树节点
在i.MX6ULL的板级设备树文件里,我通常会看到类似这样的用户自定义节点:
/ { my_led { compatible = "my-led-device"; reg = <0x020c406c 0x04>; /* 假设这是某个GPIO寄存器地址 */ status = "okay"; }; };这个节点挂在根目录下,解析时会由of_platform_default_populate自动创建为platform_device。节点里的compatible是my-led-device,reg定义了地址和长度。
有一种情况比较特殊:如果节点挂在I2C或SPI节点下面,那它不会被当作platform_device,而是i2c_client或spi_device。所以,想让一个节点走Platform匹配流程,节点必须选择正确的挂载位置。
5.2 驱动侧完整模板
对应的驱动模板如下:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> static int my_led_probe(struct platform_device *pdev) { pr_info("my_led probe success\n"); return 0; } static int my_led_remove(struct platform_device *pdev) { pr_info("my_led remove\n"); return 0; } static const struct of_device_id my_led_of_match[] = { { .compatible = "my-led-device", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_led_of_match); static struct platform_driver my_led_driver = { .probe = my_led_probe, .remove = my_led_remove, .driver = { .name = "my_led", .of_match_table = my_led_of_match, }, }; module_platform_driver(my_led_driver); MODULE_LICENSE("GPL");注意module_platform_driver是一个快捷宏,它帮你处理了module_init和module_exit,展开之后相当于:
module_init(my_led_driver_init); module_exit(my_led_driver_exit);而init函数内部就是调用platform_driver_register,exit函数内部调用platform_driver_unregister。使用这个宏可以少写很多样板代码。
5.3 查看匹配结果的方法
写完之后,编译并加载驱动,如何确认匹配事件实在发生了?几个很有效的查看手段:
- 在probe里加上打印(最简单粗暴,嵌入式开发常见。)
- 查看内核日志:
dmesg | grep my_led - 查看驱动是否绑定成功:
ls /sys/bus/platform/drivers/my_led/如果绑定成功,驱动目录下会出现设备的名字(通常是设备树节点名,可能带附加信息)。比如设备节点是my_led,你会看到my_led或类似名字的文件。
6. 匹配失败排查:说白了就是这几个原因
6.1 compatible字符不一致
这是最常见的匹配失败原因,没有之一。最常见的是大小写问题,或厂商前缀漏写。比如设备树里写的是my-led-device,驱动of_match_table里写成了my_led_device,一个连字符一个下划线,内核可不会自动容忍。
排查手段也很直接:
cat /proc/device-tree/my_led/compatible这个命令会输出设备树节点当前的compatible实际值,跟驱动里面对比一下,几个字符的差异都肉眼可见。这个办法比反复编译、抓日志高效得多。
6.2 设备树没被实际编译进dtb
有人改完设备树,编译时候没有把生成的dtb拷贝到启动分区,导致开发板启动时用的还是旧dtb。这个坑尤其隐蔽,因为直接看源码一切正常,启动日志里也没有报错。
排查手段是启动后执行:
ls /proc/device-tree/my_led如果根本不存在这个目录,说明设备树节点没有进到内核。这时候要优先检查u-boot环境变量里的fdt_file设置,以及烧录的dtb镜像是否确实是新编译出来的。
6.3 驱动模块没有被加载进来
insmod之后其实也有两种情况:一是模块本身编译失败,二是模块加载了但加载的不是最新版本。调试时如果你在修改驱动后没有重新编译,或者Makefile指定了错误的内核目录,会导致模块加载不进去。
最痛苦的其实是:模块加载进去了,但因为某种原因依赖的符号没准备好,比如of_match_table里引用了未导出的符号。内核日志里会出现Unknown symbol之类的信息,注意看dmesg。
6.4 设备节点没被创建为platform_device
有一个隐蔽场景:设备树里写了一个节点,但它被某个早期设备驱动“截胡”了,导致没有生成platform_device。最典型的例子是GPIO控制器节点,它会被GPIO子系统解析,不会走普通的platform流程。如果一个节点在/proc/device-tree里存在,但你在/sys/bus/platform/devices/里找不到对应设备,说明这个节点没有被平台总线接管。
6.5 CONFIG_OF配置缺失
i.MX6ULL这种主流芯片默认是开启了CONFIG_OF的,但如果是自己裁剪内核,不小心把设备树支持裁掉了,驱动侧的of_driver_match_device就无法正常工作。这时即使设备树解析成功也匹配不上。排查方法很简单,在内核配置里搜OF相关项,确保以下配置为y:
CONFIG_OF=y CONFIG_OF_DEVICE=y6.6 pinctrl与时钟未动导致probe报错
probe确实会被调用了,但运行到中途出错了退出,这种情况经常被误以为是匹配失败。典型流程是:驱动probe内请求GPIO、使能时钟或配置pinctrl时返回错误,probe直接返回错误码。内核日志会出现:
my_led_probe failed: -16-16是-EBUSY,说明资源被占用。这种问题的根源已经完全不是匹配问题,而是资源管理问题。需要检查设备树里GPIO是否被别的节点复用,时钟是否已被其他驱动clk_get,等等。
为了区分这两种情况,最好的办法是probe第一行就打印:
static int my_led_probe(struct platform_device *pdev) { pr_info("my_led probe entry, reasoning: %s\n", pdev->name); ... }如果连这行打印都没出现,那就是匹配阶段就失败了;如果打印出现了,但后面有错误,那就是probe内部资源获取阶段的问题。
7. 从匹配机制到驱动设计的思考:为什么内核要这样设计
7.1 解耦:设备与驱动的分离思想
把设备和驱动分开,是Linux设备模型最根本的优雅之处。以前写裸机驱动,一个驱动对应一个具体板卡,换个引脚就要重写代码。现在通过设备树描述硬件,驱动逻辑和硬件描述完全分离。同一种控制器驱动,只需要更换设备树节点里的compatible和属性,就能在不同板卡上运行。
这个设计的好处实在太明显了。NXP官方维护的imx6ull驱动,比如drivers/spi/spi-fsl-spi.c、drivers/net/ethernet/freescale/fec_main.c,同一份源码可以支持imx6ull、imx6dl、imx6q等多款芯片,靠的就是设备树里各自描述各自的寄存器地址、中断号、时钟等资源,驱动本身只关心通用的控制和数据传输逻辑。
7.2 module_platform_driver背后的懒人设计与陷阱
module_platform_driver确实省事,但它也隐藏了一个重要的初始化顺序问题。如果驱动作为模块加载,而设备树里的设备依赖另一个模块先注册,那么加载顺序错乱会导致设备匹配成功但probe里调用的子设备接口还没注册,返回-EPROBE_DEFER。
内核有一个机制专门处理这种情况:probe里返回-EPROBE_DEFER,设备模型不会判定为失败,而是把这个设备放到延迟重试队列里,等依赖模块加载完成后再次尝试匹配。
嵌入式开发里,我遇到最多的场景就是GPIO子系统和pinctrl子系统没加载完就去注册自己的驱动。解决方式有两种:一是直接编译进内核而不是作为模块加载(顺序可控);二是在probe里主动处理-EPROBE_DEFER,等待依赖的子系统就绪后再重试。
7.3 关于设备树机制,还需要特别提醒的一点
很多教程把设备树讲得太玄乎,这里说一个大实话:设备树本身只是一个硬件描述语言,它描述的是“这个板子上有什么”,而不是“这个硬件怎么工作”。CPU如何操作寄存器、如何收发数据,是驱动的职责。设备树节点里的compatible,起的作用就像一个人拿着身份证说“我是某某”,至于要怎么跟他合作,是驱动probe里去实现的。
这个认知对新手尤其重要。不少人被设备树里密密麻麻的属性吓到,其实绝大部分属性只有probe函数里的某一行代码会读取。理解完机制后再看设备树,会发现它就是一大块“供给驱动的配置结构体”。
8. 实操中值得反复确认的几处关键代码位置
8.1 检查of_match_table是否增加了MODULE_DEVICE_TABLE
写驱动时,很多人会忘记加:
MODULE_DEVICE_TABLE(of, my_led_of_match);这一行有什么作用?当驱动编译为模块时,它会把of_match_table信息导出到模块的modinfo段中。这样系统就有了“这个模块支持哪些设备”的元数据。如果缺少这一行,模块加载时可能无法被udev等用户态工具自动识别,其自动加载机制也可能失效。嵌入式平台如果使用冷插拔方式手动加载模块,可能影响不大,但规范性上应该补上。
8.2 确认device_node的是否正确关联
在probe里,可以通过pdev->dev.of_node访问设备树节点:
struct device_node *np = pdev->dev.of_node; if (!np) { pr_err("no of_node, cannot proceed\n"); return -EINVAL; }如果of_node是NULL,说明这个platform_device可能不是由设备树节点创建的(比如由板级代码静态创建),后续读取设备树属性都会失败。
8.3 根据设备树属性动态匹配多个设备
有时一个驱动需要支持多个不同型号的设备,这时of_match_table可以写多个条目:
static const struct of_device_id my_device_of_match[] = { { .compatible = "vendor,a-device", .data = &a_device_data, }, { .compatible = "vendor,b-device", .data = &b_device_data, }, { /* sentinel */ } };probe里可以通过of_device_get_match_data(&pdev->dev)拿到对应的data指针,然后根据它来区分不同型号的寄存器配置。这是设备树匹配机制里非常实用的进阶用法,可以有效避免为每个芯片型号写一份重复驱动。
9. 个人经验总结:从i.MX6ULL学到的核心逻辑
我在i.MX6ULL上调试这类匹配问题时,最大的一个体会是:不要急着看代码,先确认设备树和驱动当前是不是真的进入了内核视野。
Linux设备模型里,device和driver是两个独立存在的事物,总线负责牵线。如果牵线上出了问题,其他一切免谈。排查匹配问题时,我的建议顺序是:
- 启动后先确认设备树节点是否真的存在于运行时(
/proc/device-tree/)。 - 确认节点是否生成对应的platform_device(
/sys/bus/platform/devices/)。 - 确认驱动是否成功注册(
/sys/bus/platform/drivers/)。 - 最后再确认两侧的compatible是否一字不差。
- 如果进程都正常但probe不执行,再看CONFIG_OF和模块加载顺序。
这个排查链路我用了很多年,绝大多数“probe不执行”的问题都能在几分钟内定位。
回归到i.MX6ULL这颗芯片本身,它的设备树结构和驱动源码都很规整,特别适合用来学习Platform匹配机制。你在它上面理解清楚compatible、of_match_table、platform_match、really_probe这四者的关系,就等于打通了Linux设备模型的一条大动脉。后面无论换到树莓派、全志、瑞芯微还是其它平台,这套知识都是直接可用的,差别只是设备树语法细节和平台相关的设备数据。
最后分享一个调试技巧:在drivers/base/platform.c的platform_match函数里临时加一行printk输出,可以看到每一次匹配尝试时的device和driver名字。这个方法虽然“土”,但信息量极大,尤其是在多个驱动同时匹配同一个设备节点的时候,比猜代码高效太多。调试完记得把打印去掉,别把这个调试代码带到正式版本里。