Linux Platform总线深度解析:设备树与驱动匹配机制
2026/9/6 13:30:30 网站建设 项目流程

1. 为什么非要有Platform总线不可

先抛一个很多刚接触嵌入式Linux的开发者都会有的疑惑:GPIO、I2C、SPI、UART这些外设控制器,驱动模型里明明已经有对应的总线类型了,为什么还要搞出一个Platform总线?代码里没接在PCIe上,也没挂在USB下,一个内存映射设备凭什么非要往Platform上挂?

这个问题的答案,得从Linux设备模型的设计逻辑说起。Linux内核把所有的硬件设备都抽象成"设备-总线-驱动"三个角色,总线负责匹配设备和驱动。PCI设备有Vendor ID和Device ID可以精确识别,USB设备有VID/PID可以识别,但有一大类设备——SoC内部集成的外设控制器——它们直接挂在CPU的内存地址总线上,没有自描述能力,不响应任何枚举探测。你没法像插一块PCIe网卡那样通过扫描总线把它找出来,它就在那儿,地址固定、中断固定,你需要主动告诉内核它存在。

这类设备就是platform设备。内核搞出Platform总线,本质上是给"没有总线可挂的设备"一个统一的挂载点和管理框架。i.MX6ULL内部的UART、GPIO、I2C控制器,全部是platform设备。所以你写i.MX6ULL的驱动,尤其是板级外设驱动(比如某个GPIO控制的LED、某个挂在SPI总线上的LCD),几乎绕不开Platform设备与驱动匹配这套机制。

再直白一点:在设备树(DT)大行其道的今天,你在.dts文件里写的每一个节点,绝大多数最终都会展开成一个platform_device注册进内核;而你的每一个platform_driver,负责和这些设备配对。理解不了这套配对逻辑,你连probe函数为什么没被调用都排查不了,更别说自己写内核模块了。

这篇文章,我就用i.MX6ULL平台的实际代码,把Platform设备与驱动匹配机制从头到尾拆开讲清楚。包括设备是怎么注册进来的、驱动是怎么声明匹配条件的、内核的匹配优先级是什么、匹配成功后生命周期怎么走,以及我实际开发中踩过的一些坑。内容尽量保持能够直接照着实验的颗粒度,不只是讲概念。

2. 设备这一侧是如何进入内核的:从设备树DTS到platform_device

2.1 设备树的展开过程

在i.MX6ULL这种现代SoC平台上,我们几乎不会手动用platform_device_register去注册设备,而是写设备树。原因很简单:SoC内部外设多、复用关系复杂、板级差异大,用C代码逐个注册设备既死板又难维护。设备树则把"硬件长什么样"从内核源码里抽离出来,变成一份独立描述文件。

设备树到platform_device的转换路径大致如下:

DTS文件 --> DTB二进制 --> __unflatten_device_tree() 解析为device_node树 --> of_platform_bus_probe() 遍历根节点的compatible属性 --> 对每个匹配的节点调用of_platform_device_create_pdata() --> 创建struct platform_device并注册进内核

这个转换不是一次性全做完的。内核先扫描根节点,找到所有compatible属性匹配"simple-bus"之类值的节点,把它们视作platform总线,然后递归展开子节点,持续创建platform_device。当然,具体哪些设备会变成platform_device,取决于节点的compatible属性是否匹配总线展开条件。

以i.MX6ULL官方开发板(比如正点原子或野火那一类)的设备树为例,你在.dts里定义一个LED节点:

/ { led { compatible = "gpio-leds"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_led>; led-gpios = <&gpio1 3 GPIO_ACTIVE_LOW>; status = "okay"; }; };

当内核启动、设备驱动模型初始化完成后,这个节点会变成一个platform_device,名字(name字段)根据节点名或compatible属性推断,device_node指针指向对应的device_node。之后,驱动侧就可以通过of_match_table里声明的compatible字符串和它配对。

2.2 节点匹配"simple-bus"是第一个容易踩的坑

这里有一个特别值得注意的细节:很多初学者在设备树里自定义一个节点,然后在驱动里用platform_driver_register注册驱动,却发现probe始终不被调用。查来查去,最后发现问题是:自定义节点没有挂在任何匹配"simple-bus"的总线节点下,或者自定义节点自身的compatible属性带上了某个不会被总线路由识别的值。

