3、字符设备驱动基础(下):udev与自动创建设备节点、class_create与device_create、设备树基础概念、在设备树中描述摄像头

好,咱们接着往下聊。上一节我们把字符设备驱动的骨架搭起来了,也手动用 mknod 创建了设备节点。但说实话,这玩意儿在真实项目里根本没法用——你总不能每次加载驱动都跑去敲一遍 mknod 吧?

所以这一节,我重点讲讲怎么让设备节点自动生成。顺便把设备树这个概念也引出来,毕竟摄像头这种外设,在嵌入式 Linux 里基本都靠设备树来描述。

3.1 udev 与自动创建设备节点

先说说 udev 是干嘛的。简单理解,它就是 Linux 内核和用户空间之间的一个“快递员”。内核说“我有个新设备来了”,udev 就在 /dev 目录下帮你把节点建好。反过来,设备拔掉了,它也会帮你清理掉。

我早期做项目时,曾经偷懒没配 udev 规则,结果每次插拔摄像头都要手动 mknod,调试效率极低。后来被老大骂了一顿,才老老实实把自动创建设备节点这套机制搞明白。

其实内核里已经帮我们封装好了接口,你不需要直接跟 udev 打交道。核心就是两个函数:

  • class_create() —— 创建一个设备类
  • device_create() —— 在这个类下创建设备

这两个函数一调用,内核就会自动向用户空间的 udev 发送消息,udev 收到后就在 /dev 下生成节点。

核心思路:驱动里调用 class_create + device_create → 内核发送 uevent → udev 监听并创建设备节点。

3.2 class_create 与 device_create 实战

咱们直接看代码。假设你有一个字符设备,主设备号是 major,你想让它在 /dev/my_camera 出现。

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

static struct class *camera_class;
static struct device *camera_device;
static int major;

static int __init camera_init(void)
{
    // 1. 注册字符设备
    major = register_chrdev(0, "my_camera", &camera_fops);
    if (major < 0) {
        printk("Failed to register char device\n");
        return major;
    }

    // 2. 创建 class
    camera_class = class_create(THIS_MODULE, "camera_class");
    if (IS_ERR(camera_class)) {
        unregister_chrdev(major, "my_camera");
        return PTR_ERR(camera_class);
    }

    // 3. 创建设备节点
    camera_device = device_create(camera_class, NULL,
                                  MKDEV(major, 0), NULL,
                                  "my_camera");
    if (IS_ERR(camera_device)) {
        class_destroy(camera_class);
        unregister_chrdev(major, "my_camera");
        return PTR_ERR(camera_device);
    }

    printk("Camera driver loaded, major=%d\n", major);
    return 0;
}

static void __exit camera_exit(void)
{
    device_destroy(camera_class, MKDEV(major, 0));
    class_destroy(camera_class);
    unregister_chrdev(major, "my_camera");
    printk("Camera driver unloaded\n");
}

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

你看,代码其实不复杂。关键点就三个:

  1. class_create 的第一个参数是 THIS_MODULE,别写错了,我见过有人写成 NULL,结果内核直接 oops。
  2. device_create 的最后一个参数是设备节点的名字,比如 "my_camera",它就会出现在 /dev/my_camera
  3. 卸载时一定要先 device_destroy,再 class_destroy,顺序反了也会出问题。

小技巧:如果你想让设备节点出现在子目录里,比如 /dev/camera/my_cam,直接把名字写成 "camera/my_cam" 就行。udev 会自动创建子目录。

3.3 设备树基础概念

好,节点自动创建的问题解决了。但下一个问题来了:驱动怎么知道摄像头挂在哪条 I2C 总线上?地址是多少?用哪个 GPIO 做复位?

在旧版内核里,这些信息都硬编码在驱动代码里。换一块板子就得改代码重新编译,太折腾了。后来社区搞出了设备树(Device Tree),说白了就是用一个文本文件来描述硬件。

我个人习惯把设备树理解成“硬件的简历”。CPU 是面试官,设备树就是应聘者(外设)递上来的简历,上面写着:我叫摄像头,我挂在 I2C-1 上,地址是 0x30,我需要一个中断引脚,等等。

设备树的文件后缀是 .dts(源文件)和 .dtb(编译后的二进制)。编译工具是 dtc(Device Tree Compiler)。

