☰
Platform总线详解:设备驱动模型核心机制与最小驱动实战
2026/10/21 5:46:47 网站建设 项目流程

做嵌入式和内核驱动开发,迟早都会遇见platform总线。很多初学者第一次在内核源码里看到它时都会困惑:明明是“总线”,硬件上却找不到对应的实体,没有像I2C那样的物理信号,也没有像PCI那样的插槽。其实它是一条软件层面定义的“虚拟总线”,是设备驱动模型(device driver model)里最核心的骨架之一,专为解决SoC(片上系统)内部那些没法被传统总线硬件枚举的外设而生。这篇文章就从设备驱动模型的视角,把platform总线讲透:它到底解决什么问题,三个核心对象是什么,设备和驱动怎么匹配,以及一个能跑的最小驱动怎么写、常见坑怎么排查。适合刚入门Linux驱动开发的读者,也适合想理清框架的老手。文中代码基于Linux内核的常见通用实现,老版本和新版本之间字段略有出入,但不影响主线的理解。

1. 设备驱动模型为什么要搞一条“虚拟总线”

1.1 传统总线的局限和SoC外设的尴尬

先聊背景。在计算机架构里,PCI、USB这类总线是“能发现设备”的:硬件上电之后,设备插上去,总线控制器主动扫描,读配置空间,拿到厂商ID和设备ID,操作系统才能自动匹配到对应驱动。这个过程叫枚举(enumeration),是即插即用的基础。

但SoC内部集成的那些外设完全是另一回事。串口控制器、SPI控制器、I2C控制器、内部存储控制器、时钟控制器,它们天生就焊在CPU旁边,没有“插拔”一说。总线控制器不会去扫描它们,也扫描不到什么ID。那设备和驱动怎么找到对方?早期内核的做法是靠板级文件硬编码:在某开发板的初始化代码里手动创建设备,再手动创建驱动,用代码把两者“焊”在一起。改动一个引脚、换一块板子,就要重新编译内核。

那个年代的代码我翻过很多,典型场景就是在一个板级文件里堆几十行“注册这个设备、注册那个设备”的初始化调用,还要在头文件里维护一堆宏定义。代码一多,逻辑就乱成一锅粥:同一个设备在不同板卡上有不同的资源地址,驱动只能通过ifdef把板级差异塞进代码里,维护成本高得吓人。设备驱动模型在很大程度上就是为了收编这种混乱而被引入的:把“板子上有什么”和“内核里怎么驱动”彻底分开。

1.2 platform总线到底干了什么活

platform总线的英文就是platform,直译是“平台”。它不属于任何硬件总线规范,而是驱动模型在软件层面定义的一条虚拟总线。挂在这条总线上的设备统一叫platform_device,对应的驱动叫platform_driver。

它的核心职责可以概括为三点。

第一,提供一个统一的对象容器。设备和驱动这两个本来孤立的实体,有地方登记了,总线作为中间人维护它们的配对关系。没有这条虚拟总线,设备模型里的“设备链表”和“驱动链表”就缺少一个统一的组织者,电源管理、热插拔事件、sysfs暴露这些通用机制都无从挂靠。

第二,屏蔽板级差异。板子上接了什么样的外设、寄存器基址在哪里、用哪个中断号,这些信息全部打包进设备对象或者设备树节点。驱动程序只需要面向接口编程,调用platform_get_resource这类API拿资源,而不是把某个具体板卡的物理地址写死在代码里。

第三,融入整个驱动模型体系。一旦设备注册到platform总线上,它就成了一个标准的struct device,能享受驱动模型提供的一切通用能力,比如生命周期管理、电源管理回调、sysfs属性、设备节点自动创建设备文件等。

1.3 不要拿它和PCI总线类比过头

看到不少资料会把platform总线说成“PCI总线的替代品”,这个类比容易误导。PCI是一级真实存在的硬件总线,有物理信号、配置空间、仲裁机制,而platform是纯软件抽象,背后没有任何新硬件。更准确的说法是:platform总线是驱动模型在“传统总线覆盖不了的地方”打的一块补丁。

