4. 视频采集API实战(Linux V4L2):打开设备、查询能力、设置格式、申请缓冲区、启动/停止流、内存映射(mmap)

好,咱们进入真正的实战环节了。V4L2(Video for Linux 2)是 Linux 下视频采集的事实标准。说实话,我早年刚接触它的时候,觉得这 API 设计得有点绕——又是 ioctl 又是 mmap 的。但用熟了你会发现,这套接口其实非常规整,每一步都有明确的意图。

这一章,我会带你走完 V4L2 采集的完整链路:从打开设备,到设置格式,再到缓冲区管理,最后启动流并拿到数据。嗯,每一步我都会穿插一些我在项目中踩过的坑。

核心流程速览:打开设备 → 查询能力 → 设置格式 → 申请缓冲区 → 内存映射 → 入队 → 启动流 → 出队处理 → 停止流 → 关闭设备。

V4L2 视频采集完整流程 打开设备 查询能力 设置格式 申请缓冲区 内存映射 缓冲区入队 启动视频流 出队处理数据 停止流 & 关闭 ⚠ 注意:入队必须在启动流之前完成 否则驱动可能返回 EIO 错误 💡 出队后处理完数据,记得重新入队 形成循环队列

4.1 打开设备

第一步,打开视频设备节点。通常是 /dev/video0,也可能是 /dev/video1 或 /dev/video2。怎么确定是哪个?我一般会遍历 /dev/video*,用 VIDIOC_QUERYCAP 去试探。

int fd = open("/dev/video0", O_RDWR);
if (fd < 0) {
    perror("open");
    return -1;
}

小技巧:打开时用 O_RDWR 而非 O_RDONLY。有些驱动在只读模式下不会初始化内部状态,后续 ioctl 会莫名其妙失败。我吃过这个亏。

4.2 查询设备能力

设备打开了,别急着干活。先问问它:你支持什么?用 VIDIOC_QUERYCAP 这个 ioctl。

struct v4l2_capability cap;
memset(&cap, 0, sizeof(cap));

if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) {
    perror("VIDIOC_QUERYCAP");
    close(fd);
    return -1;
}

printf("Driver: %s\n", cap.driver);
printf("Card: %s\n", cap.card);
printf("Bus info: %s\n", cap.bus_info);
printf("Version: %u.%u.%u\n",
       (cap.version >> 16) & 0xFF,
       (cap.version >> 8) & 0xFF,
       cap.version & 0xFF);

这里有个关键字段:cap.capabilities。你得检查它是否包含 V4L2_CAP_VIDEO_CAPTURE 和 V4L2_CAP_STREAMING。为什么?因为有些设备只支持 read/write 模式,不支持流式 I/O。流式 I/O 才是高性能采集的基石。

if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
    fprintf(stderr, "设备不支持视频采集\n");
    return -1;
}
if (!(cap.capabilities & V4L2_CAP_STREAMING)) {
    fprintf(stderr, "设备不支持流式 I/O\n");
    return -1;
}

注意:有些 USB 摄像头虽然标称支持流式,但实际驱动实现有 bug。我曾经遇到一个设备,capabilities 里明明有 V4L2_CAP_STREAMING,但一调用 VIDIOC_REQBUFS 就返回 EINVAL。遇到这种情况,试试更新驱动或换一个内核版本。

4.3 设置采集格式

能力查完了,接下来告诉设备:我要什么格式。用 VIDIOC_S_FMT 设置,用 VIDIOC_G_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_NV12;  // YUV 420 半平面
fmt.fmt.pix.field = V4L2_FIELD_NONE;

if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) {
    perror("VIDIOC_S_FMT");
    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);

你想想看,为什么设置完还要读回来?因为驱动可能不支持你指定的分辨率,它会自动调整到最接近的值。比如你设 1920x1080,驱动可能只支持 1280x720。你不读回来确认,后面缓冲区大小就算错了。

