4. 视频采集API实战(Linux V4L2):打开设备、查询能力、设置格式、申请缓冲区、启动/停止流、内存映射(mmap)
好,咱们进入真正的实战环节了。V4L2(Video for Linux 2)是 Linux 下视频采集的事实标准。说实话,我早年刚接触它的时候,觉得这 API 设计得有点绕——又是 ioctl 又是 mmap 的。但用熟了你会发现,这套接口其实非常规整,每一步都有明确的意图。
这一章,我会带你走完 V4L2 采集的完整链路:从打开设备,到设置格式,再到缓冲区管理,最后启动流并拿到数据。嗯,每一步我都会穿插一些我在项目中踩过的坑。
核心流程速览:打开设备 → 查询能力 → 设置格式 → 申请缓冲区 → 内存映射 → 入队 → 启动流 → 出队处理 → 停止流 → 关闭设备。
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 后面都加错误处理,哪怕只是打印一行日志。调试的时候,这能省下大把时间。