4. V4L2 缓冲区管理:缓冲区类型与状态机

V4L2 帧采集的核心是缓冲区管理。应用程序通过内核分配的缓冲区与视频设备交换数据。理解缓冲区的类型划分以及状态机流转,是正确实现 mmap 映射与 poll 等待的前提。本章将深入剖析单平面与多平面缓冲区的差异,并完整解析缓冲区在驱动与用户空间之间的状态变迁。

4.1 缓冲区类型:单平面与多平面

V4L2 根据视频数据在内存中的存储布局,将缓冲区分为两种类型:单平面(Single Planar)和多平面(Multi Planar)。选择哪种类型取决于像素格式和硬件需求。

特性 单平面 (Single Planar) 多平面 (Multi Planar)
数据结构 struct v4l2_buffer struct v4l2_buffer + struct v4l2_plane
内存布局 所有像素数据连续存放在一个平面 每个颜色分量或数据层存放在独立平面
适用格式 RGB、YUV 打包格式(如 YUYV) NV12、NV21、YUV420 等平面格式
索引方式 buffer.index 唯一标识 buffer.index + plane.bytesused
mmap 次数 每个缓冲区一次 mmap 每个平面一次 mmap,一个缓冲区多次
驱动支持 传统设备,兼容性好 现代硬件,支持硬件裁剪/缩放
实战建议:对于大多数 USB 摄像头(UVC),使用单平面即可。对于 ISP 或硬件编码器,优先选择多平面以获得更好的性能和灵活性。

4.1.1 单平面缓冲区结构

单平面缓冲区使用 struct v4l2_buffer 描述,所有数据通过 buffer.m.offset 或 buffer.m.userptr 访问。其内存布局是线性的,适合打包格式。

struct v4l2_buffer {
    __u32           index;      // 缓冲区索引
    __u32           type;       // 缓冲区类型,如 V4L2_BUF_TYPE_VIDEO_CAPTURE
    __u32           bytesused;  // 实际数据长度
    __u32           flags;      // 标志位
    __u32           field;      // 场序
    struct timeval  timestamp;  // 时间戳
    struct v4l2_timecode timecode;
    __u32           sequence;   // 序列号
    __u32           memory;     // 内存类型:V4L2_MEMORY_MMAP
    union {
        __u32           offset;     // mmap 偏移
        unsigned long   userptr;    // 用户指针
        struct v4l2_plane *planes;  // 多平面时使用
    } m;
    __u32           length;     // 缓冲区长度
    __u32           reserved2;
    __u32           reserved;
};

4.1.2 多平面缓冲区结构

多平面缓冲区在 struct v4l2_buffer 基础上,通过 buffer.m.planes 指向一个 struct v4l2_plane 数组。每个平面独立描述一个颜色分量或数据层。

struct v4l2_plane {
    __u32           bytesused;  // 该平面实际数据长度
    __u32           length;     // 该平面缓冲区长度
    union {
        __u32           offset;     // mmap 偏移
        unsigned long   userptr;    // 用户指针
        __s32           fd;         // DMABUF 文件描述符
    } m;
    __u32           data_offset;    // 数据偏移(用于硬件对齐)
    __u32           reserved[11];
};
关键区别:多平面缓冲区中,buffer.length 表示平面数量,而非缓冲区总长度。每个平面通过 plane.length 独立描述大小。

4.2 缓冲区状态机

V4L2 缓冲区在生命周期中经历多个状态,状态转换由应用程序和驱动共同驱动。理解状态机是正确使用 DQBUF/QBUF 和 poll 的基础。

V4L2 缓冲区状态机流转图 DEQUEUED 用户空间可访问 QUEUED 等待驱动处理 IN DRIVER 硬件正在填充 DONE 数据已就绪 ERROR 采集出错 QBUF 驱动入队 硬件完成 硬件错误 DQBUF DQBUF(带错误标志) 说明:状态转换由 QBUF、DQBUF 和硬件中断驱动。poll 等待 DONE 状态,驱动完成时唤醒等待队列。