打个比方。真实总线像人才市场,硬件设备带着简历来应聘。platform总线更像是公司内部的“老员工推荐通道”:设备不用投简历,因为设备本身就是公司内部孵化的——SoC内置外设,驱动只需要拿到一张“内部资源清单”就能上岗干活。这样一想,再回头看“platform总线简介”这个题目,重点其实不在“总线”两个字,而在它背后的device-driver model体系。

2. platform总线的三个核心对象:设备、驱动、总线

2.1 platform_device:设备长什么样

先说设备对象。struct platform_device定义在include/linux/platform_device.h中,我简化一下关键字段:

struct platform_device { const char *name; // 设备名字,早期匹配的关键 int id; // 同类设备的编号,-1表示无编号 struct device dev; // 内嵌通用设备结构 struct resource *resource; // 资源数组:寄存器区域、中断等 unsigned int num_resources; // 资源数量 };

注意它内嵌了一个struct device。struct device是所有设备的“公共底座”,platform_device只是在这个底座上加了name、id、resource等外设信息。内核里到处都能看到container_of、to_platform_device这样的宏,本质上就是想通过dev指针找回外面的platform_device,这种设计是内核对象模型的惯用手法。

resource是platform_device极其重要的部分。一个典型的寄存器控制器设备,资源列表里会有IORESOURCE_MEM类型的寄存器区域和IORESOURCE_IRQ类型的中断号。驱动通过platform_get_resource来获取,而不是自己在代码里硬编码物理地址,这就实现了“设备描述”与“驱动逻辑”的分离。我见过不少新手喜欢把寄存器地址写成宏定义搁在驱动头文件里,这在老式的板级文件时代是常态,但在设备树时代就是一根必须拔掉的刺。

2.2 platform_driver:驱动怎么包装

驱动对象是struct platform_driver,常见关键字段如下:

struct platform_driver { int (*probe)(struct platform_device *pdev); int (*remove)(struct platform_device *pdev); void (*shutdown)(struct platform_device *pdev); struct device_driver driver; // 通用驱动的基类 const struct platform_device_id *id_table; // ID匹配表 };

probe是设备与驱动配对成功后回调的入口函数,可以理解成“上岗通知”:驱动在这里申请资源、初始化硬件、注册杂项设备或字符设备。remove是设备摘除或驱动卸载时调用,负责清理。很多初学者只盯着probe写代码,remove随便糊弄一个空函数,等到调试热插拔或模块反复加载卸载的时候,就会遇到资源泄漏、中断悬空这类麻烦。

里面同样内嵌了一个struct device_driver。这个名字容易让人犯迷糊:dev是device,driver是driver,是两个不同的对象。struct device_driver是通用驱动的基类,platform_driver是在它之上扩展了probe、remove、id_table这些平台相关操作的“子类”。id_table是一张ID表,里面可以列多个设备ID,让一个驱动能支持多个不同型号的设备,这个在后面的匹配章节会展开讲。

2.3 platform_bus_type:总线如何把两者撮合到一起

总线本身也是一个结构体:

struct bus_type platform_bus_type = { .name = "platform", .match = platform_match, .uevent = platform_uevent, .pm = &platform_dev_pm_ops, };

它会在内核启动早期注册。注册之后,设备模型的世界里才有了一条名为platform的总线。此后,所有platform_device注册上来时都会挂到这条总线下,所有platform_driver注册时也都会挂到这条总线下。总线最重要的工作就是match,也就是决定“设备和驱动谁跟谁是一对”。

设备与驱动在platform总线上登记的来源有两条路。系统启用设备树时,platform_device通常由设备树节点在启动阶段解析而来;没有设备树时,则需要代码里手动调用platform_device_register或platform_add_devices。不管哪种来源,设备和驱动最终都会在platform总线上被登记,然后等待匹配和绑定。

整个生命周期可以串成一条线:总线注册 → 设备注册/驱动注册 → match匹配 → 命中后回调probe → 绑定成功 → 使用设备 → 设备移除或驱动卸载时回调remove → 解绑。后面所有内容都是围绕这条线展开的。

3. platform_match机制:设备与驱动是怎么牵上线搭上桥的

3.1 解析匹配顺序

我从drivers/base/platform.c里把platform_match的核心逻辑简化出来。常见内核版本的匹配顺序大致是这样:

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表匹配 */ if (pdrv->id_table) return platform_match_id(pdrv->id_table, pdev) != NULL; /* 4. 驱动名与设备名直接比较 */ return strcmp(pdev->name, drv->driver.name) == 0; }

这个顺序非常值得花时间琢磨。在内核源码里,of_driver_match_device会通过设备树节点中的compatible属性来匹配驱动里的of_match_table,而of_match_table里最常用的字段就是compatible字符串。简单说,设备树匹配变成了当下platform总线匹配的绝对主力。

为什么比较的是compatible而不是设备节点名?因为设备树驾驭的板子极其多样,同一颗芯片在不同板卡上的节点名可能不同,但compatible是厂商定义的“我是谁”,更稳定。在这种设计下,驱动里必须写of_match_table,否则compatible匹配就无从谈起。ACPI匹配主要面向某些服务器和x86场景,嵌入式开发里不是主力,可以暂时不深究。

3.2 设备树时代:compatible成为主角

实际项目里,我们几乎只在of_match_table里写compatible。典型写法:

static const struct of_device_id foo_of_match[] = { { .compatible = "vendor,my-foo-controller" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, foo_of_match);

compatible字符串里带上厂商前缀和型号,形成命名空间,避免不同厂商之间的同名冲突。厂商前缀那里的“vendor”在真实项目里会替换成实际厂商名,这里只是演示。MODULE_DEVICE_TABLE宏会把匹配表信息写进模块的modinfo段,让modprobe等工具在自动加载模块时可以识别“该加载哪个模块”。驱动编成模块时这个宏尤其重要,少了它,即使内核里设备已经在等着了,modprobe也可能一脸茫然地找不到驱动。

到了这一步,“设备树匹配”已经替代“名字匹配”,成为platform总线上的主流。老内核里靠platform_device.name和platform_driver.driver.name硬比较的做法虽然还在,但更多只是历史包袱和极简场景的保留节目。

3.3 匹配成功之后:probe到remove的完整生命周期

匹配成功后,驱动模型会在内核里把driver和device绑定,随后回调platform_driver.probe。probe返回0表示绑定成功,返回负数错误码则绑定失败。失败时,设备不会消失,驱动模型会保持等待状态,等下一个可能的驱动注册上来再继续尝试。这个“等待”机制容易被忽略,但它解释了为什么错误日志里会反复出现同一设备的probe尝试。

绑定成功后,sysfs里就能看到关系了:/sys/bus/platform/devices/下面是设备,/sys/bus/platform/drivers/下面是驱动,驱动目录里会有指向已绑定设备的符号链接。这个“看得见摸得着”的线索,是调试时的重要抓手。

设备生命周期结束时,比如设备节点移除或者模块卸载,内核会调用remove。虽然嵌入式产品平时很少热插拔platform设备,但remove仍是必填的习惯动作:probe请求了什么,remove就释放什么。如果用了devm系列API,基础资源框架会自动回收,但自己注册的杂项设备、申请的额外中断这类东西,还是要亲手清理干净。

4. 实操:从零写一个最小platform驱动

4.1 设备树节点怎么写

假设在某开发板根节点下挂一个虚拟的“foo控制器”,寄存器基址0x1F000000、长度0x1000,使用一个中断。设备树节点大致如下:

/ { foo: foo-controller@1f000000 { compatible = "vendor,my-foo-controller"; reg = <0x0 0x1f000000 0x0 0x1000>; interrupts = <0 34 4>; status = "okay"; }; };

reg的格式需要符合父节点的#address-cells和#size-cells规范。上面按4个cell(64位地址)写,是因为根节点那边通常开了64位地址。interrupts的三个字段通常是中断控制器类型、中断号、触发方式,具体数值在不同平台上的含义不同,编译设备树前建议用厂家提供的工具校验一下。

4.2 驱动代码骨架

接下来写驱动模块。一个最小可参考的骨架长这样:

#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_device.h> #include <linux/io.h> static void __iomem *base; static int foo_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct resource *res; dev_info(dev, "foo probe begin\n"); res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(dev, "no memory resource\n"); return -ENXIO; } base = devm_ioremap_resource(dev, res); if (IS_ERR(base)) return PTR_ERR(base); /* 这里可以注册杂项设备、字符设备,然后操作base寄存器 */ dev_info(dev, "foo probe ok, regs at 0x%p\n", base); return 0; } static void foo_cleanup(struct platform_device *pdev) { struct device *dev = &pdev->dev; /* 注销注册过的设备,释放非devm资源 */ dev_info(dev, "foo remove\n"); } static const struct of_device_id foo_of_match[] = { { .compatible = "vendor,my-foo-controller" }, { /* sentinel */ } }; MODULE_DEVICE_TABLE(of, foo_of_match); static struct platform_driver foo_driver = { .probe = foo_probe, .remove = foo_cleanup, .driver = { .name = "my-foo-controller", .of_match_table = foo_of_match, }, }; module_platform_driver(foo_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("developer"); MODULE_DESCRIPTION("minimal platform driver demo");

module_platform_driver宏展开后等价于module_init(foo_driver_init)和module_exit(foo_driver_exit),省去两行模板代码,是内核驱动里的高频缩写宏,建议熟练使用。用devm_ioremap_resource而不是裸的ioremap,是一个我很想强调的养老建议:devm系列API把资源生命周期绑定到设备,probe中途失败或者驱动被移除时,框架会自动回收,能少踩不少内存泄露的坑。

4.3 加载、绑定验证与sysfs观察

把驱动编译成模块后,流程是这样的:

insmod foo_driver.ko dmesg | grep "foo" ls -l /sys/bus/platform/devices/ cat /sys/devices/platform/1f000000.foo/of_node/compatible ls -l /sys/bus/platform/drivers/my-foo-controller/

如果一切顺利,dmesg里会出现“foo probe ok”,驱动目录下会多出一个指向设备的符号链接,名字类似1f000000.foo。这里设备目录名的“1f000000.foo”是内核自动生成的别名:基址加上名字。实际开发中,设备树节点名写的是foo-controller@1f000000,但sysfs里显示可能是1f000000.foo,因为platform设备名默认取自设备树节点的name字段,不同内核版本的解析细节略有差异,看到名字不完全等于节点名时别慌,先查一下of_node下的compatible再下结论。

4.4 如果不走设备树,传统方式怎么注册

老式开发板上没有设备树,就得手动构造platform_device:

static struct resource demo_resources[] = { [0] = DEFINE_RES_MEM(0x1f000000, 0x1000), [1] = DEFINE_RES_IRQ(34), }; static struct platform_device demo_device = { .name = "my-foo-controller", .id = -1, .num_resources = ARRAY_SIZE(demo_resources), .resource = demo_resources, }; platform_device_register(&demo_device);

驱动里用platform_get_resource获取资源,匹配靠name。这个方式在旧平台和少数不用设备树的场景仍然存在,但新项目的开发基本都转向设备树了。原因也很简单:同一份内核镜像,只想靠设备树来区分不同板卡,而不再为每块板子重编内核,这在产品迭代和维护上的收益非常直观。

5. 常见问题与排查技巧实录

5.1 probe没有被调用

最经典的坑:设备树节点写好了,驱动也加载了,dmesg里就是看不到probe日志。我建议按顺序排查。

先确认设备树到底有没有被正确解析。进入设备树对应的目录看一眼:

ls /sys/firmware/devicetree/base/ | grep foo

如果节点不存在,说明设备树没更新,或者新节点没编进去。再确认compatible是否一致:

cat /sys/devices/platform/1f000000.foo/of_node/compatible

如果驱动里的compatible和这个字符串有任何不一致,包括中间一个字符、一个逗号,匹配必然失败。我踩过的坑里,有一个就是全角逗号和半角逗号的问题,看着像,实际差一个字节,内核可不会帮你纠错。

还要确认模块是否真的加载了,驱动里的of_match_table是否非空。如果驱动直接编进内核而不是模块,也要检查probe是不是曾经被调过但返回了错误,错误信息被淹没在启动日志的汪洋里,这时候要开完整日志慢慢翻。

5.2 platform_get_resource返回NULL

probe是进到了,但platform_get_resource(pdev, IORESOURCE_MEM, 0)返回NULL。问题多半出在设备树节点的reg属性上。常见的坑有三个。

第一个,reg的cell数量和父节点#address-cells/#size-cells不一致,解析时移位数不对,设备模型找不到合法的资源范围。第二个,节点挂到了错误的父节点下,比如挂在某个I2C控制器节点下,内核会把它当i2c_client而不是platform_device,资源获取方式完全不同。第三个,compatible没问题但节点state = "disabled",设备根本不生成。

解决办法是把设备树反编译出来查:dts和dtb互相转换,用编译器的校验选项查reg有没有问题。另外,新驱动可以直接用devm_platform_ioremap_resource(pdev, 0),它把“取IORESOURCE_MEM第0个资源”和“ioremap映射”两步合并成一步,代码更短,出错概率更低。这个辅助函数在较新的内核里已经非常普及,值得习惯性使用。

5.3 一个驱动绑定多个设备的问题

如果同一条总线上有多个同compatible的节点,驱动会对每个节点各probe一次。这时候如果用了共享的全局变量或者缓存的指针,第二次probe就会覆盖第一次的状态,remove时也会产生混乱。

正确做法是善用struct device本身:把每个设备的局部状态放进一个自定义结构体,probe时分配,remove时释放,再把结构体的指针存到dev_set_drvdata里,需要时dev_get_drvdata取回。千万不要用全局变量保存设备上下文。前面示例代码为了简短用了全局变量base,真实项目里这种写法很危险,两个同型号设备同时存在时,base就被后面那个覆盖了,前面那个设备的中断处理里能访问到一块错乱的映射地址。

5.4 调试三板斧:dmesg、sysfs、内核配置

调试platform总线相关问题,工具不需要多复杂,三板斧就够。

第一板斧是dmesg。probe是否调用、注册是否成功、匹配失败时的提示,都能从这里看到。启动早期日志被刷掉时,可以用内核日志缓冲的方式延迟查看,或者提高printk打印等级。

第二板斧是sysfs。挂载debugfs后能看到更多调试信息;即便不挂debugfs,/sys/bus/platform/下的device和driver目录结构也已经能说明很多问题。判断“设备在不在”和“驱动有没有绑定”要分开看:设备节点存在于/sys/bus/platform/devices/里,不代表它绑上了驱动;驱动目录下没有设备符号链接,就说明还没配对成功。

第三板斧是内核配置。确认CONFIG_DEVTMPFS、CONFIG_OF、CONFIG_OF_DEVICE以及对应驱动模块的CONFIG项都开了。很多“神秘问题”最后查下来就是某个CONFIG没开,驱动代码根本没编进内核。遇到这种问题别不信,先查配置永远是最快的路径。

我个人在实践中的体会是:学platform总线,不要一上来背结构体。先理解设备驱动模型解决的是“设备和驱动如何见面对齐”的问题,再顺着“设备注册→驱动注册→match匹配→probe→remove”这条线走一遍;然后动笔试着写一个带设备树的最小驱动,在开发板或者虚拟化环境里验证一遍绑定和解绑,比翻十篇原理文章都管用。

最后再分享一个小技巧:当你觉得某个platform设备的行为很诡异,比如probe反复调用、资源对不上,不妨在设备树节点里临时加一个唯一属性,然后在probe开头打印pdev->dev.of_node的完整路径,这样常常能一眼看清到底是匹配到了哪个节点。这个思路在调多节点、多实例的设备时特别实用。

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

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

立即咨询