第2章:Linux V4L2框架精讲:核心数据结构、ioctl调用流程、视频采集与缓冲队列管理

好,咱们直接进入正题。V4L2 是 Linux 下视频设备驱动的标准框架。说白了,它就是一套规范,让上层应用和底层硬件能好好沟通。我刚开始接触 V4L2 时,也被那一堆结构体搞得头晕。但摸清楚套路后,你会发现它设计得其实挺巧妙。

2.1 V4L2 核心数据结构

V4L2 的核心数据结构就那么几个。你想想看,一个视频设备要工作,需要描述什么?设备本身、输入输出格式、缓冲区、还有控制参数。嗯,V4L2 都帮你定义好了。

2.1.1 struct video_device

这是 V4L2 设备的核心。每个视频设备在驱动中对应一个 video_device 实例。它负责注册到内核,并关联操作函数集。

struct video_device {
    const struct v4l2_file_operations *fops;  // 文件操作
    struct v4l2_device *v4l2_dev;             // 父设备
    struct device *dev;                       // 内核设备
    // ... 还有很多字段
    v4l2_std_id tvnorms;                      // 支持的电视制式
    unsigned int minor;                       // 次设备号
};

我个人习惯在 probe 函数里先分配 video_device,然后设置好 fops 和 release 回调。记得要调用 video_register_device() 注册。我曾经犯过一个低级错误——注册前忘了初始化 v4l2_dev 指针,结果内核直接 oops 了。

2.1.2 struct v4l2_device

这个结构体是 V4L2 设备的顶层容器。一个物理设备可能包含多个子设备(比如摄像头、音频输入),v4l2_device 负责管理它们。

struct v4l2_device {
    struct device *dev;                       // 物理设备
    struct list_head subdevs;                 // 子设备链表
    spinlock_t lock;                          // 自旋锁
    char name[V4L2_DEVICE_NAME_SIZE];         // 设备名
};

实际项目中,我通常用 v4l2_device_register() 来注册。注意,这个函数需要传入父设备的 struct device * 指针。如果你用的是 platform 设备,那就是 &pdev->dev

2.1.3 struct v4l2_pix_format

描述图像格式的结构体。应用层通过它告诉驱动:我要什么分辨率、什么像素格式。

struct v4l2_pix_format {
    __u32 width;          // 宽度
    __u32 height;         // 高度
    __u32 pixelformat;    // 像素格式,如 V4L2_PIX_FMT_YUYV
    __u32 field;          // 隔行模式
    __u32 bytesperline;   // 每行字节数
    __u32 sizeimage;      // 一帧图像大小
    // ...
};

这里有个坑:bytesperlinesizeimage 必须正确计算。我见过不少驱动直接把 width * height * bpp 塞进去,但有些硬件要求行对齐到 32 字节。嗯,不按硬件要求来,采集出来的图像就是花的。

2.1.4 struct v4l2_buffer 与 struct v4l2_requestbuffers

缓冲区管理是 V4L2 的精髓。应用层通过 VIDIOC_REQBUFS 请求分配缓冲区,然后用 VIDIOC_QBUFVIDIOC_DQBUF 来入队出队。

struct v4l2_requestbuffers {
    __u32 count;          // 缓冲区数量
    __u32 type;           // 缓冲区类型,如 V4L2_BUF_TYPE_VIDEO_CAPTURE
    __u32 memory;         // 内存类型,如 V4L2_MEMORY_MMAP
};

struct v4l2_buffer {
    __u32 index;          // 缓冲区索引
    __u32 type;
    __u32 bytesused;      // 实际数据大小
    __u32 flags;          // 标志位
    __u32 field;
    struct timeval timestamp;  // 时间戳
    // ...
};

我个人建议缓冲区数量至少设 4 个。太少会导致应用层来不及处理,丢帧;太多又浪费内存。4 个是经验值,既能保证流水线顺畅,又不会太吃资源。

2.2 ioctl 调用流程

V4L2 的 ioctl 调用流程,说白了就是应用层通过系统调用进入内核,然后 V4L2 框架帮你分发到具体的驱动函数。整个过程分三步走:

  1. 应用层调用 ioctl():传入文件描述符和命令码。
  2. V4L2 框架拦截:在 v4l2_ioctl() 中根据命令码查找对应的处理函数。
  3. 驱动回调执行:调用你在 v4l2_ioctl_ops 中注册的函数。

来看一个典型的 ioctl 处理链:

// 应用层
int fd = open("/dev/video0", O_RDWR);
ioctl(fd, VIDIOC_QUERYCAP, &cap);

// 内核层
long v4l2_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct video_device *vdev = video_devdata(filp);
    // 先做权限检查、参数校验
    // 然后调用 vdev->fops->ioctl 或者走默认处理
    return v4l2_ioctl_ops->vidioc_querycap(filp, arg);
}

