3、Linux内核驱动框架:字符设备驱动基础、platform驱动模型、设备树解析、驱动与设备的匹配过程

好,咱们进入第三章。这一章可以说是嵌入式GPU驱动开发的「地基工程」。你想想看,GPU再厉害,如果跟内核的沟通方式不对,那一切都是白搭。我这些年调试过的驱动,十有八九的问题都出在「驱动和设备没对上号」——说白了,就是内核不知道你的GPU硬件在哪,也不知道该用哪个驱动去管它。

这一章我会带你走一遍Linux内核驱动框架的核心脉络。从最基础的字符设备驱动,到嵌入式领域最常用的platform驱动模型,再到设备树(Device Tree)的解析,最后把驱动与设备的匹配过程串起来。嗯,内容有点多,但都是硬通货。

3.1 字符设备驱动基础

字符设备驱动,是Linux驱动里最基础、最经典的一种。像我们的GPU,很多时候就是作为一个字符设备暴露给用户空间的。为什么?因为GPU需要频繁地跟应用层交换数据——你传个渲染命令,它回个帧缓冲,这种流式操作,字符设备最合适。

核心概念:字符设备以字节流的方式访问,不支持随机访问(不像块设备可以跳来跳去)。每个字符设备都有一个主设备号和次设备号。

我记得刚入行那会儿,写第一个字符设备驱动时,被设备号分配搞得一头雾水。后来才明白,其实就三步:

  1. 分配设备号——可以用静态分配(自己指定),也可以用动态分配(让内核给你分)。我建议新手用动态分配,省得跟别人冲突。
  2. 初始化cdev结构体——把file_operations结构体挂上去。这个结构体里定义了open、read、write、ioctl等操作函数。
  3. 注册设备——把cdev添加到内核中。

来看一个最简单的骨架代码:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/device.h>

static int gpu_major = 0;  // 动态分配主设备号
static struct cdev gpu_cdev;
static struct class *gpu_class;

static int gpu_open(struct inode *inode, struct file *filp) {
    // 这里可以初始化GPU硬件
    printk(KERN_INFO "GPU device opened\n");
    return 0;
}

static long gpu_ioctl(struct file *filp, unsigned int cmd, unsigned long arg) {
    // 处理GPU命令,比如提交渲染任务
    switch(cmd) {
        case 0x1001: // 假设是提交命令缓冲区
            // 处理逻辑...
            break;
        default:
            return -EINVAL;
    }
    return 0;
}

static struct file_operations gpu_fops = {
    .owner = THIS_MODULE,
    .open = gpu_open,
    .unlocked_ioctl = gpu_ioctl,
};

static int __init gpu_init(void) {
    dev_t dev_num;
    int ret;

    // 第一步:动态分配设备号
    ret = alloc_chrdev_region(&dev_num, 0, 1, "gpu_driver");
    if (ret < 0) {
        printk(KERN_ERR "Failed to allocate device number\n");
        return ret;
    }
    gpu_major = MAJOR(dev_num);

    // 第二步:初始化cdev
    cdev_init(&gpu_cdev, &gpu_fops);
    gpu_cdev.owner = THIS_MODULE;

    // 第三步:注册cdev
    ret = cdev_add(&gpu_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }

    // 创建设备类,方便udev自动创建设备节点
    gpu_class = class_create(THIS_MODULE, "gpu_class");
    device_create(gpu_class, NULL, dev_num, NULL, "gpu0");

    printk(KERN_INFO "GPU driver loaded, major number: %d\n", gpu_major);
    return 0;
}

static void __exit gpu_exit(void) {
    dev_t dev_num = MKDEV(gpu_major, 0);
    device_destroy(gpu_class, dev_num);
    class_destroy(gpu_class);
    cdev_del(&gpu_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "GPU driver unloaded\n");
}

