我做了多年嵌入式Linux驱动开发,从裸机寄存器操作一路啃到设备树,最让我觉得绕不过去的坎就是Platform总线机制。很多新手写驱动,明明代码看着没问题,insmod也不报错,但就是probe不执行,设备节点也不出现,最后卡在“到底怎么让内核找到我的驱动”这一步。其实这一整套逻辑,核心就一句话:Linux把SoC内部那些无法热插拔、直接挂在内存总线上的设备,抽象成了Platform设备,然后通过一套匹配规则把设备和驱动拴在一起。
这篇文章我计划用i.MX6ULL这颗经典的NXP芯片作为主线,把Platform设备与驱动的匹配机制从头到尾捋一遍。内容包括这套机制到底解决了什么问题、device和driver两个对象是怎么在内核里“对上眼”的、四种匹配方式的优先级和适用场景、怎么用设备树声明一个platform设备并写一个能被正确匹配的驱动,以及我踩过的那些“probe不执行”“match失败”的坑。不管你是刚入门嵌入式Linux驱动开发,还是已经写过几版驱动但没真正吃透总线模型,这篇文章都值得你花二十分钟读完。
1. 为什么必须有Platform机制
1.1 直接写驱动不行吗
先看一个很原始的驱动写法。对于传统的PCI、USB这类设备,总线是实际存在的硬件,设备插上去之后由总线控制器完成枚举,驱动只要向对应总线注册就能被识别。但SoC内部的大量外设,比如UART、I2C控制器、GPIO控制器,它们不是挂在PCI或者USB这种总线上,而是直接挂在SoC内部的高速总线上。你在硬件上根本找不到一条叫platform的总线,它是内核虚拟出来的。
那如果每次都直接操作寄存器、按板子写死地址来驱动这些外设,会怎么样?也能跑,但带来的问题是代码根本没法移植。换一颗芯片、换一块板子,所有寄存器地址、中断号、时钟频率都要重新改,驱动和设备强耦合在一起。现代内核讲究数据和逻辑分离,驱动代码是逻辑,而“这个设备在哪、占用哪个中断、时钟是多少”是数据,Platform机制就是为了把这两者拆开。
1.2 内核抽象出的那根“虚拟总线”
Linux引入platform_bus作为一根虚拟总线,所有不具备热插拔能力、无法被自动枚举的片上设备,都挂在这条总线上。平台设备用platform_device表示,平台驱动用platform_driver表示,匹配成功后总线会回调驱动里的probe函数,之后驱动才真正开始干活。
i.MX6ULL内部几乎所有的外设控制器,比如ECSPI、I2C、UART、GPIO、SDIO,在内核里都被注册为platform设备。你写驱动的时候,只要实现一个platform_driver结构体并注册,剩下的“找到对应设备、查设备树、分配资源”这些事情,内核通过总线匹配机制自动帮你做完。这也是为什么你在写字符设备驱动时,总是看到一连串的platform_get_resource、of_property_read_u32这些接口,它们的参数来源全部是匹配过程中挂在device上的资源信息。
1.3 谁在维护这根总线
具体到代码层面,platform总线的定义在drivers/base/platform.c中,关键结构是:
struct bus_type platform_bus_type = { .name = "platform", .dev_groups = platform_dev_groups, .match = platform_match, .uevent = platform_uevent, .dma_configure = platform_dma_configure, };重点在这个platform_match函数,它负责回答“给定的device和driver到底能不能配成一对”。这个函数不长,但它内部串起了四种匹配方式,是整篇文章的核心。
注意:platform总线的实际注册发生在内核启动阶段,由内核自动完成,驱动开发者不需要也不应该自己注册多次。你只需要注册device或driver,二者之一注册之后,总线就会触发匹配过程。
2. 匹配机制内部实现拆解
2.1 platform_match源码逻辑
直接看内核源码,drivers/base/platform.c中的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风格匹配,即设备树 compatible 匹配 */ 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) if (platform_match_id(pdrv->id_table, pdev) != NULL) return 1; /* 4. 最后尝试驱动名和设备名直接相等匹配 */ if (strcmp(pdev->name, drv->name) == 0) return 1; return 0; }这段代码信息量很大。它揭示了一个很容易被忽略的事实:匹配是有先后顺序的,OF匹配被放在最前面。只要设备树里compatible属性和驱动of_match_table里的compatible字符串能对上,直接命中,后面的ACPI、id_table都不用看了。
2.2 四种匹配方式逐个拆解
第一种,OF匹配。这在现代设备树内核里是最常用、也是最重要的一种。它的原理是把设备树节点中compatible属性与驱动结构体里driver.of_match_table数组中的compatible字符串逐一比较。i.MX6ULL的设备树里,一个节点通常这样写:
&ecspi1 { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_ecspi1>; status = "okay"; spi-dev@0 { compatible = "myvendor,spi-dev"; reg = <0>; spi-max-frequency = <1000000>; }; };而对应的驱动中会有:
static const struct of_device_id my_spi_of_match[] = { { .compatible = "myvendor,spi-dev", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, my_spi_of_match); static struct platform_driver my_spi_driver = { .probe = my_spi_probe, .remove = my_spi_remove, .driver = { .name = "my_spi", .of_match_table = my_spi_of_match, }, };bus只要发现同一个名字的字符串出现在设备节点的compatible和驱动的of_match_table中,就判定匹配成功,随后调用该驱动的probe函数。
第二种,ACPI匹配。这种主要用于x86平台和一些服务器场景,嵌入式ARM上基本用不到,但源码里顺序排在第二,这说明内核在设计上支持“一种驱动,多种枚举方式并存”。
第三种,id_table匹配。id_table是platform_driver结构体里的一个成员,它是一个platform_device_id数组。在没有设备树的古老内核里,靠它完成“设备名匹配驱动”。它的匹配逻辑会拿设备的name字段和id_table里每个entry的name字段对比。注意,它和第四种直接比较的区别是,id_table匹配还会额外维护一个“型号”信息,设备通过id_entry能知道自己被匹配到的是表中的哪一条,这在兼容多个型号时非常有用。
第四种,直接比较驱动名和设备名。这是最朴素的老式机制。设备注册为platform_device时给一个name,驱动注册为platform_driver时driver.name里也写一个字符串,如果两者字符串完全一样,可以匹配上。但这种方式在现代设备树内核中已经不再推荐。
2.3 实际匹配时走得哪条路
以i.MX6ULL官方板子为例,内核启动时会注册大量平台设备,比如fec网络控制器,它的设备树节点里compatible是"fsl,imx6ul-fec",驱动fec_main.c中的of_match_table里同样有"fsl,imx6ul-fec",所以直接命中OF匹配。你在mximux时可以在终端看到类似这样的打印信息:
fec 20b4000.ethernet: Link is Up - 100Mbps/Full - flow control rx/tx这句话,probe之前实际上是platform_bus在这个driver和设备之间牵线搭桥成功的结果。
static const struct of_device_id fec_dt_ids[] = { { .compatible = "fsl,imx6ul-fec", .data = &fec_imx6ul_info, }, { .compatible = "fsl,imx6q-fec", .data = &fec_imx6q_info, }, { /* sentinel */ } };这里还要强调一点:of_device_id结构体里的.data字段非常实用,它可以在不修改probe逻辑的前提下,让同一份驱动兼容不同型号的控制器。i.MX6UL和i.MX6Q的FEC硬件寄存器布局略有差异,驱动拿到不同的data后,再通过of_id->data转换成对应的私有信息结构体,从而走不同的初始化路径。这是工程上很常见的做法。
2.4 为什么OF匹配优先级最高
从内核维护者的角度看,设备树已经是现代ARM平台描述硬件的唯一标准,compatible字符串就是设备身份证。把它放在第一位,意味着只要设备树写了合法的compatible,驱动就一定能匹配。这样可以避免出现一种混乱场景:设备树里compatible明明对着A驱动,结果因为驱动名和设备名也相等,B驱动抢先probe了。
而且,OF匹配还从机制上解决了设备与驱动“一夫一妻”的确定性问题。id_table和name匹配都存在可能误伤的风险,但compatible是语义化的,不容易出现歧义。我建议所有i.MX6ULL上的新驱动都优先选择OF匹配。
3. i.MX6ULL平台上的设备树与驱动落地
3.1 设备树怎么生成platform设备
很多新手不理解,设备树节点到底是什么时候、被谁变成了platform_device。这里要理清一个流程:内核启动时,start_kernel会调用init_irq、time_init、init_machine等一系列初始化函数。在ARM架构下,init_machine最终会调用of_platform_populate函数遍历设备树里的根节点和子节点,对每个带compatible属性的节点创建一个platform_device。
但也不是所有节点都会变成platform_device。有三种例外情况:
- 节点compatible匹配了平台维护的“裸机初始化”列表,比如simple-bus下的时钟节点,通常不生成platform_device,因为它们在更早的阶段被初始化了。
- 节点compatible对应的是一个已经存在的、由特定子系统管理的设备,比如i2c控制器会枚举出i2c_client而不是platform_device。
- 节点的父节点没有生成platform_device,子节点通常也不会独立生成。
所以在i.MX6ULL上你写设备树时,如果想新增一个自定义platform设备,一般做法是在根节点下创建一个子节点,或者挂在某个simple-bus节点下,并在其中填好compatible、reg、interrupts等属性。内核遍历到它时就会自动分配platform_device并加入platform总线。
一个比较典型的外设挂载示例:
/ { mydev { compatible = "myvendor,mydev"; reg = <0x020c406c 0x4>; interrupts = <GIC_SPI 87 IRQ_TYPE_LEVEL_HIGH>; status = "okay"; }; };这里reg填的是i.MX6ULL某个GPIO复用寄存器的物理地址和长度,interrupts描述中断源,之后驱动里就能用platform_get_resource和platform_get_irq拿到这些信息,不需要再硬编码地址。
3.2 一个完整的platform驱动模板
我给出一个基于i.MX6ULL、使用OF匹配的完整驱动雏形,可以直接套用:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> #include <linux/resource.h> #include <linux/io.h> #include <linux/interrupt.h> struct mydev_priv { void __iomem *base; int irq; }; 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; struct mydev_priv *priv; int ret; priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(&pdev->dev, "no memory resource\n"); return -EINVAL; } priv->base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(priv->base)) return PTR_ERR(priv->base); priv->irq = platform_get_irq(pdev, 0); if (priv->irq < 0) return priv->irq; dev_info(&pdev->dev, "mydev probed successfully\n"); platform_set_drvdata(pdev, priv); return 0; } static int mydev_remove(struct platform_device *pdev) { struct mydev_priv *priv = platform_get_drvdata(pdev); dev_info(&pdev->dev, "mydev removed\n"); 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_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("i.MX6ULL platform driver example");这里面有几个地方必须解释清楚。
第一个,module_platform_driver宏。它等价于定义module_init和module_exit,内部会分别调用platform_driver_register和platform_driver_unregister。你不需要手动写init和exit函数,省事且不容易出错。
第二个,devm_开头的资源管理函数。devm_kzalloc、devm_ioremap_resource这些接口会在驱动卸载时自动释放资源,避免你忘记释放导致内存泄漏或者寄存器映射残留。对写工程代码来说,用devm系列是一个好习惯。
第三个,platform_get_resource和platform_get_irq。它们从设备树节点解析出的resource数组里取reg和interrupts信息。ioremap之后你就有了一块映射好的寄存器空间,可以直接writel/readl操作。
3.3 Makefile与编译验证
交叉编译i.MX6ULL驱动时,我一般的Makefile长这样:
obj-m := mydev.o KDIR := /path/to/your/kernel/source CROSS_COMPILE := arm-linux-gnueabihf- ARCH := arm all: $(MAKE) -C $(KDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) clean把编译好的mydev.ko拷贝到板子上,先确保设备树里已经有对应节点,然后加载模块:
insmod mydev.ko正常匹配后你会在串口看到类似打印:
mydev mydev: mydev probed successfully如果设备树节点和driver匹配不上,insmod时不会有任何输出,或者只有模块加载的提示,probe不打印。
注意:如果设备树里本来没有对应节点,insmod后一定不会probe。这不是驱动写错了,而是设备端根本没有这个平台设备。很多新手卡在这一步,反复改驱动都对,最后发现是设备树没改或者没重新编译dtb。
4. 匹配失败与调试实战
4.1 probe不执行,先查这三处
我做过不少i.MX6ULL的板级bringup,遇到最多的一个问题就是probe函数不执行。总结下来,绝大多数逃不出以下三个原因:
第一种,设备树节点没编进dtb。有时候你在源码里加了节点,但编译时没有把修改后的dts编进去。最简单的方法是在内核启动日志里搜compatible字符串:
dmesg | grep myvendor如果什么都没搜到,基本可以确定节点没被内核解析到。
第二种,驱动没有注册成功。module_platform_driver宏的注册顺序是在module_init阶段完成的,如果你改过内核配置或者和其他模块有依赖,有可能出现驱动注册失败。可以在insmod之后用/sys/bus/platform/drivers路径确认:
ls /sys/bus/platform/drivers/mydev/如果目录不存在,说明驱动没注册成功。
第三种,compatible拼写不一致。这种问题最隐蔽,因为设备树和c文件在两个不同的仓库里,肉眼很难对比。建议写驱动时把of_match_table里的compatible和设备树里的compatible同时复制粘贴,杜绝手打。
4.2 从sysfs里观察匹配结果
Linux的sysfs把总线上两边的设备与驱动都暴露出来了,利用它可以直观看到匹配状态。挂载好debugfs和sysfs后,可以这样查:
ls /sys/bus/platform/devices/ ls /sys/bus/platform/drivers/mydev/第一种,设备已经存在但driver目录下没有设备链接,说明驱动没匹配上。第二种,设备不存在就直接看设备树。
更细一步,还可以读uevent文件,它会打印驱动和设备之间的匹配事件属性:
cat /sys/bus/platform/devices/mydev/uevent输出中会包含OF_COMPATIBLE、OF_FULLNAME等字段,能确认内核解析到的compatible到底是什么。这个方法在调试中非常高效。
4.3 动态调试打开总线匹配日志
如果还查不出来,可以通过内核的动态调试机制打开platform总线匹配的调试输出。platform_match函数内部没有打印,但设备模型层有dev_dbg调试信息。你可以开关:
echo 'file drivers/base/platform.c +p' > /sys/kernel/debug/dynamic_debug/control打开之后,dmesg能看到类似:
platform mydev: Driver mydev requests probe deferral这类信息能够帮助判断是设备没有匹配上,还是匹配上了但probe被延迟。这里要特别注意“probe deferral”这个概念。有些设备的probe依赖的时钟、pinctrl、reset控制器等资源还没准备好,内核会把它放回等待队列,等资源就绪后再重新尝试probe。如果你看到probe deferral,不代表失败,可能只是时序问题。
4.4 设备树修改后的启动验证
i.MX6ULL修改设备树后不能只重编译dtb,有时还需要重新打包镜像。我通常的做法是单独编译dtb,把它放到SD卡或EMMC的启动分区,再用U-Boot加载新的dtb启动。这样可以避免整个镜像重新刷写,调试周期短很多。
make dtbs cp arch/arm/boot/dts/imx6ull-myboard.dtb /tftpboot/然后在U-Boot里:
setenv loadaddr 0x80800000 tftp ${loadaddr} imx6ull-myboard.dtb bootz ${loadaddr} - ${fdt_addr}这里fdt_addr要改成你实际加载设备树的内存地址。一旦dtb加载成功,启动日志里会显示:
OF: fdt: Machine model: Freescale i.MX6 Ultralite Board到这里,设备树的修改才算真正落地。
5. 一些你可能没注意到的高级用法
5.1 platform_get_resource_byname
当一个设备有多个内存区段时,只靠index判断很容易混乱。i.MX6ULL的有些外设控制器会有多个寄存器块,比如有些多媒体相关设备,一个节点里有两个reg区。更清晰的写法是在设备树里给reg添加名字:
reg = <0x020c4000 0x100>, <0x020c8000 0x100>; reg-names = "control", "data";驱动里再用:
res = platform_get_resource_byname(pdev, IORESOURCE_MEM, "control");这样代码自我解释能力更强,也不怕后续硬件修改导致索引变动。类似地,interrupt-names配合platform_get_irq_byname也很常用。
5.2 多个compatible的匹配顺序
一个设备节点可以写多个compatible:
compatible = "myvendor,mydev-v2", "myvendor,mydev-v1";驱动里的of_match_table可以同时声明多个字符串,也可以有选择和优先级。内核在匹配时,如果第一个compatible没有对应的驱动,会尝试第二个。但如果驱动注册的顺序影响了匹配,有时会出现版本不对应的情况。稳妥的做法是,不同版本如果寄存器差异较大,就写两个驱动或者使用of_device_id里的.data区分,而不是让两个compatible同时指向同一个驱动。同一条“compatible链”应该理解为兼容性降级策略,而不是等价设备。
5.3 什么时候用id_table而不是of_match_table
如果说驱动需要支持一个非设备树的旧平台,或者在一个同时存在DT和non-DT的混合环境里工作,只用of_match_table就不够了。这时候你需要在platform_driver里同时填充id_table,保证老方式也能匹配。一个双兼容的driver示例:
static const struct platform_device_id mydev_ids[] = { { .name = "mydev-legacy", .driver_data = 0 }, { } }; MODULE_DEVICE_TABLE(platform, mydev_ids); static struct platform_driver mydev_driver = { .probe = mydev_probe, .remove = mydev_remove, .driver = { .name = "mydev", .of_match_table = mydev_of_match, }, .id_table = mydev_ids, };匹配时总线会先尝试OF匹配,如果不命中再走id_table。这种写法加上一点platform_match_id里面的driver_data传递,可以让新旧两套平台共用一套probe逻辑。不过对嵌入式ARM项目来说,用了设备树之后基本可以忽略id_table,保持简单更香。
6. 集成到实际项目时要注意的坑
6.1 时钟、引脚复用和regulator的依赖
i.MX6ULL的probe函数里只拿到reg和irq是不足以让外设跑起来的。大多数情况下还需要时钟使能、引脚复用、复位信号等。推荐的使用方式是在probe里通过devm_clk_get获取时钟,通过pinctrl框架自动配置引脚复用,在设备树里用pinctrl-0把引脚状态描述出来,在probe里直接使用。引脚复用配置在内核启动时会被pinctrl子系统自动应用,不需要驱动里额外调用。
时钟获取:
priv->clk = devm_clk_get(&pdev->dev, NULL); if (IS_ERR(priv->clk)) { dev_err(&pdev->dev, "failed to get clock\n"); return PTR_ERR(priv->clk); } clk_prepare_enable(priv->clk);这些资源依赖如果没满足,就有可能导致probe deferral。一个合格的probe要能够处理-EPROBE_DEFER错误,不能把它当作硬错误来处理。
6.2 重复加载与removal的安全问题
热插拔和模块卸载不只是简单地释放资源。对于已经注册了字符设备、创建了设备节点的驱动,remove时要保证不会出现用户空间还在访问、内核模块已经卸载的情况。我在调试中遇到过因为remove顺序不对导致内核panic,后来统一使用devm机制和cdev_del之后才稳定下来。在i.MX6ULL上,大多数外设驱动模块可以在remove里先禁用中断、释放内核态定时器,再依次注销设备节点和释放资源,顺序不能反。devm框架能自动释放大部分资源,但字符设备相关的cdev和class仍然需要手动清理,或者使用miscdevice这类已经封装好的框架规避。
6.3 不要把platform机制当成万能法则
有一点要说清楚,Platform机制挂在虚拟总线上,只适用于那些片上外设。如果你的设备挂在I2C、SPI、USB这类真实总线上,就要使用对应的总线驱动模型,而不是硬套platform。不要养成“一切皆platform”的习惯。
比如i.MX6ULL带有I2C控制器,外接的触摸芯片、传感器、音频codec挂在I2C总线上,它们的驱动应该使用i2c_driver结构体,通过of_match_table或i2c_device_id匹配。SPI从设备对应的则是spi_driver,USB设备对应usb_driver。只有SoC内部自带的那一层控制器才适合用platform_driver。搞清自己的设备挂在哪条总线上,是驱动开发的第一个基本功。
7. 总结个人经验
从i.MX6ULL这套板子开始接触Platform总线模型,确实绕了不少弯路。回头看,最值得记住的一条经验是:当你写驱动发现probe不调用时,先不要怀疑代码逻辑,而是去看看设备和驱动到底能不能匹配上。设备树节点有没有编进去、compatible字符串是不是手滑少了个字母、设备address是不是被别的高级子系统抢走,这三个因素占了probe不执行原因的九成。把platform_match的源码读熟,远比背诵几百个API有用。
还有一个小技巧,我每次拿到一块新板子,会把/sys/bus/platform/devices/目录的全部内容导出来,对照设备树里的节点逐个确认。这个过程虽然枯燥,却能让你对整个平台的外设分布有一个直观认知。等你真的理解了哪些设备挂在platform总线上、哪些挂在i2c或spi上,再写驱动就会自然很多。
如果你正在做i.MX6ULL或类似ARM平台的开发,希望这篇文章能帮你打通Platform设备与驱动匹配机制这一环。接下来你可以动手做一个最简单的外设驱动,比如点个GPIO,体验从设备树到probe再到寄存器操作的完整链路,把这个流程刻在脑回路里。