基本语法长这样:

/dts-v1/;

/ {
    model = "My Camera Board";
    compatible = "mycompany,camera-board";

    i2c1: i2c@40010000 {
        compatible = "snps,designware-i2c";
        reg = <0x40010000 0x1000>;
        #address-cells = <1>;
        #size-cells = <0>;

        camera: camera@30 {
            compatible = "ov5640";
            reg = <0x30>;
            clocks = <&clk 12>;
            reset-gpios = <&gpio3 15 GPIO_ACTIVE_LOW>;
        };
    };
};

这里有几个关键概念:

  • compatible:驱动和设备树匹配的“暗号”。驱动里写 of_match_table,设备树里写 compatible,两者对上号了,驱动才会 probe。
  • reg:地址信息。对于 I2C 设备,就是从设备地址;对于内存映射设备,就是基地址和范围。
  • #address-cells 和 #size-cells:告诉解析器地址和大小各占几个 32 位整数。I2C 总线下一级设备地址只有 1 个 cell,所以 #size-cells = <0>

注意:设备树里的地址是十六进制,但不要加 0x 前缀?不对,实际上要加。比如 reg = <0x30> 表示地址 0x30。如果你写成 reg = <48>,那地址就变成 0x30 了吗?嗯,48 十进制确实是 0x30,但为了可读性,我建议统一用十六进制。

3.4 在设备树中描述摄像头

咱们回到摄像头这个具体场景。假设你用的是 OV5640 这款传感器,它挂在 I2C-1 总线上,地址是 0x3C,还需要一个复位 GPIO 和时钟。

完整的设备树节点大概是这样:

&i2c1 {
    status = "okay";
    clock-frequency = <400000>;

    ov5640: camera@3c {
        compatible = "ovti,ov5640";
        reg = <0x3c>;
        clocks = <&clks IMX6QDL_CLK_CKO>;
        clock-names = "xclk";
        reset-gpios = <&gpio1 19 GPIO_ACTIVE_LOW>;
        powerdown-gpios = <&gpio1 20 GPIO_ACTIVE_HIGH>;
        DOVDD-supply = <&reg_3p3v>;
        AVDD-supply = <&reg_2p8v>;
        DVDD-supply = <&reg_1p5v>;

        port {
            ov5640_ep: endpoint {
                remote-endpoint = <&csi_ep>;
                bus-width = <8>;
                data-shift = <2>;
                hsync-active = <1>;
                vsync-active = <1>;
                pclk-sample = <1>;
            };
        };
    };
};

我来解释一下这里面几个关键字段:

字段 含义 我在项目中的经验
compatible 驱动匹配字符串 一定要和驱动里的 of_match_table 完全一致,大小写都不能错。我曾经因为把 ovti,ov5640 写成 ovti,ov5640(少了个逗号?不对,是大小写问题),折腾了半天才发现。
reg I2C 从设备地址 注意 OV5640 的地址是 7 位还是 8 位。内核里用的是 7 位地址,左移一位后的值。比如 0x3C 实际上是 7 位地址 0x1E 左移一位。这个坑我踩过,当时读出来的数据全是错的。
reset-gpios 复位引脚 GPIO_ACTIVE_LOW 表示低电平复位。这个要根据硬件原理图来,别想当然。
port/endpoint 视频数据接口描述 这里描述了摄像头和 SoC 的 CSI 接口怎么连接,包括数据位宽、同步信号极性等。如果配错了,图像可能花屏或者没有信号。

避坑指南:我曾经在一个项目里,设备树配好了,驱动也加载了,但就是读不到摄像头 ID。最后发现是 DOVDD-supply 这个电源节点没配,摄像头根本没上电。所以,电源相关的 *-supply 字段一定不能漏。

设备树写好后,用 dtc 编译成 .dtb 文件,然后烧录到板子上。内核启动时会解析这个 .dtb,然后根据 compatible 匹配驱动,调用驱动的 probe 函数。

嗯,到这里,字符设备驱动的基础部分就差不多了。你想想看,从手动 mknod 到自动创建设备节点,再到用设备树描述硬件,整个流程是不是清晰多了?下一节我们就要真正开始写摄像头的 I2C 驱动了,到时候你会看到设备树里的这些属性是怎么在驱动代码里被读取和使用的。