做嵌入式或者音视频采集的兄弟,大概率都跟V4L2打过照面。V4L2(Video for Linux 2)是Linux下视频采集设备的标准接口,应用层调用流程说穿了就是一套固定套路:打开设备节点、协商采集格式、申请缓冲区、把缓冲区挂进队列、启动流、循环取帧、最后停流释放。这篇文章不聊驱动内部那一堆框架代码,只讲应用层怎么一步步把一个采集流程调通,把每个ioctl命令、每个结构体字段、每个容易翻车的地方摊开了讲。
这篇内容适合两类人:一类是刚开始调摄像头驱动,手里只有一份datasheet和一块开发板的嵌入式工程师;另一类是写上位机采集软件,对面设备的驱动已经稳定,但你不清楚V4L2的调用时序和缓冲管理,导致取不到帧或者画面错位。我会从调用模型讲起,给出可直接抄作业的完整例程,最后汇总我在实际项目中踩过的一些坑。
1. 先搞清楚V4L2的调用本质:它不是函数库,是一套"请求-应答"协议
很多新手上来看V4L2,第一反应是去找一个类似于OpenCV里VideoCapture那样现成的封装库。但V4L2本身不是库,而是Linux内核提供的一套用户态接口规范,所有操作都要通过文件I/O和ioctl系统调用来完成。理解这一点,后面看什么都很顺。
1.1 设备节点和文件描述符:一切操作的起点
V4L2设备在用户态看起来就是一个字符设备节点,常见路径是/dev/video0、/dev/video1,由内核中的video_device框架注册生成。应用层要做的第一件事,就是对这个节点调用open()拿到一个文件描述符fd。
后续所有V4L2命令,无论是查询能力、设置格式,还是申请缓冲区、启动/停止采集,全部通过这个fd进行。fd就相当于你和内核驱动之间的一条会话通道,驱动内部的硬件资源绑定在这个fd上。这里有个容易被忽略的点:close(fd)之后,驱动会释放这个会话对应的流资源和缓冲区资源,所以如果代码里频繁开设备、关设备,性能会很难看,特别是高清sensor的初始化耗时可能到几百毫秒。
设备节点本身没有太多魔法。它的主设备号是81(V4L2字符设备),次设备号区分不同设备实例。有些平台会有多个video节点,比如一个sensor同时提供预览和拍照,注册了两个video设备节点,那么预览数据在video0,拍照在video1;如果拿错节点,请求的格式或者流模式很可能报错。
1.2 ioctl命令宏:每个宏代表一次内核交互
V4L2定义了几十个命令宏,名称都是VIDIOC_*,比如:
VIDIOC_QUERYCAP:查询设备能力VIDIOC_ENUM_FMT:枚举支持的像素格式VIDIOC_S_FMT:设置采集格式VIDIOC_REQBUFS:申请缓冲区VIDIOC_QUERYBUF:查询缓冲区信息VIDIOC_QBUF:把缓冲区放入队列VIDIOC_DQBUF:从队列取出已采集的帧VIDIOC_STREAMON/VIDIOC_STREAMOFF:启动/停止流
调用方式统一是ioctl(fd, VIDIOC_XXX, &arg),参数是一个指向对应结构体的指针。内核里对应的v4l2_ioctl_ops会解析这个命令,执行实际操作后把结果写回结构体。
这些宏从命名上可以反推数据方向:带_IOR的是内核向用户空间返回数据(Read),带_IOW的是用户空间把数据写给内核(Write),双向的就用_IOWR。不过应用层写代码时不用太纠结这个,你知道"调这个命令会改动结构体里的内容"就够用了。
1.3 图像数据靠缓冲区流动,不是靠ioctl搬运
一个1080p的YUV422帧,大小是1920×1080×2字节,差不多4MB。这种量级的数据不可能通过ioctl的参数区搬运,V4L2的解决方式是让内核对缓冲区进行管理,用户态把缓冲区映射到自己进程的地址空间里,然后驱动通过DMA直接把sensor采集的数据填进这块内存,用户态直接读。
V4L2支持几种内存访问模式,应用层最常见、也最容易出问题的是V4L2_MEMORY_MMAP。在这种模式下,缓冲区物理上由驱动分配(可能是连续物理内存,也可能是IOMMU映射的散页内存),用户态通过mmap()把同一块内存映射到进程虚拟地址空间,实现零拷贝访问。
另外两种模式:V4L2_MEMORY_USERPTR是用户态自己分配内存,把用户指针交给驱动去填充;V4L2_MEMORY_DMABUF是基于DMA-BUF文件描述符共享,主要用于多设备间避免拷贝。不夸张地讲,实际工程里90%以上用的是MMAP模式,所以后面我主要按MMAP模式讲。
2. 初始化链路:从open()到STREAMON,七个步骤一个都不能少
初始化链路是V4L2使用中最核心、也最考验细节的一段。很多人调不通采集,问题都出在"自认为配置正确,但驱动实际给了你另一套参数"。
2.1 全流程调用序列总览
先把完整链路画在脑子里:
open() → VIDIOC_QUERYCAP → VIDIOC_S_FMT → VIDIOC_REQBUFS → VIDIOC_QUERYBUF + mmap() → VIDIOC_QBUF → VIDIOC_STREAMON → 循环:poll() → VIDIOC_DQBUF → 处理帧 → VIDIOC_QBUF → VIDIOC_STREAMOFF → munmap() → close()这个序列不是随便定的,每一步都在为下一步准备条件:先知道设备是什么,再决定怎么配置格式;配置完格式才知道需要多大缓冲区;申请缓冲区后才能映射和入队;所有缓冲区就绪后,才能启动DMA搬运。
2.2 open():阻塞还是非阻塞,第一行就得想清楚
打开设备最常见的写法是:
int fd = open("/dev/video0", O_RDWR);O_RDWR是读写权限,不要只用O_RDONLY打开采集设备,某些驱动在只读模式下不会初始化硬件通道。
关键在于O_NONBLOCK这个标志。如果设置了这个标志,后续VIDIOC_DQBUF在没有可用帧时会立即返回EAGAIN,适合配合事件循环使用;如果不设置,DQBUF会一直阻塞直到有帧到达。我的建议是:不要在最开始就加O_NONBLOCK,先用阻塞模式把流程调通,确认驱动出帧正常后,再根据软件架构决定是否切非阻塞。因为非阻塞模式下,排查问题的时候很难区分"驱动没出帧"和"应用还没收到数据"。
2.3 VIDIOC_QUERYCAP:先验货再下单
QUERYCAP返回的struct v4l2_capability里有驱动名、设备名、总线信息、内核版本号,最重要的是一堆能力标志。
struct v4l2_capability cap; memset(&cap, 0, sizeof(cap)); if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { perror("VIDIOC_QUERYCAP"); return -1; } if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, "设备不是视频采集设备\n"); return -1; }这里最容易踩的坑是单平面与多平面API的区分。V4L2有两套采集接口:传统单平面用V4L2_BUF_TYPE_VIDEO_CAPTURE,像素数据是"一整块"连续内存;多平面用V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE,像素数据被拆成多个plane,比如NV12就是一个Y平面加一个UV平面。能力标志里分别对应V4L2_CAP_VIDEO_CAPTURE和V4L2_CAP_VIDEO_CAPTURE_MPLANE。
如果一个设备只声明了MPLANE能力,你仍然用单平面的结构体去调VIDIOC_S_FMT,大概率会得到EINVAL。很多调试事故都源于此,后面我会专门讲。
2.4 格式协商:S_FMT不会"必须"满足你
格式协商是整个流程里最隐蔽的地带。表面上你填一个分辨率、像素格式,驱动就返回一个结果,但实际上驱动会做两件事:一是把你请求的值改写成设备支持的"最接近"值,二是把改写后的值写回你传进去的结构体。如果你不检查返回值,就会拿着你以为的640×480去分析数据,实际驱动给的是640×360,画面自然不对。
经典写法:
struct v4l2_format fmt; memset(&fmt, 0, sizeof(fmt)); fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = 640; fmt.fmt.pix.height = 480; 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"); return -1; } // 必须检查驱动实际设置的格式 printf("实际格式: %ux%u, pixelformat=%c%c%c%c, bytesperline=%u\n", fmt.fmt.pix.width, fmt.fmt.pix.height, (fmt.fmt.pix.pixelformat >> 0) & 0xff, (fmt.fmt.pix.pixelformat >> 8) & 0xff, (fmt.fmt.pix.pixelformat >> 16) & 0xff, (fmt.fmt.pix.pixelformat >> 24) & 0xff, fmt.fmt.pix.bytesperline);像素格式的选择要结合设备支持列表。用VIDIOC_ENUM_FMT可以枚举所有支持的格式;用VIDIOC_ENUM_FRAMESIZES可以枚举支持的分辨率。实际项目中常见的是YUYV、NV12、MJPG,带有硬件编码的sensor还会给H264码流。需要注意:MJPG和H264是压缩格式,读帧时bytesused是码流长度,不是图像宽×高×像素字节数,不能拿原始帧大小去memcpy,否则会读越界。
还有个字段要留意:bytesperline。它表示一行的实际字节数,很多时候因为硬件对齐要求,bytesperline > width * 2(YUV422场景),如果你按width*2去逐行拷贝图像,画面会出现斜切错位。后面我会在踩坑清单里再强调一次。
2.5 REQBUFS、QUERYBUF、mmap:缓冲区三步曲
缓冲区申请是重头戏。先VIDIOC_REQBUFS申请若干缓冲区:
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("VIDIOC_REQBUFS"); return -1; } printf("驱动实际分配的缓冲数量: %u\n", req.count);req.count是"期望值",驱动可能因为内存不足或者硬件限制而减少数量,返回后会改写count。如果返回0,说明申请失败,后面什么都不用做了。申请成功后,逐个查询缓冲区信息并做mmap映射:
void *buffers[4] = {0}; unsigned int buffer_lengths[4] = {0}; 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; if (ioctl(fd, VIDIOC_QUERYBUF, &buf) < 0) { perror("VIDIOC_QUERYBUF"); return -1; } buffers[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); if (buffers[i] == MAP_FAILED) { perror("mmap"); return -1; } buffer_lengths[i] = buf.length; }这里有几个要点。QUERYBUF返回的buf.m.offset是用于mmap的偏移参数,不是文件里的绝对字节位置,不要自己再乘什么页大小。buf.length是这块缓冲区的大小,也要记住,后面munmap要用同样的长度。
缓冲区与数据帧的关系:驱动内部维护一个队列,所有空闲缓冲区都在队列里。DMA采集到一帧后,驱动会把数据写入队列头部对应的缓冲区,然后把这个缓冲区从队列里移出,等待应用通过DQBUF取走。这就是"入队/出队"的语义。
2.6 QBUF和STREAMON:让队列转起来
映射完成后,先把所有缓冲区入队:
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; } }然后在STREAMON之前,队列里至少应该有一个可用的缓冲区。启动流:
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) { perror("VIDIOC_STREAMON"); return -1; }STREAMON是驱动真正开始采集的开关。它会开启sensor时钟、配置MIPI/USB端口的DMA通道、开始往队列里填帧。这之后,就可以进入取帧循环了。
3. 稳定取帧:DQBUF/QBUF循环的节奏与管理
初始化跑通只是一半,真正考验稳定性的,是这个循环能不能长期不出错地跑下去。
3.1 poll等待与DQBUF的配合
取帧循环的标准姿势是用poll()或select()等待fd可读,再调用DQBUF:
int frames = 0; while (frames < 100) { struct pollfd pfd; pfd.fd = fd; pfd.events = POLLIN; int ret = poll(&pfd, 1, 2000); // 2秒超时 if (ret < 0) { perror("poll"); if (errno == EINTR) continue; break; } if (ret == 0) { fprintf(stderr, "poll超时\n"); continue; } memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) continue; // 非阻塞模式下没有帧 perror("VIDIOC_DQBUF"); break; } printf("第%d帧: index=%u, bytesused=%u, sequence=%u\n", frames, buf.index, buf.bytesused, buf.sequence); // TODO: 在这里处理 buffers[buf.index] 指向的图像数据 // 注意用 bytesused 判断有效数据长度,不要用 buffer_lengths[buf.index] // 处理完必须重新入队,否则驱动很快就没buffer可用了 if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) { perror("VIDIOC_QBUF(归还缓冲区)"); break; } frames++; }poll返回可读不一定等于"有一整帧数据可读",但对于绝大多数视频采集驱动来说,POLLIN就代表队列里有已完成采集的帧。DQBUF取出的是队列头部的帧,buf.bytesused是这一帧实际有效数据长度,buf.sequence是驱动维护的帧序号。
DQBUF和QBUF必须成对出现:取出一个缓冲区,处理完,再还回去。如果你忘了QBUF,会出现一个很有意思的现象——前面几帧正常,几秒后poll一直超时。原因是空缓冲区队列已经空了,驱动没有可写入的区域只能丢弃新帧,这时DMA还在跑,但应用再也取不到数据。我之前调试某平台的MIPI摄像头时,这种"丢缓冲"问题是最常被误判成驱动挂死的。
3.2 帧数据处理:单平面和多平面两种读法
拿到缓冲区指针后,怎么解析数据取决于像素格式。
单平面格式如YUYV,内存布局就是连续的YUV交织数据,解析时遍历每个像素即可。NV12也是单平面,但它的内存里前半部分是Y数据,后半部分是UV交织数据(Y宽度与图像宽度相同,UV平面是宽度减半)。这里有一件事别做错:不要假设像素数据从缓冲区偏移0开始且长度就是 width×height×bpp。有些驱动会预留头部,真实数据起始位置和长度要通过buf.m.offset + buf.bytesused来界定,尤其是H264这类压缩格式,bytesused可能远小于缓冲区长度,甚至每帧大小都不一样。
多平面格式(MPLANE)的读取方式完全不同。DQBUF时传入的不是单个v4l2_buffer,而是一个带着planes数组的结构。每个plane单独描述一块数据区域,比如NV12_8L4有两个plane:plane[0]是Y平面,plane[1]是UV平面,各自的bytesused、length、内存offset都不一样。
struct v4l2_buffer buf; struct v4l2_plane planes[2]; memset(&buf, 0, sizeof(buf)); memset(planes, 0, sizeof(planes)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE; buf.memory = V4L2_MEMORY_MMAP; buf.length = 2; // planes数组元素个数 buf.m.planes = planes; // 指向planes数组 if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { perror("VIDIOC_DQBUF"); ... } // 第一个平面的数据: planes[0].bytesused // 第二个平面的数据: planes[1].bytesused注意:MPLANE模式下,buf.type必须是V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE,且buf.length必须先设置为plane数组的长度,否则内核不知道要把多个plane的信息写到哪里。如果漏设置,DQBUF会报EINVAL,而且这个报错藏在大型日志里极难定位。
3.3 缓冲数量规划:为什么推荐4个而不是2个
缓冲区数量对整个系统的鲁棒性影响非常大。两个缓冲区理论上可以工作,但实际表现很差:一个缓冲区正在被DMA填充,另一个在应用手里等待处理,如果应用处理耗时超过一帧间隔,驱动就只能丢帧。丢帧的后果是预览卡顿,或者编码器的PTS时间轴出现断层。
常见的选法是count=4。这样即使应用处理慢了一些,队列里还有一两个空缓冲可以过渡。代价是内存占用变大——一个1080p YUYV缓冲约4MB,4个就是16MB,对嵌入式设备来说需要评估。
有一种场景需要加大缓冲数量:应用层帧处理耗时不稳定,比如AI推理偶尔卡顿几百毫秒。这时宁可多申请缓冲区,也不要在QBUFs上省。如果四个缓冲还不够,就要考虑双线程架构:采集线程只做DQBUF/QBUF,图像处理放到另一个线程。
帧率方面,有些USB摄像头默认输出30fps,但你设置了某种像素格式后变成了15fps,这通常不是缓冲不够,而是USB带宽限制或者帧间隔没有协商。可以用VIDIOC_S_PARM设置timeperframe:
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 if (ioctl(fd, VIDIOC_S_PARM, &parm) < 0) { perror("VIDIOC_S_PARM"); }S_PARM不是所有驱动都实现,调用前先看QUERYCAP里的V4L2_CAP_TIMEPERFRAME能力标志。有些驱动的帧间隔是只读的,你设置后它不改写timeperframe字段,这种情况就只能加大缓冲区来吸收抖动,没法强行提速。
3.4 时间戳、帧状态标志与丢帧判断
DQBUF返回的buf.timestamp和buf.sequence在音视频同步场景里非常关键。timestamp是驱动打上的时间戳,基准通常是系统启动以来的单调时钟(CLOCK_MONOTONIC),这在播放处理和同步时需要注意:V4L2的时间戳不是Unix墙上时间,如果要做时间对齐,需要自己转换基准。
sequence是一个持续单调递增的序号,用来检测丢帧再好不过。如果上一帧seq=100,下一帧seq=103,说明中间丢了两帧。丢帧不等于采集失败,但统计丢帧率对调试很有用。
帧状态标志buf.flags也要检查。特别是V4L2_BUF_FLAG_ERROR,表示这一帧数据有问题(比如sensor曝光异常时驱动标记的错误帧)。读取时如果发现这个标志,直接丢弃即可,不用浪费处理时间。
4. 收尾与异常处理:STREAMOFF之后还有一堆细节
采集循环结束后,清理流程同样重要,顺序错了会在下一次初始化时莫名其妙报错。
4.1 正常的停机顺序
停机三件套的固定顺序是:
enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; // 1. 停流:让驱动停止DMA搬运,恢复sensor/接口状态 ioctl(fd, VIDIOC_STREAMOFF, &type); // 2. 解除mmap映射(用之前保存的buffer_lengths) for (int i = 0; i < req.count; i++) { if (buffers[i]) { munmap(buffers[i], buffer_lengths[i]); } } // 3. 关闭设备文件描述符 close(fd);STREAMOFF之后,队列里的缓冲区状态会被驱动重置,映射并没有失效,但如果你想重新开始采集,不能直接拿着旧映射继续跑。安全的做法是STREAMOFF之后重新走一遍QBUF → STREAMON。
STREAMOFF返回后,有的驱动会把之前已入队的缓冲区清空,有的不会。为了节省排查时间,我的习惯是:每次STREAMOFF后都把buffers数组置为NULL,下次STREAMON前重新QBUF整个队列,避免在脏状态上叠加操作。
4.2 设备热拔出和采集异常
摄像头系统里最恶心的场景就是设备中途断开。USB摄像头拔了、CSI接口的sensor供电掉了,都会让poll返回异常事件。判断方法是在poll的revents里检查POLLERR或POLLHUP:
if (pfd.revents & (POLLERR | POLLHUP)) { fprintf(stderr, "设备异常断开\n"); // 触发设备重连处理 break; }另外,DQBUF返回错误时不要只盯着EINVAL,EPIPE在V4L2里经常表示流已经被中断。这种场景下的恢复流程是:STREAMOFF→close→ 重新open→ 重新协商格式 → 重新初始化缓冲区。不要试图在旧fd上直接恢复,很多驱动的状态机经不住这么折腾。
4.3 运行期间动态调整分辨率
采集过程中想切换分辨率,首先要明白一点:大多数驱动不支持在STREAMON状态下直接改S_FMT。正确顺序是:
STREAMOFF停流- 修改
v4l2_format并调用S_FMT - 重新执行REQBUFS和mmap,因为分辨率变化后缓冲区大小通常也变了,旧映射很可能不够用
- 重新QBUF、STREAMON
有人偷懒不重新REQBUFS,以为缓冲区大小差不多就行,结果在高分辨率切低分辨率后一切正常,低切高时DQBUF返回的bytesused大于缓冲区长度,读取越界。这类问题在侧面反映了V4L2的一个设计原则:缓冲区大小和格式强绑定,格式变了就得推倒重建。
5. 一个可以直接跑起来的最小例程
前面讲了很多概念,这里给一份完整的最小例程。我在调试新板子时经常就是拿这种代码先验证驱动是否出帧,确认没问题后再封装成类或模块。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <fcntl.h> #include <unistd.h> #include <errno.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <sys/poll.h> #include <linux/videodev2.h> #define WIDTH 640 #define HEIGHT 480 #define NBUF 4 int main(int argc, char *argv[]) { const char *dev = (argc > 1) ? argv[1] : "/dev/video0"; int fd = open(dev, O_RDWR); if (fd < 0) { perror("open"); return -1; } struct v4l2_capability cap; if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { perror("QUERYCAP"); goto out_close; } if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) { fprintf(stderr, "不是视频采集设备\n"); goto out_close; } struct v4l2_format fmt; memset(&fmt, 0, sizeof(fmt)); fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = WIDTH; fmt.fmt.pix.height = HEIGHT; fmt.fmt.pix.pixelformat = V4L2_PIX_FMT_YUYV; fmt.fmt.pix.field = V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { perror("S_FMT"); goto out_close; } struct v4l2_requestbuffers req; memset(&req, 0, sizeof(req)); req.count = NBUF; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0 || req.count == 0) { perror("REQBUFS"); goto out_close; } void *bufs[NBUF] = {0}; unsigned int lens[NBUF] = {0}; struct v4l2_buffer buf; for (unsigned 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("QUERYBUF"); goto out_unmap; } bufs[i] = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); if (bufs[i] == MAP_FAILED) { perror("mmap"); goto out_unmap; } lens[i] = buf.length; } for (unsigned 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("QBUF"); goto out_unmap; } } enum v4l2_buf_type btype = V4L2_BUF_TYPE_VIDEO_CAPTURE; if (ioctl(fd, VIDIOC_STREAMON, &btype) < 0) { perror("STREAMON"); goto out_unmap; } printf("开始采集 %ux%u, bytesperline=%u\n", fmt.fmt.pix.width, fmt.fmt.pix.height, fmt.fmt.pix.bytesperline); for (int n = 0; n < 100; n++) { struct pollfd pfd = { .fd = fd, .events = POLLIN }; int pret = poll(&pfd, 1, 2000); if (pret <= 0) { fprintf(stderr, "poll超时或错误: %d\n", pret); continue; } memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) continue; perror("DQBUF"); break; } printf("#%03d index=%u bytesused=%u seq=%u\n", n, buf.index, buf.bytesused, buf.sequence); // 这里可以处理 bufs[buf.index] 中的数据 if (ioctl(fd, VIDIOC_QBUF, &buf) < 0) { perror("QBUF"); break; } } ioctl(fd, VIDIOC_STREAMOFF, &btype); out_unmap: for (unsigned int i = 0; i < req.count; i++) { if (bufs[i] && bufs[i] != MAP_FAILED) { munmap(bufs[i], lens[i]); } } out_close: close(fd); return 0; }编译:
gcc -O2 -Wall -o v4l2_capture v4l2_capture.c ./v4l2_capture /dev/video0跑起来后,每行输出一帧的信息。如果能看到bytesused和seq稳定增长,说明V4L2链路已经通了,后面就可以在这个骨架上加图像处理、加编码、加预览。
6. 实战踩坑与经验汇总
最后把我在几个不同项目里调V4L2时踩过的坑集中说一下,每个坑都对应过一次真实调试经历,按主题整理给你参考。
6.1 坑一:S_FMT"静默"改写了分辨率
某次调一个MIPI接口的摄像头模组,初始化代码里写死了设置1080p,但实际采集出来的画面比例不对。后来在格式协商后打印了fmt.fmt.pix才发现,驱动只支持到720p,把我的请求悄悄降级了,而且没有任何报错。
处理办法:S_FMT调用后立即打印实际宽度、高度、像素格式和bytesperline。如果发现实际值和期望值不一致,要么接受驱动能力,要么换一种更接近需求的像素格式再协商。千万不要"设置完就不管了"。
6.2 坑二:bytesperline不等于width×bpp
这个问题比上一个更隐蔽。很多摄像头输出的YUYV数据,每行末尾会补齐若干个字节到特定的对齐值(常见是64字节对齐)。如果直接按width * 2解析,图像会往右倾斜——第一行末尾的补齐字节被误认为下一行的开头,画面完全错位。
处理办法:所有按行处理的逻辑(比如逐行拷贝、逐行缩放)一律使用fmt.fmt.pix.bytesperline,只有整帧memcpy时才能直接按bytesused操作。
6.3 坑三:单平面/多平面混用,ioctl各种EINVAL
某项目的ISP输出NV12多平面格式,驱动能力只声明了V4L2_CAP_VIDEO_CAPTURE_MPLANE。我当时沿用老的单平面代码去调,结果从S_FMT开始就报EINVAL,一度以为是驱动有问题。后来查阅驱动源码,才发现类型要换成V4L2_BUF_TYPE_VIDEO_CAPTURE_MPLANE,所有v4l2_buffer都要配合v4l2_plane数组使用。
处理办法:拿到新设备第一件事就是打印cap.capabilities和cap.device_caps,对比单平面和多平面能力,然后再决定代码走哪套分支。多平面的几个结构体细节,包括buf.length要设成plane数组长度、buf.m.planes要指向有效数组,这些都要提前确认。
6.4 坑四:处理帧太慢,队列被抽干
做推理项目时,算法帧处理偶尔会卡顿几百毫秒。一开始缓冲数量设的是2,结果卡顿恢复后poll长期超时,看起来像驱动死了。其实不是:应用手里攥着一个buffer,驱动队里一个buffer正在被DMA占用,队列里没有空buffer可用了。
处理办法:先增加缓冲区到4到6个,再在应用层做丢帧策略——如果检测到sequence跳变超过一定阈值,就主动把当前帧丢弃而不是继续排队处理。这两个措施配合起来,能把这个坑填掉80%。
6.5 坑五:排查问题时的顺序
如果设备出不了帧,别上来就抠代码。用现成工具先验证硬件链路:
# 枚举设备支持的格式和分辨率 v4l2-ctl -d /dev/video0 --list-formats-ext # 直接用mmap方式拉流验证 v4l2-ctl -d /dev/video0 --stream-mmap --stream-count=10如果命令行的--stream-mmap能正常出帧,说明驱动没问题,问题出在应用代码。如果命令行也出不了帧,再去看dmesg里驱动报的错,比如sensor初始化失败、CSI时钟异常、供电问题。这个排查顺序能帮你少走很多弯路。
工具层没问题之后,再用我第5章那个最小例程跑一跑,一般半小时内能定位到具体环节。
最后分享一个小技巧:在DQBUF和QBUF之间不要做任何耗时操作,比如网络传输、文件写入、图像格式转换,尽量只做"拿走指针、登记信息、立刻归还缓冲区"。真正的处理放到独立线程,或者用一个队列把帧序号传给下游,配合双缓冲甚至多缓冲架构。V4L2这套流程本身很稳定,绝大多数稳定性问题都出在应用层把缓冲区滞留太久。先把"快进快出"这四个字刻在脑子里,V4L2采集这块基本就稳了。