3、V4L2框架入门:核心数据结构与驱动实现

好,咱们今天来啃V4L2这块硬骨头。说实话,我当年刚接触多摄像头同步时,V4L2框架把我折腾得够呛。但摸透了之后你会发现,它其实挺有条理的。

这一章,我会带你从最核心的数据结构入手,再走一遍ioctl调用流程,最后手写一个简单的摄像头打开/关闭驱动。嗯,都是实战干货。

3.1 V4L2核心数据结构

V4L2框架里,有两个结构体你绕不开:video_devicevb2_queue。说白了,一个管“设备节点”,一个管“数据流”。

3.1.1 video_device:设备节点的灵魂

每个摄像头在用户空间都对应一个 /dev/videoX 节点。这个节点背后,就是 video_device 结构体。

struct video_device {
    const struct v4l2_file_operations *fops;  // 文件操作函数集
    struct v4l2_device *v4l2_dev;             // 父设备
    struct device *dev;                       // 内核设备结构
    int index;                                // 设备节点编号
    // ... 其他成员
};

我个人习惯,在初始化时重点关注 fopsv4l2_dev。前者决定了用户程序怎么操作你的摄像头,后者负责和V4L2核心层通信。

关键点:video_device 注册成功后,内核会自动创建 /dev/videoX 节点。你不需要手动 mknod。

3.1.2 vb2_queue:数据流的调度中心

摄像头采集到的数据怎么流转?全靠 vb2_queue。它管理着缓冲区队列,负责从硬件取数据、交给用户程序。

struct vb2_queue {
    enum v4l2_buf_type type;          // 缓冲区类型(V4L2_BUF_TYPE_VIDEO_CAPTURE等)
    unsigned int io_modes;            // 支持的IO模式(VB2_MMAP | VB2_USERPTR | VB2_DMABUF)
    struct device *dev;               // DMA设备
    int (*queue_setup)(struct vb2_queue *q, unsigned int *num_buffers,
                       unsigned int *num_planes, unsigned int sizes[],
                       struct device *alloc_devs[]);
    // ... 回调函数
};

我记得第一次写多摄像头驱动时,queue_setup 回调里忘记设置正确的 sizes[],结果图像全是花的。排查了整整一下午。

我的经验:初始化 vb2_queue 时,io_modes 最好同时支持 VB2_MMAP 和 VB2_USERPTR。很多上层应用(比如 GStreamer)默认用 USERPTR 模式。

3.2 ioctl调用流程:从用户态到内核态

用户程序调用 ioctl(fd, VIDIOC_XXX, &arg) 后,发生了什么?我画个简化的流程图给你看:

  1. 用户程序调用 ioctl()
  2. VFS 层根据 fd 找到对应的 video_device
  3. 调用 video_device->fops->unlocked_ioctl()
  4. 进入 V4L2 核心层的 video_ioctl2()
  5. 根据命令号分发到具体的处理函数(比如 v4l2_s_fmt())
  6. 最终调用驱动实现的回调函数

你想想看,这个流程其实很清晰。但坑在哪里?

注意:V4L2 驱动中,unlocked_ioctlcompat_ioctl 都要实现。32位应用在64位内核上调用时,如果没有 compat_ioctl,会直接返回 -ENOTTY。我曾经因为这个被测试部门追着骂...

3.3 实战:简单的摄像头打开/关闭驱动

光说不练假把式。咱们直接写一个最小化的摄像头驱动,只实现打开和关闭。

#include <linux/module.h>
#include <media/v4l2-device.h>
#include <media/v4l2-ioctl.h>
#include <media/videobuf2-v4l2.h>

static struct video_device vdev;
static struct v4l2_device v4l2_dev;

static int mycam_open(struct file *file)
{
    struct video_device *vdev = video_devdata(file);
    pr_info("mycam: device opened, index=%d\n", vdev->index);
    return 0;
}

static int mycam_close(struct file *file)
{
    pr_info("mycam: device closed\n");
    return 0;
}

static const struct v4l2_file_operations mycam_fops = {
    .owner          = THIS_MODULE,
    .open           = mycam_open,
    .release        = mycam_close,
    .unlocked_ioctl = video_ioctl2,
};

static int __init mycam_init(void)
{
    int ret;

    // 1. 注册 v4l2_device
    ret = v4l2_device_register(NULL, &v4l2_dev);
    if (ret < 0)
        return ret;

    // 2. 初始化 video_device
    strscpy(vdev.name, "mycam", sizeof(vdev.name));
    vdev.v4l2_dev = &v4l2_dev;
    vdev.fops = &mycam_fops;
    vdev.release = video_device_release_empty;
    vdev.device_caps = V4L2_CAP_VIDEO_CAPTURE;

    // 3. 注册 video_device
    ret = video_register_device(&vdev, VFL_TYPE_VIDEO, -1);
    if (ret < 0) {
        v4l2_device_unregister(&v4l2_dev);
        return ret;
    }

    pr_info("mycam: registered as /dev/video%d\n", vdev.num);
    return 0;
}

static void __exit mycam_exit(void)
{
    video_unregister_device(&vdev);
    v4l2_device_unregister(&v4l2_dev);
    pr_info("mycam: unregistered\n");
}

module_init(mycam_init);
module_exit(mycam_exit);
MODULE_LICENSE("GPL");

这段代码虽然简单,但五脏俱全。你编译加载后,用 ls /dev/video* 就能看到新设备。用 cat /dev/video0 试试?嗯,会报错,因为我们还没实现读取操作。但打开和关闭的日志会打印出来。

验证方法:加载驱动后执行 v4l2-ctl -d /dev/video0 --info,如果能看到设备信息,说明注册成功。

3.4 避坑指南

最后,分享几个我踩过的坑:

  • 设备节点编号冲突:注册时 video_register_device() 的第三个参数传 -1,让内核自动分配。我曾经手写死编号,结果和内置摄像头冲突,系统直接卡死。
  • release 回调不能少:vdev.release 必须赋值,否则卸载驱动时内核会 panic。用 video_device_release_empty 是最省事的。
  • module_init 顺序:如果你的驱动依赖其他模块(比如 I2C 控制器),记得用 module_init() 的优先级宏,或者用 late_initcall()

好了,这一章的内容就到这里。下一章我们会给这个驱动加上真正的数据采集功能,到时候 vb2_queue 就要大显身手了。

课后练习:把上面的代码编译成模块,加载后试试用 v4l2-ctl --list-devices 查看设备。再试试 open()close() 系统调用,观察内核日志。