4.2.1 状态定义与转换

状态 含义 触发动作 用户空间操作
DEQUEUED 缓冲区在用户空间,可读写数据 初始状态、DQBUF 后 读取帧数据、处理完成后 QBUF
QUEUED 缓冲区已入队,等待驱动处理 QBUF 后 不可访问,等待硬件填充
IN DRIVER 硬件正在使用该缓冲区填充数据 驱动从队列取出 不可访问,poll 等待 DONE
DONE 数据采集完成,等待用户取出 硬件中断、驱动完成 poll 返回可读,DQBUF 取出
ERROR 采集过程中发生错误 硬件错误、超时 DQBUF 返回错误标志,需重新 QBUF

4.2.2 状态机核心规则

  1. 初始状态:通过 REQBUFS 申请缓冲区后,所有缓冲区处于 DEQUEUED 状态。
  2. 入队:应用程序调用 QBUF,缓冲区从 DEQUEUED 变为 QUEUED。
  3. 驱动接管:驱动从内部队列取出缓冲区,状态变为 IN DRIVER,硬件开始 DMA 填充。
  4. 完成:硬件中断触发,驱动将缓冲区状态设为 DONE 或 ERROR,并唤醒 poll 等待队列。
  5. 出队:应用程序调用 DQBUF,缓冲区回到 DEQUEUED 状态,可读取数据。
  6. 错误处理:如果状态为 ERROR,应用程序应丢弃该帧,重新 QBUF 或 REQBUFS 重置。
常见陷阱:不要在 IN DRIVER 状态下访问缓冲区数据,会导致数据竞争和内存损坏。始终通过 DQBUF 获取 DONE 状态的缓冲区后再读取。

4.3 缓冲区标志位详解

struct v4l2_buffer 的 flags 字段携带了缓冲区的附加信息,在状态机流转中起到关键作用。

标志位 值 含义
V4L2_BUF_FLAG_MAPPED 0x0001 缓冲区已通过 mmap 映射
V4L2_BUF_FLAG_QUEUED 0x0002 缓冲区处于 QUEUED 状态
V4L2_BUF_FLAG_DONE 0x0004 缓冲区处于 DONE 状态
V4L2_BUF_FLAG_KEYFRAME 0x0008 该帧为关键帧(压缩格式)
V4L2_BUF_FLAG_PFRAME 0x0010 该帧为 P 帧
V4L2_BUF_FLAG_BFRAME 0x0020 该帧为 B 帧
V4L2_BUF_FLAG_ERROR 0x0040 采集过程中发生错误
V4L2_BUF_FLAG_TIMESTAMP_UNKNOWN 0x0000 时间戳类型未知
V4L2_BUF_FLAG_TIMESTAMP_MONOTONIC 0x8000 时间戳为单调时钟
实战检查:在 DQBUF 后,检查 flags & V4L2_BUF_FLAG_ERROR 是否为真。如果为真,该帧数据无效,应直接 QBUF 重新使用。

4.4 缓冲区生命周期实战流程

以下代码片段展示了缓冲区从申请到回收的完整生命周期,结合了单平面 mmap 映射和状态机管理。

// 1. 申请缓冲区
struct v4l2_requestbuffers req = {0};
req.count  = 4;
req.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;
ioctl(fd, VIDIOC_REQBUFS, &req);

// 2. 查询并映射每个缓冲区
for (int i = 0; i < req.count; i++) {
    struct v4l2_buffer buf = {0};
    buf.index  = i;
    buf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    ioctl(fd, VIDIOC_QUERYBUF, &buf);

    // 此时缓冲区状态为 DEQUEUED
    buffers[i].length = buf.length;
    buffers[i].start  = mmap(NULL, buf.length,
                             PROT_READ | PROT_WRITE,
                             MAP_SHARED, fd, buf.m.offset);
}