更准确地说,内核在把设备树节点转换成platform_device的时候,不是对树上所有节点无差别转换。对于挂在根节点"/"下的平台设备,内核的of_platform_default_populate_init会直接扫描根节点下一层,并递归展开那些compatible为"simple-bus"、"simple-mfd"、"isa"等特定值的子节点。如果你的自定义节点不是挂在根节点下,而是挂在某个I2C控制器节点下、SPI控制器节点下,那它会被I2C或SPI核心识别为I2C设备或SPI设备,而不是platform设备,自然走不到platform_driver的probe。

所以,写设备树之前先想清楚:我的设备是挂在哪个总线上的?如果是GPIO控制的LED、内存映射的杂项外设、中断控制器这类内存映射设备,放根节点或simple-bus节点下,走platform机制;如果是I2C从设备,走I2C机制,用i2c_driver;SPI从设备同理,用spi_driver。搞混了总线类型,驱动写得再好也不会被调用,这是设备模型的基本规矩。

3. 驱动这一侧如何敲门:platform_driver注册与of_match_table

设备已经就位了,驱动这边怎么注册自己,并且告诉内核"我能管哪些设备"?

每个platform_driver本质上是一个struct platform_driver结构体,最终通过platform_driver_register()注册到内核。这个结构体里,最关键、也最容易出错的是两个字段:driver.name和driver.of_match_table。

以i.MX6ULL上一个简单的按键驱动为例:

static const struct of_device_id imx6ull_key_of_match[] = { { .compatible = "fsl,imx6ull-key", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, imx6ull_key_of_match); static struct platform_driver imx6ull_key_driver = { .probe = imx6ull_key_probe, .remove = imx6ull_key_remove, .driver = { .name = "imx6ull-key", .of_match_table = imx6ull_key_of_match, }, }; module_platform_driver(imx6ull_key_driver);

module_platform_driver是一个便捷宏,展开后就是module_init调用platform_driver_register、module_exit调用platform_driver_unregister,省得手写两个入口函数。

3.1 匹配优先级:of_match_table、id_table、name

当内核需要给一个platform_device寻找驱动时,匹配逻辑按以下优先顺序进行:

  1. 如果设备有device_node(即来自设备树),先检查驱动的of_match_table,用compatible属性做匹配。这是当前设备树平台下最常用的路径。
  2. 如果没有device_node,则匹配platform_driver的id_table(struct platform_device_id数组),通过name字段匹配。
  3. 仍然没有,就退化成用driver.name和platform_device的name字段做字符串匹配。

这里最让人困惑的是第三种情况。很多早期驱动或者习惯写内核模块的开发者,会在platform_driver里只设置driver.name,然后希望它匹配设备树节点名。系统确实会做字符串比较,但有个坑:platform_device的name字段优先取设备树节点中的"compatible"属性中第一个字符串,而不是节点名。节点名通常被存在device_node->name里,但platform_device的name已经做了转换。

举个例子。你的设备树节点叫"mydevice",compatible是"vendor,mydevice"。设备创建成platform_device之后,它的name多半是"vendor,mydevice",而不是"mydevice"。如果驱动里只写了.driver.name = "mydevice",那永远匹配不上。这就是为什么我在网上见过很多帖子问"为什么名字明明对得上却probe不了"——因为他们没搞明白这个name是从compatible来的。

3.2 一个必须记住的MODULE_DEVICE_TABLE

在of_match_table声明之后,记得带上这行:

MODULE_DEVICE_TABLE(of, imx6ull_key_of_match);

如果你打算把驱动编译成内核模块(.ko),这行宏会在模块编译时生成一个额外的别名段。这个别名用于模块自动加载场景:当设备树中出现了匹配的compatible字符串时,udev(或mdev)根据内核发出的uevent事件查找对应模块并自动加载。没有这行宏,虽然不影响手动insmod,但系统的自动加载机制就废了——很多嵌入式系统里设备都枚举出来了,驱动却没被加载,查到最后就是没写MODULE_DEVICE_TABLE。

4. probe函数到底做了什么:i.MX6ULL Platform驱动生命周期

4.1 匹配成功后的完整生命周期

平台设备和驱动匹配成功后,内核会依次调用:

  • platform_driver的probe函数
  • 驱动正常工作
  • 设备移除或驱动卸载时,调用remove函数
  • 系统关机或设备掉电时,如果有需要,调用shutdown函数

以i.MX6ULL的GPIO按键驱动为例,probe函数里通常会做这些事情:

static int imx6ull_key_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct imx6ull_key_dev *keydev; struct resource *res; int ret; keydev = devm_kzalloc(dev, sizeof(*keydev), GFP_KERNEL); if (!keydev) return -ENOMEM; keydev->gpio = of_get_named_gpio(dev->of_node, "key-gpios", 0); if (!gpio_is_valid(keydev->gpio)) { dev_err(dev, "failed to get key-gpio\n"); return -EINVAL; } ret = devm_gpio_request_one(dev, keydev->gpio, GPIOF_IN, "imx6ull-key"); if (ret < 0) { dev_err(dev, "failed to request gpio %d\n", keydev->gpio); return ret; } platform_set_drvdata(pdev, keydev); dev_info(dev, "probe success\n"); return 0; }

