做ARM Linux驱动开发尤其你上手是i.MX6ULL这块板子的话第一步往往不是点灯而是先搞懂一个东西——Platform设备与驱动匹配机制。平台总线是Linux设备模型在嵌入式端落地的最重要机制我见过太多新手把platform_driver代码抄下来insmod后却发现probe根本没被调用设备树改了也没反应最后卡在“为什么我的驱动不执行”这个坎上。这篇文章我不打算给你背源码而是从i.MX6ULL实际开发的角度把整套匹配机制拆开揉碎从设备树节点到platform_device从platform_driver注册到probe触发再到实操验证和踩坑排查一条线讲清楚。适合刚接触Linux驱动、准备在i.MX6ULL上写字符设备或者外设驱动的同学也适合那些被“驱动总是匹配不上”折磨了很久的兄弟们。1. 从“驱动硬编码”说起为什么要设计平台总线机制1.1 老式驱动的困局在Linux 2.6早期甚至更早的内核里写一个板载外设驱动往往是这样干的直接在驱动代码里硬编码寄存器地址、中断号、时钟信息。比如我要驱动i.MX6ULL上的一个UART我就在驱动里写0x02020000这样的地址再写死中断号。听起来很直接但问题非常致命。一个驱动如果硬绑定了“某个开发板上的某个外设”那换一块板子、换一个引脚、换一个中断就得改驱动源码重新编译。厂家多、板子多、外设连接方式千奇百怪Linux内核如果靠这种“驱动里写死硬件信息”的路子走下去早就被各路嵌入式板卡淹没了。更麻烦的是驱动要管理设备状态、设备要匹配驱动这两个东西如果揉在一个文件里整个内核的模块化和可复用性就是一句空话。设备是硬件实体驱动是操作逻辑它们本质上是两套生命周期需要在运行时“握手”才能协同工作。于是内核引入了设备模型把设备device、驱动driver、总线bus三者抽象出来。1.2 平台总线到底解决了什么问题Linux设备模型中总线负责匹配设备和驱动。PCI设备有PCI总线USB设备有USB总线I2C设备挂在I2C总线上SPI设备挂在SPI总线上。可i.MX6ULL里大量的板载外设比如GPIO控制器、UART、eLCDIF、SDIO控制器等它们既不在PCI上也不在USB上而是直接挂在CPU的内存映射总线上。这类设备怎么纳入设备模型内核的做法就是造了一条虚拟总线叫platform_bus。凡是直接挂在CPU总线上的设备统一注册成platform_device相应驱动注册成platform_driver然后由platform_bus来做匹配。也就是说Platform机制本质上就是“把设备树描述的硬件信息与驱动代码里的操作逻辑通过一条虚拟总线绑定起来”。设备只管描述自己是谁、有什么资源驱动只管声明自己能处理什么样的设备剩下的匹配动作交给内核完成。i.MX6ULL这种片上外设极多的SoC恰恰是platform机制最典型的应用场景。1.3 i.MX6ULL上的总线长什么样在i.MX6ULL系统起来之后你可以在板子上执行ls /sys/bus/platform看看里面会有devices和drivers两个目录。devices下挂着SoC内部几乎所有外设对应的platform_devicedrivers下则是内核已注册的platofrm_driver。启动日志里你会看到大量类似platform 20c0000.uart: probe by serial-imx driver的信息这就是platform匹配成功、驱动probe被调用的真实记录。我在刚开始学的时候一直以为probe是驱动里面主动调用出来的后来才明白probe是匹配成功后由内核回调进来的。理解这个顺序是理解整个机制的关键。2. 匹配是怎么发生的设备树节点到platform_device再到driver绑定2.1 设备树节点怎么变成platform_device现在写arm Linux驱动设备信息基本只有一个来源——设备树。i.MX6ULL的dtb文件在启动时被内核解析成一颗device_node树每一个设备节点都对应一个struct device_node。但这还不够内核要把这些节点转换成可以被设备模型管理的struct platform_device。这个过程由of_platform_default_populate这类函数完成。它扫描设备树节点对每个使能且带有compatible属性的节点分配一个platform_device然后调用platform_device_add把它挂到platform总线上。要注意这里“使能”是指节点的status属性如果没有status或者status为okay节点才会被创建成平台设备如果节点里写了status disabled这个节点就不会生成platform_device。很多同学设备树节点写完、驱动也加载了但probe不执行先检查一下这个节点有没有被内核真正创建出来。设备树的reg属性会转换成platform_device的IORESOURCE_MEM资源interrupts属性会转换成IORESOURCE_IRQ资源。所以你在驱动里用platform_get_resource(pdev, IORESOURCE_MEM, 0)拿到的地址其实就是设备树reg里的内容。2.2 platform_driver注册时的匹配入口驱动这边当我们调用platform_driver_register时内核会把platform_driver里的driver成员挂到platform总线上。总线维护一个设备链表和一个驱动链表只要两边有变动就会触发一次匹配尝试。函数入口在drivers/base/platform.c里的platform_match。这个函数就是整个匹配机制的核心判断逻辑。它接收两个参数一个是设备一个是驱动返回1表示匹配成功返回0表示不匹配。匹配成功后内核会调用device_bind_driver把驱动和设备绑定然后再调用驱动的probe函数。probe是驱动真正初始化的地方它会拿到设备树传递的资源初始化硬件注册字符设备、中断处理等。2.3 platform_match的完整判据和优先级platform_match内部的判断顺序是有讲究的我按实际优先级整理如下优先级匹配方式说明1driver_override调试用的强制绑定通过sysfs接口指定设备只能绑定某个驱动2OF匹配设备树compatible属性与驱动的of_match_table比对3ACPI匹配x86/ARM64等使用ACPI描述设备的平台使用i.MX6ULL基本不涉及4id_table匹配驱动的id_table与设备名比对5驱动名/设备名匹配老式兜底直接比较驱动name和设备name正常情况下我们做i.MX6ULL驱动主要走第二条OF匹配。这也是写设备树时那个compatible字符串为什么如此重要的原因。2.4 为什么最终优先用compatible匹配compatible匹配的核心是设备树节点的compatible属性值与驱动里of_device_id数组中的compatible字符串是否一致。这个字符串有点像身份证上的姓名住址组合一般格式是“厂商前缀,设备型号”比如fsl,imx6ull-gpio或myvendor,gpio-key。我记得有同学把compatible写成my_gpio_key这种风格结果和of_device_id里myvendor,gpio-key对不上匹配一直失败。这里有个隐藏细节of_driver_match_device匹配成功除了compatible要完全一致还需要驱动的of_match_table非空并且以空项结尾。只写了compatible但忘记在driver里声明of_match_table等于白搭。在较老的内核中如果不填of_match_table驱动还可以通过“驱动名与设备名相同”的方式匹配这也是很多老驱动只用.driver.name xxx就能工作的原因。但在设备树普及之后正规做法就是compatible匹配因为它和设备描述解耦不依赖节点名。3. i.MX6ULL实操写一个完整的platform驱动并验证匹配3.1 编译环境准备和工程布局我以i.MX6ULL开发板为例内核版本按4.x或5.x系列交叉编译工具链通常叫arm-linux-gnueabihf-。先准备一个干净的内核源码树配置好默认配置文件确认编译能通过。驱动模块不需要全量编译内核只需要内核源码树里有编译好的代码比如make zImage后生成的文件然后就可以用内核目录下的Makefile来编外部模块。下面是我的驱动工程目录布局mykey_drv/ ├── mykey_drv.c ├── dts/ │ └── mykey.dts └── MakefileMakefile内容很简单核心就两行obj-m : mykey_drv.o KDIR : /path/to/i.mx6ull/kernel all: $(MAKE) -C $(KDIR) M$(PWD) modules clean: $(MAKE) -C $(KDIR) M$(PWD) clean执行make生成mykey_drv.ko后续全部在板子上验证。3.2 设备树节点怎么写以按键为例我这里直接用一个板载GPIO按键来演示platform匹配过程。设备树节点通常放在根节点下或者某个总线节点下写法如下key0 { compatible myvendor,gpio-key; pinctrl-names default; pinctrl-0 pinctrl_key; gpios gpio1 18 GPIO_ACTIVE_LOW; };同时在iomuxc节点下补充引脚复用定义iomuxc { pinctrl_key: keygrp { fsl,pins MX6UL_PAD_UART1_CTS_B__GPIO1_IO18 0x17059 ; }; };这里compatible我用的是myvendor,gpio-key这个字符串会和驱动的of_device_id里的compatible对应。注意节点名key0和兼容字符串没有任何强制关系节点名只是调试时方便识别匹配靠的是compatible属性。gpios gpio1 18 GPIO_ACTIVE_LOW表示GPIO1组的第18号引脚低电平有效。如果你的板子按键接在别的引脚按实际原理图改。设备树编译命令一般是make dtbs或者直接在BSP里用dtc工具编译改好的dts生成dtb后烧写到板子的boot分区。i.MX6ULL开发板烧写方式各不相同这里不展开重点是确保内核加载的是你修改后的dtb。3.3 platform驱动代码框架从probe到remove接下来是驱动端。我写一个完整的按键platform驱动代码量不大但把probe、remove、of_match_table、gpiod操作、中断注册都串起来。#include linux/module.h #include linux/platform_device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/interrupt.h #include linux/of_gpio.h static struct gpio_desc *key_gpio; static irqreturn_t key_isr(int irq, void *dev_id) { pr_info(key pressed, irq%d\n, irq); return IRQ_HANDLED; } static int mykey_probe(struct platform_device *pdev) { struct device *dev pdev-dev; int irq, ret; key_gpio devm_gpiod_get(dev, NULL, GPIOD_IN); if (IS_ERR(key_gpio)) { dev_err(dev, failed to get gpio\n); return PTR_ERR(key_gpio); } irq gpiod_to_irq(key_gpio); if (irq 0) { dev_err(dev, failed to get irq\n); return irq; } ret request_irq(irq, key_isr, IRQF_TRIGGER_FALLING, mykey, NULL); if (ret) { dev_err(dev, request_irq failed: %d\n, ret); return ret; } dev_info(dev, mykey probed, irq%d\n, irq); return 0; } static int mykey_remove(struct platform_device *pdev) { int irq gpiod_to_irq(key_gpio); free_irq(irq, NULL); dev_info(pdev-dev, mykey removed\n); return 0; } static const struct of_device_id mykey_of_match[] { { .compatible myvendor,gpio-key }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mykey_of_match); static struct platform_driver mykey_driver { .probe mykey_probe, .remove mykey_remove, .driver { .name mykey, .of_match_table mykey_of_match, }, }; module_platform_driver(mykey_driver); MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(i.MX6ULL platform match key driver);重点看两个地方mykey_of_match数组定义了支持的compatible字符串最后的空项{ /* sentinel */ }绝对不能省略否则内核在遍历匹配表时没有终止条件会越界读内存。module_platform_driver宏则帮我们定义了module_init和module_exit内部调用platform_driver_register和platform_driver_unregister。3.4 有寄存器和中断的外设怎么写补充platform_get_resource很多外设不像按键这么简单它们在SoC内部有寄存器地址和中断号设备树节点里会有reg和interrupts属性。我当时第一次写这类驱动也踩了坑以为寄存器地址要自己在驱动里用ioremap硬编码其实不用的。设备树节点示例myperiph0209c000 { compatible myvendor,my-periph; reg 0x0209c000 0x1000; interrupts GIC_SPI 74 IRQ_TYPE_LEVEL_LOW; status okay; };驱动里在probe中获取资源和地址映射的方式如下static int myperiph_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; int irq; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENODEV; base devm_ioremap(pdev-dev, res-start, resource_size(res)); if (IS_ERR(base)) return PTR_ERR(base); irq platform_get_irq(pdev, 0); if (irq 0) return irq; dev_info(pdev-dev, periph probed, base%px, irq%d\n, base, irq); return 0; }这里有个容易忽略的点platform_get_irq(pdev, 0)在较新内核里如果拿不到中断会返回负数而且会打印错误日志。不要再用if (!irq)来判断这是老代码的坏习惯因为中断号0可能是合法的。3.5 加载、验证与卸载dmesg和sysfs双保险驱动编译成mykey_drv.ko后传到板子上依次执行insmod mykey_drv.ko dmesg | tail -10如果一切正常你会看到类似这样的打印mykey mykey.0: mykey probed, irq52这个输出不是凭空来的是probe被调用后dev_info打印的。注意设备名的显示格式有时候是mykey.0后面跟的序号是从设备树节点转换过来时自动加的。再确认一层用sysfs看绑定关系ls -l /sys/bus/platform/devices/key0/driver正常情况下这个软链接会指向驱动的sysfs目录比如/sys/bus/platform/drivers/mykey。如果这个软链接不存在说明设备没有绑定成功probe没跑或者匹配失败。再按一下按键dmesg里会出现key pressed, irqxx这说明中断也正常。最后卸载rmmod mykey_drv dmesg | tail -5能看到mykey removed说明remove回调被正确调用。到这里一次完整的设备与驱动匹配生命周期就走完了。4. 实战中的坑匹配失败、probe不执行、资源冲突排查清单4.1 现象一驱动注册成功但probe没调用这是我被问过最多的问题。驱动insmod不报错也就sysfs里能看到driver节点但probe就是不执行。第一步先确认设备树节点有没有生成platform_device。查看方法很直接ls /sys/bus/platform/devices/ | grep key0如果列表里根本没有key0说明设备树节点没有被解析成platform_device。常见原因有dtb没有烧录进去板子加载的还是旧设备树节点status disabled没有删掉或改为okay节点没有放在能被of_platform扫描到的位置如果key0存在再检查驱动的compatible是否一致。我见过一个很隐蔽的错误dts里写的是myvendor,gpio-key驱动of_match_table里写的是myvendor,gpio-key看起来一样但dts文件里字符串末尾多了个空格dtc编译不会报错匹配却永远失败。用十六进制方式查看最靠谱cat /proc/device-tree/key0/compatible | xxd一眼就能看出字符串里有没有隐藏字符。4.2 现象二设备树改了却完全不生效改动设备树后很多人直接把新编译的dtb放到SD卡或者emmc分区但bootloader可能还在用旧dtb。我在野火和正点原子的板子上都踩过这个坑解决方法是重启后先确认内核实际加载的dtb版本ls /proc/device-tree/如果看不到你新加的节点基本就是dtb没替换成功。另外有些开发板uboot会通过环境变量指定dtb路径你改了路径或者文件名uboot变量没跟上同样加载不到。还有一点i.MX6ULL的某些开发板nand启动和sd启动的dtb存放位置不一样烧写时一定要区分。4.3 现象三多个板级设备同时匹配同一个驱动一个平台驱动可以通过of_match_table匹配多个设备。比如我写一个驱动同时支持两个按键节点key0 { compatible myvendor,gpio-key; gpios gpio1 18 GPIO_ACTIVE_LOW; }; key1 { compatible myvendor,gpio-key; gpios gpio1 19 GPIO_ACTIVE_LOW; };驱动加载后probe会被调用两次分别传入对应的platform_device。但你要注意驱动里如果用了全局变量保存gpio_desc第二次probe就把第一次覆盖了。正确做法是用platform_set_drvdata(pdev, key_gpio)保存每个设备自己的数据remove里再用platform_get_drvdata(pdev)取回来。这种多设备共享一个驱动的模式在实际项目里非常常见全局变量一时爽多设备就火葬场。4.4 现象四ioremap或request_irq返回失败probe调用了但返回错误码那问题一般出在资源获取上。platform_get_resource返回NULL通常是因为设备树节点里没有reg属性或者index对不上。一个节点有多个reg时用index 0、1分别获取。request_irq失败常见原因是中断号被其他驱动占用了。比如你设备树里写的中断号和内核里某个现成驱动冲突或者你在probe里请求中断前没有释放同IRQ的旧驱动。可以先看/proc/interrupts确认这个中断是不是已经被占用。另外如果GPIO没有正确配置成中断模式gpiod_to_irq也可能返回错误。这时检查pinctrl部分确保引脚复用成GPIO功能同时关闭上下拉、设定正确的电气属性。4.5 常见问题速查表我把平时开发中积累的排查经验整理成一个速查表遇到问题直接对号入座现象可能原因排查步骤probe不执行设备树节点未生成platform_devicels /sys/bus/platform/devices/确认节点存在probe不执行compatible不匹配xxd /proc/device-tree/xx/compatible对比驱动of_match_tableprobe不执行节点status被disabled确认status为okay或删除该属性驱动模块加载报错Unknown symbol依赖的符号未导出modprobe加载依赖模块或检查内核配置加载后设备节点driver软链接不存在匹配失败或probe返回错误dmesg查看详细错误日志ioremap后访问系统崩溃寄存器地址写错或长度不足对照芯片手册确认reg范围不要跨界访问request_irq失败中断号冲突cat /proc/interrupts排查占用情况GPIO获取失败pinctrl配置错误或引脚下拉冲突检查iomuxc节点适当调整电气属性5. 进阶玩法driver_override、模块自动加载与驱动生命周期控制5.1 driver_override强制绑定的后门技巧正常情况下设备与驱动匹配由内核决定但调试时经常有“我想让这个设备强行绑定到某个驱动”的需求。内核提供了一个后门叫driver_override。在sysfs里每个platform_device节点下都有driver_override文件往里面写一个驱动名匹配时就会优先返回该驱动。使用方式如下echo mykey /sys/bus/platform/devices/key0/driver_override echo key0 /sys/bus/platform/drivers/mykey/bind这个特性在我调试一些初始化顺序问题的时候很管用。比如某个驱动A匹配上了一个设备但我希望它匹配驱动B直接修改dts重新编译太慢用driver_override几秒钟就能测试。不过要注意driver_override设置后无论compatible匹配结果如何设备只会绑定到指定驱动的name。如果驱动不存在设备就保持无驱动状态系统不会自动再尝试其他驱动。5.2 模块自动加载modalias与modprobe配合很多人写驱动用insmod手动加载这没问题但产品化之后总不能让客户每次开机都手动insmod。Linux的模块自动加载机制依赖于modalias。前面代码里有一行MODULE_DEVICE_TABLE(of, mykey_of_match)这行宏非常关键。它会在编译时把of_match_table里的compatible信息导出到模块的modalias字符串中类似of:NmykeyTNULLCmyvendor,gpio-key。内核在启动过程中发现设备树节点后会生成设备的uevent其中包含MODALIAS变量然后通过modprobe根据这个变量去查找对应模块。这也就是说只要你的驱动编译成模块、安装到文件系统对应的/lib/modules/目录下并且执行了depmod生成了modules.alias文件系统启动时检测到compatible为myvendor,gpio-key的节点就会自动加载你的驱动模块。不需要任何额外的手动启动脚本非常优雅。5.3 module_platform_driver到底帮你做了什么module_platform_driver(mykey_driver)这个宏展开后相当于static int __init mykey_drv_init(void) { return platform_driver_register(mykey_driver); } static void __exit mykey_drv_exit(void) { platform_driver_unregister(mykey_driver); } module_init(mykey_drv_init); module_exit(mykey_drv_exit);它把重复的init/exit模板代码封装起来了。从这里也能看出平台驱动的本质逻辑就是模块加载时注册driver到platform总线总线遍历已有设备尝试匹配匹配成功调用probe模块卸载时从platform总线注销driver有绑定关系的设备会解绑并调用remove。另外提一下platform_driver_probe这个接口和platform_driver_register的区别是注册后只会在当前时刻匹配一次之后新出现的设备不会再绑定这个驱动。它常被用在一个驱动不希望被热插拔设备绑定的场景。但注意platform_driver_probe要求probe不能在模块卸载后被调用所以probe函数还需要用__init修饰看起来简单实际要注意的细节更多我建议新手统一用module_platform_driver稳。5.4 从i.MX6ULL走向更复杂的SoC理解了Platform机制之后你再去看i.MX6ULL其它外设驱动会发现套路都一样。UART、I2C控制器、SPI控制器、PWM、看门狗基本都是platform_driver of_match_tableprobe里platform_get_resourceioremap最后注册子设备或者子总线。到i.MX8M、RK3568这类更复杂的SoC上Platform的基本用法没有变化只是外围子系统更多、设备树层级更复杂。比如I2C控制器本身是一个platform_device它注册的I2C adapter总线上又挂着许多i2c_client这些client又和i2c_driver进行另一套匹配。但从设备模型设计思路上讲和Platform是同构的总线 设备 驱动匹配成功进proberemove时清理资源。你在i.MX6ULL上把Platform这套玩明白以后学I2C、SPI、PCIe驱动都会快很多因为这些总线的匹配机制本质上都是同一套模型的变体。我在实际调试中最快确认匹配是否成功的方法就是看/sys/bus/platform/devices/下设备节点的driver软链接是否出现而不是盯着dmesg翻半天。另外每次改设备树之前先备份一份原dtb出了问题能快速回滚。平台驱动调试初期建议先在probe函数第一行加打印确认基础链路通了再往后加业务逻辑一次只改一个变量别蒙头调。这套方法我已经用了很多年希望能帮你少走弯路。