☰
Linux platform总线与驱动开发:设备树匹配及probe机制详解
2026/10/21 10:23:10 网站建设 项目流程

1. 从"设备怎么和驱动对上"说起:platform总线解决的根本问题

先讲一段我自己的经历。早几年在某嵌入式项目上,我写了一个按键驱动,代码照着参考手册抄得整整齐齐,module_init、platform_driver_register该写的都写了,编译也一次通过。结果insmod之后,dmesg里只有一句"driver registered",我预期中的probe函数压根没被执行。排查了整整两天,最后发现问题出在设备树节点里的compatible字符串和驱动里写的对不上,多了一个字母,整个匹配就成了"查无此人"。从那以后我才真正意识到:要写好platform总线上的驱动,光会填结构体远远不够,你得先理解设备和驱动是怎么"对上眼"的。

Linux的设备驱动模型,有三件套:设备、驱动、总线。PCI设备挂在PCI总线上,USB设备挂在USB总线上,这些总线是真实存在的硬件总线,它们的设备天然带有厂商标识(Vendor ID)和设备标识(Device ID),驱动只要声明自己支持哪个ID,总线就会自动做撮合。

问题来了:嵌入式SoC上还有大量的外设,既不在PCI上,也不在USB上。比如GPIO控制器、UART控制器、I2C控制器、片上定时器,它们直接焊在芯片内部,没有一根物理总线把它们连到CPU上。那它们怎么跟对应的驱动匹配?总不能让每个驱动直接去遍历所有可能的物理地址吧?内核为此专门做了一条"虚拟总线",把这类设备统一挂上去,这就是platform总线。所以我在项目里经常跟同事说一句话:platform总线干的事不是"传输数据",而是"帮片上设备找亲戚"。

1.1 设备驱动模型的三件套是怎么协作的

在Linux内核里,总线、设备和驱动都抽象成了结构体。总线用struct bus_type描述,设备用struct device描述,驱动用struct device_driver描述。总线最重要的一个回调是match,内核每注册一个新的设备或驱动时,都会回到所属总线上,调用这个match函数去判断"有没有人能跟对方配上"。

可以这样理解:总线就是婚姻介绍所。设备是一份征婚启事,驱动是一个相亲候选人,match函数就是红娘手中的本子,比对两个人的条件是否相符。一旦匹配成功,红娘就会安排双方正式见面——对于内核来说,这个"正式见面"就是调用驱动的probe函数。

platform总线有自己的总线描述符,叫platform_bus_type。它注册了平台专属的匹配函数,逻辑上专门处理platform设备与platform驱动之间的配对。实际项目里,我们遇到的绝大多数驱动——LED、按键、看门狗、电源管理、各种杂项字符设备——最终都是通过这条虚拟总线挂到内核里的。

1.2 platform总线和PCI/USB总线的本质区别在哪

有人说platform总线不是真实硬件,所以它不重要。这个说法很坑人。PCI/USB总线的确会做协议握手、地址分配、带宽协商,但platform总线在整个嵌入式的驱动生态里,作用反而更基础。

它的匹配规则不需要硬件通信参与,匹配依据是软件层面的信息:设备树节点里的compatible字符串、驱动里声明的name以及id_table。设备与驱动之间传递资源的方式也非常简单——直接把寄存器地址范围、中断号、时钟名这些信息打包放在struct resource里。换句话说,PCI总线靠"设备自己报身份",platform总线靠"内核告诉驱动你是谁、你住哪"。

两者的另一个区别是生命周期。PCI设备在热插拔时动态出现,platform设备大多是开机就固定存在的片上外设,很多在使用过程中不能被拔掉。也因此,platform总线对设备与驱动的加载顺序比较敏感,这也是后期调试时最常遇到的问题之一。理解这两点,你就明白为什么platform总线在嵌入式内核开发中如此核心了。

2. platform设备到底是怎么"出生"的:静态注册与设备树两条路

搞清楚platform设备和驱动是"一对"之后,下一个问题就是:设备对象本身从哪来?我遇到不少初学者会卡在这里,因为驱动侧有明确的代码可以看,而设备侧却像是"凭空出现"的。

实际上,platform_device的诞生有两种主要途径:一种是在板级文件里用C代码手动注册,这是老内核的做法;另一种是内核在启动阶段扫描设备树,把节点自动展开成platform_device,这是新内核的主流做法。两种途径最终都会走到同一个归宿——把设备登记在platform总线上。

2.1 板级文件里的静态注册方式

以内核3.x时代的老代码为例,很多BSP里会看到类似下面的写法:

static struct resource mykey_resources[] = { { .start = 0x1C000, /* 寄存器物理地址起点 */ .end = 0x1C000 + 0x10, /* 寄存器物理地址终点 */ .flags = IORESOURCE_MEM, /* 内存资源类型 */ }, { .start = IRQ_EINT(5), /* 中断号 */ .end = IRQ_EINT(5), .flags = IORESOURCE_IRQ, /* 中断资源类型 */ }, }; static struct platform_device mykey_device = { .name = "mykey", .id = -1, .num_resources = ARRAY_SIZE(mykey_resources), .resource = mykey_resources, }; static int __init board_init(void) { platform_device_register(&mykey_device); return 0; }

这段代码干的事情,就是手动构造一个设备,告诉内核:这个设备叫mykey,它占用一段物理地址和一个中断号。platform_device_register调用之后,内核会把这个设备挂到platform总线上,等一个名字匹配的驱动来认领。

静态注册方式的优点是,开发者可以完全控制设备的存在与否,甚至在运行时动态创建。但它的缺点也很明显:一个SoC上往往有几十个外设,如果每个外设都这么声明一遍,板级文件会膨胀得很难维护,换一块板子还得重新改代码。这也是它被设备树逐步取代的原因。

2.2 设备树方式:一个节点对应一个设备

设备树(Device Tree)用文本文件描述整块板子的硬件拓扑。内核启动时解析这个文件,构建一个树状结构。关键机制是:凡是设备树中带compatible属性的节点,如果内核发现没有更专业的框架接管它,通常会被实例化为platform_device。

举个例子,我在某块实验板上配置过一个模拟按键节点:

/ { mykey: mykey { compatible = "demo,mykey"; reg = <0x1C000 0x10>; interrupts = <5>; status = "okay"; }; };

内核遍历设备树时,看到compatible属性,就会为这个节点创建一个platform_device,并把reg和interrupts转换为对应的资源。驱动侧则用of_match_table声明自己愿意支持"demo,mykey"。两边一碰,匹配成功,驱动被"唤醒"来完成初始化。

正是这种机制,让驱动和板级代码彻底解耦。同一份驱动代码,只要设备树里的compatible不改,换任何一块板子都能跑。这也是我在后来做跨平台迁移时感受最深的一点:设备树把"设备长什么样"和"驱动怎么写"彻底分开了。

注意:并不是设备树中所有节点都会变成platform_device。有些节点会被专门的子系统接管,比如中断控制器由irqchip框架接管,时钟由clk框架接管。只有那些"孤儿"设备节点,才会变成platform设备等待驱动认领。

3. 驱动侧的编写逻辑:理解了probe就理解了一大半

设备侧的诞生方式弄清楚了,现在来看驱动侧该怎么写。很多新手把probe和module_init弄混,以为驱动加载时应该执行的就是probe。这是整条学习路线上最大的认知误差。

3.1 platform_driver结构体的骨架

一个标准的platform驱动,核心是定义一个struct platform_driver,然后注册到总线。结构体里值得关注的主要成员有这些:

成员作用使用场景
.driver.name驱动的名字,常用于和设备的name字段搭配匹配传统匹配方式
.driver.of_match_table用来匹配设备树compatible属性的表现代设备树方式
.id_table一组设备和驱动ID绑定表有多个同名变体设备时
.probe设备与驱动匹配成功后调用的初始化函数分配资源、注册字符设备等
.remove设备被移除或驱动卸载时调用的清理函数释放资源、注销设备

代码骨架一般长这样:

static const struct of_device_id mykey_of_match[] = { { .compatible = "demo,mykey", }, { /* sentinel */ } }; static int mykey_probe(struct platform_device *pdev) { struct resource *res; struct mykey_priv *priv; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) return -ENXIO; priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; platform_set_drvdata(pdev, priv); return 0; } static int mykey_remove(struct platform_device *pdev) { struct mykey_priv *priv = platform_get_drvdata(pdev); if (priv) { /* 清理工作 */ } return 0; } 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_platform_driver是module_init和platform_driver_register的组合宏,它把注册动作封装了起来。注意看,这里并没有直接初始化硬件,真正的硬件初始化都放在probe里。

3.2 四种匹配规则:先看谁,后看谁

platform总线的匹配顺序和优先级是确定的,理解了它,很多"为什么没匹配上"的问题就迎刃而解。实际的匹配函数会依次尝试下面几种途径:

第一,设备树匹配,比对设备节点中compatible和驱动of_match_table里的条目,现代内核中最常用。第二,ACPI匹配,主要给x86平台和部分服务器场景使用,嵌入式里一般用不到。第三,ID表匹配,驱动id_table中有条目与设备name字段一致。第四,名称匹配,驱动driver.name和设备的name字段一致。

