1. 为什么i.MX6ULL驱动开发总在跟Platform打交道
1.1 从寄存器直接操作到设备模型的演进
很多人刚上手i.MX6ULL的Linux驱动时,第一反应都是找芯片手册,然后把外设寄存器的地址直接写进代码里。比如控制一个LED,最简单粗暴的写法大概是这样:
#define GPIO_LED_BASE 0x020C4000 #define GPIO_DIR_REG (GPIO_LED_BASE + 0x04) #define GPIO_DATA_REG (GPIO_LED_BASE + 0x00) static void led_on(void) { writel(readl(GPIO_DATA_REG) | (1 << 24), GPIO_DATA_REG); }这段代码在板子上确实能跑,但问题也非常明显:寄存器地址、引脚号全部硬编码进了源码。今天点亮LED要改引脚,明天换一颗NAND Flash,后天整个板子换成另一颗SoC,你的驱动就得跟着改一遍。更要命的是,改完之后还得重新编译整个驱动,甚至可能影响内核镜像。
Linux内核的驱动模型就是为了解决这类问题。它把硬件信息从驱动代码里剥离开,抽象成了三个角色:设备、驱动和总线。设备负责描述“硬件长什么样”,驱动负责描述“软件怎么操作”,总线则负责把两者拉在一起。内核里绝大多数的真实总线像I2C、SPI、USB,各自有完整的协议栈去管理挂接在它上面的设备,但系统中还有很多外设并不挂在物理总线上,纯粹是SoC内部的功能单元,比如GPIO控制器、UART控制器、SDIO控制器、看门狗。这些设备怎么注册?怎么匹配驱动?Platform总线就是为这个场景准备的。
1.2 Platform总线其实是条“兜底虚拟总线”
Platform总线不是真实存在的物理连接,它是一条虚拟总线,专门用来承接那些没有专门总线承载的设备。i.MX6ULL这颗芯片上的外设,绝大多数本质上都是SoC内存映射出来的硬件模块,所以它们天然属于Platform设备。哪怕你的某些功能是通过I2C接口外挂传感器实现的,I2C控制器自身仍然会作为一个Platform设备挂在内核里,传感器才挂在I2C控制器的子总线上。
在传统的内核代码里,平台设备通过platform_device_register()注册,驱动通过platform_driver_register()注册。而在i.MX6ULL这类使用设备树的嵌入式平台上,设备信息不再由C代码手动构建,而是由设备树中的节点描述。内核在启动阶段会扫描设备树,把每个带compatible属性的节点转换为一个platform_device注册到Platform总线上,之后驱动加载时再自动完成匹配,最终触发probe函数。
这也是理解整套机制的核心:设备树负责“是什么”,驱动负责“怎么干”,Platform总线负责“配对”。你写驱动的重点也就变成了三件事:设备树节点写得准、驱动结构体声明得对、匹配信息对得上。
2. platform_match到底怎么匹配:四条路径与一条优先规则
2.1 从内核源码看匹配优先级
Platform设备与驱动的匹配逻辑集中在platform_match()函数中。不同内核版本实现稍有区别,但核心思路一直很稳定。以5.x内核的代码为例,函数大致逻辑如下:
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; if (pdrv->id_table) return platform_match_id(pdev, pdrv->id_table) != NULL; return (strcmp(pdev->name, drv->name) == 0); }第一次看这段代码时,我很自然地以为只有一种匹配方式。实际上一共存在四类匹配路径:
- 设备树匹配:调用
of_driver_match_device(),拿设备树节点的compatible属性与驱动of_match_table里的compatible字段逐一对比。只要一个匹配,即返回成功。 - 传统ID表匹配:比较
platform_device的name与platform_driver中id_table表项的name字段。 - 设备名与驱动名匹配:在
id_table为空的情况下,直接比较设备名字与驱动名字是否相同。 - ACPI匹配:在以ACPI方式描述硬件的平台(主要是x86)上还会走
acpi_driver_match_device(),嵌入式Linux一般不涉及。
在i.MX6ULL这种全设备树环境中,你平时打交道最多的就是第一条路径。设备树中写compatible = "myvendor,myled",驱动of_match_table中对应的元素也要写compatible = "myvendor,myled"。一个字符的差异,匹配都会失败。
2.2 为什么of_match_table前面经常带逗号结尾
新手经常在别人的驱动里看到这样的写法:
static const struct of_device_id my_led_of_match[] = { { .compatible = "myvendor,myled", }, { } };这里最容易误解的是最后那个{ }空元素。它不是一个“占位符”,而是一个“哨兵”——标志数组结束。内核在遍历of_device_id列表时,如果发现某个元素的compatible字段为NULL,就认为列表遍历完毕。遗漏这个哨兵,轻则遍历越界,重则驱动行为不可预测,甚至可能在内核启动阶段直接卡死。我自己就因为这个吃过亏,当时排查了很久才发现是结构体数组没有正确终止。
另外,of_match_table中的compatible字段并不要求一定和设备树节点的compatible完全一样。设备树里的属性本身可以写多个值,比如:
compatible = "myvendor,myled-v2", "myvendor,myled";匹配规则是“驱动程序只声明它支持哪个版本”,设备树通过包含多个字符串来表达“我兼容哪个版本”。内核会从头开始,把设备树compatible的每个值依次与驱动的匹配表对比,任意一个对上就算匹配成功。上面的设备树节点如果驱动of_match_table只写了"myvendor,myled",它也能成功匹配,因为设备树明确声明自己兼容老版本。这是在设计设备树兼容性时非常实用的技巧:新板子默认写新版本字符串,再往后面追加一个旧版本作为向后兼容的兜底。
3. 手写一个Platform驱动:从设备树到probe触发全流程
3.1 设备树节点应该怎么写
以最常见的GPIO按键或LED为例,先看设备树。在i.MX6ULL的板级设备树文件(通常类似imx6ull-myboard.dts)中,随便找一个方便的位置加一个自己的节点:
/ { my_led { compatible = "myvendor,myled"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_myled>; led-gpios = <&gpio5 3 GPIO_ACTIVE_LOW>; }; };注意几点。第一,根节点下面直接定义子节点是完全允许的,内核会把这种没有reg属性也没有device_type的普通节点作为平台设备注册。第二,compatible的命名规范建议采用厂商前缀,设备名称的格式,这也是设备树规范的要求,能避免不同厂商之间的命名冲突。第三,如果你的设备需要管脚复用,务必在节点里引用对应的pinctrl配置,否则GPIO的pad配置不对,硬件上根本点不亮灯,驱动却以为已经初始化成功了。
如果是更接近硬件寄存器操作的场景,也可以显式给出寄存器资源:
/ { my_led_mapped { compatible = "myvendor,myled-mapped"; reg = <0x020C4000 0x1000>; }; };reg属性的前一个数字是起始物理地址,后一个数字是长度。驱动侧可以用platform_get_resource()获取该地址,或者直接用devm_platform_ioremap_resource()完成地址映射并返回虚拟地址。
3.2 驱动代码的标准骨架
Platform驱动的代码骨架非常固定,建议把下面这个模板保存下来,每次开发时直接套用:
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> #include <linux/of_gpio.h> #include <linux/gpio/consumer.h> static int my_led_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct gpio_desc *led_gpio; struct resource *res; void __iomem *base; /* 方式一:从设备树读取GPIO描述符 */ led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_LOW); if (IS_ERR(led_gpio)) { dev_err(dev, "failed to get led gpio: %ld\n", PTR_ERR(led_gpio)); return PTR_ERR(led_gpio); } /* 方式二:从reg属性获取内存资源并映射 */ res = platform_get_resource(pdev, IORESOURCE_MEM, 0); base = devm_ioremap_resource(dev, res); if (IS_ERR(base)) { dev_err(dev, "failed to ioremap resource\n"); return PTR_ERR(base); } dev_info(dev, "probe success\n"); return 0; } static void my_led_remove(struct platform_device *pdev) { dev_info(&pdev->dev, "remove called\n"); } static const struct of_device_id my_led_of_match[] = { { .compatible = "myvendor,myled-mapped", }, { }, }; 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_driver", .of_match_table = my_led_of_match, }, }; module_platform_driver(my_led_driver); MODULE_LICENSE("GPL");MODULE_DEVICE_TABLE看起来很不起眼,但作用很重要。它负责生成模块的别名信息,让modprobe这类工具能根据设备树中解析出来的compatible信息判断是否需要加载这个模块。如果你开发的是外部模块,想让设备插入时自动触发加载,这行宏不能省。
3.3 probe函数被调用的完整链路
很多人对“设备树节点为什么会变成记忆中的probe调用”没有直观认识。我简单梳理一下这个链路,看完你就能把整条线串起来。
第一步,内核启动时,设备树二进制.dtb被解析成device_node节点树,其中每个带compatible属性的普通节点,会被of_platform_bus_probe()或of_platform_populate()处理,生成对应的platform_device。
第二步,这些platform_device被注册到Platform总线。注册过程会触发总线进行一次bus_add_device()操作,设备加入到总线的设备链表。
第三步,当你的驱动模块被插入时,module_platform_driver()宏展开会调用platform_driver_register(),驱动加入总线驱动链表。
第四步,总线框架调用bus_for_each_dev()遍历所有设备,对每个设备执行platform_match(),也就是第二节分析的匹配过程。
第五步,匹配成功,总线调用驱动的probe函数。如果probe返回0,设备与驱动绑定成功。如果返回非0,无法绑定,但后续重新尝试时仍然可能再次触发。
这个链路里的细节是:设备先注册,驱动后注册,是常见时序;驱动先注册,设备后注册,也会触发匹配。这就是为什么你的驱动可以做成模块,甚至在系统启动后再手动insmod,只要设备树里的节点存在,加载驱动的瞬间匹配就会发生。
4. probe迟迟不进的排查链路与实测案例
4.1 先确认设备到底存不存在
写驱动最难受的场景就是:模块加载了,无报错,但probe压根没被调用。这种问题很多新手会一头钻到匹配逻辑里反复检查驱动结构体,但我建议按照下面的顺序排查,效率会高很多。
先看设备树节点的状态:
ls /sys/bus/platform/devices/在输出里找有没有你设备树中compatible对应的设备目录。比如你写了myvendor,myled,设备目录可能叫myled或者内核自动生成的别名,一般会包含设备名和地址后缀。如果这里什么都没看到,说明设备树节点根本没有成功变成platform_device,问题出在设备树侧,而不在驱动侧。
接下来看驱动是否挂到了Platform总线上:
ls /sys/bus/platform/drivers/my_led_driver/如果驱动注册成功,这个目录应该存在。目录下原本会有一个空的bind接口和unbind接口。驱动注册成功后,如果某个设备与驱动成功匹配,目录下还会出现一个指向设备的符号链接。
这两个目录配合dmesg输出,基本能定位80%以上的问题。
4.2 检查compatible一致性和module装载情况
常见的匹配失败原因可以分成三类。第一类是模块根本没装载到当前内核,或者装载的是编译出错的旧版本。用insmod时有任何报错都先解决报错。第二类是对比项不一致,最常见的是设备树里写成"myvendor,myled",驱动里却写成"myvendor,myled-mapped",或者反过来。第三类是设备树更新了,但内核镜像里打包的DTS数据没有重新编译进.dtb。
第三类在实际开发中尤其隐蔽。因为i.MX6ULL上经常会用启动环境变量去指定设备树文件,如果你修改的是源码里的.dts文件,但启动eMMC或SD卡分区里的.dtb仍然还是旧文件,那么无论你怎么调驱动代码,设备树层面永远不会响应。我踩过一次很深的坑:改完设备树,也重新编译了内核镜像,自以为万事大吉,结果启动参数里写的设备树路径对应的分区根本没被我更新的文件覆盖。后来用下面的命令才确认问题:
dtc -I fs /proc/device-tree -O dts | grep myled如果/proc/device-tree里没有你的节点信息,就说明当前运行的内核设备树和你预期不一致。把启动介质中的设备树文件重新拷贝覆盖,问题立刻消失。
4.3 实测案例:一个引脚名引发的排查记录
有一回我自己在i.MX6ULL上做一个LED驱动,设备树里写的是led-gpios = <&gpio5 3 GPIO_ACTIVE_LOW>,驱动里用devm_gpiod_get(dev, "led", GPIOD_OUT_LOW)去拿GPIO。第一次probe就一直报failed to get led gpio,返回-ENOENT。
排查时我先怀疑设备树写法,把led-gpios改成leds-gpios,再改驱动里的名字去对应,绕来绕去,最后才发现问题根本不在命名的随机性上,而是我没有引用GPIO相册相关的头文件,导致设备树里的GPIO定义没有被正确解析为可用的gpio chip引用。简单说,GPIO描述符的查找依赖设备树中的gpio-xlate映射,如果对应的GPIO控制器没有正确注册,那不管名字怎么改都找不到。
后来我意识到,devm_gpiod_get这类API已经在内部封装了设备树解析、中断请求、时钟使能等大量操作,但它要求设备树描述必须完整准确。如果你的GPIO控制器在设备树里的状态异常,或者pinctrl配置缺失导致gpio chip注册失败,那么你在这个节点上怎么调都无济于事。这也提醒我们,排查Platform驱动问题时,不要只盯着Platform这一层,设备树里引用的其他子系统(GPIO、时钟、中断)可能才是真正的病根。
4.4 加点调试输出看匹配路径
匹配不成功但又不确定具体原因时,可以临时启用内核动态调试,或者在你怀疑的路径上打印信息。直接修改驱动加dev_info当然最简单,但不是所有时机都能打印。更推荐的方式是使用of_platform_bus_probe之前的早期日志来确认设备树解析状态。
如果匹配本身发生了,只是probe函数内部又返回了错误,那么dmesg里通常会有对应驱动打印的错误信息,比如我的led_gpio例子。如果匹配没有发生,dmesg里基本只会有设备树解析相关的内容,此时把of_match_table里的第一个compatible字符串和设备树节点中的compatible值放在一起人工对比,一字不差,通常就能解决。
遇到实在找不到原因的情况,可以临时禁用模块自动加载,手动执行下面的步骤:
echo my_led_driver > /sys/bus/platform/drivers/my_led_driver/bind然后立刻看dmesg。有时候内核的匹配日志会通过debugfs或tracepoint暴露,你先用bind手动绑定,能更快区分“设备不存在”和“匹配条件不满足”两种情形。
5. 让Platform充分发挥价值:多实例绑定与资源读取思路
5.1 一个驱动驱动同型号多个外设
设备树解决“硬件信息与驱动代码解耦”之后,最直接的收益就体现在多实例场景。假如板子上有4路LED灯,每一路使用不同的GPIO引脚,传统写法的驱动得为每一路单独封装逻辑,或者用模块参数传递引脚号,繁琐且易错。使用Platform模型后,设备树里直接写4个节点:
/ { led0: my_led@0 { compatible = "myvendor,myled"; led-gpios = <&gpio5 0 GPIO_ACTIVE_LOW>; }; led1: my_led@1 { compatible = "myvendor,myled"; led-gpios = <&gpio5 1 GPIO_ACTIVE_LOW>; }; };驱动侧无需为每个LED写一份重复逻辑。因为每次匹配都会创建一个独立的struct device实例,而probe函数会被调用多次,每次传入的platform_device *pdev都不同,驱动代码使用platform_get_resource、devm_gpiod_get等API时,内部自动关联当前设备对应的资源。这样一份驱动,可以同时控制多路LED,互不干扰,代码量也几乎没有增加。这种多实例绑定能力,是硬编码寄存器时代梦寐以求的。
5.2 把自定义数据写进设备树并在驱动中读取
除了reg和GPIO,Platform驱动还可以借助设备树传递任意自定义参数。假设你的LED驱动需要支持“闪烁频率”和“上电默认状态”,设备树中可以定义:
my_led { compatible = "myvendor,myled"; blink-period-ms = <500>; default-on; };驱动侧通过统一的device_property_*API去读取,这样就不需要为每一个参数设计模块加载选项了。
struct device *dev = &pdev->dev; u32 blink_period = 0; bool default_on = false; device_property_read_u32(dev, "blink-period-ms", &blink_period); default_on = device_property_read_bool(dev, "default-on"); dev_info(dev, "blink: %dms, default_on: %d\n", blink_period, default_on);这套API已经封装了设备树解析细节。无论底层是设备树、ACPI还是平台固件,驱动代码都可以保持通用。如果你的驱动不是只在i.MX6ULL上运行,将来移植到其他架构平台时,这部分代码可以原封不动保留。
5.3 资源映射的现代API
很多时候你确实需要直接在驱动里操作寄存器。比如需要控制某个自定义外设模块,设备树里给出了reg地址,但内核没有现成的子系统帮你管理。这个时候devm_platform_ioremap_resource是关键函数:
struct resource *res = platform_get_resource(pdev, IORESOURCE_MEM, 0); void __iomem *base = devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base);res是同时获取资源信息的传统方式,devm_platform_ioremap_resource则是更简洁的现代方式,它内部会完成资源获取、地址映射、错误处理,并在设备释放时自动解除映射。少写了不少样板代码,也避免了常见的未释放资源导致的内存问题和映射泄漏。
如果同一个外设需要用多个中断,设备树里可以定义interrupts属性,驱动侧用platform_get_irq(pdev, index)获取对应编号的中断号。如果中断名定义清晰,也可以用platform_get_irq_byname(pdev, "tx"),可读性更好,推荐在有多个中断或协作开发时使用。
5.4 从Platform设备到更抽象的设备模型
理解Platform设备与驱动匹配之后,再看Linux其他子系统,会发现基本都在使用同一套“总线-设备-驱动”模式。I2C总线下的i2c_driver与i2c_client、SPI总线下的spi_driver与spi_device,它们的匹配机制和Platform类似,只是匹配规则程序换成了各自总线的match函数。比如I2C驱动也会通过i2c_device_id或设备树中的compatible来匹配,SPI总线的spi_match_device同样有相似逻辑。
所以一旦你用i.MX6ULL把Platform这套机制彻底吃透,后续写哪些挂在真实物理总线上驱动,上手速度会明显提高。你会很自然地设计出这样的结构:驱动里只保留协议逻辑和业务逻辑,所有与硬件相关的寄存器地址、中断号、GPIO编号、管脚复用统统交给设备树去描述。这样不仅代码更干净,工程上对硬件改板的适应能力也更强。板卡引脚有变动时,多半只要改设备树,驱动编译一次就能继续用。
实际项目中,这套思维带来的价值往往比单纯“能点亮LED”要大得多。我后来接手过多个基于i.MX6ULL的项目,有的是音频编解码芯片,有的是车载CAN设备,有的只是纯粹的GPIO扩展器。无论硬件差异多大,只要遵循Platform这种“设备与驱动分家、通过总线匹配”的思路,驱动主体代码都能复用。硬件工程师那边改设备树也很顺手,根本不需要软件人员频繁介入。
如果你刚到嵌入式Linux驱动开发这条路上,建议先不要急着堆砌各种外设驱动,而是把Platform机制彻底弄清楚,找一块i.MX6ULL开发板,自己写一个简单节点,从设备树到驱动完整走一遍流程,再看一看内核源码里platform_match的实现。这条路走通,Linux驱动开发的一半地基就有了。