module_init(gpu_init);
module_exit(gpu_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");

个人经验:我在做GPU驱动时,ioctl是用户空间和内核空间交互的主通道。建议把ioctl命令号定义在一个头文件里,用户态和内核态共用,避免两边不一致。我曾经因为命令号定义错位,调试了整整两天才发现是宏定义冲突了。

3.2 platform驱动模型

好,字符设备驱动是基础,但嵌入式系统里,很多硬件不是PCIe或USB那种可枚举的总线。它们直接焊在主板上,地址是固定的。这时候,platform驱动模型就派上用场了。

说白了,platform驱动模型就是内核为「非可枚举设备」准备的一套虚拟总线机制。它把硬件资源(内存地址、中断号等)通过platform_device来描述,把驱动逻辑通过platform_driver来实现。内核负责把它们配对。

我习惯把platform驱动拆成两部分来看:

  • platform_device:描述硬件在哪里,有什么资源。通常在板级文件或设备树中定义。
  • platform_driver:描述怎么操作硬件。由驱动开发者实现probe、remove等回调。

来看一个platform驱动的典型结构:

// platform_driver 结构
static struct platform_driver gpu_platform_driver = {
    .probe = gpu_probe,
    .remove = gpu_remove,
    .driver = {
        .name = "my-gpu",
        .of_match_table = gpu_of_match,  // 设备树匹配表
    },
};

module_platform_driver(gpu_platform_driver);

这里有个关键点:module_platform_driver这个宏,它帮我们自动处理了module_init和module_exit。你想想看,少写多少样板代码?

避坑指南:我曾经在probe函数里忘记检查platform_get_resource的返回值,结果在空指针上解引用,内核直接panic。记住:所有资源获取都要判空,尤其是中断号和内存区域。

3.3 设备树(Device Tree)解析

设备树,这是嵌入式Linux开发的必修课。以前用板级文件(board-xxx.c),每次换硬件都要重新编译内核,太痛苦了。设备树把硬件描述和内核代码解耦,换板子只需要改.dts文件,重新编译设备树二进制(.dtb)就行。

设备树的语法其实不复杂,就是一套树形结构的键值对。我挑几个GPU驱动里常用的节点来说:

// gpu.dtsi 片段
gpu: gpu@ffa00000 {
    compatible = "vendor,my-gpu";  // 匹配驱动的关键
    reg = <0x0 0xffa00000 0x0 0x10000>;  // 寄存器基地址和大小
    interrupts = <0 42 4>;  // 中断号:GIC SPI 42, 上升沿触发
    clocks = <&gpu_clk>;    // 时钟
    resets = <&gpu_rst>;    // 复位
};

解析设备树,内核提供了丰富的API。在probe函数里,我们通常这样拿资源:

static int gpu_probe(struct platform_device *pdev) {
    struct resource *res;
    void __iomem *base;
    int irq;

    // 获取寄存器地址
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    if (!res) {
        dev_err(&pdev->dev, "No memory resource\n");
        return -ENODEV;
    }

    // 映射到内核虚拟地址空间
    base = devm_ioremap_resource(&pdev->dev, res);
    if (IS_ERR(base)) {
        return PTR_ERR(base);
    }

    // 获取中断号
    irq = platform_get_irq(pdev, 0);
    if (irq < 0) {
        dev_err(&pdev->dev, "No IRQ resource\n");
        return irq;
    }

    // 解析时钟
    struct clk *gpu_clk = devm_clk_get(&pdev->dev, NULL);
    if (IS_ERR(gpu_clk)) {
        dev_err(&pdev->dev, "Failed to get clock\n");
        return PTR_ERR(gpu_clk);
    }

    // ... 初始化GPU硬件 ...
    return 0;
}

我的习惯:在设备树里,reg属性我通常会多留一点余量。比如GPU寄存器实际只用了8KB,我会在设备树里写16KB。为什么?因为硬件版本升级时,寄存器空间可能会扩展。改设备树比改驱动代码简单多了。

3.4 驱动与设备的匹配过程

好,前面讲了字符设备、platform驱动、设备树,现在把它们串起来——驱动和设备到底是怎么匹配上的?

这个过程,我把它总结为「三步相亲」:

  1. 注册阶段:内核启动时,会遍历所有注册的platform_driver和platform_device。每个driver都有一个of_match_table,里面列出了它支持的设备类型(通过compatible属性)。
  2. 匹配阶段:内核拿device的compatible值,去driver的of_match_table里找。找到了,就调用driver的probe函数。找不到?那这个设备就没人管了。
  3. 绑定阶段:probe函数被调用,驱动开始初始化硬件。如果probe成功,驱动和设备就「绑定」在一起了。

匹配的优先级是这样的:

匹配方式 说明 优先级
设备树compatible匹配 最常用,通过of_match_table
platform ID表匹配 通过id_table中的名字匹配
驱动名字匹配 直接比较driver.name和device.name

你可能会问:「如果多种方式都匹配上了怎么办?」内核会按优先级顺序尝试,一旦某个probe成功,就不再尝试其他方式。

我曾经踩过的坑:有一次我改了设备树里的compatible字符串,但忘了更新驱动里的of_match_table。结果驱动加载时,内核说「找不到匹配的设备」。我查了半天,才发现compatible写成了"vendor,my-gpu-v2",而驱动里还是"vendor,my-gpu"。这种低级错误,真的让人抓狂。所以,改设备树一定要同步改驱动,反之亦然。

最后,我画个简单的流程图,帮你理解整个匹配过程:

内核启动/模块加载
    |
    v
注册platform_driver到内核
    |
    v
遍历所有已注册的platform_device
    |
    v
对每个device,检查driver的of_match_table
    |
    +-- 找到匹配项? --> 调用probe()
    |                       |
    |                       v
    |                   probe成功? --> 绑定完成
    |                       |
    |                       v (失败)
    |                   继续尝试下一个driver
    |
    +-- 没找到? --> 设备处于"无人认领"状态

嗯,这一章的内容就到这里。字符设备驱动是基础,platform模型是骨架,设备树是血肉,匹配过程是灵魂。把这四块吃透了,你写GPU驱动时就不会再迷茫——至少知道该从哪里下手。

下一章,我们会深入GPU硬件的内存管理单元(MMU),看看怎么给GPU分配和管理显存。那才是真正考验功底的地方。