4. V4L2 核心 ioctl 操作:从查询到帧缓冲

好,咱们进入第四章。前面几章我们把 V4L2 的框架、设备节点、内存映射这些概念捋了一遍。这一章,我要带你亲手操作几个最核心的 ioctl 命令。说白了,这些就是驱动和应用程序之间的“握手协议”。你写任何一个 Camera 应用,都绕不开它们。

我个人习惯把这几个 ioctl 分成三组:查询能力、协商格式、流转起来。咱们一个一个来。

本章核心脉络: 应用通过 ioctl 向驱动“问”能力 → “谈”格式 → “要”内存 → “扔”缓冲区 → “收”数据。每一步都是下一步的前提。
V4L2 核心 ioctl 调用流程 VIDIOC_QUERYCAP VIDIOC_ENUM_FMT VIDIOC_S_FMT VIDIOC_REQBUFS QBUF / DQBUF 先问设备支持什么 协商好格式再申请内存 循环入队出队,数据就来了

4.1 VIDIOC_QUERYCAP:先问问设备是什么来头

拿到一个 video 设备节点,别急着干活。先发一个 VIDIOC_QUERYCAP,看看它到底是不是摄像头,支持什么能力。这就像你面试一个人,先看简历,再问技术栈。

struct v4l2_capability cap;
int ret = ioctl(fd, VIDIOC_QUERYCAP, &cap);
if (ret < 0) {
    perror("VIDIOC_QUERYCAP failed");
    close(fd);
    return -1;
}

// 检查是不是视频捕获设备
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
    fprintf(stderr, "不是视频捕获设备\n");
    close(fd);
    return -1;
}

printf("驱动: %s\n", cap.driver);
printf("设备: %s\n", cap.card);
printf("总线: %s\n", cap.bus_info);

cap.driver 是内核驱动的名字,比如 uvcvideo 或者 ov5640。cap.card 是设备名,cap.bus_info 是总线位置。这三个字段,调试时特别有用。我记得有一次,客户说摄像头没图像,我一看 bus_info 发现插错了 USB 口——嗯,低级错误,但排查起来就是靠这个。

我的习惯: 在应用初始化时,把 QUERYCAP 的结果打印到日志里。线上出问题时,看一眼日志就知道设备有没有被正确识别,省得反复插拔。

4.2 VIDIOC_ENUM_FMT:看看支持什么格式

知道设备是摄像头了,接下来要问:你支持哪些像素格式?是 YUYV、MJPEG 还是 NV12?这个用 VIDIOC_ENUM_FMT 来枚举。

struct v4l2_fmtdesc fmt;
memset(&fmt, 0, sizeof(fmt));
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.index = 0;  // 从0开始枚举

while (ioctl(fd, VIDIOC_ENUM_FMT, &fmt) == 0) {
    printf("格式索引 %d: %s (fourcc: %c%c%c%c)\n",
           fmt.index, fmt.description,
           (fmt.pixelformat >> 0) & 0xFF,
           (fmt.pixelformat >> 8) & 0xFF,
           (fmt.pixelformat >> 16) & 0xFF,
           (fmt.pixelformat >> 24) & 0xFF);
    fmt.index++;
}

这里有个坑,我跟你讲一下。有些驱动会返回 V4L2_FMT_FLAG_COMPRESSED 标志,表示这是压缩格式(比如 MJPEG)。如果你要处理原始数据,记得避开压缩格式。我曾经在一个项目里,没检查这个标志,直接拿了 MJPEG 流当 YUV 处理,结果画面花得一塌糊涂。

FourCC 码 描述 常见场景
YUYV YUV 4:2:2 打包格式 USB 摄像头、嵌入式 sensor
MJPEG Motion JPEG 压缩流 高分辨率 USB 摄像头
NV12 YUV 4:2:0 平面格式 ISP 输出、硬件编码器输入
RGB3 RGB 24-bit 顺序 显示直通、截图

4.3 VIDIOC_S_FMT:谈好格式,定下规矩

枚举完了,选一个你想要的格式,用 VIDIOC_S_FMT 设置。注意,驱动不一定完全满足你的要求,它会尽量靠近。所以设置完一定要读回来确认。

struct v4l2_format fmt;
memset(&fmt, 0, sizeof(fmt));
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = 1920;
fmt.fmt.pix.height = 1080;
fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV;
fmt.fmt.pix.field = V4L2_FIELD_NONE;

if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) {
    perror("VIDIOC_S_FMT failed");
    return -1;
}

// 一定要读回来确认
printf("实际分辨率: %dx%d\n", fmt.fmt.pix.width, fmt.fmt.pix.height);
printf("实际格式: %c%c%c%c\n",
       (fmt.fmt.pix.pixelformat >> 0) & 0xFF,
       (fmt.fmt.pix.pixelformat >> 8) & 0xFF,
       (fmt.fmt.pix.pixelformat >> 16) & 0xFF,
       (fmt.fmt.pix.pixelformat >> 24) & 0xFF);