// 3. 入队所有缓冲区(DEQUEUED -> QUEUED)
for (int i = 0; i < req.count; i++) {
    struct v4l2_buffer buf = {0};
    buf.index  = i;
    buf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    ioctl(fd, VIDIOC_QBUF, &buf);
}

// 4. 开始视频采集
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ioctl(fd, VIDIOC_STREAMON, &type);

// 5. 采集循环:poll + DQBUF + 处理 + QBUF
while (1) {
    struct pollfd pfd = { .fd = fd, .events = POLLIN };
    int ret = poll(&pfd, 1, 2000);  // 等待 DONE 状态

    if (ret > 0 && (pfd.revents & POLLIN)) {
        struct v4l2_buffer buf = {0};
        buf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        buf.memory = V4L2_MEMORY_MMAP;
        ioctl(fd, VIDIOC_DQBUF, &buf);  // DONE -> DEQUEUED

        if (buf.flags & V4L2_BUF_FLAG_ERROR) {
            // 错误帧,直接重新入队
            ioctl(fd, VIDIOC_QBUF, &buf);
            continue;
        }

        // 处理帧数据:buffers[buf.index].start, buf.bytesused
        process_frame(buffers[buf.index].start, buf.bytesused);

        // 重新入队(DEQUEUED -> QUEUED)
        ioctl(fd, VIDIOC_QBUF, &buf);
    }
}
关键点:每个缓冲区在 DQBUF 后必须尽快处理并 QBUF 回驱动,否则会导致驱动无可用缓冲区而丢帧。通常保持至少 2 个缓冲区在 QUEUED 状态以保证流畅采集。

4.5 单平面 vs 多平面:mmap 映射差异

两种缓冲区类型在 mmap 映射时的操作步骤不同,主要体现在映射次数和偏移计算上。

单平面 vs 多平面 mmap 映射对比 单平面 (Single Planar) 缓冲区 0:一次 mmap 缓冲区 1:一次 mmap 缓冲区 2:一次 mmap 每个缓冲区对应一个连续的 mmap 区域 多平面 (Multi Planar) 缓冲区 0
平面 Y:mmap 缓冲区 0
平面 UV:mmap
缓冲区 1
平面 Y:mmap
缓冲区 1
平面 UV:mmap
缓冲区 2
平面 Y:mmap
缓冲区 2
平面 UV:mmap
每个缓冲区包含多个平面,每个平面独立 mmap

4.5.1 多平面 mmap 映射代码示例

// 多平面缓冲区映射
struct v4l2_buffer buf = {0};
struct v4l2_plane planes[VIDEO_MAX_PLANES] = {0};

buf.index  = i;
buf.type   = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE;
buf.memory = V4L2_MEMORY_MMAP;
buf.m.planes = planes;
buf.length   = VIDEO_MAX_PLANES;  // 平面数组大小

ioctl(fd, VIDIOC_QUERYBUF, &buf);

// 为每个平面独立 mmap
for (int p = 0; p < buf.length; p++) {
    buffers[i].planes[p].length = planes[p].length;
    buffers[i].planes[p].start  = mmap(NULL, planes[p].length,
                                       PROT_READ | PROT_WRITE,
                                       MAP_SHARED, fd, planes[p].m.offset);
}
注意:多平面缓冲区在 QBUF 和 DQBUF 时,必须传入 struct v4l2_plane 数组,并正确设置 buffer.length 为平面数量。驱动通过 buffer.m.planes 访问每个平面的信息。

4.6 本章小结

  • 缓冲区类型:单平面适用于打包格式,多平面适用于平面格式。多平面需要为每个平面独立 mmap。
  • 状态机五态:DEQUEUED → QUEUED → IN DRIVER → DONE/ERROR → DEQUEUED,形成完整生命周期。
  • 核心操作:QBUF 入队、DQBUF 出队、poll 等待 DONE 状态。错误帧通过 V4L2_BUF_FLAG_ERROR 标志识别。
  • 性能关键:保持足够缓冲区在 QUEUED 状态,避免驱动饥饿导致丢帧。