说实话,我第一次碰 V4L2 的时候,差点以为这玩意儿是给硬件工程师准备的“玄学接口”。当时在某个 ARM 平台上接了一颗 USB 摄像头,脑子里想的还是单片机那套思路:open 一个设备文件,read 几字节,像素就应该像泉水一样流出来。结果呢?open 是成功了,read 却一直返回空,调试信息打印出来的错误码让我一头雾水。
后来才知道,V4L2(Video for Linux 2)是 Linux 内核里那套完整的视频采集框架,它不跟你讲“字节流”的故事,它讲的是“缓冲队列”的故事。你要是不按它的规矩来——先协商格式、再申请缓冲、把缓冲排进队列、最后才 stream on——那摄像头就跟你装死。这篇文章就是把我从“read 失败”到真正拿到一帧 YUV 图像完整踩过的路写下来,全是实操笔记,适合刚接手 Linux 摄像头驱动的开发、想自己写采集模块的人,也适合那种项目中只是“用一下摄像头”但被迫卷进驱动层的倒霉蛋。
1. 设备节点与能力探测:先确认你在跟谁说话
1.1 /dev/video* 不是玩具
在 Linux 下,摄像头驱动注册成功后,内核会在设备模型里生成一个字符设备节点,最常见的就是 /dev/video0、/dev/video1 这种。你可能会问:为什么会有好几个 video 节点?同一颗摄像头为什么同时占据 video0 和 video1?
多数情况下,这是 UVC(USB Video Class)驱动的老朋友了。一颗 UVC 摄像头往往同时暴露一个视频采集接口(Video Capture)和一个视频输出接口(Video Output),甚至还有一个 Metadata 节点。你要是 ls 一下 /dev,看到 video0、video1、video2 同时存在,别急着选最小的那个,先用 v4l2-ctl --list-devices 看清楚哪个节点对应哪个设备。
这一步很重要,因为我见过不止一个人对着 video1 折腾了一下午,后来才发现那是摄像头自带的 video output 接口,压根儿不能做采集。用一句话总结:device 节点是入口,但入口后面有好几个房间,你得确认自己进的到底是不是采集间。
bash复制# 列出所有视频设备与对应节点
v4l2-ctl --list-devices
输出大致长这样:
code复制USB Camera: USB Camera (usb@x.x.x.x):
/dev/video0
/dev/video1
但注意,--list-devices 只是给你看了个名字,它没有告诉你哪个节点能 capture。所以我的习惯是四个字:挨个 QUERYCAP。
1.2 VIDIOC_QUERYCAP:能读,但到底能不能采集?
在写任何代码之前,第一件事就是调用 VIDIOC_QUERYCAP 读取 struct v4l2_capability。这个结构体里藏着设备最基本的身份信息:driver 名字、capabilities 标志位、总线信息等等。
cpp复制struct v4l2_capability cap;
memset(&cap, 0, sizeof(cap));
int ret = ioctl(fd, VIDIOC_QUERYCAP, &cap);
if (ret < 0) {
// 注意:有些设备节点会拒绝 QUERYCAP,说明这个节点压根不完整
}
// 关键是检查能力位
if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
// 当前节点不是视频采集节点,换一个 /dev/video*
}
capabilities 这个字段容易看走眼。以前旧内核版本用 cap->type 判断设备类型,新内核统一改成了 capabilities 位掩码。你需要检查的至少有三个位:
V4L2_CAP_VIDEO_CAPTURE:支持视频采集V4L2_CAP_STREAMING:支持流式 IOV4L2_CAP_READWRITE:支持普通的 read/write 方式读取
其中 STREAMING 是 mmap 方式的基石,绝大多数现代驱动都支持;READWRITE 反而很多驱动早就不做了。你要是照着老教程去 read() 一个摄像头,驱动直接返回 EINVAL 或者干脆不给你数据,这就是原因之一。
这里有一个非常实用的判断逻辑:如果 V4L2_CAP_STREAMING 都没置位,后面我们讲的 MMAP 流程基本走不通,趁早换设备或者查驱动。
1.3 v4l2-ctl 快速验证:写代码前先确认硬件行为
很多嵌入式工程师的习惯是一上来就打开编辑器写代码,我建议你反过来:先用 v4l2-ctl 这个用户态工具把摄像头的基本行为摸一遍。它的原理跟咱们调用 ioctl 拿数据一模一样,但胜在快,能省一大半调试时间。
bash复制# 查看支持的所有格式
v4l2-ctl -d /dev/video0 --list-formats-ext
这条命令会打印设备支持的所有像素格式、分辨率、帧率范围。比如:
code复制ioctl: VIDIOC_ENUM_FMT
Type: Video Capture
[0]: 'YUYV' (YUYV 4:2:2)
Size: Discrete 640x480
Interval: Discrete 0.033s (30.000 fps)
Size: Discrete 1280x720
Interval: Discrete 0.033s (30.000 fps)
[1]: 'MJPG' (Motion-JPEG)
看到这个输出,你心里大概有数了:这颗摄像头在 YUYV 下最高支持 1280x720@30fps,在 MJPG 下可能支持到 1920x1080。这些信息直接决定了后面格式协商的代码怎么写。有些摄像头还支持多个离散分辨率,但注意它标记的是 Discrete 而不是 Continuous,意味着你不能随便设置一个“不在列表里”的分辨率,协商时驱动会静默给你改回最接近的合法值——这又是一个经典大坑,后面细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 格式协商的潜规则:你以为的 1080p,不一定是驱动接受的 1080p
2.1 fourcc 对抗:YUYV、NV12、MJPG 到底怎么选
struct v4l2_format 里的 pixelformat 字段用的是一种叫 fourcc 的四字符编码,比如 V4L2_PIX_FMT_YUYV 对应字符串 "YUYV",V4L2_PIX_FMT_NV12 对应 "NV12",V4L2_PIX_FMT_MJPEG 对应 "MJPG"。
新人最容易困惑的点是:我该选 YUYV 还是 MJPG?
这个选择本质上是在“CPU 负担”和“画质/带宽”之间做 trade-off:
- YUYV:无压缩的原始视频格式,每个像素占 2 字节(Y 分量和 UV 分量交织)。优点是拿到就能直接用,不需要解码;缺点是分辨率一大,数据量非常恐怖。以 1080p@30fps 为例,一秒就是 192010802*30 = 124 MB/s,USB 2.0 的 480 Mbps 带宽(实际有效大概 280 Mbps)根本扛不住。
- MJPG:每帧都是一张 JPEG 图,体积小、带宽占用低。代价是你拿到数据之后必须用 JPEG 解码器解一遍,CPU 占用率会显著上升。
- NV12:这是很多编解码硬件最喜欢的输入格式,Y 平面和 UV 平面分开存储。如果你的后续链路是 H.264 硬件编码器,NV12 往往是性价比最高的选择。
在开发阶段,我的建议是优先选 YUYV,因为它的调试路径最短——抓到一帧数据可以直接裸丢给 ffmpeg 或者写成 PPM 文件查看,不需要引入额外的解码依赖。等链路跑通了,再根据实际场景切换 MJPG 或者 NV12。
2.2 S_FMT 成功后要二次确认,驱动可能悄悄改参数
下面这段代码几乎是每个 V4L2 采集程序的开场白:
cpp复制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;
ret = ioctl(fd, VIDIOC_S_FMT, &fmt);
if (ret < 0) {
// 设置失败,按错误码排查
}
这里有个绝大多数新手不知道的细节:ioctl 返回 0 并不代表你设置的参数被原样接受了。 驱动完全可能把分辨率改成最接近的某个离散值,或者把 pixelformat 换成一个它更喜欢的格式,然后通过 fmt 结构体把修改结果回填给你。你如果不检查 S_FMT 之后的 fmt 内容,直接就按 1920x1080 去申请缓冲区,后面采集出来的图像尺寸跟预期完全对不上,画面混乱到怀疑人生。
正确做法是设置之后立刻打印并确认:
cpp复制printf("实际分辨率: %ux%u\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);
驱动给你回填的 width/height 才是你后续 mmap 和图像处理的真实依据。别信你写在代码里的宏定义,信 fmt。
2.3 sizeimage 与步长对齐:很多人在这里栽跟头
S_FMT 之后,fmt.fmt.pix.bytesperline 和 fmt.fmt.pix.sizeimage 这两个字段也完成了填充,它们直接决定了缓冲区的映射大小和行跨度。
这里头有个容易忽略的对齐问题:bytesperline 不一定是 width * 2(YUYV 每像素 2 字节)。很多硬件要求每行字节数对齐到 32、64 甚至 128 字节。举个例子,19202 = 3840 恰好能被 64 整除,但如果你设置一个非标准宽度比如 1280(YUYV 下 bytesperline = 2560),有些驱动为了对齐会回填成 2560 或者 2688。你要是拿着 width2 去逐行拷贝数据,图像会整体斜掉——每一行都比实际宽度多出几个字节的 padding。
正确的图像拷贝逻辑应该按 bytesperline 来,而不是 width*2。
cpp复制for (int y = 0; y < height; y++) {
memcpy(dst + y * width * 2,
src + y * fmt.fmt.pix.bytesperline,
width * 2);
}
同理 sizeimage 也不是简单等于 width * height * 2,它已经包含了所有 padding。申请 MMAP 缓冲区大小时,必须以 sizeimage 为准,自己乘一遍算出来的数很可能偏小,后面驱动把数据往里塞的时候,实际写入越过了你映射的边界,轻则图像花掉,重则内存踩踏,程序莫名其妙崩溃。
3. 缓冲管理与 MMAP:理解 V4L2 的“车库模型”
3.1 为什么不能像读普通串口一样 read() 摄像头
如果你接触过串口或者普通字符设备,会天然认为摄像头也是“打开->读取->拿到数据”的模型。但 V4L2 流式接口的设计完全不同,它的典型工作模式是:
- 用户空间向驱动申请若干个缓冲区(内存可以由内核分配,也可以由用户分配)
- 将这些缓冲区全部加入驱动内部的“待填充队列”
- 启动数据流(stream on)
- 摄像头硬件/传感器把一帧画面填充进某个缓冲区
- 驱动把这个缓冲区从“待填充队列”挪到“已填充队列”
- 用户空间从“已填充队列”取出一个缓冲区,处理完后再放回去
这个过程非常像一个车库:你有几个固定车位,车(帧数据)来了就停进车位里,你开走一辆车之前,必须得先有一辆新车等着停进来,车位总数不变。
如果你不预先把缓冲区排进队列,stream on 之后驱动根本没有地方放帧数据,摄像头就会一直等,你的 poll 永远超时,或者连 stream on 都失败。这也是为什么直接 read() 摄像头往往拿不到数据的原因之一——那种模式下驱动走的是另一种内部路径,很多现代驱动干脆就不支持了。
3.2 REQBUFS / QUERYBUF / MMAP / QBUF / DQBUF 的完整流程
以 MMAP 模式为例,完整的代码流程如下。
第一步,申请缓冲区:
cpp复制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;
ret = ioctl(fd, VIDIOC_REQBUFS, &req);
if (ret < 0) {
// 申请失败:可能是因为驱动不支持这么多缓冲
}
req.count 不是你想设多少就多少,驱动可能会给你裁剪。ioctl 返回后建议再读一下 req.count,看看实际给了几个。有些老驱动最多支持 2 个缓冲,有些支持 8 个。
第二步,逐个查询缓冲区信息并进行内存映射:
cpp复制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;
ret = ioctl(fd, VIDIOC_QUERYBUF, &buf);
// 关键:buf.length 是内核告诉你这个缓冲有多大
void *buffer = mmap(NULL, buf.length, PROT_READ | PROT_WRITE,
MAP_SHARED, fd, buf.m.offset);
if (buffer == MAP_FAILED) {
// mmap 失败,大概率是 offset 或 length 用错了
}
}
第三步,把所有缓冲区排入输入队列:
cpp复制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;
ret = ioctl(fd, VIDIOC_QBUF, &buf);
// 这里就是把“空车位”交给驱动
}
第四步,开启数据流:
cpp复制enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ret = ioctl(fd, VIDIOC_STREAMON, &type);
之后,只要 poll 到可读事件,就调用 VIDIOC_DQBUF 取出一个已经填充好数据的缓冲,处理完后再 VIDIOC_QBUF 放回去。
整条链路你不需要记死 ioctl 的名字,但要记住顺序:REQBUFS -> QUERYBUF -> MMAP -> QBUF(全部入队) -> STREAMON -> DQBUF -> 处理 -> QBUF。任何一步跳过去,后面一定翻车。
3.3 缓冲数量与帧率的关系:不是越多越好
缓冲数量到底选几个?我在实际项目里的经验值是:USB 摄像头 3~4 个够用,MIPI CSI 摄像头 4~6 个比较稳。
缓冲太少,比如只有 2 个,那么一帧数据还没被用户空间取走,驱动就已经把第二个车位填满了,新帧到达时无处可放,只能丢弃,表现为掉帧、卡顿。缓冲太多也不好,比如 16 个甚至 32 个,内存占用是个问题不说,取帧的延迟也会变大——因为队列里堆了好几帧旧数据,你看到的是几百毫秒前的画面,这在实时交互场景里非常致命。
如果你发现采集画面总是比真实场景“慢半拍”,优先考虑减少缓冲数量,而不是增加。这就是一个典型的“more is not always better”。
4. 一个可用的采集循环:从 QUEUE 到 DEQUEUE 的日常
4.1 用 poll 等帧,而不是 sleep
做了前面那些准备工作,接下来就是一个无限循环去取帧。很多刚上手的人会写一个 usleep 然后 DQBUF 轮询的死循环。这在低帧率下似乎能跑,但负载一高就抓瞎——DQBUF 返回 EAGAIN 的频率变得不可控,白白占着 CPU 干等。
正确方式是用 poll 等待文件描述符可读,再 DQBUF。
cpp复制#include <poll.h>
struct pollfd fds;
fds.fd = fd;
fds.events = POLLIN;
while (running) {
int ret = poll(&fds, 1, 3000); // 3 秒超时
if (ret == 0) {
// 超时,可能摄像头没在出帧
continue;
}
if (ret < 0) {
// 被信号打断,重试或退出
continue;
}
struct v4l2_buffer buf;
memset(&buf, 0, sizeof(buf));
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
int rc = ioctl(fd, VIDIOC_DQBUF, &buf);
if (rc < 0) {
if (errno == EAGAIN) {
// 暂时没有帧,继续 poll
continue;
}
// 其他错误,可能需要断流重连
break;
}
// 此时 buf.index 对应的 buffer 里有完整一帧
process_frame(buffers[buf.index], buf.bytesused, width, height);
// 处理完毕,立刻放回队列
ioctl(fd, VIDIOC_QBUF, &buf);
}
有一个细节值得单独说:buf.bytesused 表示这一帧实际占用的字节数。在 MJPG 格式下它往往远小于 sizeimage,因为每帧 JPEG 大小都不同;处理时不要拿着 sizeimage 去读数据后面的空白区域。
4.2 处理帧与重新入队的节奏
每个缓冲在处理完之后必须马上 QBUF 回到驱动队列,这个动作的时机决定了你丢不丢帧。
假设你 DQBUF 拿到第 2 号缓冲,里面是一帧 1080p 的 YUYV 数据,你需要做的是:立刻把这个缓冲里的一帧数据拷贝到你自己的用户空间帧缓存里(或者直接引用内存地址,视具体算法而定),然后立刻 QBUF 回去。
千万不要在 DQBUF 和 QBUF 之间做耗时很长的图像处理。举个例子,如果你在中间做了一次耗时的 JPEG 压缩,耗时 100ms,在这一百毫秒里,驱动如果有新帧到达但没空闲缓冲可放,就得丢帧。到高帧率场景下,你会发现实际出帧率严重低于理论值,而 google 半天才明白问题出在自己“占着茅坑不拉屎”。
所以我把采集循环和算法处理设计成两个模块:
- 采集线程:DQBUF -> 拷贝出来 -> 立刻 QBUF,完事。
- 算法线程:处理采集线程拷贝出来的帧,干多久都行。
中间用环形队列或者双缓冲存帧数据。这套结构虽然多一次 memcpy,但换来的是采集时序的稳定,划得来。
4.3 超时、断流与 EIO:恢复采集的正确姿势
长时间运行之后,摄像头偶尔会出幺蛾子:poll 超时、DQBUF 返回 EIO、或者 STREAMON 阶段直接失败。这些情况处理不好,程序就卡死了,必须拔插摄像头才能恢复——这在无人值守的嵌入式设备上完全不可接受。
我的应对策略是“分层复位”:
- 第一层:停止数据流,调用
VIDIOC_STREAMOFF,把之前所有 buffer 的状态清掉。 - 第二层:如果 STREAMOFF 之后重开 STREAMON 还是失败,那就尝试关闭 fd,重新 open 设备节点,重新做一遍格式协商和缓冲申请。
- 第三层:如果重开设备节点都失败,那问题可能出在 USB 链路或者驱动层,可尝试通过用户态重置 USB 设备(这里就需要结合具体平台能力了,别轻易在生产环境搞硬复位)。
伪代码如下:
cpp复制void recover_stream() {
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ioctl(fd, VIDIOC_STREAMOFF, &type);
// 清理所有已映射缓冲
for (auto &b : buffers) {
munmap(b.addr, b.length);
b.addr = nullptr;
}
// 重新初始化,复用全局初始化函数
init_device(fd);
start_capturing(fd);
}
这套三级复位流程能覆盖绝大多数 USB 摄像头“假死”的情况,特别是那种休眠后唤醒、USB 供电不稳导致的异常。生产环境里配合心跳监控,每几秒检查一下最近一次拿到帧的时间,超过阈值就触发恢复逻辑,系统能自己“活过来”。
5. 实战踩坑排查链路:从黑屏到彩色画面的步步拆解
5.1 场景一:S_FMT 成功但采集到的全是杂色条纹
这个坑我到现在都记忆犹新。S_FMT 返回成功,width 和 height 也不意外,DQBUF 也正常,但把 YUYV 数据保存成图片后,看到的全是彩色斜条纹,完全不是摄像头前的东西。
排查下来发现问题出在 bytesperline 上。我把数据按 width * 2 拷贝走了,而驱动实际上给每行做了 padding。你需要看清楚 S_FMT 回填的 bytesperline 是不是比 width*2 大,是的话拷贝循环必须按 bytesperline 切行。
另外还有个更隐蔽的情况:你申请的 buffer 是按 sizeimage 映射的,但后续显示程序需要的行跨度又跟驱动给的 bytesperline 不一致。这种情况下图像颜色不一定错,但画面会“斜”。处理办法就是前面提的:以驱动回填值为准。
如果你调的是 MIPI CSI 接口的摄像头,还有一个常见来源是裁剪(crop)配置不对,但那个更底层,属于 sensor 驱动问题,一般先用 media-ctl 把 pipeline 打出来看一眼,确认 sensor 输出的尺寸是否和 V4L2 节点 S_FMT 一致。
5.2 场景二:帧率上不去,UVC 带宽不够怎么办
UVC 摄像头对 USB 带宽依赖非常大。你在设备树里插了很多 USB 外设,或者 USB Hub 是那种便宜的 2.0 Hub,带宽一挤,摄像头帧率直接掉到 5fps 以下。
怎么排查?先看枚举出来的帧率区间,再看当前的带宽是否足够。v4l2-ctl --get-parm 可以看当前帧率,但多数情况下你要自己算一下带宽占用。以 YUYV 1080p@30fps 为例,裸流带宽 124MB/s,USB 2.0 理论最大 60MB/s,肯定跑不到、直接协商失败;解决思路是:
- 切换 MJPG 格式,把裸流带宽降到 1~2MB/s
- 降低分辨率到 720p 或 480p
- 给摄像头单独一路 USB 控制器,不要跟别的设备共享 Hub
其中切换 MJPG 是最立竿见影的。只是要承担 JPEG 解码的 CPU 开销。我在某主流 ARM 上测试过,720p MJPG 解码大概会增加 20~30% CPU 负载,但只要 SoC 不是特别弱,完全可以接受。
5.3 场景三:DQBUF 一直超时,摄像头“假死”
DQBUF 超时是嵌入式开发里最常见的“玄学问题”。原因五花八门,但按发生频率排,大概率是下面几个:
- 摄像头还在初始化或模式切换中,S_FMT 之后立刻就 STREAMON,驱动内部没准备好
- 曝光/自动增益调整阶段,前几帧迟迟不来
- 驱动 bug,DQBUF 永远没有数据
- USB 带宽被无线网卡之类瞬时抢走
我的建议是:DQBUF 超时不要直接杀进程,做一个尝试次数计数,比如 5 次超时后才进入恢复流程。 摄像头冷启动时前几帧往往来得很慢,如果第一次 poll 超时就触发复杂恢复逻辑,反而容易把正常的启停流程搞乱。
如果进入恢复流程还是不行,这时候建议打开内核打印:
bash复制echo 'file drivers/media/usb/uvc/* +p' > /sys/kernel/debug/dynamic_debug/control
dmesg -w
UVC 驱动的动态调试信息会直接告诉你 USB 传输有没有收到数据、URB 是不是提交失败。这比自己瞎猜效率高一个数量级。
5.4 场景四:V4L2 帧缓冲撕裂与显示不同步
画面出现“撕裂”(tearing)通常有两种情况。
第一种是你在读取缓冲数据的同时,驱动已经开始了下一帧的写入。这其实是多缓冲机制下的正常现象,只要你不长时间占用同一个缓冲,撕裂的概率很小。如果频繁出现,多半是采集线程处理太慢了,老霸着一个缓冲,驱动不得不接着往你正在读的缓冲里覆盖新数据。解决方法是减少拷贝和处理的耗时,或者增加缓冲数量。
第二种是硬件层面的,比如你的显示控制器和编码器在访问同一块 DDR 时产生了带宽竞争,导致 DMA 写入不连续。这种情况就比较“嵌入式”了,普通应用层代码解决不了,只能从内存分配、VPU 优先级、DDR 频率这些方向去调。
6. 性能优化与其他采集路径:零拷贝与多路并发
6.1 USERPTR / DMABUF:绕过 CPU 拷贝
如果你的应用场景是 AI 推理或视频编码,每帧数据最好都能直接喂给硬件模块,避免 CPU 反复拷来拷去。MMAP 模式虽然已经避免了 open/read 那套系统调用拷贝,但当你把数据从 V4L2 缓冲区搬到算法模块时,还是产生了一次 memcpy。
想消灭这次 memcpy,两条路:
- USERPTR:用户自己分配内存,通过
VIDIOC_REQBUFS时指定V4L2_MEMORY_USERPTR,把用户缓冲区地址传给驱动。这样驱动直接往你指定的内存里写 DMA 数据。不过这里有个坑:你分配的内存必须物理连续(对很多嵌入式平台),或者用 CMA 分配器,否则驱动没法做 DMA。在普通 PC 上,malloc 出来的内存可能是分散的,需要格外小心。 - DMABUF:这是现代平台最推荐的方式。通过
VIDIOC_EXPBUF把 V4L2 缓冲区导出成 dma-buf fd,再把这个 fd 传给其他驱动(ISP、编码器、GPU)。数据全程不经过 CPU。
DMABUF 虽然好,但调试难度也高,新手不一定能驾驭。如果是第一次做摄像头采集,先用 MMAP 把整个链路跑通再说,优化真是后话。
6.2 帧率与曝光控制:VIDIOC_S_PARM 的小坑
部分摄像头支持通过 V4L2 控制帧率:
cpp复制struct v4l2_streamparm parm;
memset(&parm, 0, sizeof(parm));
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
parm.parm.capture.timeperframe.numerator = 1;
parm.parm.capture.timeperframe.denominator = 30; // 目标 30fps
ret = ioctl(fd, VIDIOC_S_PARM, &parm);
结果可能跟你想象的不太一样。很多 UVC 摄像头只在特定分辨率组合下支持 30fps,你非要 30fps 但当前分辨率支持不了,驱动会静默调整成 25fps 或 15fps。同样,S_PARM 后也要检查回填值。
另外 Webcam 上常见的是“自动曝光下实际帧率不稳定”,比如背光变暗时帧率往下掉,这种其实是 sensor 曝光时间变长导致的,不属于驱动 bug。如果业务要求固定帧率,需要手动关闭自动曝光并设置固定曝光时间,这是 V4L2 的扩展控制(V4L2_CID_EXPOSURE_AUTO)范畴,不太基础,但值得知道方向。
6.3 多路摄像头并发采集的内存规划
接了三四路摄像头之后,内存规划就得认真算了。假设三路 1080p YUYV,每路 4 个 buffer:
单路每缓冲大小约 192010802 ≈ 4.15MB
一路 4 缓冲 ≈ 16.6MB
三路就是差不多 50MB 只用来做采集缓冲。
听起来不多,但如果你每路再复制一份到算法侧,内存就翻倍了。128MB 内存的小设备可能会直接 OOM。所以多路场景强烈建议:
- 优先选 MJPG 或 NV12,减小 buffer 占用
- 缓冲数量从 4 减到 3,大多数情况完全够用
- 算法侧尽量直接引用 MMAP 地址,避免拷贝
我在一个项目里就是没注意内存,四路摄像头直接把系统跑崩了,dmesg 里全是 oom-killer。后来按上面对策一调,内存占用直接降了六成。
根据我这几年反复折腾 V4L2 的经验,整个开发过程最忌讳的其实是“不读回填值”。S_FMT 要读回填、REQBUFS 要读 count、S_PARM 要读回填,凡是 ioctl 返回成功但你没看内容的地方,后面都可能埋雷。V4L2 这个框架并不复杂,但它跟纯粹的串口编程完全是两套心智模型,一旦你把“内存映射+队列周转”这套东西想通了,会发现它是目前 Linux 多媒体领域里设计得相当优雅的接口。希望这篇笔记能帮你少走几个我当年绕过的弯路。