你可能会问:为什么 V4L2 不直接用标准的 unlocked_ioctl?其实它用了,但 V4L2 框架在 v4l2_ioctl() 里做了很多通用处理,比如参数拷贝、命令码匹配。这样驱动开发者只需要关注业务逻辑。

我记得有一次调试,发现应用层调用 VIDIOC_S_FMT 后驱动没反应。查了半天,原来是 v4l2_ioctl_ops 里没注册 vidioc_s_fmt 回调。嗯,这种低级错误,犯过一次就记住了。

2.3 视频采集与缓冲队列管理

视频采集的核心就是缓冲队列。驱动负责把硬件采集到的数据填到缓冲区,应用层负责取走处理。两者通过队列来解耦。

2.3.1 队列初始化

驱动中通常用 vb2_queue_init() 来初始化一个 vb2_queue 结构体。这个结构体管理着所有缓冲区。

struct vb2_queue {
    enum v4l2_buf_type type;       // 缓冲区类型
    unsigned int io_modes;         // 支持的 IO 模式
    const struct vb2_ops *ops;     // 操作函数集
    struct mutex *lock;            // 互斥锁
    // ...
};

初始化时,你需要设置好 vb2_ops 回调。其中最重要的几个:

  • queue_setup():分配缓冲区时调用,告诉框架你需要多少内存。
  • buf_prepare():缓冲区入队前调用,做准备工作。
  • start_streaming():开始采集时调用,启动硬件。
  • stop_streaming():停止采集时调用,关闭硬件。

2.3.2 入队与出队流程

典型的采集流程是这样的:

  1. 应用层调用 VIDIOC_REQBUFS 申请缓冲区。
  2. 调用 VIDIOC_QBUF 将空缓冲区入队。
  3. 调用 VIDIOC_STREAMON 启动采集。
  4. 硬件采集完一帧,触发中断。驱动在中断处理中标记缓冲区为完成状态。
  5. 应用层调用 VIDIOC_DQBUF 取出已填满的缓冲区。
  6. 处理完数据后,再次 VIDIOC_QBUF 入队,形成循环。
// 驱动中断处理伪代码
void irq_handler(struct vb2_queue *q)
{
    struct vb2_buffer *vb = q->bufs[completed_index];
    vb->timestamp = ktime_get_ns();
    vb2_buffer_done(vb, VB2_BUF_STATE_DONE);
    // 唤醒等待队列
    wake_up(&q->done_wq);
}

这里有个关键点:vb2_buffer_done() 必须在中断上下文调用吗?不一定。如果你的硬件需要较长时间处理,可以在下半部(tasklet 或 workqueue)中调用。但要注意,vb2_buffer_done() 内部会调用 wake_up(),所以不能在原子上下文里调用带睡眠的函数。

2.3.3 多摄像头同步采集的挑战

多摄像头同步,说白了就是让多个摄像头在同一时刻开始采集,并且每一帧的时间戳对齐。我做过一个双目视觉项目,两个摄像头硬件上通过同一个 trigger 信号触发。但驱动里怎么处理?

我的做法是:

  • 两个摄像头共用同一个 vb2_queue?不行,因为每个摄像头有自己的 DMA 通道。
  • 分别初始化两个 vb2_queue,但共享一个 start_streaming 回调。当第一个摄像头调用 start_streaming 时,不立即启动硬件,而是等待第二个摄像头也调用 start_streaming,然后同时触发硬件。
  • 时间戳同步:在中断处理中,用同一个时钟源(比如 ktime_get_boottime())来打时间戳。
注意: 多摄像头同步时,VIDIOC_STREAMON 的调用顺序会影响同步精度。建议应用层先打开所有设备,再依次调用 STREAMON,间隔尽量短。

2.4 避坑指南

我曾经踩过的坑:

  • 缓冲区溢出: 有一次我忘了检查 bytesused 是否超过 sizeimage,结果应用层读取时内存越界。后来我在 buf_prepare 里加了校验。
  • 时间戳混乱: 多摄像头项目中,我一开始用了 do_gettimeofday(),结果发现不同摄像头的时间戳有几十毫秒的偏差。换成 ktime_get_boottime() 后问题解决。
  • 队列死锁:stop_streaming 回调里调用 vb2_buffer_done() 时,如果持有某个锁,而 vb2_buffer_done() 内部又尝试获取同一个锁,就会死锁。嗯,这种问题用 lockdep 能检测出来。

好了,V4L2 的核心内容就这些。你想想看,其实框架并不复杂,关键是理解数据结构和流程。下一章我会讲如何编写一个实际的摄像头驱动,到时候咱们再深入细节。