4.2 为什么都推荐devm_系列API

注意我上面全部使用了devm_前缀的资源管理函数:devm_kzalloc、devm_gpio_request_one。这是设备驱动开发中特别值得养成习惯的一点。

devm即device managed。这些API分配的资源自动绑定到设备生命周期上:设备detach、驱动probe失败返回、设备释放的时候,内核会自动帮你把这些资源全部释放掉。这有几个明显好处:

  • probe函数中途出错时,不需要手工逐个回滚前面已经申请的资源,直接return错误码即可。
  • remove函数里不需要做一大堆释放操作,代码干净很多。
  • 避免忘记释放导致的内存泄漏。这种泄漏在内核里排查起来相当痛苦,devm从根本上降低犯错概率。

传统非devm写法下,probe里申请了GPIO、注册了中断、分配了内存,中途某个步骤失败,你得想着把前面的都释放掉。一旦漏了哪个,轻则资源泄漏,重则下次probe时申请资源失败一直起不来。我一直跟做驱动的新人强调:能用devm就用devm,这是Linux内核近几年主推的资源管理方式,不是花架子。

4.3 device_node、pdev->dev.of_node与of_get_*系列

probe函数里另一个常见操作是从设备树节点中读取属性。pdev->dev.of_node就是devicetree node指针,所有of_开头的API基本都围绕它操作。

比如读取一个32位整数属性:

u32 poll_interval; ret = of_property_read_u32(dev->of_node, "poll-interval", &poll_interval);

比如读取GPIO编号,除了上面那种of_get_named_gpio方式,更现代的是用GPIO descriptor接口:

struct gpio_desc *key_gpio; key_gpio = devm_gpiod_get_optional(dev, "key", GPIOD_IN);

两种接口的取舍,我建议新项目优先用gpiod_系列。gpiod有引用计数、有设备绑定关系、还能直接处理设备树里的GPIO极性标志(GPIO_ACTIVE_LOW/HIGH),老gpio号接口在这些方面都很粗糙,而且从设备树取出的gpio号范围还有全局gpio号变化的风险(当gpio控制器探测顺序变化时)。

我在实际项目中就遇到过:某个gpio controller驱动先加载时gpio号是某个值,换了一种内核配置后gpio控制器探测顺序变了,gpio号整体偏移,老接口写死的gpio号全乱了。gpiod接口按name或index去查找,内核内部处理了映射,完全不受全局编号影响。这就是推荐它的核心理由。

5. i.MX6ULL平台数据从哪来:设备树属性解析与复用

5.1 设备树节点和platform_data的关系

在一些老旧的内核代码或非设备树平台(比如以前的ARM板级代码、x86平台),platform_device通常配合platform_data使用,即platform_device.dev.platform_data指针指向一个结构体,里面填好硬件配置参数。驱动通过pdev->dev.platform_data拿到参数。

在设备树平台下,platform_data的角色被设备树属性完全替代了。驱动不再直接拿platform_data,而是从pdev->dev.of_node读取各种属性。从驱动开发的角度,设备树节点本质就是一个"键值对集合",和platform_data提供参数的功能一样,只是存储方式从C结构体变成了文本描述。

唯一要注意的是:代码里不能同时依赖of_node属性和platform_data来初始化同类参数,否则可能出现平台数据为空却未做校验导致空指针访问的问题。

5.2 实际i.MX6ULL开发中如何解析pinctrl和时钟

Platform机制真正强大的地方,在于它和Linux的pin control框架、clock框架深度集成。i.MX6ULL的每个外设引脚都要配置MUX模式、电气属性,这些现在都在设备树里配置,内核会在platform_device创建后自动应用这些配置。

pinctrl属性通常在设备节点中这样配置:

&iomuxc { pinctrl-names = "default"; pinctrl-0 = <&pinctrl_uart1>; pinctrl_uart1: uart1grp { fsl,pins = < MX6UL_PAD_UART1_TX_DATA__UART1_DCE_TX 0x1b0b1 MX6UL_PAD_UART1_RX_DATA__UART1_DCE_RX 0x1b0b1 >; }; };

驱动probe时,不需要显式调用任何pinctrl API。platform子系统在设备probe之前会自动寻找并应用pinctrl-0指定的pin配置。驱动只需要在probe里正常操作UART寄存器即可。同理,时钟框架也类似——设备节点里的clocks属性、clock-names属性,驱动可以通过clk_get(dev, "name")拿到对应的时钟,也可以配置成runtime PM自动开关时钟。

这几个框架协同工作的时候,Platform驱动就能做到"驱动代码里几乎没有硬件配置信息",所有板级差异都收敛到设备树里。这也是为什么现在的ARM Linux驱动代码普遍短小精悍——配置和逻辑分离,驱动专注于逻辑处理。

6. 调试匹配问题的实战手段:从sysfs看到force_probe

实际开发中匹配不上的情况太多了,我自己就经历过几次卡了一整天的调试。这里分享一下最实用的排查链路。

第一步,去sysfs确认设备到底有没有被创建。运行:

ls /sys/bus/platform/devices/

如果设备树节点正确展开,你会看到类似这样的条目:

1e10000.uart 20a0000.i2c led

设备条目的名字通常是"地址.名称"格式,或者纯节点名。如果这里没有你的设备,那问题出在设备树展开阶段,驱动代码看都不用看。

第二步,确认设备节点是否带driver链接:

ls -l /sys/bus/platform/devices/led/ # 如果已经匹配了驱动,会出现driver -> ../../bus/platform/drivers/imx6ull-key

如果设备有了,但driver链接没出现,说明匹配失败。此时去查:

  • 设备树compatible属性和驱动of_match_table是否完全一致,注意厂商前缀是否多了少了、大小写。
  • 驱动是否已加载:lsmod | grep key
  • 驱动自己的probe是不是失败了:dmesg | grep imx6ull-key

第三步就是一种比较少为人知的调试手法:使用force_probe。

比如你确认compatible是对的,驱动也加载了,系统却还是没匹配上,而且你想快速验证驱动本身的probe逻辑有没有问题,可以这样做:

echo "imx6ull-key" > /sys/bus/platform/drivers/imx6ull-key/bind

bind文件允许你手动把设备和驱动绑定起来。不过更常用的是force_probe(需要内核开启CONFIG_DEBUG_FS且platform驱动支持)。如果你不想重新编译内核,可以手动把设备地址写入bind,让platform核心尝试匹配。

还有一种更狠的调试方式:把驱动暂时改成不需要匹配,直接在init函数里手动构造platform_device并注册。这种方式在早期开发裸外设驱动时效率很高,先验证寄存器操作逻辑,再接入设备树匹配流程。我不建议一直这样写,但作为阶段性验证手段,非常有效。

7. 一个完整的可编译实例:LED Platform驱动

为了把上面这些机制串起来,我给出一个完整的i.MX6ULL平台LED驱动示例,并且会标注编译和加载的要点。

设备树侧需要准备如下内容。在根节点下添加LED子节点:

/ { goodix_led: goodix-led { compatible = "example,goodix-led"; pinctrl-names = "default"; pinctrl-0 = <&pinctrl_goodix_led>; led-gpios = <&gpio1 3 GPIO_ACTIVE_LOW>; poll-interval = <100>; status = "okay"; }; };

iomuxc节点里添加引脚配置:

&iomuxc { pinctrl_goodix_led: goodixledgrp { fsl,pins = < MX6UL_PAD_GPIO1_IO03__GPIO1_IO03 0x10b0 >; }; };

