我这几年主要和 i.MX6ULL 这类嵌入式 Linux 平台打交道,写过不少字符驱动和设备驱动。经常有刚入行的朋友问:为什么我写了一个 platform_driver,设备树里也加了节点,probe 就是不走?为什么驱动要分 platform 设备、platform 驱动,明明直接注册一个字符设备不就能跑吗?
这些问题背后其实牵扯到 Linux 设备驱动模型的核心设计思路。今天我用 i.MX6ULL 平台为例,把 Platform 设备和驱动的匹配机制彻底拆开讲一遍。从为什么要有这套机制,到结构体长什么样,再到完整实操代码、常见坑位排查,一篇讲透。适合正在做 i.MX6ULL、IMX6ULL 或类似 Cortex-A 平台驱动开发的人阅读,也适合刚入门嵌入式 Linux、想搞懂驱动框架底层逻辑的初学者收藏。
1. 为什么 Linux 需要 Platform 匹配机制
很多人的驱动学习路线是从 STM32 这类单片机开始的,思路通常是:配好引脚、打开时钟、初始化外设、注册中断,然后进入一个 while(1)。到了 i.MX6ULL 上,这套思维会立刻碰壁,因为 Linux 下面驱动不是简单操作寄存器,而是要先“接对线”。
1.1 字符设备驱动的痛点
先看一段传统的字符设备驱动代码,很多教程里都有:
static int __init my_led_init(void) { char __iomem *base; base = ioremap(0x0209C000, 0x1000); writel(0x8, base + 0x4); return 0; }这种写法在单片机上没问题,但在 Linux 内核里就完全不可接受。为什么?因为你把硬件地址、寄存器偏移、引脚号、时钟配置全部硬编码在驱动里了。一旦换了板子、换了一个 GPIO、甚至换了同一颗 SoC 的另一个 BSP 版本,你都得改驱动源码重新编译。
更麻烦的是设备越来越多的时候,内核里同一类驱动可能有几个版本,设备信息又散落在各个驱动里,维护起来就是灾难。
1.2 设备模型的三元组思想
Linux 内核为了解决这个问题,引入了设备驱动模型,核心是三个角色:
- 设备:描述“硬件有什么”,比如寄存器地址、中断号、GPIO 编号、时钟频率,这些信息来自设备树或者代码里注册的 platform_device 结构体。
- 驱动:描述“怎么操作硬件”,包含 probe 函数、remove 函数、file_operations 操作集,纯粹是逻辑代码,不写死具体地址。
- 总线:负责把设备和驱动“配对”。常见的总线有 I2C 总线、SPI 总线、USB 总线、PCI 总线,而我们今天讲的主角 Platform 总线,其实是一种虚拟总线。
这样设计之后,设备信息和驱动处理逻辑彻底分离。设备树里改一个 status 为 disabled,驱动代码不用动;换一个平台,只要设备树的节点属性对得上,驱动的 probe 自动被调用。这才是 Linux 下驱动开发应该有的思路。
1.3 Platform 总线在 i.MX6ULL 上的定位
你可能会问:i.MX6ULL 内部的 UART、GPIO、I2C 控制器,这些明明是 SoC 内部硬件,为什么也要挂在 Platform 虚拟总线上?
原因很简单:这些内部外设没有物理总线枚举能力。CPU 不会像 PCI 那样主动去扫描发现设备,所以内核就做了一个虚拟总线,让这些 SoC 内部外设和对应驱动都挂在这条“线”上,通过名字、设备树 compatible 属性等方式进行匹配。
实际上你在 i.MX6ULL 上开发驱动,绝大多数都是写 Platform 驱动。不管是自己点个 LED、读取按键,还是外扩一个 SPI 设备、并口设备,先挂到 Platform 总线是标准做法。另外设备树原生的 gpio-leds、pinctrl 这些机制,本质也是基于 Platform 设备模型工作的。
2. Platform 设备与驱动的关键结构体
这一节是全文的重点,先把结构体弄清楚,后面写代码才不会懵。
2.1 struct platform_driver 结构体
在 Linux 内核源码 include/linux/platform_device.h 中定义了platform_driver。这个结构体是你写平台驱动时用的最多、也是最核心的东西:
struct platform_driver { int (*probe)(struct platform_device *); int (*remove)(struct platform_device *); void (*shutdown)(struct platform_device *); int (*suspend)(struct platform_device *, pm_message_t state); int (*resume)(struct platform_device *); struct device_driver driver; const struct platform_device_id *id_table; bool prevent_deferred_probe; };实际开发中你只需要关注这几个成员:
- probe:设备与驱动匹配成功后,内核立刻调用这个函数。你在里面申请资源、注册字符设备、创建设备节点、初始化硬件。
- remove:驱动卸载或者设备移除时调用,做反操作,释放资源、注销字符设备。
- driver:一个内嵌的
device_driver结构体,重点要初始化.name成员,这是 Platform 总线匹配的一个重要依据。同时支持 for 的设备树匹配属性 here。 - id_table:一种传统匹配方式,用于非设备树场景下的 ID 匹配表。i.MX6ULL 上你从 NXP 官方 BSP 里,能看到大量老驱动还在用这个。
这里有个很容易忽视的细节:probe函数接收的参数不是struct device *,而是struct platform_device *。这意味着你可以在 probe 里直接 pdev->resource 拿资源、pdev->dev.of_node 拿设备树节点,非常方便。
2.2 struct platform_device 与资源管理
有了驱动还得有设备。一种方式是在设备树里声明节点,内核会在启动阶段把 compatible 匹配到的节点转换成一个platform_device;另一种方式是用platform_device_register在 C 代码里注册一个设备结构体。后者在老 BSP(无设备树)中常见,i.MX6ULL 上我强烈建议用设备树。
platform_device结构体在标准内核中大概是:
struct platform_device { const char *name; int id; bool id_auto; struct device dev; u32 num_resources; struct resource *resource; const struct platform_device_id *id_entry; /* ... 省略若干 */ };两个常用字段:
- name:匹配时会和 platform_driver 的 driver.name 比较,也需要和 id_table 里的 name 比较。
- resource:保存 IO、中断、DMA 等信息。设备树里的 reg、interrupts 属性经过内核解析后,就会生成对应的 resource;当然你在传统 C 代码里也可以手动填充 resource 数组。
resource 驱动的代码中一般怎么取呢?利用platform_get_resource系列函数:
struct resource *res; res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (res) { reg_base = ioremap(res->start, resource_size(res)); } int irq = platform_get_irq(pdev, 0);这也是为什么要接触 Platform 机制的原因之一——它帮你把设备树解析、资源映射都封装好了,probe 函数里拿来即用。
2.3 匹配过程到底是怎么发生的
这一小节建议结合内核源码看,入口在 drivers/base/platform.c 中的platform_match函数。大致匹配顺序如下:
| 优先级 | 匹配方式 | 说明 |
|---|---|---|
| 1 | of_match_table 设备树 compatible 匹配 | 最常用,i.MX6ULL 设备树默认匹配 |
| 2 | ACPI 匹配 | ARM 嵌入式基本不关注 |
| 3 | id_table 匹配 | 无设备树时使用 platform_device_id 比较 |
| 4 | driver.name 与 device.name 匹配 | 比较传统的方案 |
| 5 | 是否支持异步探测等辅助判断 | 决定 probe 是否延后 |
实际 i.MX6ULL 上我们主要关注第 1 和第 3、4 种。内核在启动时,会扫描设备树里所有 compatible 设备节点,为它们创建 platform_device;同时内核注册了各种 platform_driver 后,总线子系统就会调用 platform_match 进行匹配。匹配成功则调用驱动的 probe。
这里有一个关键理解:设备树节点只要 compatible 没写错,官方驱动就会自动匹配并运行相应的 probe。所以很多时候你不需要写任何代码,内核自带的驱动就能把外设初始化好。
3. 通过 i.MX6ULL 实现一个完整的 Platform 设备驱动
接下来我把一个从零到能跑的完整案例拆开,代码基于 i.MX6ULL 的 Linux 内核 4.1.15/5.x 等常规内核,设备树采用标准的compatible匹配方式。场景是:点一个外部 LED,但这个 LED 的 GPIO 引脚和寄存器信息全部放在设备树里,驱动代码完全看不到具体地址。
3.1 设备树节点编写与 compatible 设置
i.MX6ULL 导出 GPIO 通常走gpio-leds,但为了演示 Platform 机制,我们自建一个节点:
/ { my_platform_test { compatible = "mycompany,my-platform-test"; reg = <0x0209C000 0x1000>; interrupts = <GIC_SPI 66 IRQ_TYPE_LEVEL_HIGH>; status = "okay"; my-led-gpio = <&gpio1 3 GPIO_ACTIVE_LOW>; }; };注意compatible的值。设备树匹配优先看这个字符串,一般推荐格式是厂商名,设备名,这个属于公约定俗成,也方便通用驱动匹配。所有设备树节点编译成 dtb 后,内核解析时会为这个节点生成 platform_device,compatible 属性会成为 of_node 的关键匹配依据。
3.2 驱动框架完整代码
下面的驱动代码就是正规的 Platform 驱动了。核心思路是:驱动加载时只注册 platform_driver,具体硬件初始化全部放在 probe 里。
#include <linux/module.h> #include <linux/platform_device.h> #include <linux/of.h> #include <linux/of_gpio.h> #include <linux/gpio.h> #include <linux/io.h> #include <linux/interrupt.h> #include <linux/miscdevice.h> #include <linux/uaccess.h> #include <linux/fs.h> #define DEVICE_NAME "my_platform_test" #define CLASS_NAME "myplatform" static int major; static struct class *my_class; static struct gpio_desc *led_desc; static void __iomem *reg_base; static long my_platform_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { /* 简化处理,这里用 copy_from_user 等方式可以扩展更多命令 */ return 0; } static int my_platform_open(struct inode *inode, struct file *file) { return 0; } static int my_platform_release(struct inode *inode, struct file *file) { return 0; } static const struct file_operations my_platform_fops = { .owner = THIS_MODULE, .open = my_platform_open, .release = my_platform_release, .unlocked_ioctl = my_platform_ioctl, }; static int my_platform_probe(struct platform_device *pdev) { struct resource *res; struct device *dev = &pdev->dev; int ret; dev_info(dev, "probe start\n"); /* 1. 从设备树中获取内存资源 -> 寄存器基地址 */ res = platform_get_resource(pdev, IORESOURCE_MEM, 0); if (!res) { dev_err(dev, "failed to get mem resource\n"); return -ENXIO; } reg_base = devm_ioremap(dev, res->start, resource_size(res)); if (!reg_base) { dev_err(dev, "failed to ioremap\n"); return -ENOMEM; } /* 2. 从设备树获取 GPIO 描述符 */ led_desc = devm_gpiod_get(dev, "my-led", GPIOD_OUT_HIGH); if (IS_ERR(led_desc)) { dev_err(dev, "failed to get gpio\n"); return PTR_ERR(led_desc); } /* 3. 注册字符设备 */ major = register_chrdev(0, DEVICE_NAME, &my_platform_fops); if (major < 0) { dev_err(dev, "failed to register chrdev\n"); return major; } my_class = class_create(THIS_MODULE, CLASS_NAME); if (IS_ERR(my_class)) { unregister_chrdev(major, DEVICE_NAME); return PTR_ERR(my_class); } device_create(my_class, dev, MKDEV(major, 0), NULL, DEVICE_NAME); gpiod_set_value(led_desc, 0); dev_info(dev, "probe success, major=%d\n", major); return 0; } static int my_platform_remove(struct platform_device *pdev) { device_destroy(my_class, MKDEV(major, 0)); class_destroy(my_class); unregister_chrdev(major, DEVICE_NAME); gpiod_set_value(led_desc, 1); return 0; } static const struct of_device_id my_platform_of_match[] = { { .compatible = "mycompany,my-platform-test" }, { } }; MODULE_DEVICE_TABLE(of, my_platform_of_match); static struct platform_driver my_platform_driver = { .probe = my_platform_probe, .remove = my_platform_remove, .driver = { .name = DEVICE_NAME, .of_match_table = my_platform_of_match, }, }; module_platform_driver(my_platform_driver); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name"); MODULE_DESCRIPTION("i.MX6ULL platform driver demo");代码不多,但每个函数都有它存在的意义。
module_platform_driver是内核提供的宏。展开后就是 module_init 注册 platform_driver,module_exit 注销 platform_driver。如果你需要了解注册细节,自己去 drivers/base/platform.c 里翻platform_driver_register和platform_driver_unregister的实现,这个过程不是魔法。
devm_ioremap属于 devm 系列,不需要手动 unmap,remove 时内核自动释放,这种资源管理风格在 Linux 内核中非常推荐,能减少错误路径上的资源泄漏。
devm_gpiod_get的第二个参数 "my-led",对应设备树里的my-led-gpio属性。这个命名不是随便定的,内核会把-gpio后缀自动处理成“gpiod_get 里的后缀名”,实际查找的是my-led-gpios或my-led-gpio。如果不确定,直接查看内核文档 Documentation/gpio/board.txt 里关于设备树 GPIO 描述符的说明。
我自己写的时候最开始对“-gpio”和“-gpios”区别踩过坑。简单说:新软件推荐用-gpios,例如my-led-gpios;但内核也会兼容-gpio的形式。devm_gpiod_get会自动匹配-gpio和-gpios两种。不过为了规范,设备树里建议用复数形式-gpios。
3.3 编译与加载测试全流程
驱动写好后,在 i.MX6ULL 上编译有两种主流方式:
- 整机编译内核时把驱动编进内核。
- 使用内核模块方式,独立编译
.ko文件。
调试阶段,我几乎始终用模块方式。原因是改驱动不用重烧整个内核,加载.ko如果有问题,rmmod重新改代码再编译,循环很快。下面是一个典型 Makefile:
obj-m := my_platform_drv.o KERNELDIR := /home/user/linux-imx PWD := $(shell pwd) ARCH := arm CROSS_COMPILE := arm-linux-gnueabihf- all: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) ARCH=$(ARCH) CROSS_COMPILE=$(CROSS_COMPILE) clean编译后拷贝到开发板:
insmod my_platform_drv.ko dmesg | tail -20如果设备树节点正常、compatible 匹配成功,dmesg 里会出现:
my_platform_drv my_platform_test.0: probe start my_platform_drv my_platform_test.0: probe success, major=240如果没有这些日志,多半是设备树节点没生效或者驱动没匹配上,排查思路我放在下文。
4. 常见匹配失败与实操排查技巧
驱动开发中最让人抓狂的就是“驱动编译没问题,insmod 也不报错,但 probe 就是不执行”。这种问题十有八九出在匹配环节。下面整理我的实际排查经验。
4.1 probe 不执行的核心排查思路
先确认设备树节点是否被内核正确解析,在板子上执行:
ls /sys/bus/platform/devices/应该能看到类似my_platform_test.0的目录。如果没有,说明设备树节点本身没进去,检查 dtb 是否烧录到正确分区,查看启动日志中是否有语法错误:
cat /proc/device-tree/my-platform-test/status然后确认驱动的 of_match_table 是否写对了 compatible。一个建议做法:直接在设备树里 grep 一下自己节点的 compatible:
grep -r "mycompany,my-platform-test" /proc/device-tree/返回值存在,说明设备树有该 compatible。再用:
grep "mycompany" /sys/bus/platform/drivers/my_platform_test/这个目录如果能找到设备的 symlink,说明匹配成功过。注意/sys/bus/platform/drivers/下目录名用的是driver.name,不是 compatible 名字。
如果发现 sysfs 里没有对应设备节点,还有一种情况是设备树里status = "disabled",或者 pinctrl 引脚冲突导致内核 probe error 被跳过。
4.2 资源获取失败怎么定位
probe 打印了 start,但中途 return -ENXIO 或者 -ENOMEM,这种情况很好查,直接看 dmesg 里的 dev_err 日志。常见问题:
device tree node not found:GPIO 属性名拼写错误,或者 GPIO 控制器在设备树里没有 alias。invalid resource:reg 属性没写对,地址或者长度不对。i.MX6ULL 的 GPIO 寄存器基地址可以参考手册,但不能从 0x0000 开始写, 要有具体外设基址。- 设备树里引用了不存在的&gpio1,或者 pinctrl 配置有误,导致 gpio_request 失败。
- irq 获取失败时,确认设备树中断属性是否完整,GIC 类型是否写错(SPI 和 PPI 类型不能混)。
4.3 我常用的几个排查命令速查
| 命令 | 用途 |
|---|---|
| ls /sys/bus/platform/devices/ | 查看所有平台设备 |
| ls /sys/bus/platform/drivers/ | 查看所有平台驱动 |
| cat /sys/bus/platform/drivers/xxx/uevent | 查看驱动设备状态 |
| dmesg | tail -50 | 查看内核最近日志 |
| cat /proc/device-tree/my-platform-test/status | 确认节点状态 |
| grep -r "compatible" /proc/device-tree/my-platform-test/ | 确认 compatible 值 |
| echo -n "my_platform_test" > /sys/bus/platform/drivers/my_platform_test/bind | 手动绑定设备到驱动 |
最后那条 bind 命令很实用。如果你不想重启板子,可以强制解绑、重新绑定驱动:
echo -n "my_platform_test.0" > /sys/bus/platform/drivers/my_platform_test/unbind echo -n "my_platform_test.0" > /sys/bus/platform/drivers/my_platform_test/bind这在你修改设备树 node 但不想重新启动内核的情况下(虽然一般还是要重启才能重新解析设备树,但在 device_reattach 等场景中非常有用),节省大量时间。
还有一个大坑:把驱动编入了内核,但又用 insmod 加载同名 .ko。这时内核会报 "module already registered" 之类的错误,或者驱动被“吃了”,probe 不会执行。确认一下模块是否已经处于 built-in 状态:
cat /lib/modules/$(uname -r)/modules.builtin | grep my_platform如果有输出,说明内核里已经带了,别再 insmod 了。
4.4 调试技巧:在匹配流程中打点
如果想要更高阶的跟踪方式,可以用 ftrace。内核配置好CONFIG_FTRACE后,可以追踪platform_match函数:
echo function > /sys/kernel/debug/tracing/current_tracer echo platform_match > /sys/kernel/debug/tracing/set_ftrace_filter echo 1 > /sys/kernel/debug/tracing/tracing_on cat /sys/kernel/debug/tracing/trace这样你能看到平台总线匹配的大致调用顺序。不过调试驱动时我更推荐直接查 sysfs 和 dmesg,ftrace 看 Platform 匹配有点杀鸡用牛刀,但在确认是不是“总线匹配阶段”卡住时很管用。
5. 深入理解 Platform 匹配的多场景扩展
除了标准的设备树 compatible 匹配,i.MX6ULL 开发中还常碰到几种特殊情况。
5.1 传统 ID 表匹配方式
如果你手里有个老的 BSP,没有设备树,而是用arch/arm/mach-imx/下经典板级文件,那就要用platform_device_id表匹配:
static const struct platform_device_id my_platform_id_table[] = { { "my_platform_test", 0 }, { } }; MODULE_DEVICE_TABLE(platform, my_platform_id_table); static struct platform_driver my_platform_driver = { .probe = my_platform_probe, .remove = my_platform_remove, .driver = { .name = "my_platform_test", }, .id_table = my_platform_id_table, };这种模式下,系统初始化代码里通过platform_device_register注册一个 name 为 "my_platform_test" 的 device,就能匹配上。但 i.MX6ULL 的新官方 BSP 已经全面转向设备树,我不太建议新项目再用这种方式。
5.2 驱动里同时处理多个兼容设备节点
一个驱动可以支持多个 compatible 设备。比如你做了一个通用 GPIO 控制器驱动,希望兼容不同版本硬件,可以这么写:
static const struct of_device_id my_gpio_of_match[] = { { .compatible = "mycompany,gpio-v1", .data = (void *)1 }, { .compatible = "mycompany,gpio-v2", .data = (void *)2 }, { } }; MODULE_DEVICE_TABLE(of, my_gpio_of_match);在 probe 里通过of_match_device获取匹配到的 of_device_id,再通过data区分版本:
const struct of_device_id *match; match = of_match_device(my_gpio_of_match, &pdev->dev); if (match) { unsigned long version = (unsigned long)match->data; if (version == 1) { // v1 初始化流程 } }这个技巧对 i.MX6ULL 上驱动同一控制器多版本非常实用。很多 NXP 官方驱动就是这么处理的,你在阅读内核源码时会经常看到这个模式。
5.3 为什么 probe 最好只做初始化,不做业务
我见过不少写着写着就把业务逻辑全塞进 probe 的驱动:读温度、上报事件、开定时器轮询……全在 probe 里干完了。短时间内没问题,一旦驱动需要解绑再绑定、或者设备热插拔时,remove 和 probe 反复执行,业务逻辑就会串台。
建议的做法:probe 只负责“拿资源 + 初始化硬件 + 创建接口”,真正的业务通过 file_operations、中断底半部、内核线程等方式跑。这样驱动模型的生命周期管理才真正帮到你。尤其在 i.MX6ULL 上做项目,后面要加电源管理、休眠唤醒时,probe 职责清晰会省很多事。
6. 驱动匹配机制和整个软件生态的关系
很多人觉得 Platform 匹配机制只是“一个内核函数流程”,其实它和整个嵌入式 Linux 的设计哲学是绑在一起的。
设备树解决了硬件描述问题,但如果没有 Platform 总线的自动匹配,那么设备树解析出来的节点也只是一堆内存里的字符串。正是“设备树节点 -> platform_device -> platform_driver -> probe”这条链路,让板级硬件配置和驱动代码解耦。你在 i.MX6ULL 上跑 NXP 官方的 BSP,稍加留意会发现:同样的内核代码,换到另一个板子只要替换设备树就能跑起来,这正是这套机制存在的意义。
另外,devicetree中 compatible 的选择也会影响驱动加载的优先级。比如你板子上有一个外设,希望用内核通用驱动而不是自己写的驱动,那么 compatible 就让它在两者之间做“仲裁”。内核里很多drivers/mfd/、drivers/input/下驱动都支持多个 compatible,匹配机制越熟练,你在做板级适配时越能精准控制“哪段代码初始化哪个设备”。
从驱动开发者角度,理解匹配机制还能帮助你调试更复杂的问题,比如引脚复用冲突、驱动 probe 顺序、设备依赖关系。i.MX6ULL 上有过不少 case 是 probe 顺序问题导致外设初始化异常,如果你能理解 deferred probe 机制(平台总线在驱动依赖未满足时返回 -EPROBE_DEFER),就能知道它其实也是在 platform_driver 框架内工作的。
7. 我的一点实操心得
做 i.MX6ULL 驱动开发这几年,我最大的感受是:设备驱动模型刚开始学的时候会有一些抽象,但一旦自己把一个带设备树的 platform 驱动从零写通,后面的 I2C、SPI、USB 驱动理解起来都会顺很多。因为它们本质上都是在各自总线上做类似的事情——匹配、probe、实现操作集。
有个小建议给你:不要只看文章,最好找一块 i.MX6ULL 开发板,打开官方 BSP 源码,搜索of_match_table,随便挑一个驱动慢慢读。对比设备树节点、驱动 probe 里的资源获取、以及 sysfs 中的对应目录,三个维度串起来看,很快你就能形成自己的排查思路。
我最早调试平台驱动时,probe 不走,折腾了一天,最后发现只是设备树里 compatible 比驱动里多写了一个空格。这种低级问题会让人很崩溃,但这也是嵌入式开发的常态。所以我的习惯是:设备树和驱动里的 compatible 始终先用 grep 确认完全一致,再往下排查其他原因。
这套匹配机制本质不难,难的是把设备树、驱动框架、sysfs、内核日志串成一个完整的调试闭环。这篇文章能把它们串起来,让你真正掌握 Platform 驱动的开发方法,那就算没白写。