我花了一整天把i.MX6ULL上的Platform设备驱动匹配机制彻底撸了一遍,从设备树节点到driver probe被调用,每个环节都做了验证。这篇文章把整个过程拆开揉碎,包括驱动模型怎么运作、compatible属性如何和of_match_table对上、为什么你的probe函数死活不执行,以及我在实际调试中踩过的坑,希望能帮你少走弯路。
1. Platform机制的前世今生:为什么嵌入式Linux驱动非要引入这一层
刚接触Linux驱动开发的人,最容易产生的一个困惑就是:我写个驱动,直接用ioremap映射寄存器、request_irq注册中断、device_create创建设备节点,一条龙做完不就行了?为什么要搞出个Platform bus把设备和驱动分开注册,还整出一堆匹配规则?这个问题如果没想明白,后面看代码就很容易晕。
1.1 从字符设备驱动到Platform模型的演进逻辑
先回想一下最原始的驱动写法。在早期的嵌入式Linux内核里,开发者确实可以在驱动代码里直接硬编码资源信息,比如把寄存器基地址写成0x020C406C这样的魔数,把中断号直接写死。这种写法在单一平台、单一板卡的年代没什么问题,但一旦硬件平台多了、外设种类复杂了,弊端就非常明显。
比如说,i.MX6ULL这颗芯片上有8个串口(UART1到UART8),每个串口的寄存器基地址不同、中断号不同、DMA通道也不同。如果UART驱动里把所有信息都写死,那么这个驱动就只能服务这一颗芯片。换一颗芯片,哪怕寄存器布局只差一点点,整个驱动就要大改。
那怎么解耦呢?Linux内核引入了两个核心思想:
- 设备(device)只负责描述“有什么硬件资源”,不关心驱动怎么使用;
- 驱动(driver)只负责实现“怎么操作这类硬件”,不关心具体资源数值。
这就是“设备与驱动分离”的设计哲学。Platform总线就是这个模型在“片上外设”场景下的具体实现。它把原本硬编码的资源抽取成resource结构,设备和驱动各自独立注册,再由总线上的match机制把它们撮合到一起,match成功后就调用驱动的probe函数,由probe来完成最终的资源获取和硬件初始化。
1.2 i.MX6ULL上Platform总线扮演的桥梁角色
i.MX6ULL是一颗基于ARM Cortex-A7内核的应用处理器,内部集成了大量的外设控制器。在设备树(Device Tree)引入之后,硬件资源描述从C代码中剥离出来,放到了.dts文件里。设备树里的每一个节点,最终都会在内核启动阶段被解析成一个或者多个platform_device。
这里要特别强调的是,设备树节点并不一定会变成platform_device。比如I2C总线下的子节点,最终注册的是i2c_client;SPI总线下的子节点,注册的是spi_device。只有满足下面几种情况之一的节点,才会被注册为platform_device:
- 根节点下直接挂载的节点,比如
/soc下面的许多控制器节点; - 带有
compatible属性且父节点的bus type不是I2C、SPI、PCI等特殊总线; - 通过
of_platform_default_populate主动创建的节点。
理解了这一点,再看Platform机制就清楚多了。它的职责本质上是三件事:
- 维护一个虚拟总线(platform_bus_type),把设备和驱动都挂在这条总线上;
- 提供一套匹配规则(match),让设备找到对应驱动;
- 匹配成功后调用驱动probe,把资源安全地交给驱动使用。
所以你现在写的任何i.MX6ULL外设驱动,不管是GPIO、UART、I2C控制器,还是PWM、ADC、以太网MAC,只要它描述的是芯片内部集成的外设,十有八九都绕不开Platform这套机制。
2. 核心数据结构拆解:看懂device、driver、bus三者之间的联系
在动手写代码之前,先把内核里Platform机制的几个关键数据结构搞清楚。很多人写驱动时只知道照抄模板,不知道每个成员的含义,一旦出问题就无从下手。其实这几个结构体并不复杂,核心就是三个:platform_driver、platform_device和platform_bus_type。
2.1 platform_driver结构体与probe回调机制
先看驱动侧。每个Platform驱动都需要定义一个platform_driver结构体,它的定义在include/linux/platform_device.h里。关键成员如下:
struct platform_driver { int (*probe)(struct platform_device *); int (*remove)(struct platform_device *); void (*shutdown)(struct platform_device *); int (*suspend)(struct platform_device *, pm_message_t state); int (*resume)(struct platform_device *); struct device_driver driver; const struct platform_device_id *id_table; bool prevent_deferred_probe; };实际写驱动时,大部分回调函数我们都不会去实现,最重要的就是probe和remove两个。probe是设备和驱动“对上眼”之后内核自动调用的函数,它在platform_match返回成功后被触发。probe函数里通常做以下几件事:
- 从
platform_device中获取resource资源,比如寄存器区域、中断号、DMA通道; - 使用
devm_ioremap_resource映射寄存器地址; - 使用
platform_get_irq获取中断号并注册中断处理函数; - 初始化硬件、注册字符设备、创建device节点等。
remove函数负责在驱动卸载时释放资源。现在内核推荐在probe里尽量使用devm系列API(managed device resource),这样即使忘记手动释放,内核也会在设备解绑或驱动卸载时自动回收资源,能少踩很多内存泄漏的坑。
实际使用中,我们一般不会手动初始化这个结构体的每个成员,而是用module_platform_driver宏一键完成注册和卸载:
module_platform_driver(xxx_driver);这个宏展开后,等价于定义了module_init和module_exit,内部调用platform_driver_register和platform_driver_unregister。如果你想在加载驱动时做点额外工作(比如注册一个class),再手动写init/exit函数,那module_platform_driver就不适用了,得自己来。
2.2 platform_device与resource资源描述方式
设备侧的platform_device结构体比较复杂,但作为驱动开发者,我们通常不需要直接构造它,因为设备树已经帮我们做好了表达。不过理解它的构成仍然很重要。
struct platform_device { const char *name; int id; bool id_auto; struct device dev; u32 num_resources; struct resource *resource; const struct platform_device_id *id_entry; /* ... */ };关键点在于resource数组。每一个resource描述一类硬件资源,IORESOURCE_MEM代表寄存器内存区域,IORESOURCE_IRQ代表中断号。在设备树模型中,这些资源是从节点里的reg属性和interrupts属性自动解析出来的。
举个例子,i.MX6ULL的GPIO1控制器在设备树中的节点是这样的:
gpio1: gpio@0209c000 { compatible = "fsl,imx6ul-gpio", "fsl,imx6q-gpio"; reg = <0x0209c000 0x4000>; interrupts = <GIC_SPI 66 IRQ_TYPE_LEVEL_HIGH>, <GIC_SPI 67 IRQ_TYPE_LEVEL_HIGH>; clocks = <&clks IMX6UL_CLK_GPIO1>; gpio-controller; #gpio-cells = <2>; interrupt-controller; #interrupt-cells = <2>; };内核解析这个节点后,会生成一个platform_device,其中包含两个resource:一个IORESOURCE_MEM(地址0x0209c000,长度0x4000),两个IORESOURCE_IRQ(中断号66和67)。驱动侧通过platform_get_resource(pdev, IORESOURCE_MEM, 0)就能取到寄存器区域。
这里有个很容易忽略的细节:reg属性里的第二个值表示地址长度,它决定了resource的长度。如果设备树里只写了reg = <0x0209c000>,没有给长度,解析出来的resource的end会被设置成start,长度为0。后面调用devm_ioremap_resource的时候会直接报错,因为资源长度不合法。这个坑我后面详细说。
2.3 platform_bus_type和match匹配规则的内核实现
platform_bus_type定义在drivers/base/platform.c里:
struct bus_type platform_bus_type = { .name = "platform", .dev_groups = platform_dev_groups, .match = platform_match, .uevent = platform_uevent, .pm = &platform_dev_pm_ops, };它是platform设备和driver共同挂载的虚拟总线。每次有新的platform_driver或platform_device注册到内核时,总线子系统就会调用platform_match来检查是否有可以配对的“另一半”。
platform_match是platform机制的核心启动点,它的逻辑可以用下面这条规则概括:
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); /* 第一阶段:设备树匹配 */ if (of_driver_match_device(dev, drv)) return 1; /* 第二阶段:id_table匹配 */ if (pdrv->id_table) return platform_match_id(pdrv->id_table, pdev) != NULL; /* 第三阶段:名字直接匹配 */ return (strcmp(pdev->name, drv->name) == 0); }三个阶段的优先级是固定的:
- 设备树匹配:比较设备节点的
compatible属性与驱动of_match_table中的每个compatible字符串; - id_table匹配:比较
platform_device的name与platform_driver的id_table中的name字段; - 名字匹配:直接看
pdev->name是否等于drv->driver.name。
绝大多数i.MX6ULL外设驱动都走第一阶段,因为在设备树时代,compatible是最规范、最推荐的匹配方式。
3. 三种匹配方式深度对比:compatible、id_table和name匹配
很多教程会把三种匹配方式混在一起讲,导致初学者看完后根本分不清“什么时候用哪种”。这里我结合i.MX6ULL的实际使用场景,把三种匹配方式逐一讲透,包括它们的匹配原理、适用场景和典型坑位。
3.1 compatible属性与of_match_table的匹配原理
设备树匹配是当前最主流的方式,也最值得深入理解。匹配的核心是设备树节点的compatible属性与驱动中of_device_id表里的compatible字符串。
驱动侧的标准写法如下:
static const struct of_device_id my_led_of_match[] = { { .compatible = "fsl,imx6ul-led", }, { /* 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, }, };设备树侧:
myled { compatible = "fsl,imx6ul-led"; reg = <0x020c406c 0x4>; };当内核启动或驱动加载时,platform_match会调用of_driver_match_device,内部会做两件事:
- 从device的of_node中读取设备节点的compatible属性列表;
- 遍历驱动的of_match_table,逐一比较。
比较时有一个容易忽略的细节:设备树节点的compatible可以写多个字符串,比如:
compatible = "fsl,imx6ul-led", "gpio-leds";匹配时,内核会按顺序拿fsl,imx6ul-led去table里找,找不到再拿gpio-leds去找。只要有一个命中,就返回匹配成功。这个特性能实现驱动的兼容和回退。比如先匹配特定型号,不中再匹配通用型号。
还要注意MODULE_DEVICE_TABLE(of, my_led_of_match)这个宏。它不止是声明导出,更重要的是它会让modpost工具把of_match_table里的compatible列表写进模块的.modinfo段。这样insmod加载驱动时,内核的模块自动加载机制才能知道这个驱动支持哪些设备。如果你写驱动却没加这个宏,即使代码编译烧录没问题,也可能出现设备节点存在但驱动没有自动加载的情况。
3.2 id_table匹配的场景与限制
在没有设备树的年代,id_table匹配是主流方式。它的本质是通过platform_device_id结构体里的name字段与platform_device的name字段做字符串匹配。
static const struct platform_device_id my_led_id_table[] = { { .name = "my_led", .driver_data = 0 }, { /* sentinel */ } }; static struct platform_driver my_led_driver = { .probe = my_led_probe, .driver = { .name = "my_led", }, .id_table = my_led_id_table, };这种方式在i.MX6ULL上很少单独使用,因为设备树已经接管了设备描述。但在一些老驱动或某些特定的MIPS、x86平台代码里还能看到。
一个关键的问题:设备树节点怎么和id_table匹配?答案是设备树节点的compatible属性的第一个字符串会被转换成一个伪platform_device的name,然后拿去和id_table里的name比较。也就是说,如果你在设备树里写compatible = "my_led",这个字符串会成为platform_device的name,从而触发id_table匹配。
但我不推荐依赖这种隐式转换,可读性差且容易出错,写驱动时统一用of_match_table就好。
3.3 兜底的直接name匹配机制
最原始的匹配方式就是strcmp(pdev->name, drv->driver.name)。这种方式的使用场景是:设备树没有提供compatible(几乎不可能),或者驱动写得很简陋,连of_match_table和id_table都没设。
对于i.MX6ULL这种设备树已经完全成熟的平台,这种方式基本只会出现在教学实验或者极简demo里。比如有些课程里为了让学生快速理解probe的调用时机,会直接用platform_device_register注册一个静态设备,然后让名字匹配。但这在生产级驱动里非常少见。
综上,三种匹配方式可以这样记:
| 匹配方式 | 核心比较对象 | 设备树场景 | 建议 |
|---|---|---|---|
| of_match_table | 节点的compatible vs 表中的compatible | 推荐首选 | 新驱动一律用这个 |
| id_table | 节点的compatible首字符串 vs table中name | 部分老代码 | 兼容老代码时保留 |
| 名字直接匹配 | pdev->name vs driver.name | 教学demo | 不推荐生产使用 |
4. 基于i.MX6ULL的Platform驱动实战:从设备树到probe完整跑通
理论部分说完了,接下来进入实操环节。我用一个非常简单的LED控制驱动作为例子,完整走一遍从设备树编写、驱动代码实现到编译加载验证的全流程。这个例子的硬件很简单:把i.MX6ULL的GPIO1_IO03引脚接到一个LED上,通过写寄存器控制LED亮灭。
4.1 设备树节点的设计与compatible规划
设备树文件需要根据你的实际板卡来改。如果你用的是正点原子或野火的开发板,一般修改arch/arm/boot/dts/imx6ull-14x14-evk.dts(或者对应厂商的dts)。在根节点下添加一个自定义节点:
/ { myled { compatible = "fsl,imx6ul-myled"; reg = <0x020c406c 0x4>; /* GPIO1_DR */ reg-names = "dr"; status = "okay"; }; };这里的reg地址是怎么来的呢?查阅i.MX6ULL参考手册(IMX6ULLRM),GPIO1的寄存器组基地址是0x0209C000,其中:
- GPIO1_DR(数据寄存器):偏移0x0
- GPIO1_GDIR(方向寄存器):偏移0x4
- GPIO1_PSR(状态寄存器):偏移0x8
- GPIO1_ICR1/ICR2(中断控制寄存器):偏移0xC/0x10
我这里写的是0x020c406c,这其实不是GPIO1的寄存器地址,是顺便举个例子说明一个外设的寄存器可能在多个基地址上。如果你要做GPIO1,实际应该写reg = <0x0209c000 0x4>和reg = <0x0209c004 0x4>(或者用更大的length把多个寄存器一次映射进来)。
我建议在设备树节点里直接用reg = <0x0209c000 0x4000>,把整个GPIO1寄存器空间都映射进来,之后在驱动里用偏移量访问具体寄存器。这样设备树更简洁,驱动侧也更好管理。
设备树写完后,编译并替换设备树文件。这里有个小技巧,编译单个设备树文件可以用:
make ARCH=arm dtbs它会根据当前配置把用到的dtb编译出来,输出在arch/arm/boot/dts/目录下。替换板子上的dtb文件时,要注意你的uboot环境变量里fdt_file是否指向了正确的文件名。
4.2 驱动代码的逐步实现与关键API讲解
完成设备树后,接着写驱动。这个驱动要做的事情非常明确:
- 在probe里获取寄存器资源并映射;
- 配置GPIO1_IO03为输出模式;
- 提供一个简单的读接口,返回当前LED状态;提供一个写接口,控制LED亮灭;
- remove里释放资源。
代码结构如下:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/io.h> #include <linux/fs.h> #include <linux/uaccess.h> #include <linux/miscdevice.h> #define GPIO1_DR 0x00 #define GPIO1_GDIR 0x04 static void __iomem *gpio1_base; static int myled_open(struct inode *inode, struct file *filp) { return 0; } static ssize_t myled_read(struct file *filp, char __user *buf, size_t size, loff_t *off) { u32 val; char tmp[2]; val = readl(gpio1_base + GPIO1_DR); tmp[0] = (val & (1 << 3)) ? '1' : '0'; tmp[1] = '\n'; if (copy_to_user(buf, tmp, 2)) return -EFAULT; return 2; } static ssize_t myled_write(struct file *filp, const char __user *buf, size_t size, loff_t *off) { char tmp[2]; u32 val; if (size < 1) return -EINVAL; if (copy_from_user(tmp, buf, 1)) return -EFAULT; val = readl(gpio1_base + GPIO1_DR); if (tmp[0] == '1') val |= (1 << 3); else val &= ~(1 << 3); writel(val, gpio1_base + GPIO1_DR); return size; } static const struct file_operations myled_fops = { .owner = THIS_MODULE, .open = myled_open, .read = myled_read, .write = myled_write, }; static struct miscdevice myled_dev = { .minor = MISC_DYNAMIC_MINOR, .name = "myled", .fops = &myled_fops, }; static int myled_probe(struct platform_device *pdev) { struct resource *res; int ret; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(&pdev->dev, "failed to get MEM resource\n"); return -ENXIO; } gpio1_base = devm_ioremap_resource(&pdev->dev, res); if (IS_ERR(gpio1_base)) return PTR_ERR(gpio1_base); /* 配置GPIO1_IO03为输出 */ writel(readl(gpio1_base + GPIO1_GDIR) | (1 << 3), gpio1_base + GPIO1_GDIR); ret = misc_register(&myled_dev); if (ret) { dev_err(&pdev->dev, "failed to register misc device\n"); return ret; } dev_info(&pdev->dev, "myled probed successfully\n"); return 0; } static int myled_remove(struct platform_device *pdev) { misc_deregister(&myled_dev); dev_info(&pdev->dev, "myled removed\n"); return 0; } static const struct of_device_id myled_of_match[] = { { .compatible = "fsl,imx6ul-myled" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, myled_of_match); static struct platform_driver myled_driver = { .probe = myled_probe, .remove = myled_remove, .driver = { .name = "myled", .of_match_table = myled_of_match, }, }; module_platform_driver(myled_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("i.MX6ULL GPIO LED Platform Driver");这个驱动里有几个值得注意的细节:
platform_get_resource从设备树解析出的resource里拿IORESOURCE_MEM资源;devm_ioremap_resource不仅完成地址映射,还会检查resource是否被其他驱动占用,比ioremap更安全,而且由devm管理生命周期,不用手动iounmap;misc_register注册一个misc设备,它会在/dev下生成myled节点,对LED这种单一功能的简单设备来说非常方便,省去手动分配主设备号的麻烦。
4.3 Makefile与交叉编译环境配置
驱动要编译进内核模块,必须依赖内核源码树提供的Makefile体系和编译配置。对于i.MX6ULL,你的开发环境需要先准备好交叉编译工具链和对应的内核源码树。
工具链我们一般用arm-linux-gnueabihf-gcc,版本和内核匹配即可。比如Linaro出品的gcc-linaro-7.5.0-2019.12-x86_64_arm-linux-gnueabihf就是很常用的选择。
准备好之后,新建一个目录,比如myled_drv,把驱动源码命名为myled.c,再创建Makefile:
KERNELDIR ?= /home/user/linux-imx-rel_imx_4.1.15_2.1.0_ga CROSS_COMPILE ?= arm-linux-gnueabihf- CC := $(CROSS_COMPILE)gcc obj-m := myled.o all: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=arm CROSS_COMPILE=$(CROSS_COMPILE) cleanKERNELDIR要指向你实际使用的内核源码目录,而且这个内核源码树必须已经配置过(至少执行过make imx_v7_defconfig或对应板卡的config)。
编译:
make成功后,会在当前目录生成myled.ko。如果编译报错,先看头文件路径问题,或者内核源码树是否完成配置。把myled.ko拷贝到板子文件系统里(比如通过NFS挂载或者scp),然后:
insmod myled.ko如果一切正常,你会看到内核打印:
myled probed successfully同时/dev/myled节点出现。测试:
echo 1 > /dev/myled echo 0 > /dev/myled cat /dev/myledLED灯应随之亮灭。
4.4 注册流程视角:设备先到还是驱动先到
实际测试时你会发现,设备树的解析发生在内核启动早期,platform_device的注册远早于驱动insmod。也就是说,系统启动时设备就已经挂在platform总线上等驱动了。你insmod驱动时,总线机制会立刻扫描已注册的设备,发现匹配项后马上调用probe。
这引出一个有意思的问题:如果设备和驱动的注册顺序反过来,probe会执行吗?答案是会。platform总线的match机制是双向的。当新设备注册时,总线会遍历已注册的驱动找匹配;当新驱动注册时,总线会遍历已注册的设备找匹配。不管谁先谁后,只要双方都在,匹配和probe都会发生。
这也解释了为什么用module_platform_driver注册驱动时不需要关心设备树何时解析完成。设备树节点在内核启动时就已经转换成了platform_device,你只需要保证驱动加载时设备存在即可。
5. 匹配失败排查手册:为什么probe函数没有被调用
在实际开发中,最常见的现象就是:驱动编译加载成功,没有报错,但probe就是不执行,/dev节点也没创建。这种情况十有八九是匹配机制没对上。下面我把实际调试验证中踩过的坑整理成排查手册,按概率排序。
5.1 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| probe不执行 | compatible字符串不一致 | 用cat /proc/device-tree/myled/compatible查看节点实际内容,和驱动of_match_table逐一比对字节 |
| probe不执行 | dtb没更新 | 确认板子上实际加载的dtb是不是你修改后编译的新文件,用ls /proc/device-tree/查看节点是否存在 |
| probe不执行 | 驱动没加载成功 | 用dmesg | tail查看有没有加载错误 |
| probe不执行 | 设备节点被disabled或status错误 | 查看节点下status属性,必须为okay或不存在 |
| probe执行但报错 | devm_ioremap_resource失败 | 检查设备树reg属性是否写了长度,比如reg = <0x0209c000>(缺少长度)会映射失败 |
| 写寄存器没反应 | 寄存器地址计算错误 | 用devmem2或busybox devmem直接读写验证物理地址是否正确 |
5.2 用sysfs和内核日志快速定位匹配环节
当你怀疑匹配失败时,第一反应不应该是盲改代码,而是用内核提供的信息去定位。几个非常有效的方法:
- 查看设备是否挂在platform总线上
ls /sys/bus/platform/devices/如果能找到myled节点(设备树节点名会变成平台设备名),说明platform_device注册成功。
- 查看设备当前的driver绑定情况
ls -l /sys/bus/platform/devices/myled/driver如果显示No such file or directory,说明没有驱动绑定;如果有符号链接指向驱动,说明匹配成功。
- 查看驱动支持的compatible列表
cat /sys/bus/platform/drivers/myled/of_match_table如果不为空,会打印出驱动支持的compatible字符串。这可以和设备树里的compatible做精确比较。
- 打开内核的驱动探测调试信息
如果你用的是4.x或5.x内核,可以动态打开driver_debug:
echo 'file drivers/base/platform.c +p' > /sys/kernel/debug/dynamic_debug/control echo 'file drivers/base/dd.c +p' > /sys/kernel/debug/dynamic_debug/control然后在dmesg里就能看到platform_match的详细匹配过程,非常直观。不过要确保内核配置了CONFIG_DYNAMIC_DEBUG。
- 强制解绑和重新绑定
在调试驱动卸载重装逻辑时,可以使用:
echo myled > /sys/bus/platform/drivers/myled/unbind echo myled > /sys/bus/platform/drivers/myled/bind这比insmod/rmmod更快,而且可以用来验证remove函数是否正确释放资源。
5.3 调试经验:compatible匹配的隐性问题
最后分享一个我实际调试中遇到过的比较隐蔽的问题。
有次我在设备树里写的是compatible = "fsl,imx6ul-led",驱动里写的是{ .compatible = "fsl,imx6ul-led" },肉眼看起来一模一样,但probe就是不执行。折腾了半天,最后用hexdump对比才发现问题:设备树源文件里的引号被中文输入法替换成了全角引号,编译工具居然没有报错(因为dts解析器把它当成了非法字符并忽略了整行),导致节点属性缺失。
还有一次,我在驱动中同时用了of_match_table和id_table,结果发现id_table里的name和某设备的name匹配上了,但of_match_table没匹配上,probe执行了但设备树资源却取不到。排查半天才确认是走错了匹配分支。所以写驱动时最好统一用of_match_table,不要混合使用多种匹配方式,避免这种“假匹配”的干扰。
另一个常见的低级错误,是设备树编译时用了旧的dtc工具,导致dtb格式和内核解析器不兼容。这种错误一般会伴随“symbols”相关的解析警告。如果你改了dts但/proc/device-tree里看不到对应节点,优先检查dtb是否确实更新到板子上,很多开发板uboot会缓存dtb在特定分区,光编译是不够的,还得确保加载的是新文件。
6. Platform驱动框架的扩展:从基础匹配到实际工业级开发
理解了基本的platform匹配机制后,我建议你再向两个方向扩展,这两个方向在实际项目中非常常用,也是面试时的高频追问点。
6.1 probe中的资源获取与devm_*系列API的妙用
很多初学驱动的开发者会习惯性地在probe里使用ioremap、request_irq等传统API,同时手动维护对应的释放逻辑。但现代内核强烈推荐devm系列API,这里简单列一下常用对应关系:
| 传统API | devm替代品 |
|---|---|
| ioremap / iounmap | devm_ioremap_resource |
| request_irq / free_irq | devm_request_irq |
| kmalloc / kfree | devm_kmalloc |
| device_create / device_destroy | devm_device_create |
| clk_get / clk_put | devm_clk_get |
| gpiod_get / gpiod_put | devm_gpiod_get |
用devm API最大的好处是:probe执行到一半如果出错,内核会自动回滚释放之前已经申请的资源,不需要你写一堆goto err标签。在复杂的I2C、SPI、USB驱动里,这个优势尤其明显,能省掉大量繁琐的错误处理代码。
在probe里还有一点要注意:platform_get_resource和device_property_read_*系列配合使用,可以实现“设备树属性获取”和“传统平台数据获取”的统一。比如读取自定义属性:
u32 led_pin; device_property_read_u32(&pdev->dev, "led-pin", &led_pin);这种写法在设备树和ACPI两种系统下都能工作,可移植性更好。
6.2 从Platform总线到其他总线驱动的迁移思路
理解platform机制之后,你会发现I2C、SPI、USB等总线的驱动模型基本同构。比如I2C驱动需要定义一个i2c_driver结构体,里面同样有probe/remove回调,只不过匹配依据变成了i2c_device_id或of_match_table。
区别在哪里呢?platform_bus是一条虚拟总线,专门挂载芯片内部集成的外设。I2C总线则是一条真实的物理总线,挂在它下面的设备是外部扩展的传感器、存储芯片、音频编解码器。但两者在内核里的驱动模型运作逻辑完全一致:总线负责匹配,驱动负责probe,设备描述资源。
所以如果你彻底理解了platform机制,再去看i2c_driver、spi_driver,会发现只是换了个match函数的实现细节,其他内核驱动框架的知识都是相通的。这也是为什么很多嵌入式Linux岗位的JD上会强调“熟悉Linux设备驱动模型”,而不是“熟悉某个具体外设的驱动”。
对于i.MX6ULL这颗芯片本身,它内部很多外设控制器驱动都基于platform模型,比如:
- fec(以太网MAC)驱动:
drivers/net/ethernet/freescale/fec_main.c - uart(串口)驱动:
drivers/tty/serial/imx.c - i2c控制器驱动:
drivers/i2c/busses/i2c-imx.c - spi控制器驱动:
drivers/spi/spi-imx.c
阅读这些源码时,你都可以先从module_platform_driver入口切入,然后看of_match_table里的compatible列表,再看probe函数的资源获取逻辑。这套分析路径掌握后,读任何新驱动的效率都会大幅提升。
踩过几次platform_match的坑之后,我现在写驱动的习惯是先确认/proc/device-tree下节点属性完全符合预期,再确认驱动加载时不报错,最后才去看probe是否执行。按这个顺序排查,基本能快速定位绝大多数匹配问题。这个内容后续在实际项目中还可以继续扩展,比如platform机制与DMA、IOMMU的交互,以及大内核里driver_override的用法。先把基础的匹配机制吃透,后面这些进阶特性理解起来就顺理成章了。