避坑指南: 我曾经遇到一个 sensor,设置 1080p 时驱动返回了 720p,但没报错。应用层没检查实际分辨率,直接按 1080p 分配缓冲区,结果内存越界,系统直接 oops。所以,S_FMT 之后必须读回确认,这是铁律。

4.4 VIDIOC_REQBUFS:申请缓冲区,准备干活

格式谈好了,接下来要申请内存缓冲区。V4L2 支持三种内存类型:V4L2_MEMORY_MMAP(内核分配,用户映射)、V4L2_MEMORY_USERPTR(用户分配,内核使用)、V4L2_MEMORY_DMABUF(跨设备共享)。工程上最常用的是 MMAP,简单高效。

struct v4l2_requestbuffers req;
memset(&req, 0, sizeof(req));
req.count = 4;          // 申请4个缓冲区
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP;

if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) {
    perror("VIDIOC_REQBUFS failed");
    return -1;
}

printf("实际分配的缓冲区数量: %d\n", req.count);

为什么是 4 个?太少容易丢帧,太多浪费内存。我个人习惯:嵌入式设备用 3~4 个,PC 端用 4~6 个。你想想看,如果只申请 2 个,应用处理慢一点,驱动就没地方放新数据了,只能丢帧。

申请完缓冲区,还要用 VIDIOC_QUERYBUF 查询每个缓冲区的物理信息,然后 mmap 映射到用户空间。这部分代码比较固定,我直接给个模板:

struct v4l2_buffer buf;
for (int i = 0; i < req.count; i++) {
    memset(&buf, 0, sizeof(buf));
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    buf.index = i;

    ioctl(fd, VIDIOC_QUERYBUF, &buf);

    // 映射到用户空间
    buffers[i].start = mmap(NULL, buf.length,
                            PROT_READ | PROT_WRITE,
                            MAP_SHARED, fd, buf.m.offset);
    buffers[i].length = buf.length;
}

4.5 VIDIOC_QBUF / VIDIOC_DQBUF:让数据流转起来

缓冲区准备好了,但驱动还没开始往里面填数据。你需要先把缓冲区“入队”(QBUF),告诉驱动:这个缓冲区空着,你可以往里写数据了。然后调用 VIDIOC_STREAMON 启动流。

之后,在一个循环里:

  • DQBUF:从驱动取回一个已经填满数据的缓冲区。
  • 处理数据:比如显示、编码、保存。
  • QBUF:处理完再还回去,让驱动继续用。
// 启动流之前,先把所有缓冲区入队
for (int i = 0; i < req.count; i++) {
    struct v4l2_buffer buf;
    memset(&buf, 0, sizeof(buf));
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    buf.index = i;
    ioctl(fd, VIDIOC_QBUF, &buf);
}

// 启动视频流
int type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ioctl(fd, VIDIOC_STREAMON, &type);

// 主循环:DQBUF → 处理 → QBUF
while (1) {
    struct v4l2_buffer buf;
    memset(&buf, 0, sizeof(buf));
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;

    if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) {
        perror("DQBUF failed");
        break;
    }

    // 处理数据:buffers[buf.index].start 指向图像数据
    process_image(buffers[buf.index].start, buf.bytesused);

    // 处理完,重新入队
    if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) {
        perror("QBUF failed");
        break;
    }
}
性能小技巧: DQBUF 默认是阻塞的,如果没有数据,它会一直等。如果你需要非阻塞模式,可以在 open() 时加 O_NONBLOCK 标志,或者用 select() / poll() 监听文件描述符。我一般用 poll,超时 2 秒,这样既能及时响应,又不会卡死。

4.6 常见错误与调试思路

这几个 ioctl 调用,出错时 errno 会告诉你原因。我列几个常见的:

errno 含义 排查方向
EBUSY 设备忙,流已经在运行 检查是否重复调用了 STREAMON
EINVAL 参数无效 检查 type、memory、pixelformat 是否匹配
ENOMEM 内存不足 减少缓冲区数量,或者检查系统内存
EIO I/O 错误 硬件问题,检查 sensor 供电、时钟、I2C

嗯,说到 EIO,我想起一个案例。有一次调试一款 sensor,DQBUF 总是返回 EIO。查了半天,发现是 sensor 的复位引脚没拉高。硬件问题,软件怎么调都没用。所以,遇到 EIO,先检查硬件。

总结一下: QUERYCAP 问能力,ENUM_FMT 看选项,S_FMT 定格式,REQBUFS 要内存,QBUF/DQBUF 循环取数据。这五步走完,一个基本的 Camera 采集应用就搭起来了。下一章,我们会深入缓冲区管理的细节,包括如何避免丢帧、如何做零拷贝。