4、设备驱动模型:总线、设备、驱动三者的关系,platform总线与设备树匹配机制
好,咱们进入第四章。这一章可以说是整个驱动开发的骨架——设备驱动模型。很多初学者写驱动,上来就写 file_operations,注册字符设备,然后就完事了。但实际项目中,你会发现这样写出来的驱动,移植性差、代码混乱、没法跟硬件自动匹配。
我刚开始做驱动时也踩过这个坑。那时候我写了一个 GPIO 按键驱动,硬编码了寄存器地址。换了一个板子,所有地址都得改,改得我头皮发麻。后来我才明白,Linux 内核早就帮你设计好了这套机制——总线、设备、驱动分离。
4.1 核心思想:为什么需要设备驱动模型?
说白了,设备驱动模型要解决一个问题:驱动代码和设备信息解耦。
你想想看,一个 I2C 控制器驱动,可能在几十种芯片上都能用。但每种芯片的寄存器基地址、中断号、时钟配置都不一样。如果把这些信息硬编码在驱动里,那每换一个芯片就得改驱动代码,这谁受得了?
所以内核搞了三样东西:
- 总线(Bus):负责匹配设备和驱动
- 设备(Device):描述硬件资源(地址、中断、DMA 通道等)
- 驱动(Driver):实现硬件操作逻辑
驱动只关心怎么操作硬件,设备只告诉驱动硬件在哪。中间的总线负责牵线搭桥。这就是典型的面向对象思想——把变化的部分(设备信息)和不变的部分(驱动逻辑)分开。
核心原则:驱动代码中不要出现任何硬件地址、中断号、时钟频率等具体数值。这些都应该来自设备节点或设备表。
4.2 总线、设备、驱动三者的关系
这三者的关系,我用一张图来展示:
流程是这样的:
- 系统启动时,总线会扫描所有注册的设备
- 同时,总线也维护着所有注册的驱动
- 每当新设备或新驱动加入,总线就尝试匹配
- 匹配成功 → 调用驱动的
probe()函数 probe()里拿到设备资源,初始化硬件
个人经验:我习惯把 probe() 函数看作是驱动的「入口 main 函数」。所有初始化工作都在这里完成。如果 probe() 返回失败,驱动就不会生效。
4.3 platform 总线:最常用的虚拟总线
在嵌入式 Linux 里,大部分设备都挂在 platform 总线上。为什么?因为很多设备(比如 GPIO、UART、SPI 控制器)并不像 PCI 或 USB 那样有物理总线可以枚举。它们直接挂在 CPU 的内存总线上,内核没法自动发现它们。
所以内核搞了一个虚拟总线——platform 总线。它不依赖任何物理总线协议,纯粹靠软件来匹配设备和驱动。
| 总线类型 | 特点 | 典型设备 |
|---|---|---|
| platform 总线 | 虚拟总线,软件匹配 | GPIO、UART、I2C 控制器、SPI 控制器 |
| I2C 总线 | 物理总线,硬件协议 | 温度传感器、加速度计、EEPROM |
| SPI 总线 | 物理总线,硬件协议 | Flash、LCD 控制器、ADC |
| PCI 总线 | 物理总线,可枚举 | 网卡、显卡、声卡 |
你看,platform 总线是唯一一个「虚拟」的。它专门为那些没有硬件枚举能力的设备服务。
4.4 设备树匹配机制
好,现在问题来了:设备和驱动怎么匹配?
在老版本内核(2.6 时代),设备信息写在 C 代码里,用 platform_device 结构体描述。每次换板子都得改 C 代码重新编译。后来内核引入了设备树(Device Tree),把设备信息独立成 .dts 文件,编译成 .dtb 给内核用。
设备树匹配的核心是 compatible 属性。驱动里声明自己支持哪些设备,设备树里声明自己是什么设备,两者对上号就匹配成功。
4.4.1 驱动侧:of_match_table
驱动里需要定义一个 of_match_table:
static const struct of_device_id my_driver_of_match[] = {
{ .compatible = "vendor,my-device-v1", },
{ .compatible = "vendor,my-device-v2", },
{ /* sentinel */ }
};
static struct platform_driver my_driver = {
.probe = my_probe,
.remove = my_remove,
.driver = {
.name = "my_device",
.of_match_table = my_driver_of_match,
},
};
4.4.2 设备侧:设备树节点
设备树里这样写:
my_device: my-device@12345678 {
compatible = "vendor,my-device-v1";
reg = <0x12345678 0x1000>;
interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>;
};
匹配时,内核会拿设备树节点的 compatible 值,去驱动的 of_match_table 里逐个比对。只要有一个匹配上,就调用 probe()。
我曾经踩过的坑:compatible 字符串的格式一定要规范。标准格式是 "厂商名,设备名-版本号"。我见过有人写成 "my-device" 不带厂商前缀,结果跟别的驱动冲突了,排查了两天才找到原因。
4.5 匹配优先级:不只是 compatible
platform 总线的匹配其实有多个维度,按优先级排序:
- 设备树 compatible 匹配(最常用)
- ACPI 匹配(x86 平台用)
- name 字段匹配(驱动名 == 设备名)
- ID 表匹配(platform_device_id)
在嵌入式 ARM 平台,绝大多数情况走的是第 1 条。但有些老驱动或者特殊场景,也会用到 name 匹配。
4.6 probe() 里拿到设备资源
匹配成功后,probe() 被调用。这时候驱动需要从设备节点里读取资源:
static int my_probe(struct platform_device *pdev)
{
struct resource *res;
void __iomem *base;
int irq;
// 获取寄存器基地址
res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
base = devm_ioremap_resource(&pdev->dev, res);
if (IS_ERR(base))
return PTR_ERR(base);
// 获取中断号
irq = platform_get_irq(pdev, 0);
if (irq < 0)
return irq;
// 从设备树读取自定义属性
u32 my_param;
of_property_read_u32(pdev->dev.of_node, "vendor,my-param", &my_param);
// 注册设备
// ...
return 0;
}
你看,驱动里完全没有硬编码的地址和中断号。所有资源都从 platform_device 里拿。换板子?改设备树就行,驱动代码不用动。
关键 API 总结:
platform_get_resource()— 获取 MEM 或 IO 资源platform_get_irq()— 获取中断号of_property_read_u32()— 读取设备树中的整数属性devm_ioremap_resource()— 映射物理地址到虚拟地址(带资源管理)
4.7 实际项目中的经验
我参与过一个项目,需要在三款不同芯片上跑同一个 LCD 控制器驱动。三款芯片的寄存器布局完全一样,但基地址和中断号不同。
我们做了两件事:
- 驱动代码只写一份,用
of_match_table声明支持三个 compatible - 每个芯片的设备树里写各自的 reg 和 interrupts
结果呢?驱动代码一次编译,三款芯片各自用不同的 .dtb 文件。硬件团队改板子,我们只需要改设备树,驱动代码零改动。这就是设备驱动模型的价值。
我的建议:写新驱动时,哪怕当前只有一个平台,也请用设备树 + of_match_table 的方式。因为你永远不知道明天会不会换芯片。提前做好解耦,后面省大事。
4.8 本章小结
设备驱动模型的核心就三句话:
- 总线负责牵线搭桥
- 设备描述硬件资源
- 驱动实现操作逻辑
platform 总线是嵌入式 Linux 最常用的虚拟总线。设备树匹配靠 compatible 属性。probe() 里用 platform_get_resource 系列 API 获取资源。
这套机制用好了,你的驱动就能做到「一次编写,到处运行」。嗯,至少不用到处改代码。