4、设备驱动模型:总线、设备、驱动三者的关系,platform总线与设备树匹配机制

好,咱们进入第四章。这一章可以说是整个驱动开发的骨架——设备驱动模型。很多初学者写驱动,上来就写 file_operations,注册字符设备,然后就完事了。但实际项目中,你会发现这样写出来的驱动,移植性差、代码混乱、没法跟硬件自动匹配。

我刚开始做驱动时也踩过这个坑。那时候我写了一个 GPIO 按键驱动,硬编码了寄存器地址。换了一个板子,所有地址都得改,改得我头皮发麻。后来我才明白,Linux 内核早就帮你设计好了这套机制——总线、设备、驱动分离。

4.1 核心思想:为什么需要设备驱动模型?

说白了,设备驱动模型要解决一个问题:驱动代码和设备信息解耦。

你想想看,一个 I2C 控制器驱动,可能在几十种芯片上都能用。但每种芯片的寄存器基地址、中断号、时钟配置都不一样。如果把这些信息硬编码在驱动里,那每换一个芯片就得改驱动代码,这谁受得了?

所以内核搞了三样东西:

  • 总线(Bus):负责匹配设备和驱动
  • 设备(Device):描述硬件资源(地址、中断、DMA 通道等)
  • 驱动(Driver):实现硬件操作逻辑

驱动只关心怎么操作硬件,设备只告诉驱动硬件在哪。中间的总线负责牵线搭桥。这就是典型的面向对象思想——把变化的部分(设备信息)和不变的部分(驱动逻辑)分开。

核心原则:驱动代码中不要出现任何硬件地址、中断号、时钟频率等具体数值。这些都应该来自设备节点或设备表。

4.2 总线、设备、驱动三者的关系

这三者的关系,我用一张图来展示:

总线-设备-驱动关系图 总线、设备、驱动三者关系 总线 (Bus) 设备 (Device) 硬件资源描述 驱动 (Driver) 硬件操作逻辑 匹配成功 → probe() 总线负责匹配设备和驱动,匹配成功后调用驱动的 probe() 函数 设备提供资源信息,驱动使用这些资源操作硬件

流程是这样的:

  1. 系统启动时,总线会扫描所有注册的设备
  2. 同时,总线也维护着所有注册的驱动
  3. 每当新设备或新驱动加入,总线就尝试匹配
  4. 匹配成功 → 调用驱动的 probe() 函数
  5. 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 总线的匹配其实有多个维度,按优先级排序:

  1. 设备树 compatible 匹配(最常用)
  2. ACPI 匹配(x86 平台用)
  3. name 字段匹配(驱动名 == 设备名)
  4. 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 获取资源。

这套机制用好了,你的驱动就能做到「一次编写,到处运行」。嗯,至少不用到处改代码。