优先级上,内核会优先进行设备树的of_match_table匹配。如果驱动只填了driver.name而设备来自设备树,那么设备树节点里的name属性并不等于你在dts里写的那个标签,它通常取自节点名称的第一段,所以很多老式匹配方式在新架构下会失效。

3.3 为什么probe不能在module_init里完成

我可以直接说结论:module_init里注册驱动,只是把驱动这个"候选人"放进了婚介所的名单,真正的配对动作得等内核把设备和驱动都汇总到总线上之后才会触发。

假如某驱动在module_init阶段就主动去访问设备寄存器,有两种情况会出问题:要么设备还没被创建,寄存器读取的结果毫无意义;要么设备处于未上电状态,读操作直接在总线上产生一次错误。probe函数的调用时机,保证了设备存在、基本资源已经分配好,驱动此时初始化硬件是安全的。

我习惯把probe看成驱动的"正式入职日",module_init只是"提交简历"。分清这两件事,排查驱动问题时才会有一条清晰的主线。

4. 一次完整的实操:从零写一个基于platform总线的模拟按键驱动

前面原理讲得再多,不如亲手走一遍。我以一块不带真实按键硬件的实验板为例,用设备树描述一个虚构按键设备,驱动侧按platform风格实现注册与probe。这样整个流程干净、完整,又不会牵涉具体厂商的寄存器细节。

4.1 设备树片段:先让内核知道"有这么个设备"

在设备的/* 某个SoC */根节点下增加如下内容:

&my_soc { demo_key: demo-key { compatible = "demo,mykey"; reg = <0x10001c000 0x10>; interrupts = <0 31 4>; interrupt-parent = <&intc>; status = "okay"; }; };

这段描述告诉内核:有一个叫demo-key的设备,它的寄存器基址是0x1C000,占用16字节,并且使用中断号为31的中断。compatible就是它的人格ID。

4.2 驱动侧完整实现

驱动模块需要做三件事:注册为platform驱动、在probe里保存资源信息、在remove里清理。我给出一个最小但完整的实现,文件命名为demo_key.c:

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> #include <linux/resource.h> struct demo_key_priv { phys_addr_t reg_base; resource_size_t reg_size; int irq; }; static int demo_key_probe(struct platform_device *pdev) { struct resource *res; struct demo_key_priv *priv; dev_info(&pdev->dev, "probe called, matching successful\n"); res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(&pdev->dev, "no memory resource found\n"); return -ENXIO; } priv = devm_kzalloc(&pdev->dev, sizeof(*priv), GFP_KERNEL); if (!priv) return -ENOMEM; priv->reg_base = res->start; priv->reg_size = resource_size(res); priv->irq = platform_get_irq(pdev, 0); platform_set_drvdata(pdev, priv); dev_info(&pdev->dev, "resource: base=%pa size=%pa irq=%d\n", &priv->reg_base, &priv->reg_size, priv->irq); return 0; } static int demo_key_remove(struct platform_device *pdev) { struct demo_key_priv *priv = platform_get_drvdata(pdev); if (priv) dev_info(&pdev->dev, "remove called\n"); return 0; } static const struct of_device_id demo_key_of_match[] = { { .compatible = "demo,mykey", }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, demo_key_of_match); static struct platform_driver demo_key_driver = { .probe = demo_key_probe, .remove = demo_key_remove, .driver = { .name = "demo_key", .of_match_table = demo_key_of_match, }, }; module_platform_driver(demo_key_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("A Developer"); MODULE_DESCRIPTION("A demo platform driver");

platform_get_resource是这里最核心的API,它根据资源类型和索引取出设备携带的资源。第0个内存资源就是设备树里的reg,中断则在platform_get_irq中取到。使用devm_系列接口的好处是,即使probe中途失败,内存也会自动释放,省去手动管理带来的泄漏风险。

4.3 编译加载与最终验证

写一个简单的Makefile来编译外部模块:

obj-m := demo_key.o KERNELDIR ?= /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean

然后依次执行:

make sudo insmod demo_key.ko dmesg | tail

如果设备树节点正常,内核会打印类似下面的日志:

demo-key probe called, matching successful demo-key resource: base=0x1c000 size=0x10 irq=31

同时可以到/sys下面确认匹配状态:

ls -l /sys/bus/platform/drivers/demo_key/ ls -l /sys/bus/platform/devices/demo-key

当设备与驱动匹配成功后,drivers/demo_key目录下会出现一个指向设备的符号链接,名字就是设备树里的demo-key。看到这个链接,才算是真正把整个链路打通了。

5. 匹配不上的常见坑:从debugfs到sysfs的排查链路

写驱动的过程中,项目里最耗时间的阶段不是写代码,而是排查"为什么probe没被调用"。我自己踩过不少坑,其中很多问题就连资深工程师也会犯。后来我总结了一套固定的排查顺序,能解决绝大多数匹配问题。

