开发日志

V4L2 摄像头驱动开发入门:从 ioctl 到视频流

· luo980 · linux · 约 3 分钟阅读

#linux#内核#v4l2#驱动

V4L2(Video4Linux2)是 Linux 内核里统一的视频设备接口,摄像头、采集卡、TV tuner 都走这一套 API。跟很多”用起来很魔法”的框架不一样,V4L2 的用户态编程模型其实很 直白:一切都是对 /dev/videoN 这个字符设备做 ioctl

设备节点与能力查询

插上一个 UVC 摄像头,v4l2-ctl --list-devices 能看到对应的 /dev/video0。 打开之后第一件事永远是查能力:

query_cap.c
int fd = open("/dev/video0", O_RDWR);
struct v4l2_capability cap;
ioctl(fd, VIDIOC_QUERYCAP, &cap);
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
// 这个设备不支持视频采集(可能只是 metadata 节点)
return -1;
}
if (!(cap.capabilities & V4L2_CAP_STREAMING)) {
// 不支持 mmap/DMA streaming,只能退化到 read()
}

一个物理摄像头往往对应好几个 /dev/videoN 节点(视频流、metadata、有时还有单独的 控制节点),VIDIOC_QUERYCAP 是区分它们的第一步。

协商格式

V4L2 不允许你”随便要一个格式”,得先 TRY_FMT/S_FMT 协商:

set_format.c
struct v4l2_format fmt = {0};
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
fmt.fmt.pix.width = 1280;
fmt.fmt.pix.height = 720;
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"); // 驱动可能会静默改成它支持的最接近格式
}
// 关键:驱动改过之后要用 fmt 里回填的实际值,不能假设申请的就是拿到的

常见格式:YUYV(未压缩,体积大但 CPU 友好)、MJPEG(USB 摄像头常见,省带宽)、 NV12(很多硬件编码器偏好的格式)。UVC 摄像头通常两种都支持,高分辨率高帧率往往 只有 MJPEG 能撑住 USB2.0 的带宽。

申请缓冲区:mmap 还是 userptr

现代 V4L2 采集不用 read()(那是兼容模式,有拷贝开销),标准做法是让内核分配一组 缓冲区,用户态 mmap 过来直接读,零拷贝:

request_buffers.c
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);
void *buffers[4];
size_t buf_len[4];
for (int i = 0; i < req.count; i++) {
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = i;
ioctl(fd, VIDIOC_QUERYBUF, &buf);
buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, buf.m.offset);
buf_len[i] = buf.length;
}

除了 MMAP,还有 USERPTR(用户自己分配内存交给内核)和 DMABUF(跟 GPU/编码器 之间传递缓冲区不做拷贝,现代摄像头 + 硬件编码管线的标配)。

抓帧循环

把所有缓冲区先入队(QBUF),开流,然后就是标准的”出队 - 处理 - 入队”循环:

capture_loop.c
for (int i = 0; i < req.count; i++) {
struct v4l2_buffer buf = {0};
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);
while (capturing) {
fd_set fds;
FD_ZERO(&fds);
FD_SET(fd, &fds);
select(fd + 1, &fds, NULL, NULL, NULL); // 也可以用 poll/epoll
struct v4l2_buffer buf = {0};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
ioctl(fd, VIDIOC_DQBUF, &buf); // 拿到一帧,buf.index 指向哪块内存
process_frame(buffers[buf.index], buf.bytesused);
ioctl(fd, VIDIOC_QBUF, &buf); // 处理完还回去,驱动可以继续往里写
}
ioctl(fd, VIDIOC_STREAMOFF, &type);

这就是整个 V4L2 采集的骨架——libv4l2、GStreamer 的 v4l2src、甚至 FFmpeg 的 -f v4l2 输入,底层都是这一套 ioctl 序列,区别只在错误处理和格式协商的完善程度。

跟 libcamera 的关系

新一点的板子(尤其是手机 ISP、树莓派新款摄像头)会发现 V4L2 不够用了——复杂的 ISP pipeline(去噪、自动曝光、多路 sensor)没法用一个 /dev/videoN 表达清楚。 libcamera 就是为了解决这个问题出现的上层框架,底下仍然是 V4L2 + media controller API,但把 pipeline 编排这层复杂度封装掉了。简单摄像头直接用 V4L2 完全 够用,复杂 ISP 场景才需要 libcamera。