2、Linux内核驱动框架:字符设备驱动模型、platform驱动框架、设备树匹配机制

好,咱们今天聊聊驱动框架。说实话,我刚入行那会儿,面对Linux内核里那一堆结构体和回调函数,头都是大的。但后来我发现,搞懂这三个东西——字符设备驱动模型、platform驱动框架、设备树匹配机制——基本上就能应付90%的嵌入式驱动开发场景了。

我个人习惯把这套东西叫做「驱动三板斧」。你想想看,不管你是写一个简单的LED灯驱动,还是搞一个复杂的摄像头传感器驱动,底层逻辑都离不开它们。咱们一个一个来拆解。

2.1 字符设备驱动模型:最基础的驱动形态

字符设备,说白了就是像文件一样操作硬件。你打开一个设备节点,读它、写它、控制它,内核帮你把这些操作映射到硬件上。我最早接触的驱动就是字符设备驱动,一个简单的GPIO按键。

核心结构体是 struct file_operations。它定义了设备能干什么:

struct file_operations {
    struct module *owner;
    loff_t (*llseek) (struct file *, loff_t, int);
    ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
    ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
    long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
    int (*open) (struct inode *, struct file *);
    int (*release) (struct inode *, struct file *);
    // ... 还有不少,但常用的就这些
};

注册一个字符设备,流程其实很固定:

  1. 分配设备号(动态或静态)
  2. 初始化 cdev 结构体
  3. 调用 cdev_add 添加到内核
  4. 创建设备节点(手动或通过udev自动创建)

我举个例子,一个最简单的虚拟字符设备:

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

#define DEVICE_NAME "my_camera_dev"
#define CLASS_NAME  "camera_class"

static int major;
static struct class *camera_class;
static struct cdev camera_cdev;

static int camera_open(struct inode *inode, struct file *file) {
    pr_info("Camera device opened\n");
    return 0;
}

static ssize_t camera_read(struct file *file, char __user *buf, 
                           size_t len, loff_t *off) {
    // 这里模拟从摄像头读取数据
    char data[] = "frame_data";
    if (*off >= sizeof(data)) return 0;
    if (copy_to_user(buf, data, sizeof(data))) return -EFAULT;
    *off += sizeof(data);
    return sizeof(data);
}

static struct file_operations fops = {
    .owner = THIS_MODULE,
    .open = camera_open,
    .read = camera_read,
};

static int __init camera_init(void) {
    dev_t dev;
    // 动态分配设备号
    alloc_chrdev_region(&dev, 0, 1, DEVICE_NAME);
    major = MAJOR(dev);
    
    // 初始化cdev
    cdev_init(&camera_cdev, &fops);
    cdev_add(&camera_cdev, dev, 1);
    
    // 创建设备类
    camera_class = class_create(THIS_MODULE, CLASS_NAME);
    device_create(camera_class, NULL, dev, NULL, DEVICE_NAME);
    
    pr_info("Camera driver loaded, major=%d\n", major);
    return 0;
}

static void __exit camera_exit(void) {
    dev_t dev = MKDEV(major, 0);
    device_destroy(camera_class, dev);
    class_destroy(camera_class);
    cdev_del(&camera_cdev);
    unregister_chrdev_region(dev, 1);
    pr_info("Camera driver unloaded\n");
}

module_init(camera_init);
module_exit(camera_exit);
MODULE_LICENSE("GPL");

我的经验: 写字符设备驱动时,copy_to_usercopy_from_user 这两个函数一定要用对。我曾经犯过一个低级错误,直接在内核空间用 memcpy 往用户空间写数据,结果系统直接崩溃。记住,内核和用户空间的数据拷贝,必须用专用函数。

2.2 platform驱动框架:让驱动和设备解耦

字符设备驱动虽然简单,但有个问题——它把硬件信息和驱动逻辑混在一起了。比如你的摄像头挂在哪条I2C总线上、中断号是多少,这些信息如果硬编码在驱动里,换个板子就得改代码。

platform驱动框架就是来解决这个问题的。它把「设备」和「驱动」分开管理。设备负责描述硬件资源,驱动负责操作逻辑。内核通过一个叫 platform_bus 的虚拟总线来匹配它们。

核心是两个结构体:

  • struct platform_device:描述硬件设备(资源、名称等)
  • struct platform_driver:描述驱动(probe、remove、id_table等)

匹配流程是这样的:

  1. 内核注册一个platform设备
  2. 内核注册一个platform驱动
  3. platform总线根据名称或设备树信息进行匹配
  4. 匹配成功,调用驱动的 probe 函数

看个例子,一个虚拟的摄像头platform驱动:

static int camera_probe(struct platform_device *pdev) {
    struct resource *res;
    int irq;
    
    // 获取I/O内存资源
    res = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    if (!res) {
        dev_err(&pdev->dev, "No memory resource\n");
        return -ENODEV;
    }
    
    // 获取中断号
    irq = platform_get_irq(pdev, 0);
    if (irq < 0) {
        dev_err(&pdev->dev, "No IRQ resource\n");
        return -ENXIO;
    }
    
    dev_info(&pdev->dev, "Camera probed: mem=0x%llx, irq=%d\n",
             (unsigned long long)res->start, irq);
    return 0;
}