5.1 compatible字符串的"一字之差"

设备树里的compatible = "demo,mykey",驱动里的of_match_table写成了"demo,mykey2"。这种问题最隐蔽,因为编译时不会有任何报错,dmesg里甚至能看到drvier注册成功的日志,但probe就是进不去。

我常用的排查手段是直接查看设备树解析后的实际节点:

cat /proc/device-tree/demo-key/compatible

如果输出demo,mykey,就去核对驱动源码。在选择要不要把MODULE_DEVICE_TABLE也写上时,记住:这个宏不是为了运行时的匹配必需,而是为了生成模块别名,让udev或modprobe在设备出现时能自动加载对应模块。开发阶段即使忘记写它,只要手动insmod,probe依然能触发。

5.2 状态属性没设置成okay

设备树默认的status在很多SoC平台上是disabled。如果某个节点的状态是disabled,内核在遍历设备树时不会为它创建platform_device,驱动自然永远等不到probe。

排查方法很简单,在/proc/device-tree下检查对应目录的status文件:

cat /proc/device-tree/demo-key/status

如果是okay,设备对象才会存在。如果没有这个文件,内核按默认值处理,不同的SoC默认策略略有差异,但都能通过给status = "okay"来显式打开。这个坑我在某些参考板上踩到过不止一次,尤其是那些开发板自带的示例设备树,很多外设默认是被关掉的。

5.3 id_table里的名称与设备不匹配

传统匹配方式下,驱动侧填的是driver.name,设备侧在板级文件里填的是platform_device.name。两边看似都是字符串,但实际上存在两个容易忽视的细节:一是id_table匹配优先于driver.name匹配,如果id_table里没有一个能匹配上的,即使driver.name一致,也可能不做匹配;二是平台设备有时会设置id = -1,此时设备名会被内核加上".0"之类的后缀,跟你预期中的名字不同。

碰到这种情况,直接看设备在sysfs中注册的名字:

ls /sys/bus/platform/devices/

找到真实存在设备名,再反向确认驱动里的name。记住,设备树生成的设备名通常是节点名,不是compatible,但兼容匹配依赖的是compatible。把这两个概念分开,是新手必须跨过的一道坎。

5.4 probe返回了负错误码时怎么办

还有一种情况:匹配是成功了,probe也进去了,但函数中间有一步失败,返回了-ENXIO或-ENOMEM。内核会把驱动和设备"解绑",你看到的结果仍然是"驱动没生效",很容易误判为匹配失败。

处理方式是先在dmesg里找probe called这类日志,确认probe确实执行过,再继续看dev_err的报错。如果probe里调用了platform_get_resource并返回NULL,检查设备树中reg是否写对;如果是request_irq失败,检查中断号是否正确。总之,把问题分成"没匹配上"和"匹配上但初始化失败"两个阶段来排查,能少走大量弯路。

6. 我对platform总线的一些个人体会与建议

如果是刚开始学内核驱动的朋友,我的建议是不要急着通读内核源码,先在一个具体的板子上跑通一个最简platform驱动。跑通之后再回头翻代码,你会发现platform_bus_type、platform_match这些概念都变得非常具体。整个过程中,有一件事我觉得比具体API更重要:建立"设备与驱动分离"的思维模式。

分离意味着,硬件信息归设备,处理逻辑归驱动。设备描述"我是什么、我在哪、我用哪个中断",驱动描述"我会初始化哪些寄存、中断来了做什么"。它们的联系靠一种约定——compatible或name。这套约定一旦破坏,哪怕代码写得再漂亮,也只会得到一个"孤零零的驱动"。

另外,调试platform驱动时,/sys/bus/platform目录堪称宝藏。设备侧在devices/下,驱动侧在drivers/下,如果哪个设备没有对应驱动来认领,它的目录下就只有自身;如果驱动找不到目标设备,它的目录下空无一物。做任何驱动开发之前,先花十分钟浏览一遍这个目录,比盲猜问题高效得多。

最后分享一个小技巧:遇到probe不执行的疑难杂症,与其反复改代码重编译,不如先用modprobe加上参数看模块依赖,再用dmesg -w实时观察内核日志。改设备树后务必重新编译dtb,并且确认启动日志中加载的是新的dtb,否则你在源码里改得欢,板子上跑的还是旧描述。经历了这些折腾之后,再回头看platform总线,你就会发现,它并不神秘,只是内核为了把一堆"没有物理总线的设备"组织起来而设计的一套优雅规则。理解了这套规则,后面学I2C、SPI、PCI驱动子系统的模型,都会轻松许多。

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

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

立即咨询