像素格式 描述 典型用途
V4L2_PIX_FMT_YUYV YUV 4:2:2 打包格式 USB 摄像头常见
V4L2_PIX_FMT_NV12 YUV 4:2:0 半平面 H.265 编码输入首选
V4L2_PIX_FMT_MJPEG MJPEG 压缩流 高分辨率 USB 摄像头
V4L2_PIX_FMT_RGB24 RGB 24位 显示或 OpenCV 处理

我的建议:如果最终要做 H.265 编码,直接选 NV12。省去一次颜色空间转换,CPU 开销能降不少。我在一个嵌入式项目里,就是因为用了 YUYV 再转 NV12,导致编码帧率上不去。换成 NV12 后,问题迎刃而解。

4.4 申请缓冲区

格式定好了,接下来申请缓冲区。V4L2 支持三种 I/O 模式:read/write、userptr 和 mmap。咱们这里只讲 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");
    return -1;
}

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

这里 req.count 是申请数量,但驱动可能给不了那么多。我习惯申请 4 个,最少也得 3 个。为什么?因为要形成流水线:一个在驱动里填充,一个在用户态处理,一个等待入队。少于 3 个,CPU 和 DMA 容易互相等待,帧率就掉下去了。

曾经踩过的坑:有一次我申请了 8 个缓冲区,想着多备点总没错。结果驱动只支持 4 个,req.count 返回 4。我没检查返回值,后面 mmap 时索引越界,直接段错误。所以一定要检查 req.count 的实际值。

4.5 内存映射(mmap)

缓冲区申请好了,但它们还在内核空间。我们需要把它们映射到用户空间,才能读写数据。这一步用 VIDIOC_QUERYBUF 查询每个缓冲区的物理信息,然后用 mmap 映射。

struct v4l2_buffer buf;
struct Buffer {
    void *start;
    size_t length;
} *buffers;

buffers = calloc(req.count, sizeof(struct Buffer));

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;

    if (ioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) {
        perror("VIDIOC_QUERYBUF");
        return -1;
    }

    buffers[i].length = buf.length;
    buffers[i].start = mmap(NULL, buf.length,
                            PROT_READ | PROT_WRITE,
                            MAP_SHARED,
                            fd, buf.m.offset);

    if (buffers[i].start == MAP_FAILED) {
        perror("mmap");
        return -1;
    }

    printf("Buffer %d: addr=%p, length=%u\n",
           i, buffers[i].start, buffers[i].length);
}

注意 buf.m.offset 这个字段。它不是文件偏移量,而是驱动内部 DMA 缓冲区的物理偏移。你直接用 mmap 的 offset 参数传进去就行。千万别自己算,我见过有人试图用 i * buf.length 当偏移,结果映射出来的全是乱码。

4.6 缓冲区入队

映射完了,缓冲区还是空的。得把它们交给驱动,告诉驱动:你可以往这里面写数据了。这叫入队(Queue)。

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;

    if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) {
        perror("VIDIOC_QBUF");
        return -1;
    }
}

这一步必须在启动流之前完成。为什么?因为驱动需要知道哪些缓冲区是可用的。如果你不先入队,驱动启动后没有缓冲区可用,它会直接报错或者丢帧。

4.7 启动/停止视频流

缓冲区都就绪了,启动流吧。

enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) {
    perror("VIDIOC_STREAMON");
    return -1;
}

printf("视频流已启动\n");

启动后,驱动就开始往你入队的缓冲区里填数据了。怎么取数据?用 VIDIOC_DQBUF 出队。

// 采集循环
for (int frame = 0; frame < 100; frame++) {
    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("VIDIOC_DQBUF");
        break;
    }

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

    // 处理完重新入队
    if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) {
        perror("VIDIOC_QBUF");
        break;
    }
}

// 停止流
if (ioctl(fd, VIDIOC_STREAMOFF, &type) < 0) {
    perror("VIDIOC_STREAMOFF");
}

// 清理
for (int i = 0; i < req.count; i++) {
    munmap(buffers[i].start, buffers[i].length);
}
free(buffers);
close(fd);

