做嵌入式Linux驱动开发这几年,i.MX6ULL算是我用得最多的一块主控,Cortex-A7内核、资源适中、资料也多,从入门学习到小批量产品都很合适。不管点灯、读按键还是驱动外设,Linux下都绕不开Platform设备和驱动匹配机制这个问题。很多朋友从裸机转到Linux,写驱动还习惯把寄存器地址、中断号直接硬编码在驱动里,一换板子就崩。要真正理解现代Linux驱动,必须先把平台总线、设备树文件、probe函数这一整套流程串起来。这篇基于i.MX6ULL,把匹配机制从源码到实操完整拆一遍,适合刚接触字符设备驱动框架、想搞懂设备树到底怎么和驱动对上号的开发者。
1. 先搞清楚Platform总线解决了什么问题
1.1 传统驱动写法:硬件信息硬编码的痛点
从裸机或者RTOS转过来的人,写的驱动基本就是“寄存器操作集合”。比如点亮一个LED,直接在代码里写:
#define GPIO1_DR (0x0209C000) #define GPIO1_GDIR (0x0209C004) static void led_init(void) { *(volatile unsigned int *)GPIO1_GDIR |= (1 << 18); *(volatile unsigned int *)GPIO1_DR &= ~(1 << 18); }这种写法在单片机裸机里问题不大,因为整个系统的硬件是确定的,地址就那几个。但到了Linux、到了i.MX6ULL这种一个SoC可以搭配几十种底板的场景,问题就出来了:地址、中断号、DMA通道这些硬件资源写死在代码里,换了板子就要改代码重新编译;内核的电源管理、运行时PM、设备热插拔模型全都没法接入,驱动和系统是割裂的。
我在一个量产项目里就吃过亏:底板改了版,按键从一个GPIO挪到另一个GPIO,驱动里改了一个宏定义、重新编译,结果忘了另一处硬编码的中断标志,排查花了一晚上。那时候我就强烈意识到,Linux驱动必须把“硬件长什么样”和“驱动怎么操作”这两件事彻底分开。
1.2 Platform机制的引入:设备与驱动分离
分开的思路就是引入总线模型。Linux内核的设备模型里,device代表硬件设备,driver代表驱动逻辑,bus负责把它们匹配到一起。对于PCI、USB这种可以枚举的总线,设备是硬件上报来的;但GPIO控制器、UART、看门狗这类SoC内部外设,没有枚举能力,系统怎么知道它们存在?所以内核搞了platform总线,一条虚拟的总线,专门用来挂“非可枚举设备”。
你可以把platform总线理解成一个中介平台。设备侧把硬件资源登记成一份简历:我有哪几个寄存器地址、哪几个中断、兼容什么型号;驱动侧也提交一份能力说明:我能处理哪些兼容型号、需要哪些资源。总线负责比对简历和能力,匹配上了就安排一次“入职见面会”——这个见面会就是probe函数。
对应到i.MX6ULL的实际开发里:
- 设备侧,现代内核都是用设备树(Device Tree)描述硬件,编译出dtb后启动时解析。设备树文件里每个带compatible属性的节点,都会在内核初始化阶段被转成platform_device。
- 驱动侧,你写的platform_driver结构体注册到platform总线上。
- 匹配成功,内核调用你的probe;匹配失败则一直等待,不报错也不执行。
这也是为什么很多新手把驱动写好了、insmod也成功了,却看不到probe打印信息——不是驱动没加载,而是匹配根本没成功。这个体会后面细说。
2. 匹配机制核心原理:probe为什么会被调用
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步:driver_override强制指定 */ if (pdev->driver_override) return !strcmp(pdev->driver_override, drv->name); /* 第2步:设备树OF匹配 */ if (of_driver_match_device(dev, drv)) return 1; /* 第3步:id_table匹配 */ if (pdrv->id_table) return platform_match_id(pdrv->id_table, pdev) != NULL; /* 第4步:name名称匹配 */ return (strcmp(pdev->name, drv->name) == 0); }第1步driver_override是调试和强制绑定时用的,正常开发很少碰到,先忽略。
第2步的of_driver_match_device,核心是拿设备树节点的compatible属性,和drv->driver.of_match_table里的每个条目逐个比较。以i.MX6ULL为例,imx6ull.dtsi里的usdhc1节点:
usdhc1: usdhc@02190000 { compatible = "fsl,imx6ull-usdhc", "fsl,imx6sx-usdhc"; reg = <0x02190000 0x4000>; interrupt-parent = <&gpc>; interrupts = <GIC_SPI 22 IRQ_TYPE_LEVEL_HIGH>; ... };如果这个节点的compatible跟sdhci-esdhc-imx驱动of_match_table里的任何一个条目相等,就匹配成功。这也是设备树驱动最常用的匹配方式,我后面讲的按键驱动就走这条路。
第3步id_table是传统板级文件时代的产物。驱动用platform_device_id结构体数组声明自己支持的设备名,比如:
static const struct platform_device_id imx_uart_ids[] = { { .name = "imx-uart", }, { }, }; MODULE_DEVICE_TABLE(platform, imx_uart_ids);第4步name匹配更古老,直接把device名称和driver名称做字符串比较。现在还有不少驱动在用,但没有设备树灵活,所以我的看法是:花时间把第2步搞透,性价比最高。
2.2 设备树是怎么变成platform_device的
问题来了:你写了设备树节点,它什么时候变成platform_device的?答案是内核启动阶段。
在start_kernel之后,内核的initcalls会执行到of_platform_default_populate_init这个初始化函数,它从根节点开始遍历设备树。遍历有规矩:只有带compatible属性的节点才会创建platform_device;中间层的节点,必须compatible带有"simple-bus"、"simple-mfd"这类标记,才会继续向下扫描。这就是为什么设备树里soc节点一般写成这样:
soc { compatible = "simple-bus"; #address-cells = <1>; #size-cells = <1>; ... };i.MX6ULL所有外设节点都挂在soc下面,所以都会被逐个创建为platform_device。节点里的reg属性会被转成IORESOURCE_MEM资源,interrupts属性会被转成IORESOURCE_IRQ资源,of_node指针则会指向设备树里的原始节点。这几个资源,就是你probe里最常用的platform_get_resource、platform_get_irq依赖的数据。
注意,创建platform_device不等于驱动会运行。它只是把设备挂到了platform总线上,进到总线的设备列表里等“有心人”来领。之后设备与驱动的匹配动作,可以发生在设备注册时(系统启动),也可以发生在驱动注册时(你insmod的时候)。
2.3 of_match_table、id_table与name匹配的取舍
作为驱动作者,到底要用哪种匹配?我的经验是:
- 新板子、新驱动,默认就用设备树compatible匹配。硬件的差异(引脚、地址、频率、参数)全部交给设备树描述,驱动只关心这类设备怎么操作,不同板子通过修改dtb适配,驱动代码一行都不动。
- 除非你在维护老代码,或者设备树改造代价太大,才去考虑id_table和name匹配。
- of_match_table里可以挂多个compatible。比如i.MX6ULL的网卡驱动,可能同时兼容imx6ull和imx6sx的多个型号,就在表里多放几条。
- 每条of_device_id还有个.data指针,可以携带自定义数据结构,probe里通过of_match_device取出,适合区分同一驱动兼容多个硬件型号、初始化参数不同的情况。
一句话总结:现在做i.MX6ULL驱动,请把所有注意力放在“设备树compatible对上of_match_table”这条匹配链路上。
3. 实操:在i.MX6ULL上从零写一个Platform按键驱动
3.1 设备树节点与引脚复用配置
拿GPIO按键举例,因为按键驱动麻雀虽小五脏俱全:设备树节点、pinctrl配置、GPIO申请、中断注册全都能演示到。
假设按键接在GPIO1_IO18上,低电平表示按下。先在项目dts的根节点下添加子节点:
/ { mykey { compatible = "mydev,gpio-key"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_mykey>; key-gpio = <&gpio1 18 GPIO_ACTIVE_LOW>; status = "okay"; }; };然后需要在iomuxc节点里配置引脚复用。i.MX6ULL的UART1_CTS_B引脚可以复用为GPIO1_IO18,所以:
&iomuxc { pinctrl_mykey: mykeygrp { fsl,pins = < MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0xF080 >; }; };这里的0xF080是电气配置属性,含义大致包括上下拉、驱动能力、施密特触发器。实际项目中建议对照参考手册的IOMUXC章节选值,别盲目照抄,因为同样的功能值在不同引脚上的默认状态可能不一样。我习惯先选一个带内部上拉的值,避免按键悬空时电平抖动。
pinctrl-0这个属性很重要,它让内核在probe之前先帮你把引脚复用和电气属性配好,驱动里就不用再手动操作IOMUX寄存器了。这也是“硬件描述交给设备树”思想的直接体现。
3.2 驱动的核心源码结构
接下来写驱动,完整代码如下,我加了一些注释:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of_gpio.h> #include <linux/gpio.h> #include <linux/interrupt.h> #include <linux/slab.h> struct mykey_data { int gpio; int irq; }; static irqreturn_t mykey_isr(int irq, void *dev_id) { struct mykey_data *data = dev_id; int val = gpio_get_value(data->gpio); pr_info("mykey irq, gpio=%d val=%d\n",>export ARCH=arm export CROSS_COMPILE=arm-linux-gnueabihf- make mykey.ko把生成的ko文件拷贝到板子上,依次执行:
insmod mykey.ko dmesg | tail如果一切正常,dmesg里会看到:
mykey mykey: mykey probe ok, gpio=18 irq=...注意这里的“mykey mykey”,第一个是platform_device层名字,第二个是驱动层名字,都来自设备树节点名和driver.name。看到这一行,说明匹配成功、probe已经执行。
还可以去sysfs里确认绑定关系:
ls /sys/bus/platform/drivers/mydev_gpio_key/ cat /sys/bus/platform/drivers/mydev_gpio_key/mykey如果能读出设备信息,说明设备和驱动已经绑定。这时候按一下按键,dmesg里就会打印出中断日志。
我的习惯是先验证匹配,再验证功能。匹配都没有就开始折腾中断,出了问题根本分不清是哪一环。
4. 高频问题排查与调试心得
4.1 probe不执行的常见原因
我见过的probe不执行,九成出在这几件事上:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| dmesg完全没输出 | compatible拼写不一致 | 对比dts与of_match_table,注意大小写、逗号后的空格 |
| dmesg完全没输出 | 节点status="disabled" | 检查节点status,改为"okay"后重新编译dtb |
| dmesg完全没输出 | dtb没更新 | 确认启动加载的是新编译的dtb,重新烧写 |
| dmesg完全没输出 | 节点挂在非simple-bus下 | 确认父节点compatible包含simple-bus,或直接挂在根节点 |
| 没输出但模块加载成功 | 设备已被同类型驱动绑定 | cat /sys/bus/platform/devices/ 查看设备占用情况 |
这里特别提醒dtb更新这一步。很多朋友改了dts,编出dtb,却忘了确认uboot实际加载的是哪个分区、哪份dtb。排查的时候我从来不猜,直接先跑:
cat /proc/device-tree/mykey/compatible如果在板子上看不到这个属性,就说明dtb根本没生效,驱动写得再对也没用。
另外,如果你的驱动是编译进内核而不是模块,probe会在系统启动阶段执行。此时如果dts节点不匹配,启动日志里会有与platform相关的提示,可以用dmesg搜索“platform”关键字。
4.2 资源申请与引脚冲突排查
还有一种情况是probe进去了,但中途返回错误。最常见的是devm_gpio_request_one失败,返回-EBUSY,意思是这个GPIO已经被别的驱动申请走了。
排查方法:打开内核的GPIO调试接口:
mount -t debugfs none /sys/kernel/debug cat /sys/kernel/debug/gpio这里会列出所有GPIO的使用状态。如果看到gpio-18显示被某个驱动占用,而且不是你的,那就得去设备树里查谁和它冲突了。i.MX6ULL的引脚复用在pinctrl里也是一样道理,多个节点引用同一个引脚,会导致pinctrl配置冲突。此时可以查:
cat /sys/kernel/debug/pinctrl/20e0000.iomuxc/pinmux-pins过往项目里“明明dts写了、驱动也probe了,但引脚就是不工作”的问题,用这个方法定位过很多次。基本套路是:先看GPIO占用,再看pinctrl复用,最后看电气配置值。
中断请求失败也会导致probe失败。gpio_to_irq返回0或者负数,通常意味着GPIO还没有正确配成输入,或者父中断控制器没有就绪。遇到时先别急着看驱动,回设备树里检查pinctrl有没有配到中断对应的输入引脚上。
4.3 我常用的几个调试手段
最后说几个自己常用的调试思路,算不上高端,但真的省时间。
第一,用dev_err、dev_info而不是printk裸奔。dev_xxx会自动带出device和driver名字,日志里能看到“mykey mykey”这种上下文,不用自己拼设备名,排查多设备时特别有用。
第二,善用sysfs的手动绑定。如果想要在运行时来回测试,可以往bind/unbind写值强制解绑再绑定:
echo mykey > /sys/bus/platform/drivers/mydev_gpio_key/unbind echo mykey > /sys/bus/platform/drivers/mydev_gpio_key/bind每次绑定都会重新走probe,等效于反复插拔设备。调probe代码时我经常这么干,比反复重启板子快得多。
第三,遇到设备树相关怀疑,优先看/proc/device-tree。它是当前生效设备树的完整展开,包含属性和实际启动用的dtb严格一致。所有“我觉得我写了”都不算数,以这个目录下的内容为准。
第四,匹配顺序也别忘。有的驱动同时写了of_match_table和id_table,甚至有name字段。一旦id_table存在,只要id_table里的某个name和设备名相同,也会匹配上。所以看到probe被调用了,先想想是不是走了你没想到的匹配路径。我自己有一个习惯,所有新写驱动里只挂of_match_table,让匹配路径保持唯一,排查起来一眼就能定到问题所在。