拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Linux Platform总线匹配机制详解:从设备树到驱动probe

1. 为什么Linux要搞出一套Platform匹配机制很多刚从单片机转过来做嵌入式Linux的朋友第一次接触到platform_driver、platform_device、device tree这些概念时整个人是懵的。我当初也一样——明明是点个灯、读个按键直接操作寄存器不就行了为什么内核非要绕一大圈搞出一个叫Platform总线的东西还整出device和driver两端让它们匹配先说结论这套机制的核心目的有两个一是解耦二是自动管理。解耦怎么理解在没有设备树、没有platform模型的老式内核里比如早期的ARM Linux板级文件board-xxx.c里直接硬编码了设备地址、中断号、GPIO编号然后驱动里也硬编码一份。结果就是换一块板子驱动代码要跟着改换一个外设板级文件也要跟着动。代码跟硬件死死绑在一起一种板卡一套代码根本没法维护。Platform机制做的事就是把设备有什么资源和驱动怎么操作设备这两件事彻底分开。设备侧只负责描述我叫什么名字、我的寄存器基地址在哪、中断号是多少、用到了哪个GPIO。驱动侧只负责描述我能处理什么设备、我该怎么操作这些寄存器。两者都注册到内核里由Platform总线来判断这个驱动能不能处理这个设备能处理就把驱动里的probe函数调用起来把设备资源作为参数传进去。自动管理怎么理解你想想如果设备信息写在驱动里那设备拔插、热移除、电源管理这些事驱动都要自己处理。而通过device/driver模型内核的电源管理框架、热插拔框架、sysfs文件系统全都建立在这层抽象之上。设备什么时候被找到、什么时候被移除驱动无需关心框架会自动调用对应的回调函数。i.MX6ULL作为NXP i.MX6家族里的一颗Cortex-A7内核芯片在工业控制、物联网网关领域用得非常多它的Linux BSP也是完全基于设备树和设备模型来组织的。搞清楚Platform匹配机制基本就等于拿到了理解这颗芯片所有外设驱动的钥匙。接下来我会从环境准备开始把设备侧、驱动侧、内核匹配源码、常见调试手段一条线串起来讲。2. 前置基础i.MX6ULL开发环境与内核目录结构2.1 实验平台选型与内核版本选择我手头用的是一块正点原子的Alpha开发板核心板搭载i.MX6ULL主频528MHz256MB DDR3L256MB NAND或4GB EMMC。内核版本用的是NXP官方维护的4.1.15分支这也是i.MX6ULL最常见的内核版本。如果你用的是其他厂家的板子也没关系只要是i.MX6ULL内核代码路径基本一致。内核源码解压之后重点关注这几个目录arch/arm/boot/dts/ # 设备树源文件imx6ull-*.dts 和 imx6ull-*.dtsi drivers/base/platform.c # Platform总线核心代码 drivers/base/dd.c # device/driver 绑定核心逻辑 include/linux/platform_device.h # platform_device 相关API include/linux/of_device.h # 设备树相关API这里提醒一点很多初学者喜欢直接在ubuntu上apt装一个linux-source就开始改代码这跟实际的嵌入式内核开发完全是两回事。嵌入式开发必须使用芯片厂商提供或你自己配置过的内核源码树因为涉及大量板级配置、驱动裁剪和交叉编译工具链跟PC上通用的内核源码目录结构虽然相似但实际编译、烧录、调试方式完全不同。2.2 编译内核和设备树的基础操作以我使用的4.1.15内核为例编译之前先确认交叉编译工具链已经装好。我一般用arm-linux-gnueabihf-gcc版本选gcc-linaro-4.9.4或者gcc-arm-8.3都可以只要内核能正常编过就行。export ARCHarm export CROSS_COMPILEarm-linux-gnueabihf- make distclean make imx_v7_defconfig # i.MX6ULL 默认配置 make -j4编译完成后在arch/arm/boot/目录下会生成zImage设备树文件在arch/arm/boot/dts/目录下生成imx6ull-14x14-evk.dtb。如果你的板子和评估板引脚定义有差异需要先修改对应的dts文件再编译。编译设备树的命令是make dtbs这个命令会编译所有使能了的设备树源文件。实际开发中我习惯单独编译某一个dtb更快make imx6ull-14x14-evk.dtb2.3 tftp/nfs调试环境是必须的做驱动开发如果每次改一点代码都要重新烧写SD卡或者EMMC效率太低。我的做法是搭一套tftpnfs的调试环境内核zImage和设备树dtb通过tftp从服务器下载根文件系统通过nfs挂载。这样每次改完内核或驱动模块只需要重新编译然后把新文件拷贝到tftp目录重启开发板即可整个过程不到一分钟。在uboot里设置环境变量setenv bootargs consolettymxc0,115200 root/dev/nfs nfsroot192.168.1.100:/opt/nfsroot ip192.168.1.101:192.168.1.100:192.168.1.1:255.255.255.0::eth0:off setenv bootcmd tftp 80800000 zImage; tftp 83000000 imx6ull-14x14-evk.dtb; bootz 80800000 - 83000000 saveenv这里有个经验之谈在调试platform驱动时如果设备树做了修改一定要确认dtb真的更新到了tftp目录并确认uboot加载的也是新文件。我踩过好几次坑改了dts结果发现板子还是旧行为排查半天发现是tftp服务端文件缓存或者uboot环境变量里加载了错误的dtb路径。3. 设备树侧设备信息是如何被挂上Platform总线的3.1 设备树节点是如何变成一个platform_device的设备树的作用就是告诉内核硬件上有哪些设备它们各自占用了哪些资源。以我这次实验要用的一个GPIO按键为例设备树节点是这么写的/ { gpio_keys_100ask { compatible 100ask,gpio-key; pinctrl-names default; pinctrl-0 pinctrl_gpio_key; key-gpio gpio5 1 GPIO_ACTIVE_LOW; status okay; }; };内核在启动阶段会解析整个设备树挨个节点检查这个节点对应的设备在总线上有没有驱动能处理它对于挂在自身总线上比如i2c、spi总线的设备设备节点会被交给对应总线去处理而对于那些不挂在任何标准总线上的设备比如GPIO按键、LED、外部内存映射设备它们会被挂到Platform总线上成为platform_device。这个过程涉及一个具体的源码流程。在启动早期内核调用of_platform_default_populate_init()定义在drivers/of/platform.c它会遍历设备树根节点下的所有子节点对每个节点调用of_platform_bus_create()创建对应的platform_device。这里有一个细节如果一个节点的compatible属性里有simple-bus关键字内核会递归处理它的子节点把子节点也创建成platform_device。static int of_platform_bus_create(struct device_node *bus, const struct of_device_id *matches, struct device *parent, bool strict) { // 创建当前的platform_device dev of_platform_device_create_pdata(bus); // 如果节点是simple-bus递归创建子节点 if (of_node_check_flag(bus, OF_POPULATED_BUS)) { for_each_child_of_node(bus, child) { of_platform_bus_create(child, matches, dev-dev, strict); } } }所以设备树里定义的节点在内核启动阶段就被物化成了一个个platform_device对象注册到了Platform总线上。这一步不需要驱动参与只要设备树节点存在设备就会被创建。3.2 资源信息是怎么被驱动读取的设备树节点创建成platform_device之后节点里的各种属性会被解析成两种形式一种是struct resource结构体数组主要存放寄存器地址范围、中断号另一种是struct property形式的原始属性驱动可以通过设备树API按名字读取。就以前面的按键节点为例key-gpio gpio5 1 GPIO_ACTIVE_LOW;这行属性不会自动变成resource因为GPIO不算是内存映射资源。要读取这个属性驱动里得用of_get_named_gpio()这类APIint gpio of_get_named_gpio(pdev-dev.of_node, key-gpio, 0);而如果你的设备是一个外设控制器比如UART、I2C、SPI控制器它的寄存器地址和中断号会通过reg和interrupts属性定义uart1: serial02020000 { compatible fsl,imx6ul-uart; reg 0x02020000 0x4000; interrupts GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH; };这两个属性会被解析为struct resource数组驱动通过platform_get_resource()系列函数来获取struct resource *res platform_get_resource(pdev, IORESOURCE_MEM, 0); void __iomem *base devm_ioremap_resource(pdev-dev, res); int irq platform_get_irq(pdev, 0);理解这个对应关系非常关键。我看到很多初学者设备树里写好了reg和interrupts驱动里却还是用of_property_read_u32去手动读取地址然后把物理地址硬编码到ioremap里——这完全是绕远路不仅代码冗余而且破坏了资源抽象换成另一块板子就没法用了。正确做法就是通过platform_get_resource框架自动获取。3.3 同类型设备的区分别让两个外设共用一个驱动实例一个容易被忽略的问题如果板子上有两个相同型号的I2C控制器设备树里写两个节点内核会创建两个platform_device但驱动模块只有一个。这两个设备会各自调用一次probe函数各自拿到不同的resource各自维护一份私有数据。这就是platform_device和platform_driver是多对多关系的具体体现。实际操作中这意味着你的驱动代码里所有动态资源寄存器映射、中断申请、工作队列、定时器都必须放在probe里分配挂在设备私有结构体上不能使用全局变量来保存这类per-device信息。如果用了全局变量两个设备会互相覆盖产生的bug非常隐蔽。4. 驱动侧一个完整的platform_driver应该怎么写4.1 驱动结构体和probe函数的正确打开方式驱动侧的核心是一个platform_driver结构体它是驱动与内核框架沟通的接口。以我的GPIO按键驱动为例完整定义是#include linux/platform_device.h #include linux/of.h #include linux/gpio/consumer.h #include linux/interrupt.h #include linux/of_gpio.h #include linux/module.h struct gpio_key_device { int gpio; int irq; struct device *dev; }; static irqreturn_t gpio_key_isr(int irq, void *dev_id) { struct gpio_key_device *key_dev dev_id; // 读取GPIO电平处理按键逻辑 int val gpio_get_value(key_dev-gpio); pr_info(key irq triggered, val %d\n, val); return IRQ_HANDLED; } static int gpio_key_probe(struct platform_device *pdev) { struct device_node *np pdev-dev.of_node; struct gpio_key_device *key_dev; int ret; key_dev devm_kzalloc(pdev-dev, sizeof(*key_dev), GFP_KERNEL); if (!key_dev) return -ENOMEM; key_dev-dev pdev-dev; platform_set_drvdata(pdev, key_dev); key_dev-gpio of_get_named_gpio(np, key-gpio, 0); if (!gpio_is_valid(key_dev-gpio)) { dev_err(pdev-dev, failed to get key-gpio\n); return -EINVAL; } ret devm_gpio_request_one(pdev-dev, key_dev-gpio, GPIOF_IN, gpio-key); if (ret) { dev_err(pdev-dev, failed to request gpio\n); return ret; } key_dev-irq gpio_to_irq(key_dev-gpio); ret devm_request_any_context_irq(pdev-dev, key_dev-irq, gpio_key_isr, IRQF_TRIGGER_FALLING, gpio-key, key_dev); if (ret) return ret; dev_info(pdev-dev, gpio key probed, gpio%d, irq%d\n, key_dev-gpio, key_dev-irq); return 0; } static int gpio_key_remove(struct platform_device *pdev) { struct gpio_key_device *key_dev platform_get_drvdata(pdev); dev_info(pdev-dev, gpio key removed\n); return 0; } static const struct of_device_id gpio_key_of_match[] { { .compatible 100ask,gpio-key }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, gpio_key_of_match); static struct platform_driver gpio_key_driver { .probe gpio_key_probe, .remove gpio_key_remove, .driver { .name gpio_key, .of_match_table gpio_key_of_match, }, }; module_platform_driver(gpio_key_driver); MODULE_LICENSE(GPL);这段代码里需要特别注意几个点。第一probe函数里第一个动作就是给设备私有结构体分配内存后续所有数据都放在这个结构体里。我见过很多新手把所有变量全定义成模块级全局变量这在单设备情况下能跑通但一上多设备就翻车。第二devm_*系列函数device-managed非常推荐使用。这类函数把资源释放跟设备生命周期绑定probe失败或者设备移除时内核自动帮你释放内存、GPIO、中断不会忘记也不会重复释放。如果你还在手动用kfree、free_irq配合错误处理goto标签建议尽快切换到devm风格。第三platform_set_drvdata和platform_get_drvdata是框架提供的私有数据存取接口不要在驱动里另开全局变量来保存pdev指针。4.2 module_platform_driver宏的展开逻辑module_platform_driver(gpio_key_driver)是一个便捷宏它的展开相当于static int __init gpio_key_init(void) { return platform_driver_register(gpio_key_driver); } module_init(gpio_key_init); static void __exit gpio_key_exit(void) { platform_driver_unregister(gpio_key_driver); } module_exit(gpio_key_exit);也就是说模块加载时调用platform_driver_register把我们定义好的driver挂到Platform总线上。从这一刻起总线就会拿着这个driver去跟所有已注册的platform_device做匹配测试。如果匹配成功会立刻调用对应的probe函数。这里有个时序问题值得注意platform_driver_register执行时总线上的设备可能早就注册好了比如启动阶段创建的设备树platform_device也可能还没创建比如设备是热插拔的。Platform总线机制天然支持这两种情况driver后注册会遍历已有设备做匹配设备后注册也会遍历已有驱动做匹配。这对我们写驱动模块非常友好不需要关心设备树解析和驱动加载谁先谁后。4.3 编译驱动的Makefile示例我的驱动模块Makefile通常长这样KERN_DIR : /home/user/linux-imx-rel_imx_4.1.15_2.1.0_ga obj-m : gpio_key.o all: $(MAKE) -C $(KERN_DIR) M$(PWD) modules clean: $(MAKE) -C $(KERN_DIR) M$(PWD) clean编译完成后生成gpio_key.ko通过nfs根文件系统拷贝到板子里insmod加载即可。加载之后在板子上执行insmod gpio_key.ko如果一切正常串口会打印出probe函数里的dev_info信息。如果没有打印说明匹配失败或者设备树节点没创建这正是下一节要重点讨论的问题。5. 内核到底怎么匹配platform_match函数逐行拆解5.1 匹配优先级四种方式谁先谁后设备侧有platform_device驱动侧有platform_driverPlatform总线负责撮合它们。撮合的核心逻辑就在platform_match函数里定义在drivers/base/platform.c。这个函数是理解整个匹配机制的重中之重我把核心代码摘出来逐段分析。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); /* When driver_override is set, only bind to the matching driver */ if (pdev-driver_override) return !strcmp(pdev-driver_override, drv-name); /* Attempt an OF style match first */ if (of_driver_match_device(dev, drv)) return 1; /* Then try ACPI style match */ if (acpi_driver_match_device(dev, drv)) return 1; /* Then try to match against the id table */ if (pdrv-id_table) return platform_match_id(pdev, pdrv-id_table) ! NULL; /* fall-back to driver name match */ return (strcmp(pdev-name, drv-name) 0); }匹配顺序依次是driver_override覆盖匹配、OF设备树匹配、ACPI匹配、id_table匹配、name字符串匹配。对于i.MX6ULL这类嵌入式ARM平台我们最常用的就是OF匹配和id_table匹配ACPI主要用于x86平台这里不展开。逐个说清楚匹配方式OF匹配驱动侧通过of_match_table声明自己支持的compatible列表内核会把设备树节点的compatible属性值跟这个列表逐一对比。这是目前ARM平台的主流方式。id_table匹配驱动侧通过platform_driver.id_table提供一个platform_device_id数组数组里有name字段。总线会比较id_table里的name和platform_device的name是否一致。注意platform_device的name来源有两个一是设备树节点的name属性一般是节点名去掉地址部分二是节点compatible属性的第一个字符串。具体取哪个跟设备树解析过程有关。name匹配如果驱动没有提供id_table就退回到最原始的匹配方式直接比较pdev-name和drv-name是否相等。这种方式现在用得少了因为它匹配条件太宽泛容易误匹配——两个完全不同的设备如果取了相同的name就会被错误地绑定。对于绝大多数i.MX6ULL外设驱动走的是OF匹配路径。所以驱动侧要正确设置of_match_table设备树节点的compatible字符串必须和of_device_id里的compatible完全一致包括大小写、逗号、点号都不能差。这是新手最容易翻车的地方。5.2 of_match_table的两种定义风格在实际的内核驱动代码里of_match_table的定义有两种常见风格我分别说明。风格一只有compatible没有datastatic const struct of_device_id mydev_of_match[] { { .compatible myvendor,mydev }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, mydev_of_match);风格二compatible datadata用来区分同系列不同型号static const struct of_device_id mydev_of_match[] { { .compatible myvendor,mydev-v1, .data v1_data }, { .compatible myvendor,mydev-v2, .data v2_data }, { /* sentinel */ } };使用风格二时probe函数里通过of_match_device获取匹配到的of_device_id进而取出data字段const struct of_device_id *match; match of_match_device(mydev_of_match, pdev-dev); if (match) const struct mydev_data *data match-data;很多老驱动比如LED、GPIO、PWM子系统里的核心驱动都大量使用了这种data方式用来区分同系列芯片的寄存器布局差异。5.3 匹配成功之后really_probe都做了什么当platform_match返回1表示匹配成功之后内核并不会直接调用probe而是走了一段封装好的逻辑。核心在drivers/base/dd.c的really_probe函数里。static int really_probe(struct device *dev, struct device_driver *drv) { // 1. 调用bus的probe回调对于platform总线就是platform_drv_probe // 2. platform_drv_probe内部调用drv-probe // 3. 如果probe返回-EPROBE_DEFER进入deferred列表等待重试 }而platform_drv_probe长这样static int platform_drv_probe(struct device *_dev) { struct platform_driver *drv to_platform_driver(_dev-driver); struct platform_device *dev to_platform_device(_dev); if (driver_deferred_probe_enable dev-pdev_archdata.defer_all_probes) return -EPROBE_DEFER; if (drv-probe) return drv-probe(dev); if (drv-id_table) return platform_probe_fail(dev); return -ENODEV; }注意这里有个关键中间层platform_drv_probe是platform_bus类型对通用device模型probe流程的具体化它拿到platform_driver后再调用真正的probe函数。这个中间层是内核设计上的一个小技巧让不同的总线可以自定义各自的probe行为而不影响通用框架。另外-EPROBE_DEFER这个返回值非常值得展开说。如果你的probe函数发现它依赖的某个资源还没准备好比如依赖的时钟控制器驱动还未加载、依赖的reset控制器还没注册可以直接返回-EPROBE_DEFER内核会把设备放入deferred probe列表等后续有新的驱动注册时再重新对deferred设备进行匹配和probe尝试。这个机制解决了很多驱动加载顺序依赖问题你看内核代码里大量驱动都返回-EPROBE_DEFER就是为了应对复杂的外设依赖链。理解了这个机制你就明白我前面为什么强调driver后注册会遍历已有设备做匹配——对于平台设备来说即便你的设备节点在驱动注册之前就已经存在驱动注册时系统会重新扫描deferred设备列表不会错过。5.4 匹配成功后sysfs里的可见证据匹配注册成功之后在板子上的sysfs文件系统里能找到直接证据ls /sys/bus/platform/devices/你会看到一个以设备树节点名或地址命名的目录。继续查看ls -l /sys/bus/platform/devices/100ask_gpio_key/注意这里100ask_gpio_key是我的节点名称。在这个目录下如果驱动已经绑定成功你会看到一个driver软链接指向驱动目录。如果只有device文件没有driver链接说明设备存在但尚未匹配到驱动。这是排查问题时的第一直觉入口。对应的驱动侧如果驱动已经成功绑定到至少一个设备执行ls /sys/bus/platform/drivers/gpio_key/也能看到设备链接。两个链接互相指向就是匹配成功的铁证。6. probe不执行的经典排查链路6.1 从设备树到内核日志的全链路验证调试Platform驱动时最让人崩溃的场景是驱动insmod成功了probe函数却死活不执行。这种问题的排查我建议按照下面的链路逐步走不要跳步。第一步确认设备树节点有没有被正确解析。在板子上执行ls /sys/bus/platform/devices/看看有没有以你的设备节点名命名的目录。如果没有说明设备树解析阶段就没创建这个platform_device大概率是设备树编译/加载有问题或者节点状态不是okay。第二步查看/sys/firmware/devicetree/base/目录ls /sys/firmware/devicetree/base/这里会展开显示设备树里的所有节点包括status属性。如果在这个目录里都找不到你的节点说明设备树根本没编进去或者uboot加载的dtb不对。第三步确认你的compatible属性字符串。在内核日志里搜dmesg | grep -i platform设备创建时会有对应的日志。同时可以确认of_match_table里的compatible和设备树节点里的compatible是否完全一致。这里我提一个排错技巧在板子上直接读取设备树节点的compatiblecat /sys/firmware/devicetree/base/100ask_gpio_key/compatible注意这个文件末尾会带一个\0字符显示上可能看不到但用hexdump能看到hexdump -C /sys/firmware/devicetree/base/100ask_gpio_key/compatible第四步确认驱动注册本身没有问题。insmod之后先看lsmod | grep gpio_key如果模块已经加载但sysfs里没有driver目录说明platform_driver_register阶段就出了问题查看dmesg里有没有加载错误。第五步如果以上都正常但还是不probe就要考虑是不是其他驱动抢先占用了设备。设备只能绑定一个驱动如果你不小心写了两个驱动都能匹配同一个设备后注册的驱动就绑不上了。6.2 设备树statusdisabled这个隐形的坑我实际排查过最多的一个案例设备树节点一切正常compatible也对得上sysfs里也能看到设备节点但probe就是不被调用。最后发现是设备节点或者父节点被标记了status disabled内核在创建platform_device时会直接跳过这些节点。gpio_keys_100ask { compatible 100ask,gpio-key; status disabled; // 就是这行 ... };修改方法很简单把status改为okay或者直接删除默认是okay。但要注意如果你在dtsi里定义了disabled你在dts里要覆盖为okay而不是再新建一个相同名字的节点。正确写法gpio_keys_100ask { status okay; };确认status之后还有一个常被忽略的坑父节点如果有status disabled子节点即使写成okay也不会被创建。所以检查时要沿着节点树往上看确保父节点也都是okay。6.3 compatible字符串匹配的眼见不一定为实compatible字符串不一致导致的匹配失败是另一个高频问题。比如设备树里写的是compatible 100ask,gpio-key;而of_match_table里写的是static const struct of_device_id gpio_key_of_match[] { { .compatible 100ask,gpio_key }, // 注意这里是下划线 { } };一个是连字符-一个是下划线_肉眼几乎看不出来但内核比较字符串时是严格逐字节比对一个字符不对就直接返回不匹配。遇到这种情况我推荐一个笨但有效的办法直接把设备树里的compatible读出来复制粘贴到驱动代码里不要手动敲。另外通过MODULE_DEVICE_TABLE(of, ...)导出的模块别名在某些内核版本里也参与自动加载逻辑如果这个表写错了modprobe可能找不到对应模块。6.4 手动绑定绕过匹配机制的临时验证手段如果驱动和设备树已经注册好但匹配就是不成功还有一个临时手段可以帮你快速区分是匹配规则问题还是probe函数本身有问题——手动绑定。echo 100ask_gpio_key /sys/bus/platform/drivers/gpio_key/bind这个命令强制让指定驱动去绑定指定设备绕过platform_match的匹配逻辑。如果手动bind能成功且probe函数正常执行说明probe本身没问题问题确实出在匹配环节。如果不能bind说明probe里可能有动态错误要看dmesg的具体报错。反过来也可以解绑echo 100ask_gpio_key /sys/bus/platform/drivers/gpio_key/unbind这个手段非常实用在开发调试阶段强烈建议掌握。不过要注意bind操作需要设备名与驱动目录下的设备名严格一致用tab补全最稳妥。6.5 终极调试手段pr_err加在really_probe里如果上面的排查链路都走完了还是找不到问题那就别客气了直接在内核源码里加打印。我的做法是在drivers/base/dd.c的really_probe函数里加这么几行static int really_probe(struct device *dev, struct device_driver *drv) { struct platform_device *pdev; struct platform_driver *pdrv; int ret; if (dev-bus platform_bus_type) { pdev to_platform_device(dev); pdrv to_platform_driver(drv); pr_err(really_probe: %s match %s, driver_override%s\n, dev_name(dev), drv-name, pdev-driver_override ? pdev-driver_override : none); } ... }加上之后重新编译内核烧录启动在dmesg里就能看到哪些设备在尝试匹配哪些驱动、匹配结果如何。这是最底层的真相比在驱动模块里加打印更早介入。用完之后记得把打印去掉或者用pr_debug避免影响性能。7. 进阶场景多设备、deferred probe与资源依赖7.1 一个驱动挂多个设备节点前面提到过一个platform_driver可以匹配多个platform_device。在实际项目中这个特性非常有用。我的i.MX6ULL板子上有4个按键我可以在设备树里写4个节点都使用同一个compatiblegpio_keys { compatible 100ask,gpio-key; pinctrl-names default; pinctrl-0 pinctrl_gpio_keys; status okay; key1 { label KEY1; gpios gpio5 1 GPIO_ACTIVE_LOW; }; key2 { label KEY2; gpios gpio5 2 GPIO_ACTIVE_LOW; }; };注意如果设备树里的compatible带了厂商前缀驱动里的of_device_id必须保持一致。而且多个同compatible节点内核会为每个节点创建一个platform_device各自的probe会被调用多次。此时必须保证probe里所有资源分配是基于per-device的不能在probe里使用静态全局变量保存设备相关状态。7.2 defer probe机制处理外设依赖的推荐方案另一个进阶场景是资源依赖。比如你的设备需要使用某个GPIO而这个GPIO控制器驱动还没加载完成。此时probe函数里调用devm_gpiod_get会返回-EPROBE_DEFER你的驱动应该把这个错误原样返回而不是直接失败退出。static int mydev_probe(struct platform_device *pdev) { struct gpio_desc *gpio; gpio devm_gpiod_get(pdev-dev, enable, GPIOD_OUT_LOW); if (IS_ERR(gpio)) { if (PTR_ERR(gpio) -EPROBE_DEFER) { dev_info(pdev-dev, gpio not ready, defer\n); return -EPROBE_DEFER; } return PTR_ERR(gpio); } ... }内核会把返回-EPROBE_DEFER的设备加入deferred_probe列表每当有新的驱动注册时系统都会重新尝试probe这些deferred设备。这也是为什么很多复杂外设驱动比如USB PHY、PMIC、显示屏面板看起来加载顺序很乱但最终都能正常工作。写驱动时养成一个好习惯凡是依赖外部子系统资源的获取都要检查是否返回-EPROBE_DEFER并做相应处理。不要图省事直接把错误一return了事那样内核可能会彻底放弃该设备。7.3 设备树指定driver_override的情况有个比较少见但值得知道的机制driver_override。它允许你在设备侧强制指定某个设备必须由某个驱动绑定即使这个驱动的of_match_table里根本没有对应compatible。在板子上可以通过sysfs设置echo my_driver /sys/bus/platform/devices/100ask_gpio_key/driver_override echo 100ask_gpio_key /sys/bus/platform/drivers/my_driver/bind内核源码里对应platform_match函数的第一个判断分支if (pdev-driver_override) return !strcmp(pdev-driver_override, drv-name);如果有driver_override设置匹配规则会直接退化为字符串比较只看override值跟驱动name是否一致不再尝试其他匹配方式。这个机制在容器虚拟化、DPDK绑核这类场景用得比较多嵌入式常规驱动开发不算常用但了解了之后遇到不会懵。8. 实验复盘一个从probe不执行到点亮GPIO的完整案例8.1 问题现象与初步猜测有一次我在调试一个新板子自写的GPIO按键驱动设备树节点也加了驱动也编译进去了但insmod之后probe就是不打印任何信息。dmesg里没有任何errorlsmod显示模块已加载sysfs的platform devices目录里也能看到设备节点但就是没有driver链接。按照排查链路我先看设备的compatiblehexdump -C /sys/firmware/devicetree/base/gpio_keys_100ask/compatible输出显示100ask,gpio-key跟驱动of_match_table里的compatible字符串用肉眼比照完全一致。这时候我犯了经验主义错误以为问题不在这开始怀疑是不是of_match_table没被正确编进模块。然后我用modinfo gpio_key.ko查看模块信息alias确实有of:N...T...C100ask,gpio-key...。表面看一切正常。8.2 真正的根因节点名和compatible的微妙关系后来我改用手动bind方式测试echo gpio_keys_100ask /sys/bus/platform/drivers/gpio_key/bind结果报错No such device。这就奇怪了/sys/bus/platform/devices/下面明明有gpio_keys_100ask目录。我再仔细一查发现设备名多了个地址后缀gpio_keys_100ask.0。设备树里同一个compatible的多个节点内核会用节点名加序号来区分所以platform_device的设备名是gpio_keys_100ask.0而手动bind时要用完整的设备名echo gpio_keys_100ask.0 /sys/bus/platform/drivers/gpio_key/bind这次bind成功了probe打印也出来了。但我还是没搞明白为什么insmod时不自动匹配。于是我去内核源码里找答案看了of_device_is_available函数的实现才发现问题所在。动态调试打开后发现我的父节点——根节点下的某个中间节点——在另一个dtsi里被设置了status disabled。子节点的status是okay但父节点被禁用了导致整个子树都没有被of_platform_populate处理。此前的代码只检查了自身的status没检查父节点链路所以走了弯路。8.3 修复过程与一条排查经验修复很简单把父节点status改为okayusdhc1 { status okay; };重新编译dtb加载这次insmod后probe立刻执行了。GPIO按键的中断也能正常触发串口打印出按键电平变化。这个案例给我留下的最大教训是compatible字符串比对只是表面的操作匹配机制真正依赖的是设备节点能不能出现在sysfs和内核的device list里。而设备节点能否出现、能否真正参与匹配与设备树里的status、父节点的status、设备树解析逻辑都有关系。排查问题时不要只看节点自身要顺着设备树路径一路往上查。另外一条通用经验在写of_match_table的时候建议把MODULE_DEVICE_TABLE加上并且确保of_device_id数组以空节点结束。少了这个哨兵节点内核遍历of_match_table时可能读越界行为会变得不可预测而且很难排查。8.4 验证匹配成功的最终标准在整条链路都打通之后我一般用下面这个标准判断匹配是否完全成功也推荐给大家参考dmesg里probe函数打印了进入日志/sys/bus/platform/devices/设备名/driver软链接存在指向驱动目录/sys/bus/platform/drivers/驱动名/设备名软链接反向存在设备对应的功能实际可用按键中断触发、GPIO输出有效等。四个条件缺一不可。前三个只说明驱动和设备建立了绑定关系最后一个才是功能真正的验证。我见过不少场景前三条全满足但probe里注册的中断函数有bug导致功能不可用。所以最终还要回到实际功能测试。9. 常见误区和设计建议9.1 误区一把platform_driver当成万能框架Platform总线本身只是一个虚拟总线它的职责是把没有标准总线归属的设备统一管起来。但这不是说所有驱动都要写成platform_driver。如果你的设备明确挂在I2C、SPI、USB、PCI等标准总线上应该使用对应的子系统框架i2c_driver、spi_driver、usb_driver等而不是把它们硬塞进platform_driver里。实际开发中i.MX6ULL上很多I2C外设驱动比如eeprom、rtc、触摸屏控制器都是标准的i2c_driver它们挂在I2C总线上匹配方式跟platform总线类似但又不完全相同。如果一个外设既可以用I2C接口也可以映射为内存设备通常优先选择I2C框架因为内核子系统对I2C总线的电源管理、时序控制、错误处理都已经封装得很完善。9.2 误区二probe里做耗时操作或阻塞等待probe函数是在内核线程上下文执行的如果在这里做大量耗时操作比如msleep几百毫秒、等待某个中断、做复杂的初始化计算会拖慢内核启动或者阻塞驱动注册路径。特别是多个设备共享一个bus锁时一个probe卡住了后面所有同总线的设备都跟着遭殃。正确做法是probe只做必要的资源申请和初始化把真正耗时的操作比如固件加载、自检、启动工作线程放到异步机制里比如request_workqueue、tasklet或者内核线程。如果一定要等某个资源用-EPROBE_DEFER让出CPU而不是自旋等待。9.3 误区三只看到probe不执行忽略remove很多新手写驱动只顾proberemove函数随便写或者干脆空着。但在实际产品里remove函数是否做好收尾工作直接关系到驱动模块能否被安全卸载、系统能否进入低功耗状态。remove里至少要做好几件事取消工作队列、释放中断、释放GPIO、释放IO映射、释放私有结构体。用devm_*系列API可以让大部分释放工作自动化但有些资源比如request_irq申请的旧式中断还是需要手动释放。如果你在开发阶段频繁insmod/rmmodremove写不好很容易遇到第二次insmod时资源已被占用的问题。这种问题的报错往往在request_irq或者io_remap阶段实际原因是第一次卸载时没释放干净。排查起来很费时间不如一开始就把remove写完整。9.4 设计建议让驱动可配置化最后给一个设计层面的建议驱动的of_match_table、设备树节点的compatible、寄存器配置参数尽量做到可配置化。比如一个LED驱动不要硬编码GPIO编号和极性而是通过属性让设备树指定led_user { compatible myvendor,led; led-gpio gpio5 3 GPIO_ACTIVE_LOW; label user-led; default-state off; };驱动里用of_property_read_u32或者devm_gpiod_get解析这些属性。这样同一份驱动代码只需要修改设备树就能复用到不同板卡上。我见过的最好的内核驱动几乎没有任何硬件参数硬编码所有硬件差异全部通过设备树和platform_data来体现。这也是Linux设备模型设计的核心理念之一。10. 匹配机制与中断、时钟、GPIO子系统的联动10.1 驱动框架的依赖注入Platform匹配机制不是孤立存在的。当设备与驱动成功绑定、probe被调用时驱动往往还需要获取设备树里描述的中断、时钟、GPIO、DMA等资源。这些子系统的资源获取API在设计时就跟设备模型深度联动。以时钟为例在probe里经常这么写struct clk *clk; clk devm_clk_get(pdev-dev, uart); if (IS_ERR(clk)) return PTR_ERR(clk); clk_prepare_enable(clk);devm_clk_get会根据设备树节点的clock属性去对应的时钟控制器驱动里找到时钟句柄。如果时钟控制器驱动还没就绪同样会返回-EPROBE_DEFER。这样设备驱动依赖时钟驱动的加载顺序问题就被deferred probe机制自动解决了。10.2 多设备竞争同一个资源的处理当多个platform_device对应的物理设备共享同一个寄存器区块、同一个DMA通道时匹配机制本身不会处理这种冲突需要驱动自己通过互斥锁、原子操作来保护共享资源。举个例子i.MX6ULL内部有多个UART它们是独立的寄存器区块各自有独立的platform_device。但是它们共用同一个时钟源比如UART1和UART2的根时钟可能都来自同一个PLL如果两个驱动同时操作这个PLL的寄存器就会产生竞争。这类问题在很多BSP里通过时钟框架的引用计数来协调但如果驱动不经过时钟框架直接操作PLL寄存器就很容易出bug。10.3 电源域和休眠唤醒里匹配机制同样适用设备模型在电源管理里的作用也不可忽视。当系统进入suspend状态时内核会遍历系统中所有设备调用它们的dev_pm_ops里的回调。这个遍历正是基于device/driver模型来组织的。如果你的平台驱动实现了这些回调static const struct dev_pm_ops mydev_pm_ops { .suspend mydev_suspend, .resume mydev_resume, };在platform_driver里挂上pm指针系统挂起时就会自动调用。这里的挂载与匹配是同一套设备模型给你带来自动化便利的又一体现。很多新人在这个阶段发现为什么我的驱动没有pm回调——多半是platform_driver定义里没加driver.pm这一项。11. 写给后来者掌握匹配机制的正确学习路径如果你正处在从单片机思维过渡到Linux驱动开发的阶段我给你一个比较实际的学习路径建议。第一步不要急着写驱动。先把i.MX6ULL的参考手册里内存映射时钟树中断控制器这几章啃下来同时把设备树的基本语法弄明白。第二步跟着内核源码读一遍platform.c和dd.c。不需要每一行都懂但至少把platform_match、really_probe这两个函数的核心流程理清。读源码的时候拿笔画出设备树节点如何变成platform_device、platform_driver如何注册、匹配成功后如何进入probe这条主线。第三步自己动手改一个现成的简单驱动比如把内核里gpio_keys_polled驱动的设备树节点改一改在板子上跑通。然后逐步加需求增加一个自定义节点、自定义compatible、自定义属性体验从设备树到驱动的完整链路。第四步才是从头写一个自己的platform驱动。我前面给的GPIO按键驱动模板可以直接拿来改。在写驱动的过程中你需要掌握的辅助工具和技能包括但不限于交叉编译工具链的配置、tftp/nfs调试环境的搭建、dmesg日志分析、sysfs文件系统的使用、设备树编译与反编译。这些内容看起来杂但每一个都是在实际项目里绕不开的。等你把这些链路都走通了再回头看i.MX6ULL的官方BSP里的各种platform驱动比如enet、uart、ecspi、lcdif你会发现它们虽然代码量庞大、回调函数多但骨架完全一致of_match_table声明compatibleprobe里解析资源并初始化硬件remove里释放资源pm_ops处理电源切换。看懂一个就能看懂所有。最后说一个我个人的深刻体会Linux驱动开发本质上不是写代码而是搭积木——设备的积木、驱动的积木、总线的积木靠一套精妙的匹配机制拼在一起。理解了这个模型远比记下某个具体驱动的API调用方式更重要。因为API会随着内核版本变化但如果理解了机制任何版本的代码拿过来都能快速上手。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门