关键点:出队后处理完数据,必须重新入队。否则驱动里的缓冲区越来越少,最终所有缓冲区都被你占着,驱动无缓冲区可用,DQBUF 就会阻塞或返回 EAGAIN。这就是典型的「缓冲区泄漏」。

4.8 完整代码骨架

我把上面所有步骤串起来,给你一个可以直接跑的骨架。你把它复制到 v4l2_capture.c,编译运行看看。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <linux/videodev2.h>
#include <sys/ioctl.h>

struct buffer {
    void   *start;
    size_t  length;
};

int main() {
    const char *dev = "/dev/video0";
    int fd = open(dev, O_RDWR);
    if (fd < 0) { perror("open"); return -1; }

    // 查询能力
    struct v4l2_capability cap;
    memset(&cap, 0, sizeof(cap));
    ioctl(fd, VIDIOC_QUERYCAP, &cap);
    if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, "no capture\n"); return -1; }
    if (!(cap.capabilities & V4L2_CAP_STREAMING)) { fprintf(stderr, "no streaming\n"); return -1; }

    // 设置格式
    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_NV12;
    fmt.fmt.pix.field = V4L2_FIELD_NONE;
    if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { perror("S_FMT"); return -1; }

    // 申请缓冲区
    struct v4l2_requestbuffers req;
    memset(&req, 0, sizeof(req));
    req.count = 4;
    req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    req.memory = V4L2_MEMORY_MMAP;
    if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) { perror("REQBUFS"); return -1; }

    // 内存映射
    struct buffer *buffers = calloc(req.count, sizeof(struct buffer));
    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_QUERYBUF, &buf);
        buffers[i].length = buf.length;
        buffers[i].start = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset);
    }

    // 入队
    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);
    }

    // 启动流
    enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    ioctl(fd, VIDIOC_STREAMON, &type);

    // 采集 30 帧
    for (int i = 0; i < 30; i++) {
        struct v4l2_buffer buf;
        memset(&buf, 0, sizeof(buf));
        buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        buf.memory = V4L2_MEMORY_MMAP;
        ioctl(fd, VIDIOC_DQBUF, &buf);
        printf("Frame %d: size=%d\n", i, buf.bytesused);
        ioctl(fd, VIDIOC_QBUF, &buf);
    }

    // 停止流
    ioctl(fd, VIDIOC_STREAMOFF, &type);

    // 清理
    for (int i = 0; i < req.count; i++)
        munmap(buffers[i].start, buffers[i].length);
    free(buffers);
    close(fd);
    return 0;
}

编译命令:gcc -o v4l2_capture v4l2_capture.c。不需要链接任何额外库,V4L2 是内核接口,头文件在 linux/videodev2.h。

4.9 常见问题与排查

现象 可能原因 解决方法
open 返回 Permission denied 没有权限访问 /dev/video0 sudo chmod 666 /dev/video0 或加入 video 组
VIDIOC_QUERYCAP 返回 EINVAL 设备不是 V4L2 设备 检查 /dev/video* 是否对应摄像头
VIDIOC_S_FMT 返回 EBUSY 设备已被其他进程占用 fuser /dev/video0 找出占用进程
VIDIOC_REQBUFS 返回 EINVAL 驱动不支持 mmap 模式 尝试 V4L2_MEMORY_USERPTR 或更新驱动
VIDIOC_DQBUF 阻塞不返回 没有帧数据到达 检查摄像头是否正常工作,或使用 select/poll 加超时
mmap 返回 MAP_FAILED buf.m.offset 无效 确认 VIDIOC_QUERYBUF 调用成功,检查 buf.length

嗯,V4L2 的这套流程,说白了就是「你给驱动缓冲区,驱动给你填数据」。只要把每一步的顺序走对,把每个返回值检查好,基本不会出大问题。我个人习惯在每一个 ioctl 后面都加错误处理,哪怕只是打印一行日志。调试的时候,这能省下大把时间。