ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

设备树到probe:i.MX6ULL平台总线匹配机制全解析

设备树到probe:i.MX6ULL平台总线匹配机制全解析 做嵌入式Linux开发这几年我接触最多的SoC就是NXP的i.MX6ULL。这颗芯片在工业控制、IoT网关、智能设备里出现频率极高Cortex-A7单核外设丰富资料也相对开放很多人拿它入门Linux驱动开发。而驱动开发里绕不开的一个核心概念就是Platform设备与驱动匹配机制——说白了就是搞清楚“内核是怎么知道眼前这个硬件该由哪个驱动来服务的”。很多新手写驱动时代码看着没问题probe就是不执行dmesg里什么报错都没有问题几乎都出在对这套匹配机制的细节理解上。这篇文章不打算泛泛讲概念而是结合i.MX6ULL的实际开发场景把设备树到platform_device、platform_driver注册、匹配顺序、probe时机、以及常见踩坑位置整条链路讲透希望对正在调板子的朋友有帮助。1. 为什么ARM Linux要专门造一个Platform总线出来1.1 板级文件时代的耦合问题在设备树大规模普及之前ARM Linux描述硬件的方式非常原始给每块开发板写一个板级文件arch/arm/mach-xxx/board-xxx.c把板子上有哪些外设、外设挂在哪段地址、用哪个中断全部硬编码成一个一个platform_device结构体数组然后在machine_desc里注册。这些设备对象和具体的SoC、具体的板子死死绑在一起换一颗Flash、换一个PHY芯片都要改内核重新编译。那个年代最常见的一幕就是同一个SoC厂商给十几家客户各维护一份BSP每份BSP里都是一堆mach文件因为A客户板子的GPIO复用方式跟B客户不一样。这种模式最致命的问题就是“硬件描述”和“驱动逻辑”完全耦合驱动要正常工作必须先有板级文件把平台设备创建出来而板级文件一旦写错地址驱动再对也白搭。1.2 总线的价值把“设备”和“驱动”拆开后来Linux引入了总线、设备、驱动模型Platform bus就是这个模型里最特殊的一个角色。PCI和USB设备自己能枚举设备插上总线后会主动报告“我是谁”系统自然知道该加载哪个驱动。但SoC内部的大多数外设根本不支持这类自描述枚举UART控制器、I2C控制器、GPIO控制器它们固定焊接在芯片内部地址固定中断固定不会像一个PCI网卡那样自己报ID。Platform bus就是为这些“无法自描述的片上设备”准备的虚拟总线。Platform bus上挂着两类对象platform_device描述“有什么硬件”包括名字、寄存器地址范围、中断号、时钟、DMA通道等资源。platform_driver描述“谁能操作这些硬件”包括probe函数、remove函数、支持哪些型号的匹配表。而总线本身扮演的角色就是“牵线搭桥”。你可以把它想象成一个招聘网站platform_driver是投递简历的求职者platform_device是挂在网上的岗位JD总线负责把“技能标签”和“岗位要求”做匹配匹配成功就安排面试这个面试就是probe回调。这套机制最大的价值在于解耦。SoC厂商在imx6ull.dtsi里描述芯片内部有哪些控制器板厂在自己的dts里描述板子上接了哪些外部器件驱动开发者写一个platform_driver就能服务所有板子上的同类设备。硬件怎么变驱动代码可以纹丝不动。1.3 i.MX6ULL里的Platform设备都有哪些在i.MX6ULL上绝大多数内部控制器都是以platform_device形式注册到内核的UART、I2C、ECSPI、FEC以太网、USB控制器、LCDIF显示控制器、PWM、GPIO等等。与此同时I2C控制器和SPI控制器本身是platform_device注册成功后再创建出I2C总线或SPI总线挂在上面的外部传感器芯片则作为i2c_client或spi_device存在。所以你可以在i.MX6ULL上遇到一条非常典型的挂载链一个i2c控制器节点在设备树里被描述内核启动时把它变成platform_devicei2c-imx驱动通过of_match_table匹配到它并probeprobe里面调用i2c_add_adapter注册了一个I2C总线适配器之后挂在同一条I2C总线上的触摸屏、传感器芯片才由各自的客户端驱动去处理。理解Platform匹配机制是整个链条的第一步。2. 设备树节点是怎么一步步变成platform_device的2.1 从DTB到device_node设备树的起点是编译好的DTB文件U-Boot启动时会把DTB加载到内存把地址通过r2寄存器传给内核。内核在setup_arch阶段调用unflatten_device_tree()把扁平的DTB二进制数据解析成一颗device_node树。这颗树在内存里以struct device_node节点形式组织每个节点保留了我们在dts里写的一切属性compatible、reg、interrupts、clocks、pinctrl等。注意这个阶段的device_node还不是platform_device它只是对硬件描述的“纯数据”。真正变成设备对象要等到内核的platform总线初始化时去主动“认领”。2.2 of_platform_default_populate的展开规则内核初始化时会调用of_platform_default_populate()从设备树根节点开始往下扫描。但并不是每个节点都会变成platform_device展开规则有讲究。关键条件是一个设备树节点只有满足以下情况它的子节点才会被继续展开成platform_device——该节点自身的compatible属性里包含“simple-bus”或者节点带“simple-bus”标志。i.MX6ULL的soc节点就是典型例子它的compatible通常会写成“simple-bus”所以soc节点下的uart、i2c、ecspi这些子节点会逐个被创建为platform_device。同时每个被展开的设备节点还会检查status属性。只有当status为“okay”或者status属性不存在时节点才会被创建成platform_device如果写了status disabled这个节点会被直接跳过。这一点在调试时经常被忽略后面我会单独说。2.3 reg和interrupts如何变成resource设备节点被认领后内核会把节点里的硬件资源抽取到platform_device的resource数组里规则很直接reg属性 → IORESOURCE_MEM或IORESOURCE_IOinterrupts属性 → IORESOURCE_IRQdma-coherent等DMA相关属性 → IORESOURCE_DMA用得少驱动侧获取资源的标准姿势是调用platform_get_resource()。以i.MX6ULL常见的地址中断资源为例static int my_drv_probe(struct platform_device *pdev) { struct resource *res; void __iomem *base; res platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(pdev-dev, missing MEM resource\n); return -ENXIO; } base devm_ioremap_resource(pdev-dev, res); if (IS_ERR(base)) return PTR_ERR(base); return 0; }这里有个新手常犯的错platform_get_resource返回的struct resource里start和end是物理地址绝对不能直接拿来做指针访问。必须经过ioremap或者用devm_ioremap_resource帮你做ioremap和长度的校验得到虚拟地址才能操作寄存器。2.4 一个完整的设备树节点到probe的示例以i.MX6ULL的UART1为例在imx6ul.dtsi里节点定义大致如下不同内核版本略有差异uart1: serial02020000 { compatible fsl,imx6ul-uart, fsl,imx6q-uart; reg 0x02020000 0x4000; interrupts GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH; clocks clks IMX6UL_CLK_UART1, clks IMX6UL_CLK_UART1_SERIAL; clock-names ipg, per; status disabled; };板级设备树里再打开它uart1 { pinctrl-names default; pinctrl-0 pinctrl_uart1; status okay; };内核启动后这个节点就变成了一个名字类似2020000.serial的platform_device挂到了platform总线上。而真正驱动它的imx串口驱动drivers/tty/serial/imx.c是这样声明自己的匹配表的static const struct of_device_id imx_uart_dt_ids[] { { .compatible fsl,imx6q-uart, }, { .compatible fsl,imx6ul-uart, }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, imx_uart_dt_ids); static struct platform_driver imx_uart_platform_driver { .probe imx_uart_probe, .remove imx_uart_remove, .driver { .name imx-uart, .of_match_table imx_uart_dt_ids, }, }; module_platform_driver(imx_uart_platform_driver);这个节点的compatible里有两个字符串驱动声明匹配“fsl,imx6ul-uart”或“fsl,imx6q-uart”都行。这种多级别兼容写法在i.MX6ULL的设备树里很常见芯片之间复用同一个IP核只是厂商字符串不同。3. platform_match的匹配顺序源码走读加场景对应3.1 platform_driver结构体里到底要填哪些字段平时写驱动最常用的就是module_platform_driver()宏它把module_init和module_exit包了一层。展开之后的核心就是platform_driver_register()。这个函数会把你定义的结构体注册到内核里其中最关键的是driver子结构体里那两个字段driver.name和driver.of_match_table。driver.name是驱动在sysfs里的名字驱动加载后你在/sys/bus/platform/drivers/下面看到的目录名就是它。of_match_table是设备树匹配表一个由struct of_device_id组成的数组每个元素里最重要的就是compatible字符串。这两个字段别搞混of_match_table管的是“设备树里的compatible能不能对上”driver.name管的是“非设备树场景下的名字兜底匹配”。3.2 OF匹配与of_device_id表设备树节点的compatible和驱动的of_match_table是怎么比的内核里是of_driver_match_device()最终落到of_device_is_compatible()。这个函数会拿设备树节点compatible属性里的每一个字符串逐个和of_match_table每个元素里的compatible比对两者任何一个相等就算匹配成功。有一点很多人没注意到of_device_id里除了compatible还有type和name两个字段type匹配的是设备树节点的device_type属性name匹配的是节点去掉地址后的名字。但现代设备树规范里已经很少用这几个属性了实际开发基本只用compatible。所以你在写匹配表时老老实实把compatible填对就够了。3.3 id_table存在的意义再看另一个容易被忽略的字段platform_driver.id_table类型是struct platform_device_id数组。它的作用是在没有设备树时用platform_device的name字段做匹配。每个元素长这样static const struct platform_device_id my_ids[] { { .name my-char-dev, .driver_data 0 }, { }, };这里有个非常实用的场景同一个驱动要服务同一型号芯片上的多个实例比如i.MX6ULL的两个ECSPI控制器设备树里会有两套寄存器地址但它们都注册成spi_imx的platform_device。驱动在probe里可以用platform_get_device_id(pdev)拿到当前正在匹配的ID再取出driver_data从而区分是哪个实例避免写多个probe函数。3.4 匹配顺序很重要4条规则的优先级内核驱动模型实际执行匹配的函数是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); /* 1. 设备树匹配 */ if (of_driver_match_device(dev, drv)) return 1; /* 2. ACPI匹配 */ if (acpi_driver_match_device(dev, drv)) return 1; /* 3. id_table匹配 */ if (pdrv-id_table) return platform_match_id(pdev, pdrv-id_table) ! NULL; /* 4. 驱动名兜底匹配 */ return (strcmp(pdev-name, drv-name) 0); }匹配顺序整理成表格优先级匹配方式比较对象适用场景1OF匹配of_match_table[].compatible ↔ 节点compatible设备树2ACPI匹配acpi_device_id ↔ ACPI表主要x863id_table匹配pdev-name ↔ id_table[].name非设备树/同驱动多实例4name兜底pdev-name ↔ driver.name最早期的平台驱动写法理解这个顺序对实际调驱动特别重要。比如你的驱动同时设置了of_match_table和id_table在设备树板子上走的一定是第1条id_table只在设备树匹配失败或者根本没有设备树节点时才轮得上。还有一旦驱动设置了id_table第4条name兜底就不会执行因为platform_match里先判断了pdrv-id_table是否存在。如果只填了driver.name、然后幻想靠名字匹配而设备树匹配又失败那probe就永远不跑。4. probe没有被调用先查EPROBE_DEFER和依赖链路4.1 probe失败和deferred probe的本质区别匹配成功之后总线会调用platform_drv_probe()也就是执行你写的probe函数。probe正常返回0设备和驱动就绑定成功返回负数绑定失败。但失败和失败不一样返回-ENODEV、-EIO这类错误内核会认为“你不想要这个设备”本次绑定作废设备继续挂在总线上等待其他驱动返回-EPROBE_DEFER则是一个特殊信号告诉内核“我暂时没法完成初始化但我没说永远不行请过会儿再试我一次”。很多新手遇到依赖没就绪直接在probe里返回-ENODEV结果设备再也没有被重新探测然后开始怀疑人生。正确的做法是当probe里获取的资源、时钟、GPIO、regulator等依赖还没准备好时要返回-EPROBE_DEFER而不是其他错误码。4.2 依赖没就绪时的处理流程内核处理EPROBE_DEFER有一套完整的机制。probe返回-EPROBE_DEFER后设备会被放进一个deferred probe队列同时内核调度一个工作队列反复重新执行这些设备的probe。每次有新的platform_driver注册成功或者系统中新增了regulator、clock、gpio等provider都会触发一次重新探测。只有重新探测成功设备才会从deferred队列里移走。如果某个依赖的驱动根本没有被加载设备会一直留在deferred队列里。这在模块化的用户态rootfs里很常见你手动insmod一个设备驱动但它的PMIC驱动、时钟驱动没加载probe就会无限期defer。调试时可以直接查看# 内核开启了debugfs的情况下 cat /sys/kernel/debug/devices_deferred如果这个文件列出的设备里正好有你关心的那个说明它一直在等待依赖而不是匹配失败。4.3 实战i.MX6ULL上I2C设备probe延迟的排查举一个i.MX6ULL上很典型的场景。板子上有一颗外部传感器挂在I2C2上传感器驱动在probe里用devm_regulator_get()获取一个由PMIC提供的电源轨而PMIC芯片本身又挂在I2C1上。内核对设备的probe顺序并不保证I2C1的PMIC一定先于I2C2的传感器被探测到。当传感器驱动先probe时regulator框架会发现对应的regulator还没注册于是devm_regulator_get()返回-EPROBE_DEFER。传感器驱动如果在probe里直接把这个错误原样return出去内核就会把它放到deferred队列等PMIC驱动就绪、regulator注册完成后内核自动重新调用传感器驱动的probe这次regulator就能正常拿到了。我自己的排查习惯是三步走第一步看/sys/kernel/debug/devices_deferred确认设备在不在deferred队列里以及内核记录的等待原因第二步dmesg里查“deferred probe”相关日志确认是哪一层依赖没准备好第三步检查对应的provider驱动到底加载了没有——这一步几乎总能找到问题根源。5. 不使用设备树时手动创建platform_device的玩法5.1 手动注册代码怎么写虽然现在几乎都是设备树的天下了但偶尔还是会遇到需要手动创建platform_device的场景尤其是调试阶段不想为了一个小改动重新编译dtb直接在内核模块里注册一个设备对象会更省事。手动注册最推荐的方式是用platform_device_register_full()static struct resource my_res[] { { .start 0x0209c000, .end 0x0209cfff, .flags IORESOURCE_MEM, }, { .start 78, .end 78, .flags IORESOURCE_IRQ, }, }; static struct platform_device_info my_pdevinfo { .name my-char-dev, .id PLATFORM_DEVID_AUTO, .res my_res, .num_res ARRAY_SIZE(my_res), }; static int __init my_init(void) { platform_device_register_full(my_pdevinfo); return 0; } module_init(my_init);这样注册出来的platform_device同样会在/sys/bus/platform/devices/下面生成一个设备节点名字类似“my-char-dev.0”id通过PLATFORM_DEVID_AUTO自动分配。5.2 什么场景还值得手动注册手动注册platform_device在以下几类场景里仍然很常见板级文件时代的遗留代码或者从老内核移植过来、还来不及迁移到设备树的驱动。MFD多功能设备驱动里由一个父设备在probe中动态注册多个子单元设备子单元分别由各自的platform_driver匹配。快速验证驱动逻辑不想为了测试一个寄存器读写就改动设备树并重新烧录。手动注册设备能让驱动的probe先跑通确认逻辑没问题后再回到设备树做正式方案。虚拟设备比如调试用的字符设备、模拟测试设备它们不占用真实硬件资源。5.3 手动注册的匹配坑手动注册设备时最容易踩的坑就是设备的匹配方式变了。设备树场景下驱动的of_match_table就能完成匹配但手动注册出来的platform_device没有device_nodeof_driver_match_device()必然失败。所以你的驱动必须额外提供id_table或者确保driver.name和platform_device的name完全一致才能通过第3条或第4条规则匹配上。我见过有人把设备树场景下的驱动原封不动拿来手动注册设备后probe不执行查了半天才发现驱动里只有of_match_table没有id_table。所以如果你要玩手动注册一定记得补上static const struct platform_device_id my_ids[] { { .name my-char-dev, }, { }, }; MODULE_DEVICE_TABLE(platform, my_ids);并在platform_driver里把id_table赋值进去。这算是我亲手踩过的坑写出来给大家提个醒。6. i.MX6ULL驱动开发里我踩过的Platform匹配坑6.1 compatible字符串差一个字母就静默失败设备树匹配对字符串的比对是精确匹配多一个空格、少一个字母、大小写不一致都会导致匹配失败而且内核不会为此打印出任何显眼报错。匹配不上时设备对象依然挂在platform总线上但驱动侧probe完全没反应dmesg干干净净。所以当你怀疑是匹配问题时先确认两件事第一设备树节点里的compatible到底写了什么第二驱动of_match_table里的compatible到底写了什么。设备树侧可以直接读内核解析后的视角cat /proc/device-tree/soc/serial02020000/compatible驱动侧如果已经加载去sysfs看驱动的modalias或者直接去源码目录里grep对应字符串。很多时候问题就出在有人把“fsl,imx6ul-uart”写成了“fsl,imx6ull-uart”多打了一个l。6.2 of_match_table的哨兵元素不能省of_match_table数组必须以空元素结尾也就是我前面代码里写的{ /* sentinel */ }。内核遍历这张表时全靠这个哨兵判断“表结束了”。如果漏了它内核会继续往后读内存里不属于这张表的内容轻则匹配异常重则直接oops。另外还要记得配合MODULE_DEVICE_TABLE(of, ...)使用这个宏会生成设备的modalias信息用户态的udev/mdev才能根据设备树节点自动加载对应的驱动模块。没有这个宏即使你的驱动编译成了.ko设备树里有了匹配的compatible系统也不会自动modprobe它probe自然不执行。6.3 检查status与内核config还有一个很低级但很常见的坑设备树里节点被status disabled关掉了。很多人改了compatible、改了驱动probe还是不执行最后发现板级dts里根本没有把节点重新打开或者打开的外设的时钟配置被其他节点占用了。i.MX6ULL的设备树里绝大部分外设节点默认都是disabled必须由板级dts里显式打开才生效。同时还要确认内核的CONFIG有没有开。i.MX6ULL上如果你写的驱动是编译进内核还是编译成模块有本质区别。编译成模块时根文件系统里有没有对应的加载机制、模块路径对不对都是probe不执行的原因。别一头扎进匹配机制里先确认驱动到底加载了没有——用lsmod看一眼比什么都快。6.4 利用sysfs和driver_override反向定位排查Platform匹配问题sysfs是你最趁手的工具。设备注册没注册、驱动注册没注册、绑定关系是什么一眼就能看出来ls /sys/bus/platform/devices/ ls /sys/bus/platform/drivers/imx-uart/如果两个目录下该有的都有但就是没绑定那问题基本锁定在匹配条件不满足上。此时可以再查看设备uevent里导出的modalias信息它会直接显示设备树匹配时会用到的compatible字符串cat /sys/bus/platform/devices/2020000.serial/uevent另一个调试利器是driver_override机制。它允许你在sysfs里强制指定某个设备必须和某个驱动绑定完全绕开正常的匹配规则。这在确认“到底是匹配失败还是驱动本身probe有bug”时非常有用echo imx-uart /sys/bus/platform/devices/2020000.serial/driver_override echo 2020000.serial /sys/bus/platform/drivers/imx-uart/bind如果强制绑定后probe正常执行了说明驱动本身没问题问题出在匹配规则上如果强制绑定后probe照样报错那就回到驱动代码里找问题。这个二分法能帮你省掉大量瞎猜的时间。最后再分享一个我个人的小习惯每次改完compatible都会顺手把设备树反编译出来看一眼原始字符串用xxd检查末尾有没有异常字符。以及调试时始终按“设备在不在→驱动在不在→匹配规则对不对→deferred队列有没有”的顺序排查而不是一上来就怀疑内核。这套流程在i.MX6ULL上帮我解决过太多“probe莫名其妙不执行”的怪问题了希望能给你省下几个加班的夜晚。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进