内核模块代码如下,保存为goodix_led_platform.c:

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_gpio.h> #include <linux/gpio/consumer.h> #include <linux/delay.h> struct goodix_led_dev { struct gpio_desc *led_gpio; int poll_interval; }; static int goodix_led_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct goodix_led_dev *ldev; u32 val = 0; int ret; ldev = devm_kzalloc(dev, sizeof(*ldev), GFP_KERNEL); if (!ldev) return -ENOMEM; platform_set_drvdata(pdev, ldev); ldev->led_gpio = devm_gpiod_get(dev, "led", GPIOD_OUT_HIGH); if (IS_ERR(ldev->led_gpio)) { ret = PTR_ERR(ldev->led_gpio); dev_err(dev, "failed to get led gpio: %d\n", ret); return ret; } ret = of_property_read_u32(dev->of_node, "poll-interval", &val); if (ret == 0) ldev->poll_interval = val; else ldev->poll_interval = 100; gpiod_set_value(ldev->led_gpio, 1); dev_info(dev, "probe ok, poll_interval=%d\n", ldev->poll_interval); return 0; } static void goodix_led_shutdown(struct platform_device *pdev) { struct goodix_led_dev *ldev = platform_get_drvdata(pdev); gpiod_set_value(ldev->led_gpio, 0); dev_info(&pdev->dev, "shutdown, led off\n"); } static int goodix_led_remove(struct platform_device *pdev) { struct goodix_led_dev *ldev = platform_get_drvdata(pdev); gpiod_set_value(ldev->led_gpio, 0); dev_info(&pdev->dev, "remove, led off\n"); return 0; } static const struct of_device_id goodix_led_of_match[] = { { .compatible = "example,goodix-led", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, goodix_led_of_match); static struct platform_driver goodix_led_driver = { .probe = goodix_led_probe, .remove = goodix_led_remove, .shutdown = goodix_led_shutdown, .driver = { .name = "goodix-led", .of_match_table = goodix_led_of_match, }, }; module_platform_driver(goodix_led_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("i.MX6ULL Platform LED driver example");

编译这个模块的Makefile:

obj-m := goodix_led_platform.o KERNELDIR := /path/to/your/kernel/source PWD := $(shell pwd) all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

加载注意事项:

insmod goodix_led_platform.ko dmesg | tail

如果设备树里已经配好了节点,dmesg里会出现:

goodix-led: probe ok, poll_interval=100

如果dmesg里没看到信息,按前面说的方法逐层排查。这里还补充一个细节:如果你的内核编译时启用了modules_install,可以顺手把模块安装到根文件系统/lib/modules/$(uname -r)/extra/下,然后depmod,这样以后设备树节点出现时模块能够自动加载。

8. 打通Platform机制后,下一步往哪走

从这个简单LED实例延伸出去,i.MX6ULL平台驱动的进阶方向大概有这几个。

第一是中断处理。把GPIO按键接到Platform驱动里,使用request_irq或devm_request_irq注册中断处理函数,注意在设备树里配置interrupts属性时,i.MX6ULL的GPIO中断号需要经过gpio_to_irq或gpiod_to_irq转换,不是设备树里直接写死某个数字。这一点写过x86 PCI驱动的人容易踩坑——PCI中断资源由总线枚举分配,GPIO中断却要通过GPIO控制器动态分配。

第二是miscdevice和platform_driver的组合。一个Platform驱动,挂在一个Linux字符设备框架下,主设备号使用misc动态分配,实现read/write/ioctl接口,这是嵌入式Linux里最常见的外设驱动形态(LED、按键、传感器都这么干)。misc设备注册和注销极其轻量,一个ngpio引脚的设备用misc比用alloc_chrdev_region简单太多。

第三是异步probe。如果某个Platform驱动的probe过程比较耗时(比如要等待外部芯片复位、要初始化一个很大的DMA缓冲),可以在platform_driver.driver中设置.probe_type = PROBE_PREFER_ASYNCHRONOUS,让内核允许该驱动异步执行probe,不阻塞整条设备初始化链。尤其在系统上有多个设备并行初始化时,能明显缩短启动时间。这一个字段就能解决的问题,很多团队却为了优化启动时间在init脚本里做各种串行操作,实在没有必要。

最后还是要强调:Platform机制不是孤立概念,它是整个Linux设备模型的外延。通过platform_device和platform_driver这套配对逻辑,你能自然理解到为什么驱动只需要写probe函数、为什么设备树改了引脚配置驱动代码不用动、为什么sysfs下有那层层叠叠的目录结构。这套机制看似绕,实则把"硬件描述"和"软件逻辑"拆得干干净净。懂了这个,再看PCI、USB、I2C这些具体总线驱动,你会发现框架全是同一套路子,只是匹配键值从compatible字符串变成了Vendor ID和Device ID而已。

我在实际开发里最大的体会是:花一个下午把Platform匹配机制的源代码走一遍(drivers/base/platform.c和drivers/base/dd.c这两个文件就够了),比看十篇博客都有用。drivers/base/dd.c里的really_probe函数,就是驱动匹配成功后从probe到设备真正ready的全过程,一行行看下去,整个Linux驱动的"一半江山"你基本上就拿下了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询