3、V4L2驱动框架详解:核心数据结构、ioctl调用流程、视频采集流程

V4L2,全称 Video for Linux 2,是 Linux 内核里处理视频设备的统一接口。说白了,它就是摄像头和应用程序之间的翻译官。你上层调 open、ioctl、mmap,底层怎么跟硬件寄存器打交道,V4L2 帮你封装好了。

我刚开始接触 V4L2 时,觉得数据结构又多又绕。后来调了几个 sensor,才摸清门道。今天咱们就把它的核心骨架拆开看看。

3.1 核心数据结构:V4L2 的骨架

V4L2 驱动里,有几个结构体你绕不开。我习惯把它们分成三层:设备层、队列层、缓冲区层。

层级核心结构体作用
设备层struct video_device注册字符设备,暴露 /dev/videoX
队列层struct vb2_queue管理缓冲区队列,处理入队出队
缓冲区层struct vb2_buffer代表一帧数据,含 dma 地址、长度、状态

这三个结构体,是 V4L2 驱动的基石。你写驱动,本质上就是在填充和操作它们。

3.1.1 video_device:暴露给用户的门面

video_device 负责注册字符设备。它内部有个 v4l2_file_operations,定义了 open、close、ioctl 等回调。我一般这样初始化:

static struct video_device vdev = {
    .name = "my_camera",
    .vfl_dir = VFL_DIR_RX,
    .fops = &my_fops,
    .ioctl_ops = &my_ioctl_ops,
    .release = video_device_release_empty,
};

嗯,这里要注意:release 回调不能乱填。我曾经图省事填了个 NULL,结果卸载模块时内核直接 panic。老老实实用 video_device_release_empty 或者自己实现一个。

3.1.2 vb2_queue:数据流的调度中心

vb2_queue 是 videobuf2 框架的核心。它管理着所有缓冲区的生命周期。你想想看,应用程序调用 QBUF 把缓冲区交给你,你填完数据再通过 DQBUG 还回去——这一切都由 vb2_queue 调度。

初始化时,你需要实现一组 vb2_ops:

static const struct vb2_ops my_vb2_ops = {
    .queue_setup     = my_queue_setup,
    .buf_prepare     = my_buf_prepare,
    .buf_queue       = my_buf_queue,
    .start_streaming = my_start_streaming,
    .stop_streaming  = my_stop_streaming,
};

我个人习惯把 queue_setup 放在最前面写。它决定了缓冲区数量和大小。如果这里算错了,后面采集时要么内存不够,要么帧率上不去。

3.1.3 vb2_buffer:一帧数据的容器

vb2_buffer 代表一个缓冲区。它里面存着 dma 地址、长度、时间戳、状态标志。驱动里你拿到它,往里面填数据,然后标记完成。

关键字段:

  • planes[]:多平面时,每个平面一个 entry
  • timestamp:时间戳,我踩过坑——忘了赋值,结果应用层拿到的时间全是 0
  • state:缓冲区状态,VB2_BUF_STATE_DONE 表示数据已准备好
避坑: 我曾经在中断里直接修改 vb2_buffer.state,没加锁。结果偶发性的 DQBUG 返回空数据。后来老老实实用 vb2_buffer_done() 接口,再也没出过问题。

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

应用程序调用 ioctl(fd, VIDIOC_QBUF, &buf),这中间发生了什么?我画了个流程图,你看一眼就明白了。

V4L2 ioctl 调用流程 用户态应用 sys_ioctl() 系统调用 v4l2_ioctl() 核心分发 驱动 ioctl_ops 回调 硬件寄存器操作 关键路径 1. 应用调用 ioctl() 2. 进入 V4L2 核心分发 3. 匹配 cmd 调用驱动回调 4. 驱动操作硬件

流程其实很清晰:

  1. 应用调用 ioctl(fd, cmd, arg)
  2. 进入内核 sys_ioctl(),通过文件操作找到 v4l2_ioctl()
  3. v4l2_ioctl() 根据 cmd 查表,调用你注册的 ioctl_ops 回调
  4. 你的回调里操作硬件,返回结果