static int camera_remove(struct platform_device *pdev) {
    dev_info(&pdev->dev, "Camera removed\n");
    return 0;
}

static const struct of_device_id camera_of_match[] = {
    { .compatible = "mycompany,my-camera" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, camera_of_match);

static struct platform_driver camera_driver = {
    .probe = camera_probe,
    .remove = camera_remove,
    .driver = {
        .name = "my_camera",
        .of_match_table = camera_of_match,
    },
};

module_platform_driver(camera_driver);

关键点: probe 函数是驱动的入口。所有硬件初始化、内存申请、中断注册都应该放在这里。如果 probe 返回非0值,内核会认为驱动加载失败。

2.3 设备树匹配机制:硬件描述的标准化

说到设备树,我刚开始接触的时候觉得这东西很玄乎。说白了,设备树就是一个描述硬件拓扑结构的文本文件。它用节点和属性的方式,告诉内核「我这块板子上有什么硬件、接在哪个引脚上、用哪个中断」。内核启动时解析这个文件,然后动态创建设备。

设备树的匹配机制,核心就是 compatible 属性。驱动里声明自己支持哪些设备,设备树里声明自己是什么设备,两者匹配上了,驱动就干活。

一个典型的设备树节点:

// 在 .dts 文件中
i2c@ff110000 {
    compatible = "snps,designware-i2c";
    reg = <0xff110000 0x1000>;
    interrupts = <0 56 4>;
    #address-cells = <1>;
    #size-cells = <0>;
    
    camera: camera@10 {
        compatible = "mycompany,my-camera";
        reg = <0x10>;
        clocks = <&clkc 15>;
        reset-gpios = <&gpio0 5 GPIO_ACTIVE_LOW>;
        power-gpios = <&gpio0 6 GPIO_ACTIVE_HIGH>;
    };
};

驱动里怎么获取这些信息?用设备树API:

// 在 probe 函数中
struct device *dev = &pdev->dev;
struct device_node *np = dev->of_node;
u32 reg_val;
int ret;

// 读取 reg 属性
ret = of_property_read_u32(np, "reg", &reg_val);
if (ret) {
    dev_err(dev, "Failed to read reg property\n");
    return ret;
}

// 读取 GPIO
struct gpio_desc *reset_gpio;
reset_gpio = devm_gpiod_get(dev, "reset", GPIOD_OUT_LOW);
if (IS_ERR(reset_gpio)) {
    dev_err(dev, "Failed to get reset GPIO\n");
    return PTR_ERR(reset_gpio);
}

// 读取时钟
struct clk *clk;
clk = devm_clk_get(dev, NULL);
if (IS_ERR(clk)) {
    dev_err(dev, "Failed to get clock\n");
    return PTR_ERR(clk);
}

我曾经踩过的坑: 设备树里 reg 属性的地址和长度,一定要和硬件手册一致。有一次我写摄像头驱动,I2C地址写成了0x10,但实际芯片地址是0x30,结果驱动一直probe失败。查了两天才发现是设备树写错了。所以,拿到一块新板子,第一件事就是核对设备树里的地址和中断号。

2.4 三者如何协同工作?

现在咱们把这三个东西串起来。一个完整的摄像头驱动,通常是这样工作的:

  1. 设备树 描述摄像头挂在哪条I2C总线上、用哪个GPIO做复位、用哪个中断
  2. platform驱动 通过 of_match_table 匹配设备树节点,进入 probe 函数
  3. probe 函数里,字符设备驱动模型 被用来注册设备节点,提供 open/read/ioctl 等操作接口给用户空间

我画个简单的流程:

设备树 (.dts)
    ↓ 内核解析
platform_device (自动创建)
    ↓ 匹配
platform_driver → probe()
    ↓
字符设备注册 (cdev_add)
    ↓
用户空间通过 /dev/camera 访问

这个架构的好处是:硬件变了,只需要改设备树文件,驱动代码不用动。比如你从A板子换到B板子,摄像头从I2C-0换到I2C-1,中断号从56变成57,改一下 .dts 文件重新编译就行。

我的建议: 写驱动时,尽量把硬件相关的信息(地址、中断、GPIO)都放到设备树里。哪怕你现在只有一个板子,也养成这个习惯。因为项目后期换硬件是常有的事,到时候你会发现这个习惯救了你一命。

2.5 实战中的注意事项

组件 常见问题 解决方案
字符设备 设备节点不自动创建 检查udev规则,或手动 mknod
platform驱动 probe不执行 检查 compatible 是否匹配,设备树是否被正确加载
设备树 语法错误导致启动失败 dtc -I dts -O dtb 编译检查,用 fdtdump 查看二进制内容

嗯,这套框架说复杂也复杂,说简单也简单。你只要记住:设备树描述硬件长什么样,platform驱动负责找到硬件并初始化,字符设备驱动负责给用户提供操作接口。这三者配合好了,一个稳定、可移植的驱动就出来了。

下一章咱们会基于这个框架,真正开始写一个摄像头传感器的驱动。到时候你会看到,这些理论是怎么落到实处的。