第一次给i.MX6ULL写驱动的时候,我盯着设备树里的一堆节点想不明白:这个叫“平台总线”的东西到底挂在哪条硬件总线上?为什么驱动里填了一个compatible字符串,probe函数就会被自动调用?后来把Platform设备和驱动的匹配机制彻底搞懂了,之前很多“玄学”才真正消失。
这篇文章就是围绕i.MX6ULL上的Platform设备与驱动匹配机制展开的,适合正在学Linux驱动开发、尤其是用i.MX6ULL这样的ARM SoC做嵌入式项目的开发者。我会从内核为什么要弄一条虚拟总线讲起,再把设备树、platform_device、platform_driver三者如何“对上眼”的过程拆开,最后结合i.MX6ULL的板级环境给出一套可以照着跑的最小示例和排查套路。
1. Platform总线为什么存在:没有物理总线,也要有统一的设备管理
1.1 i.MX6ULL的片上外设并不“挂在”任何物理总线上
i.MX6ULL是NXP基于Cortex-A7内核设计的嵌入式处理器,芯片内部集成了UART、I2C、SPI、GPIO、SDIO、以太网MAC等大量外设控制器。这些外设有一个共同特点:它们的寄存器就映射在芯片的某个内存地址上,CPU直接通过地址访问,不存在一条像USB、PCIe那样可以枚举设备的物理总线。
那问题来了:Linux的设备模型要求任何一个设备都必须归属于某条总线,因为bus_type上挂着device和driver两个链表,内核要靠“设备加入总线时遍历驱动、驱动注册时遍历设备”这套逻辑来完成配对。没有总线归属,设备生命周期管理、电源管理、sysfs属性导出、自动加载驱动这些机制全都无从谈起。
所以内核做了一个非常务实的决定:凭空造一条platform总线。它不对应任何真实硬件,只是一个软件概念,专门收纳那些“直接挂在CPU地址空间上、无法被标准物理总线枚举”的设备。i.MX6ULL内部几乎所有的片上外设控制器,在Linux眼里都是platform_device。
1.2 从设备树节点到platform_device的转化时机
在i.MX6ULL上,设备树(dtb)由U-Boot加载进内存,内核启动早期会把FDT格式的二进制数据解析成设备树结构,然后调用of_platform_default_populate_init等机制,把设备树里符合条件的节点一个个转换成platform_device,挂到platform总线上。
哪些节点会被转化?最核心的条件是:这个节点带有compatible属性。比如imx6ull.dtsi里gpio控制器节点是这样定义的:
gpio5: gpio@020ac000 { compatible = "fsl,imx6ul-gpio", "fsl,imx6ull-gpio"; reg = <0x020ac000 0x4000>; interrupts = <GIC_SPI 74 IRQ_TYPE_LEVEL_HIGH>; ... };节点里有compatible,内核就认为这是一个“平台设备”,把它变成platform_device注册到platform总线。这里请记住:设备树里节点的名字五花八门不重要,compatible才是驱动和设备匹配的关键身份证。
当然,并不是所有compatible节点都会变成platform_device。如果节点挂在i2c总线下,它会被i2c控制器驱动识别为i2c_client;挂在spi总线下会变成spi_device。节点能不能展开成platform_device,还取决于父节点的总线类型和compatible是否包含“simple-bus”之类的标识。这一点后面排查问题时会专门说。
1.3 platform_device和platform_driver各自的角色
platform_device描述“硬件有什么”,比如寄存器地址、中断号、DMA通道、时钟等资源,这些信息在设备树里写清楚,内核帮助生成resource结构;platform_driver描述“软件怎么操作这个硬件”,比如probe函数、remove函数、电源管理回调。
这两个角色经过platform总线的match撮合,一旦配对成功,内核就会调用platform_driver的probe函数,驱动开发者的主要工作就是在这个函数里初始化硬件、注册字符设备、建立中断处理等。probe被调用,说明驱动和设备已经“领证”,之后开发者的代码才开始真正执行。
那么配对的具体规则是什么?这是整个机制里最核心的部分。
2. 匹配机制拆解:compatible、id_table、driver.name三层逻辑
2.1 bus_type->match是被调用的总入口
platform总线的类型定义在drivers/base/platform.c里,其中match函数是platform_match。当发生两种情况时,这个函数会被调用:
- 一个platform_device注册到platform总线时,内核会遍历总线上所有platform_driver,逐个调用platform_match,看有没有驱动愿意认领这个设备。
- 一个platform_driver注册到platform总线时,内核会遍历总线上所有platform_device,逐个调用platform_match,看有没有设备可以被这个驱动接管。
理解了触发时机,你就明白为什么probe总会在“设备注册”或“驱动注册”之后的某个时刻被调用。如果你只写了一个驱动并insmod加载,但设备树里没有对应设备,probe永远不会执行;反过来,设备树上写了节点,但内核里没有注册匹配的驱动,设备就一直在总线上“无人认领”。
2.2 匹配顺序:先看设备树,再看ID表,最后比名字
platform_match函数的匹配逻辑大致按下面顺序执行:
- 优先使用设备树匹配:如果设备有of_node,并且驱动的of_match_table不为空,就遍历of_match_table里的每一条of_device_id,把它的compatible字符串和设备树节点compatible属性里的字符串逐一比较,任何一个相等就算匹配成功。
- 然后尝试ACPI匹配,在嵌入式ARM设备上基本不会走到,i.MX6ULL也用不到。
- 如果上面都没有成功,再看驱动的id_table,调platform_match_id函数,拿platform_device的name字段去和id_table中每条id_entry的name字段比较。
- 如果连id_table都没有,那就直接比较platform_driver.driver.name和platform_device的name是否相同。
注意第1步和第3步的关系:即使设备树节点存在,只要of_match_table里没有对应项,内核并不会立即放弃,还会继续尝试id_table和名字匹配。但在实际设备树开发中,强烈建议直接走第1步,因为这是语义最清晰、最不易出错的方式。
我把三种匹配方式整理一下,方便对比:
| 匹配方式 | 关键字段 | 设备树环境推荐度 | 说明 |
|---|---|---|---|
| DT compatible匹配 | 驱动.of_match_table[].compatible 与 设备节点compatible | 最推荐 | 直观、稳定、标准做法 |
| id_table name匹配 | 驱动.id_table[].name 与 platform_device.name | 少用 | 设备树生成的device name不好控制,容易踩坑 |
| 直接比较driver.name | 驱动.driver.name 与 platform_device.name | 不推荐 | 设备树环境下几乎不可靠 |
2.3 compatible里的“厂商,型号”为什么必须完全一致
设备树里常见这种写法:
compatible = "myvendor,mydevice";驱动侧对应的of_device_id数组:
static const struct of_device_id mydevice_of_match[] = { { .compatible = "myvendor,mydevice", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydevice_of_match);compatible字符串的比较是纯字符串比较,不能有多余空格,不能大小写不一致,逗号必须是英文半角逗号。很多初学者在设备树里写的是myvendor, mydevice(逗号后面多了一个空格),驱动里写的是myvendor,mydevice,结果死活匹配不上,查半天发现是一个空格的问题。
另外,设备树节点的compatible可以写多个值,比如:
compatible = "myvendor,mydevice", "vendor,generic-device";匹配时内核会先用第一个值去遍历of_match_table,没找到再用第二个值。这种写法在Linux里叫“板级兼容+芯片级兼容”,目的是让同系列硬件可以共用驱动。实际开发中,驱动侧只要在其中任何一个值上命中即可。
2.4 一次完整匹配的“时间线”
以i.MX6ULL启动过程为例,一次成功的匹配大致是这样的:
- U-Boot加载设备树dtb,传入内核。
- 内核解压设备树,构建of_node节点树。
- of_platform_default_populate等流程把带compatible属性的节点转成platform_device,注册到platform总线。
- platform总线触发match,此时对应的platform_driver可能还没注册,匹配暂时失败,设备进入“待认领”状态。
- 驱动代码执行platform_driver_register(或module_platform_driver宏展开后的注册函数),向platform总线注册platform_driver。
- platform总线再次触发match,逐一遍历总线上尚未绑定驱动的设备。
- 设备树compatible和驱动of_match_table命中。
- 内核调用驱动的probe函数,传入platform_device指针,驱动开发者拿到设备资源,开始初始化硬件。
如果驱动是编译进内核的,第5步会在内核启动更早阶段就完成,第3步生成设备时直接匹配成功,probe几乎和设备注册同时发生,所以你看到的现象就是“设备树节点写着写着,probe就跑了”。
3. i.MX6ULL上从零写一个Platform驱动动:设备树、驱动框架与验证
3.1 硬件环境与最小目标
在i.MX6ULL的板子上验证这套机制,最简单的方式不是直接去驱动UART或GPIO控制器,而是先加一个自定义的虚拟设备节点,跑通“设备树生成设备、驱动匹配、probe打印”的最小链路。这个最小链路一旦成立,后面不管换什么外设,本质流程都是一样的。
我用的平台是正点原子或野火这类常见的i.MX6ULL开发板,内核版本4.x/5.x都可以,设备树文件对应arch/arm/boot/dts/imx6ull-14x14-evk.dts。如果你用的是其他板子,找到自己的板级dts文件即可。
3.2 在设备树上添加自定义节点
在板级dts的根节点/内部添加一个子节点:
/ { mydevice { compatible = "myvendor,mydevice"; reg = <0x020c406c 0x4>; status = "okay"; }; };这里reg随便填了一个i.MX6ULL的寄存器地址空间,只是为了演示资源获取。实际驱动里,probe函数可以用platform_get_resource拿到这段region。
修改完dts后重新编译。如果环境里单独编译设备树:
make imx6ull-14x14-evk.dtb然后确认新的dtb被U-Boot加载。很多老手在这翻车:改了dts但U-Boot加载的还是旧dtb,启动后设备树根本没变,驱动自然匹配不上。
3.3 驱动的完整代码框架
驱动代码的结构其实很固定,核心就四件事:定义of_device_id、定义platform_driver、实现probe/remove、注册模块。
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/io.h> static int mydevice_probe(struct platform_device *pdev) { struct resource *res; dev_info(&pdev->dev, "mydevice probe success\n"); res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (res) { dev_info(&pdev->dev, "reg phys addr: 0x%llx, size: %lld\n", (unsigned long long)res->start, (unsigned long long)resource_size(res)); } return 0; } static int mydevice_remove(struct platform_device *pdev) { dev_info(&pdev->dev, "mydevice remove\n"); return 0; } static const struct of_device_id mydevice_of_match[] = { { .compatible = "myvendor,mydevice", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydevice_of_match); static struct platform_driver mydevice_driver = { .probe = mydevice_probe, .remove = mydevice_remove, .driver = { .name = "mydevice", .of_match_table = mydevice_of_match, }, }; module_platform_driver(mydevice_driver); MODULE_LICENSE("GPL");代码里最关键的就是MODULE_DEVICE_TABLE(of, mydevice_of_match)。这一步的作用是导出of_device_id表,让内核模块加载时通过modinfo能看到这个驱动支持的compatible列表。如果不写,很多情况下of_match_table虽然在模块内可见,但在某些工具链配置下会出现“驱动加载了但匹配不上”的诡异问题。
of_match_table = mydevice_of_match把表挂到驱动上,platform_match在第一步就会拿到这张表。
module_platform_driver是一个便捷宏,展开后相当于:
module_init(platform_driver_register(&mydevice_driver)); module_exit(platform_driver_unregister(&mydevice_driver));它同时承担注册和注销工作,比自己写module_init/module_exit更不容易漏。
3.4 编译、加载与验证
把驱动编译成模块:
make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- M=/path/to/driver/dir modules把生成的.ko拷贝到板子上,插入模块:
insmod mydevice.ko立刻去看dmesg:
dmesg | tail -20正常应该看到:
mydevice mydevice.0: mydevice probe success mydevice mydevice.0: reg phys addr: 0x20c406c, size: 4“mydevice.0”这个带编号的名字是内核给platform_device自动生成的,后缀0一般是同名设备序号。
在sysfs里也能看到绑定关系。设备侧:
ls -l /sys/bus/platform/devices/mydevice.0/如果驱动已经绑定,里面会有一个driver符号链接指向驱动的sysfs目录。驱动侧:
ls /sys/bus/platform/drivers/mydevice/正常会有mydevice.0这个条目,表示该设备已经被这个驱动接管。如果目录下什么都没有,说明驱动注册了但没有设备匹配,或者设备注册了但驱动还没匹配上。
3.5 probe里还能做什么
匹配成功后,开发者可以在probe里做很多事:用platform_get_resource获取reg和irq资源,用of_property_read_u32读取设备树自定义属性,用ioremap映射寄存器地址,用devm_request_irq注册中断,用misc_register或cdev注册设备节点。probe函数成功返回0,设备和驱动才算正式绑定;返回负值,内核会认为probe失败,绑定关系解除,之后除非驱动或设备状态变化,否则不会自动重试。
我个人建议第一次验证时,probe里只做dev_info输出,确认机制通了再逐步加代码。这样定位问题时,可以明确区分“是匹配没成功,还是probe里面某一步失败”,排查成本低很多。
4. 匹配失败的排查链路:问题、原因与sysfs诊断方法
4.1 设备存在但就是没driver:先从设备树侧找原因
我在项目里见过最多的匹配失败案例,不是驱动代码写错了,而是设备树侧就没生成预期的platform_device。遇到这种情况,先按下面顺序排查。
第一步,确认设备树真的被加载了。在板子上执行:
cat /proc/device-tree/mydevice/compatible如果设备树解析成功,这个文件存在,内容是myvendor,mydevice。如果文件不存在,要么节点没写进dts,要么U-Boot加载的dtb不是新编译的。
第二步,确认节点被展开成platform_device:
ls /sys/bus/platform/devices/ | grep mydevice没有输出,说明内核压根没把它变成platform_device。常见原因有两个:
- 节点没有compatible属性,或者status = "disabled",被of_platform跳过。
- 节点挂在某个非platform总线下,比如i2c节点或spi节点下的子节点,它们会被对应总线框架接管,不会生成platform_device。
举个例子,如果把自定义节点放到&i2c1 { ... }下面,它就会被i2c子系统当作地址设备等待探测,而不是成为platform_device。这在初学时非常容易踩坑:明明写了compatible,却总匹配不上platform驱动,一查发现节点位置不对。
第三步,确认设备树节点里的compatible和驱动of_match_table里的字符串完全一致。为了保险,可以反编译dtb再查一遍:
dtc -I dtb -O dts -o out.dts imx6ull-14x14-evk.dtb grep -n "myvendor,mydevice" out.dts这一步能排除“代码里看着是对的、实际编译进dtb的是旧版本”这种隐藏问题。
4.2 设备树正常,驱动也加载了,但probe没执行
如果/sys/bus/platform/devices/下面能看到设备名,/sys/bus/platform/drivers/下面也能看到驱动名,两者就是没绑定,就要看匹配环节。
先检查驱动是不是真的注册到了platform总线。有可能ko文件insmod了,但module_init没执行成功。看dmesg里有没有加载报错,比如符号缺失、内存不足、probe函数初始化失败之类。不过module_platform_driver方式下,驱动注册基本不会失败,失败大多出在匹配逻辑上。
is_match的关键在of_match_table。检查点:
- of_device_id数组最后有没有
{ /* sentinel */ }空结构体。内核遍历表依赖这个哨兵项,缺了它遍历可能出现未定义行为,匹配结果不可控。 - 表里compatible字段是否和设备树完全一致。建议在驱动里对of_match_table里的字符串做一次“肉眼比对+字节数比对”,空格、大小写、逗号位置都要看。
- 确认驱动编译时,of_match_table确实被编译进目标文件。可以
modinfo mydevice.ko看有没有alias: of:N...T...C...类似信息,这个是MODULE_DEVICE_TABLE导出的效果。如果完全没有alias,说明表没生效。
还有一个容易被忽略的点:即使of_match_table匹配失败,只要驱动id_table里有匹配项,或者driver.name与platform_device.name一致,platform_match依然可能返回true。但在设备树环境下,platform_device的name来自内核of_device_make_bus_id生成的设备名,一般形如“mydevice.0”,很难预先猜到,所以靠名字匹配属于碰运气,开发时不要依赖它。
4.3 用sysfs手动绑定来验证驱动本身没问题
如果设备存在、驱动存在、匹配又查不出问题,可以用sysfs的bind/unbind接口手动触发绑定和解除绑定。先把设备从当前驱动上解绑(如果没有绑定可以忽略):
echo mydevice.0 > /sys/bus/platform/drivers/mydevice/unbind然后手动绑定:
echo mydevice.0 > /sys/bus/platform/drivers/mydevice/bind每执行一条命令,立刻看dmesg。如果bind成功,probe被调用;如果bind报错,错误信息会直接告诉你失败原因,比如设备忙、probe返回值错误等。这个技巧在调试驱动时非常高效,比反复insmod/rmmod省事得多。
驱动卸载不想让probe再介入时,也可以用unbind先把设备摘下来,再rmmod驱动,避免remove与probe交叉调用的混乱。
4.4 probe为何返回了错误:设备和驱动解除绑定
probe函数返回非0值会被内核视为probe失败,设备和驱动之间的绑定关系被撤销。常见几种情况:
- 寄存器资源获取失败:platform_get_resource返回NULL,处理时直接return -ENXIO。
- ioremap失败:地址空间无效或内存不足。
- 中断申请失败:devm_request_irq返回非0。
- 某些初始化子步骤失败:比如时钟使能失败、GPIO请求失败。
排查此类问题最简单的办法,就是在probe里每一步都加上dev_err或dev_info打印,逐步缩小范围。等到驱动逻辑稳定后,再把这些调试打印移除或转为dev_dbg。还有一个容易被忽略的点:devm_系列API申请的资源在probe失败时会被自动释放,这是好事,避免忘记资源回收导致的内存泄漏,但反过来,如果你在probe失败后还继续访问已经释放的资源,就会踩到野指针,调试起来非常痛苦。
4.5 模块加载与设备树展开的优先级问题
在设备树驱动开发中,还有一个经典困惑:驱动被编译成模块时,设备树里的设备可能在系统启动时就已经注册,但驱动模块还没加载。操作系统会先把这个设备挂起在总线上等待驱动,不会因为“暂时没有驱动”就把它删除。
所以在i.MX6ULL上,只要驱动模块最终能被加载,probe就会在insmod之后被触发。这个“先有设备、后有驱动”的动态匹配能力,正是Linux设备模型相比传统查询式初始化的优势所在。反过来,如果驱动先注册、设备后生成(比如热插拔设备),platform_match同样会在设备注册时把驱动找出来。理解这一点,就不会纠结probe到底在哪个瞬间执行的问题。
4.6 一张排查顺序表
把上面这些经验整理成一张排查顺序表,按序号执行,基本能覆盖绝大多数匹配失败场景:
| 序号 | 检查项 | 方法/命令 | 失败说明 |
|---|---|---|---|
| 1 | dtb是否加载了新配置 | cat /proc/device-tree/mydevice/compatible | 文件不存在说明节点没进设备树 |
| 2 | 设备是否生成 | ls /sys/bus/platform/devices/ | grep mydevice | 没有设备说明节点没被展开 |
| 3 | 驱动是否注册 | ls /sys/bus/platform/drivers/ | grep mydevice | 没有驱动说明module_init失败 |
| 4 | compatible是否一致 | 检查dts和of_match_table字符串 | 多空格、大小写、逗号都会导致失败 |
| 5 | 是否已经绑定 | ls -l /sys/bus/platform/devices/mydevice.0/driver | 无链接说明绑定未建立 |
| 6 | 手动绑定测试 | echo mydevice.0 > .../bind | bind报错会给出直接原因 |
| 7 | probe执行结果 | 在probe内加dev_info打印 | 定位失败发生在哪一步 |
4.7 其他值得留意的坑位
除了上面说的,还有几个在特定条件下才会遇到,但遇到了就非常难查的情况。
一个是设备树节点存在,但父节点的compatible包含simple-bus与否会影响子节点是否被展开成platform_device。如果你的自定义节点直接挂在根节点下,根节点本身会被特殊处理,所以一般没问题;但如果挂在某个外设控制器节点下,需要确认那个控制器的驱动是否已经把子节点注册为platform_device,或者总线的class类型是否符合预期。
另一个是设备树里status = "okay"的坑。如果你在dtsi里定义了一个节点,板级dts里通过&xxx覆盖它,但覆盖时忘了写status = "okay",或者写成disabled,设备就不会生效。这个事在调试I2C、SPI外设时经常出现,Platform设备节点同理。
还有MODULE_DEVICE_TABLE和of_match_table的关系。很多人以为of_match_table填了数组就行,忘了导出alias。虽然大部分情况下内核照样能匹配,但如果你使用了自动加载机制,比如udev、mdev在设备出现时自动modprobe驱动,alias缺失就可能导致驱动不自动加载,看起来像匹配失败。嵌入式系统里如果手动insmod,这个问题不容易暴露,但如果做了modprobe自动加载,就要留意。
5. 最后说一点个人经验
这套Platform匹配机制第一次接触时很容易被各种术语绕晕:platform_device、platform_driver、of_match_table、MODULE_DEVICE_TABLE……但换个角度看,它就是“设备树提供数据、驱动提供逻辑、平台总线负责撮合”的三方协作。设备是“甲方”,把硬件资源写在dts里;驱动是“乙方”,把操作逻辑写在C代码里;总线就是“中介”,通过compatible这个暗号让双方相认。暗号对上了,probe就是签约仪式。
想通这一层,再去学i2c_driver、spi_driver,你会发现骨架几乎一样,只是总线实现细节不同。把Platform这个模型吃透,Linux驱动开发就已经迈过最重要的一道门槛。
补充一个调试小技巧:在驱动里临时把probe函数里的dev_info全改成dev_err,这样日志等级更高,不容易被dmesg级别过滤掉。还有,在调试阶段建议把printk开关打开,通过/proc/sys/kernel/printk临时调整日志级别,能少走不少弯路。等机制跑通,再把调试信息清理干净。