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 的基础。
4.2.1 状态定义与转换
| 状态 | 含义 | 触发动作 | 用户空间操作 |
|---|---|---|---|
| DEQUEUED | 缓冲区在用户空间,可读写数据 | 初始状态、DQBUF 后 | 读取帧数据、处理完成后 QBUF |
| QUEUED | 缓冲区已入队,等待驱动处理 | QBUF 后 | 不可访问,等待硬件填充 |
| IN DRIVER | 硬件正在使用该缓冲区填充数据 | 驱动从队列取出 | 不可访问,poll 等待 DONE |
| DONE | 数据采集完成,等待用户取出 | 硬件中断、驱动完成 | poll 返回可读,DQBUF 取出 |
| ERROR | 采集过程中发生错误 | 硬件错误、超时 | DQBUF 返回错误标志,需重新 QBUF |
4.2.2 状态机核心规则
- 初始状态:通过
REQBUFS申请缓冲区后,所有缓冲区处于 DEQUEUED 状态。 - 入队:应用程序调用
QBUF,缓冲区从 DEQUEUED 变为 QUEUED。 - 驱动接管:驱动从内部队列取出缓冲区,状态变为 IN DRIVER,硬件开始 DMA 填充。
- 完成:硬件中断触发,驱动将缓冲区状态设为 DONE 或 ERROR,并唤醒 poll 等待队列。
- 出队:应用程序调用
DQBUF,缓冲区回到 DEQUEUED 状态,可读取数据。 - 错误处理:如果状态为 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 映射时的操作步骤不同,主要体现在映射次数和偏移计算上。
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 状态,避免驱动饥饿导致丢帧。