搞嵌入式Linux驱动开发的人,早晚都会遇到Platform这套机制。我自己第一次正儿八经碰i.MX6ULL时,就对着设备树里几百个节点发懵:明明外设都在芯片内部,代码里也没看到谁去“枚举”硬件,怎么驱动一加载就自动找到设备了?后来把Platform总线和匹配流程翻了一遍,才真正把设备树、驱动模型、probe调用之间的关系串起来。这篇文章就围绕i.MX6ULL,把Platform设备与驱动匹配机制从设计缘由到内核源码再到手写驱动完整拆一遍,适合刚入门Linux驱动、或者已经在写驱动但总感觉“匹配”这步像黑盒的读者。
1. Platform总线的来源与设计思路
1.1 为什么SoC内部外设需要一条“虚拟总线”
先想一个问题:UART、I2C、SPI、USB这些控制器,在芯片上都有对应的物理总线协议,外设挂在总线上能被自动扫描和枚举。但SoC内部集成的GPIO、DMA、LCD控制器、以太网MAC这些设备,它们并不挂在某个可枚举的物理总线上,不存在“设备插入”这种事件,也没有硬件ID寄存器能被总线控制器轮询。
那驱动怎么找到设备?内核为此引入一条虚拟总线,叫Platform总线,也有人叫“平台总线”。它专门用来管理那些直接集成在SoC上、地址映射固定的设备。这类设备在代码中统一叫platform_device,对应的驱动叫platform_driver。命名虽然叫platform,但它不是某个具体硬件,而是软件抽象出来的“挂载点”,把所有非可枚举设备统一纳入Linux设备模型管理。
i.MX6ULL是NXP基于Cortex-A7内核设计的应用处理器,片内集成了大量这样的控制器。你在设备树里看到的gpio、uart、i2c、epit、adc等节点,最终在内核启动阶段都会转换成platform_device,挂在Platform总线上。驱动侧的匹配、probe、remove则统一由Platform总线完成调度。
1.2 从device、driver、bus三层模型看Platform
Linux设备模型的核心逻辑可以简化成三层:device描述“有什么硬件”,driver描述“能驱动什么硬件”,bus负责把两者牵线。Platform总线就是bus的具体实例之一。
device(设备树节点/平台设备) -----> platform_bus_type <----- driver(驱动) | | └---------匹配成功后调用probe()在内核源码里,Platform总线定义在drivers/base/platform.c,bus_type结构体叫platform_bus_type。它最重要的一个回调就是.match函数,也就是整篇文章的主角——设备与驱动的匹配逻辑。当内核每次注册一个platform_driver,或者注册一个platform_device时,都会遍历总线上另一侧的设备/驱动,调用.match检查是否配对。配对成功,内核立即调用驱动的.probe方法,驱动正式开始接管硬件。
理解了这个结构,就明白为什么写驱动时不需要手动找设备:只要设备树里有对应节点,驱动匹配成功后probe必然被调用。你所有初始化代码都写在probe里就行。
1.3 i.MX6ULL上典型的Platform设备例子
i.MX6ULL内部外设数量很多,下面列几个我实际接触过的典型例子:
| 外设 | 设备树节点路径示例 | 驱动源码位置 |
|---|---|---|
| GPIO控制器 | soc/gpio@0209c000 | drivers/gpio/gpio-mxc.c |
| UART串口 | soc/serial@02020000 | drivers/tty/serial/imx.c |
| I2C控制器 | soc/i2c@021a0000 | drivers/i2c/busses/i2c-imx.c |
| LCD控制器 | soc/lcdif@021c8000 | drivers/video/fbdev/mxsfb.c |
| 看门狗 | soc/wdog@020bc000 | drivers/watchdog/imx2_wdt.c |
这些驱动文件里,清一色都声明了platform_driver和of_device_id匹配表。例如imx串口驱动里会有一张表,列出所有兼容的芯片型号字符串,I2C驱动也类似。内核启动时,设备树里的serial节点会被解析为platform_device,然后Platform总线在注册驱动时,用这张表比对节点compatible属性,匹配上就probe。整个“外设无物理总线可枚举”的问题,就这样被Platform总线解决了。
2. 匹配机制内核源码拆解
2.1 platform_match的执行顺序与核心逻辑
设备与驱动匹配的核心函数是platform_match,位于drivers/base/platform.c。不同内核版本细节略有差异,但主干逻辑基本一致。我整理过一份简化流程,对应Linux 5.4左右的内核:
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. 先尝试驱动自带的id_table */ if (pdrv->id_table) if (platform_match_id(pdev, pdrv->id_table) != NULL) return 1; /* 2. 检查驱动是否强制覆盖设备绑定 */ if (pdev->driver_override) return !strcmp(pdev->driver_override, drv->name); /* 3. ACPI匹配,x86/ARM服务器场景下使用 */ if (acpi_driver_match_device(dev, drv)) return 1; /* 4. 设备树匹配,of_device_id表比对 */ if (of_driver_match_device(dev, drv)) return 1; /* 5. 最原始方式:设备名与驱动名直接比较 */ return (strcmp(pdev->name, drv->name) == 0); }注意每一步成功都会立刻返回1,匹配流程立即结束。这套顺序在不同内核版本稍有调整,但思路一致。对我们写设备树驱动的场景,第4步才是重点。第5步是早年间没有设备树时用的老办法——platform_device的名字和platform_driver->driver.name完全一致,现在一些杂项设备或测试驱动还能看到这种写法。
2.2 设备树节点与of_device_id的匹配细节
设备树匹配过程中,内核真正执行的是of_driver_match_device,它会遍历设备树节点的compatible属性列表和驱动of_device_id表中的compatible字符串。
设备树里一个节点可以写多个compatible,比如:
led_test { compatible = "myvendor,led-test", "myvendor,generic-led"; ... };驱动侧of_device_id表这样声明:
static const struct of_device_id led_test_of_match[] = { { .compatible = "myvendor,led-test" }, { .compatible = "myvendor,generic-led" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_test_of_match);匹配时,内核把设备树节点里每个compatible字符串,依次与驱动of_device_id表里的每个compatible做字符串比较,完全一致才算通过。也就是说,设备树写“A,B”,驱动表里必须有一个完全相等的“A,B”,少个逗号、多个空格、大小写不一致,全都不行。
补充一个细节:of_device_id结构里还有.name和.type字段,可用来匹配节点name属性和device_type属性,但实际设备树中很少有节点设置device_type,所以绝大多数驱动只填.compatible。还有很多人问MODULE_DEVICE_TABLE有什么用,它主要把这张表导出到模块的modinfo信息里,让insmod/modprobe能识别这个模块支持哪些硬件,同时帮助udev在热插拔时自动加载模块。虽然Platform设备不是热插拔的,但养成写这个宏的习惯是对的。
2.3 id_table老方法 vs 新设备树方法
在没有大规模使用设备树的时代,或者使用板级文件(arch/arm/mach-xxx)注册设备时,经常用platform_device_id表来匹配设备。这种表主要匹配的是platform_device的name字段:
static const struct platform_device_id led_test_id_table[] = { { .name = "led-test", .driver_data = 0 }, { } }; MODULE_DEVICE_TABLE(platform, led_test_id_table);假设板级文件里注册了一个名字叫“led-test”的platform_device,或者设备树节点没有compatible只有节点名,那么驱动加载时platform_match_id就会命中,probe照常执行。在老内核和部分x86平台驱动里,这种方式依然常见。
两者对比如下:
| 匹配依据 | 设备树方式 | platform_device_id方式 |
|---|---|---|
| 设备侧信息 | 节点compatible属性 | platform_device.name |
| 驱动侧信息 | of_device_id.compatible | id_table[].name |
| 推荐场景 | 所有设备树平台 | 老平台、无设备树场景 |
| 携带自定义数据 | 通过driver_data也可 | id_table[].driver_data |
| 查找函数 | of_driver_match_device | platform_match_id |
i.MX6ULL这种现代BSP基本都走设备树,所以建议直接写of_device_id表。但遇到一些复用老驱动的场景,比如某些从3.x内核迁移过来的老代码,可能还在用id_table,多了解没坏处。
2.4 匹配优先级、driver_override与手动绑定
实际调试时,偶尔会遇到一个platform_device同时满足多种匹配条件的情况。比如设备树节点里的name刚好和设备驱动名一致,但compatible不一致,谁先谁后就是顺序问题。上面源码注释已经给出优先级,一般先id_table,再ACPI,再设备树,最后是名字比较。不过大厂BSP可能会有定制,查源码最准确。
除了自动匹配,内核也支持手动指定某设备强制使用某驱动,这就是driver_override机制。它通常通过sysfs接口操作:
echo led_test_drv > /sys/bus/platform/devices/led_test/driver_override echo led_test > /sys/bus/platform/drivers_probe操作完后,总线会忽略其他匹配结果,强制把led_test设备绑定到led_test_drv驱动。这个机制在驱动调试初期非常有用,尤其是设备树写错了、不想反复改dtb重启时,可以临时手动强制绑定验证驱动本身是否正常。
3. 在i.MX6ULL上从零写一个Platform驱动
3.1 准备硬件与编译环境
手头有一块i.MX6ULL开发板即可。大多数板子会用NXP官方BSP或正点原子/野火适配的内核,版本一般在4.1.15到5.4之间。开发环境推荐Ubuntu 18.04或20.04虚拟机,安装交叉编译工具链。以arm-linux-gnueabihf-为例,确认工具链存在:
arm-linux-gnueabihf-gcc -v还需要准备对应的内核源码树,并且已经编译过。编译外部模块时,内核会用源码目录里的Makefile和生成的头文件,所以先完整编译一次内核可以避免很多奇怪问题。源码路径假设放在~/kernel/linux-imx,后面所有KERNELDIR都指向这里。
我习惯用板子自带的内核源码版本,而不是随便下载主线,因为NXP BSP会在arch/arm/mach-imx、include/dt-bindings等目录加入很多官方补丁,直接影响设备树宏定义和pinctrl配置。用错版本很容易在编译设备树时报一堆找不到宏的错误。
3.2 第一步:添加设备树节点
以控制i.MX6ULL一个GPIO点灯为例,写一个Platform测试设备。先看原理图,假设LED接在GPIO1_IO04上,低电平点亮。在imx6ull对应的dts里,找一个合适的位置添加节点,比如在根节点下添加:
/ { led_test { compatible = "myvendor,led-test"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_led_test>; led-gpio = <&gpio1 4 GPIO_ACTIVE_LOW>; status = "okay"; }; };其中的pinctrl_led_test需要在iomuxc节点下补充引脚复用配置,参照IMX6ULL的pinctrl格式:
&iomuxc { pinctrl_led_test: led-testgrp { fsl,pins = < MX6UL_PAD_GPIO1_IO04__GPIO1_IO04 0x10b0 >; }; };MX6UL_PAD_GPIO1_IO04__GPIO1_IO04这个宏定义在arch/arm/boot/dts/imx6ul-pinfunc.h中,0x10b0是引脚配置寄存器值,包括上下拉、驱动强度、速率等配置。低有效用GPIO_ACTIVE_LOW,这是从include/dt-bindings/gpio/gpio.h里来的宏。
添加完重新编译设备树:
make dtbs把生成的imx6ull-xxx.dtb拷贝到开发板,替换原来的dtb后重启。启动后在/proc/device-tree或/sys/firmware/devicetree/base下能看到led_test节点,说明设备树已经生效。
3.3 第二步:写Platform驱动框架代码
驱动代码放到一个独立目录,比如~/led_test_drv/,创建led_test.c。下面是一个最简但完整的platform驱动,包含匹配表、probe、remove和基本的文件操作接口,方便验证匹配流程。
#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/fs.h> #include <linux/uaccess.h> #include <linux/miscdevice.h> static struct gpio_desc *led_gpiod; /* 设备树匹配表 */ static const struct of_device_id led_test_of_match[] = { { .compatible = "myvendor,led-test" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, led_test_of_match); static int led_test_open(struct inode *inode, struct file *filp) { return 0; } static long led_test_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) { return 0; } static const struct file_operations led_test_fops = { .owner = THIS_MODULE, .open = led_test_open, .unlocked_ioctl = led_test_ioctl, }; static struct miscdevice led_test_miscdev = { .minor = MISC_DYNAMIC_MINOR, .name = "led_test", .fops = &led_test_fops, }; static int led_test_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; int ret; dev_info(dev, "led_test probe success\n"); /* 从设备树获取GPIO,使用新的gpiod API */ led_gpiod = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW); if (IS_ERR(led_gpiod)) { ret = PTR_ERR(led_gpiod); dev_err(dev, "failed to get led gpio: %d\n", ret); return ret; } ret = misc_register(&led_test_miscdev); if (ret) { dev_err(dev, "failed to register misc device\n"); return ret; } dev_info(dev, "led_test driver ready\n"); return 0; } static void led_test_remove(struct platform_device *pdev) { misc_deregister(&led_test_miscdev); if (!IS_ERR_OR_NULL(led_gpiod)) gpiod_set_value(led_gpiod, 0); dev_info(&pdev->dev, "led_test driver removed\n"); } static struct platform_driver led_test_driver = { .probe = led_test_probe, .remove = led_test_remove, .driver = { .name = "led_test", .of_match_table = led_test_of_match, }, }; module_platform_driver(led_test_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("i.MX6ULL platform device match test driver");注意这里用devm_gpiod_get(dev, "led", ...)去获取设备树里led-gpio属性对应的GPIO。函数会从设备树属性名“led-gpio”自动推断出con_id是“led”,最终拿到对应的gpio_desc。这套gpiod API比老的gpio_request更推荐,它内部配合设备树和pinctrl子系统完成引脚申请和配置,而且devm前缀意味着资源随设备自动释放,probe失败或驱动卸载时不用手动清理,省掉不少麻烦。
3.4 第三步:编写Makefile并编译加载
同目录创建Makefile,内容如下:
KERNELDIR := /home/user/linux-imx ARCH ?= arm CROSS_COMPILE ?= arm-linux-gnueabihf- obj-m := led_test.o all: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) clean把KERNELDIR换成你实际的内核源码路径,然后执行make。能正常生成led_test.ko就说明编译通过。把ko文件拷贝到开发板,用insmod加载:
insmod led_test.ko正常现象有两种:一是驱动以模块方式加载且和设备树节点匹配成功,probe立即调用,dmesg里能看到“led_test probe success”;二是把驱动编进内核,那probe会在设备树节点创建并注册platform_device时被调用,内核启动日志里能看到相同信息。
3.5 怎么确认匹配真的成功了
匹配成功后,Linux设备模型会在sysfs下建立关联符号链接。这是判断“设备和驱动是否绑定”的最直接证据。
ls -l /sys/bus/platform/devices/led_test/如果看到driver符号链接指向/sys/bus/platform/drivers/led_test,说明绑定成功:
lrwxrwxrwx 1 root root 0 Jan 1 00:00 driver -> ../../bus/platform/drivers/led_test反过来,在驱动目录下也能看到设备:
ls -l /sys/bus/platform/drivers/led_test/里面会出现led_test设备名。同时/sys/bus/platform/drivers/led_test/目录下还有bind、unbind、uevent等接口文件,可用来手动解绑和绑定设备,调试时很好用。
驱动卸载用rmmod led_test,remove回调会执行,/sys/bus/platform/devices/led_test/driver链接会消失,说明设备和驱动成功解绑。
3.6 probe里怎么拿到设备树资源
匹配成功只是开始,驱动真正干活还需要从设备树节点获取各种资源。除了GPIO,常见的有寄存器地址、中断号、时钟、DMA通道等。下面列几个高频API,都是在probe里用的:
/* 获取寄存器物理地址和长度 */ struct resource *res; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (res) { unsigned long start = res->start; unsigned long end = res->end; } /* 获取中断号 */ int irq = platform_get_irq(pdev, 0); /* 从设备树获取GPIO,对应属性 xxx-gpios */ struct gpio_desc *gpio = devm_gpiod_get(dev, "xxx", GPIOD_IN); /* 读取设备树里自定义的整型属性 */ u32 val; of_property_read_u32(dev->of_node, "max-speed", &val); /* 获取时钟 */ struct clk *clk = devm_clk_get(dev, NULL);在i.MX6ULL点灯例子里,如果用老的gpio_request API,需要自己把设备树里的<&gpio1 4 GPIO_ACTIVE_LOW>转换成gpio number,计算公式是:GPIO1组对应bank 0,GPIO1_IO04就是032+4=4,GPIO5_IO03就是432+3=131。但gpiochip的base号在系统里可能动态分配,直接算数字不保险,所以我强烈建议用gpiod系列API。gpiod_get直接返回描述符,不再依赖全局gpio编号,这也是内核社区推荐的方向。
4. 常见问题与排查技巧实录
4.1 probe没执行,先把这五步走一遍
匹配不成功最常见的表现就是insmod后dmesg干干净净,没有任何probe日志。遇到这种情况,我一般不急着改代码,先按下面的顺序排查:
- 确认设备树节点存在且status不是disabled:
ls /sys/firmware/devicetree/base/led_test cat /sys/firmware/devicetree/base/led_test/status cat /sys/firmware/devicetree/base/led_test/compatible- 确认驱动已经注册到Platform总线:
ls /sys/bus/platform/drivers/led_test/- 看设备跟驱动是否已经绑定,有没有driver链接:
ls -l /sys/bus/platform/devices/led_test/- 手动触发一次匹配,排除module加载顺序问题:
echo led_test > /sys/bus/platform/drivers/led_test/bind- 拉出内核日志里所有platform相关消息:
dmesg | grep -i platform第四步的bind接口在设备已经驱动绑定过时会报错,但如果设备树节点和驱动都正常而之前没自动绑定,手动bind往往能看到具体报错。
4.2 compatible属性低级错误清单
compatible匹配是设备树驱动最常用的匹配方式,踩坑也最多。我整理过一张速查表,每次匹配不上就逐项核对:
| 检查项 | 典型错误 |
|---|---|
| 字符串完全一致 | 设备树多写一个空格或逗号 |
| 大小写 | “MyVendor”写成“myvendor” |
| 厂商前缀 | 漏掉厂商名,只写设备名 |
| 逗号位置 | “myvendor,led-test”写成“myvendor-led-test” |
| of_device_id表末尾 | 忘记加sentinel空结构体 |
| status属性 | 设备树节点被status = "disabled"屏蔽 |
| MODULE_DEVICE_TABLE宏 | 没写导致modinfo信息缺失 |
of_device_id表末尾的sentinel很容易漏。没有结尾空项,内核遍历时会越界,匹配结果不可预期。写表时宁可多写一行{ /* sentinel */ },也不能省。
4.3 模块加载报错与编译问题
编译驱动时经常遇到“Unable to handle kernel NULL pointer dereference”这类运行时错误,多数跟资源获取失败但没检查返回值有关。probe里每步都检查返回值、用dev_err输出错误码,是保命习惯。
加载模块时如果报:
insmod: ERROR: could not insert module led_test.ko: Invalid parameters常见原因是内核版本与模块编译环境不一致,比如用5.4内核头文件编出来的模块,insmod到4.1.15内核里。内核不强制模块版本号完全一致时还能加载,但一旦用了两个内核差异较大的数据结构就可能出问题。最稳妥做法是在开发板同版本的内核源码树下编译模块。
另外有时候insmod能加载,但/sys/bus/platform/drivers下没有驱动目录,说明platform_driver_register本身失败了。可能是同名驱动已经注册过,或者driver.name和系统里其他驱动冲突。
4.4 设备树改了但没生效
改了dts重新编译dtb后,如果发现节点还是老样子,别急着怀疑代码,先确认板子启动用的是不是新dtb。有些板卡用u-boot环境变量指定dtb分区,有些是从boot分区读取,要确保文件真正覆盖了。在板子上查看设备树实际内容:
ls /proc/device-tree/ cat /proc/device-tree/led_test/compatible/proc/device-tree是调试设备树的利器,里面内容就是内核解析后的结果。如果节点没出现,要么dtb没更新成功,要么设备树编译时语法出错,内核回退到旧dtb。
还有一个隐藏坑:i.MX6ULL的pinctrl配置。GPIO要正常工作,不仅要设设备树led-gpio属性,还需要在iomuxc里把对应引脚复用为GPIO功能。如果忘了配pinctrl或配置宏错误,probe可能成功,但操作GPIO没反应。检查/sys/kernel/debug/pinctrl/下引脚占用状态,能帮助确认。
5. 几个能提升开发效率的实用技巧
做i.MX6ULL驱动调试,除了上面这些,我自己还有几个固定习惯,分享给大家。
第一,测试驱动时不要每次都编进内核烧写固件。先用module方式编译,在板子上insmod/rmmod,验证匹配和probe逻辑没问题后,再把代码编进内核或做成开机自动加载。这样迭代速度快很多,省掉反复烧写的几分钟,一天下来能省不少时间。
第二,学会看/sys/bus/platform下的目录结构,比反复dmesg更直观。driver链接存在与否就是绑定状态的最直接反映。我经常写一句话脚本:
watch -n 1 "ls -l /sys/bus/platform/devices/led_test/driver 2>&1"加载驱动前后观察这条命令输出变化,匹配流程一目了然。
第三,如果驱动里同时支持多个设备树节点,可以用of_device_id的.data字段区分硬件版本。每个compatible项可以携带不同的driver_data,probe里通过of_match_device获取当前匹配到的数据:
const struct of_device_id *match; match = of_match_device(led_test_of_match, &pdev->dev); if (match) { unsigned long data = (unsigned long)match->data; /* 根据data区分版本 */ }第四,设备树节点不要写得太多没用的属性。匹配机制只看compatible和status,其他属性都是留给驱动读的。保持节点干净,以后排查问题容易很多。
从个人经验看,Platform匹配机制理解透了,Linux驱动开发的半壁江山就算拿下了。它不只适用于i.MX6ULL,几乎所有现代ARM Linux平台都遵循同一套路:设备树描述硬件,Platform总线完成匹配,probe里初始化驱动。这套模型真正理解之后,再去看其他子系统驱动会顺畅得多。调试过程中遇到匹配不上,我建议按文中的sysfs路径一层层剥开看,别急着怀疑内核,大多数时候问题都出在compatible字符串或者设备树节点本身。