为什么 V4L2 要搞这么一层分发?说白了,是为了统一接口。不管你接的是 USB 摄像头还是 MIPI sensor,应用层调用的 VIDIOC_* 命令都一样。驱动开发者只需要填好回调表就行。

我的习惯: 调试 ioctl 时,我会在驱动里加 printk(KERN_DEBUG "cmd: 0x%x\n", cmd)。这样能快速确认应用层发来的命令是否被正确路由。曾经有个项目,应用层传错了参数,我靠这行日志半小时定位了问题。

3.3 视频采集流程:一帧数据的一生

视频采集,说白了就是:申请缓冲区 → 入队 → 启动流 → 硬件填数据 → 出队 → 处理 → 再入队。这个循环跑起来,摄像头就活了。

我把它拆成 5 步:

步骤应用层调用驱动层响应
1. 申请缓冲区VIDIOC_REQBUFS调用 queue_setup,分配内存
2. 查询并映射VIDIOC_QUERYBUF + mmap返回缓冲区信息,建立用户态映射
3. 入队VIDIOC_QBUF调用 buf_prepare,缓冲区加入队列
4. 启动流VIDIOC_STREAMON调用 start_streaming,开启硬件采集
5. 出队处理VIDIOC_DQBUF等待硬件填完数据,返回给应用

这里有个关键点:入队和出队是异步的。硬件在后台填数据,应用在前台取数据。如果队列空了,DQBUF 会阻塞,直到有数据回来。

3.3.1 中断处理:数据回来的那一刻

硬件采集完一帧,会触发中断。驱动在中断处理函数里要做三件事:

  1. 从硬件寄存器读取帧完成标志
  2. 获取当前正在使用的 vb2_buffer
  3. 调用 vb2_buffer_done() 标记完成

代码大概长这样:

static irqreturn_t my_irq_handler(int irq, void *dev_id)
{
    struct my_device *dev = dev_id;
    struct vb2_buffer *vb;

    // 1. 检查中断状态
    if (!(readl(dev->base + REG_STATUS) & FRAME_DONE))
        return IRQ_NONE;

    // 2. 获取当前缓冲区
    vb = dev->cur_vb;
    if (!vb)
        return IRQ_HANDLED;

    // 3. 设置时间戳
    vb->timestamp = ktime_get_ns();

    // 4. 标记完成
    vb2_buffer_done(vb, VB2_BUF_STATE_DONE);

    // 5. 启动下一帧(如果有)
    my_start_capture(dev);

    return IRQ_HANDLED;
}
关键点: 中断里不要做耗时操作。我曾经在中断里加了 printk 调试,结果帧率直接掉了一半。中断服务函数要快进快出,复杂逻辑放到 tasklet 或 workqueue 里。

3.3.2 流控制:start 和 stop 的配合

start_streaming 回调里,你要开启硬件采集。但有个细节:如果硬件启动失败,你得把已经入队的缓冲区全部归还。否则应用层会一直等 DQBUF,死锁。

我踩过这个坑。当时硬件初始化失败,我直接返回了错误码,但没清理队列。结果应用层卡在 DQBUF 上,kill 都杀不掉。后来在 start_streaming 失败路径里加了:

if (ret) {
    spin_lock_irqsave(&q->lock, flags);
    list_for_each_entry_safe(vb, tmp, &q->queued_list, queued_entry) {
        list_del(&vb->queued_entry);
        vb2_buffer_done(vb, VB2_BUF_STATE_ERROR);
    }
    spin_unlock_irqrestore(&q->lock, flags);
}

嗯,从此再也没死锁过。

3.4 小结:V4L2 驱动的心法

V4L2 框架,说白了就是一套约定。你遵守它的数据结构,实现它的回调,它帮你搞定字符设备、内存管理、用户态交互。你只需要关注硬件操作本身。

我个人觉得,学 V4L2 最好的方法就是读一个现成的驱动。比如内核里 drivers/media/platform/ 下的那些,挑一个简单的 sensor 驱动,从头到尾读一遍。数据结构怎么初始化,回调怎么注册,中断怎么处理——看一遍比看十遍文档都管用。