ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

不装SDK:Linux USB摄像头V4L2采集与Qt显示实战

不装SDK:Linux USB摄像头V4L2采集与Qt显示实战

简介:面向Linux平台学习V4L2与Qt摄像头开发的初中级开发者,这份工程提供USB摄像头从设备初始化、分辨率/帧率/色彩空间设置,到mmap取流、YUV转RGB、QImage显示及QTimer连续刷新的完整实践路径。代码组织包含V4L2操作封装、摄像头管理、Qt界面与主程序,并附带v4l库及转换模块,便于理解底层取流与上层UI的衔接,也可作为嵌入式或桌面采集显示的参考模板。压缩包共56个文件,以30个C源码、16个头文件、3个C++源码为主,另有ui、pro、lib及说明文档,整体约164KB,体积轻便。已有1890人学习,适合据此梳理参数配置、内存映射采集、格式转换与信号槽交互等关键环节,快速搭建可运行的摄像头采集显示示例。

1. USB摄像头采集显示:不装SDK,V4L2直采加Qt渲染能做到什么程度

手上有一台装了 Linux 的开发板或者工控机,插着一个 USB 摄像头,你只想把画面调出来看看对焦准不准、视野够不够,结果发现装 OpenCV 要编半小时,上 GStreamer 得先搞清楚一堆 plugin 名字,装完还不一定能出画。这种场景在 linux 下调摄像头太常见了,而真正的解法比想象中轻:内核里早就有一套 V4L2 视频采集接口,用户态用 ioctl 就能把帧拿到手,Qt 拿到一块内存就能画出来。这份程序走的就是这条路——V4L2 负责采集,Qt Widget 负责显示,中间只做一次像素格式转换,不依赖任何多媒体框架。适合谁?适合要在嵌入式板子上做摄像头预览、要做二次开发加录制或参数调节、又不想被大框架绑死的开发者。下面按工程骨架、跑通步骤、采集链路、显示层、踩坑、改造六段拆开讲。

2. 工程骨架拆解:采集线程、转换层、显示层怎么切

2.1 目录结构与模块职责

这套程序的分层很直白,一个采集线程、一个转换器、一个绘制控件,边界清楚到你可以单独把 V4L2 那层抠出来塞进别的项目。目录大概长这样:

usb-cam-viewer/ ├── CMakeLists.txt ├── src/ │ ├── main.cpp │ ├── mainwindow.h / mainwindow.cpp # 主窗口、按钮、状态栏 │ ├── v4l2device.h / v4l2device.cpp # ioctl 封装:打开、协商、申请缓冲 │ ├── cameraworker.h / cameraworker.cpp # 采集线程,跑 DQBUF 循环 │ ├── frameconverter.h / .cpp # YUYV / MJPEG -> RGB888 │ └── videowidget.h / videowidget.cpp # QWidget 子类,paintEvent 画 QImage └── third_party/ └── libjpeg-turbo/ # 可选,MJPEG 分支需要

v4l2device只做一件事:把裸 ioctl 包成有返回值的成员函数,出错了能告诉你错在哪一步。cameraworker继承 QThread 或者持有 QThread,里面是一个 while 循环,负责 select、DQBUF、转换、QBUF。videowidget只认 QImage,不认 V4L2,这一层解耦很关键——你想换成 QOpenGLWidget 或者 QtQuick 的 Image 都只改这一个文件。

值得说的是转换层单独成文件这件事。很多人图省事把 YUYV 转 RGB 写在采集循环里,结果一加 MJPEG 支持就得在循环里塞 if-else,再想加个 NV12 分支就彻底糊了。拆出来之后,转换函数签名固定为bool convert(const uint8_t* src, size_t len, int w, int h, uint32_t pixfmt, QImage& out),采集线程只管把 buf 和格式丢进去。

2.2 线程模型:为什么采集必须离开UI线程

V4L2 的 DQBUF 是阻塞语义(或者非阻塞但可能返回 EAGAIN 让你反复轮询),一旦你把它放在 UI 线程里跑,窗口拖动一下就卡住,鼠标点击延迟肉眼可见。常见的做法是采集跑独立线程,帧数据用信号槽丢回主线程。

信号槽这里有个必须注意的点:跨线程连接默认是Qt::AutoConnection,发射线程和目标线程不同时会自动走队列连接,参数会被拷贝进事件队列。也就是说你 emit 一个QImage,Qt 会做一次隐式共享的浅拷贝,成本很低——前提是 QImage 底层的 buffer 不被你复用。如果直接拿 mmap 的内存构造 QImage 再 emit,等主线程真去画的时候,那块内存早被重新 QBUF 给驱动写脏了。所以要么在采集线程里QImage::copy(),要么自己弄个双缓冲,我一般选前者,一帧 640×480 RGB888 才 900KB,拷贝一次对现代 CPU 来说无所谓。

// cameraworker.h —— 采集线程对外只暴露一个信号 class CameraWorker : public QObject { Q_OBJECT public slots: void start(); // 由主线程调用,进入采集循环 void stop(); // 置停止标志 signals: void frameReady(const QImage &img); // 跨线程自动走队列连接 void errorOccurred(const QString &msg); };

start()和stop()声明成 slot,是为了配合QThread::started/ 退出时用QMetaObject::invokeMethod调用,避免直接在别的线程里动成员变量。frameReady的 QImage 参数在队列连接下会被 Qt 复制一份元数据,实际像素数据还是引用计数,所以前面说的 copy 是必须的,不能省。

2.3 像素格式选型:YUYV、MJPEG、RGB的取舍

USB 摄像头(UVC 协议)能吐的格式就那么几种,常见的是 YUYV(也叫 YUY2)和 MJPEG,个别支持 NV12 或 H.264。选哪个不是喜好问题,是带宽问题。

格式每像素字节640×480@30fps 带宽1920×1080@30fps 带宽是否需要解码
YUYV2约 18 MB/s约 124 MB/s否,只需色彩转换
MJPEG压缩,帧长约 1/8~1/15约 2 MB/s约 10 MB/s是,需 libjpeg
RGB243约 27 MB/s约 186 MB/s否,但多数 UVC 不支持直接输出

USB 2.0 的实际有效带宽在 40~50 MB/s 这个量级,1080p 的 YUYV 根本传不动,驱动会自动降帧或者直接协商失败。所以在 640×480 这个分辨率下用 YUYV 最省事,不用带 libjpeg;一旦上到 720p 以上就必须切 MJPEG,然后老老实实解 JPEG。这份程序的做法是把两条路都留着,根据VIDIOC_S_FMT返回的实际pixelformat走分支,而不是自己写死一个。

顺带提一句 YUYV 的排布:每两个像素共用一组 UV,内存顺序是 Y0 U Y1 V,四字节。转换的时候一次处理两个像素,别傻乎乎地按单像素循环,那样 UV 索引会算错,画面会偏色——这个坑我后面第 5 章还会再提。

3. 环境搭建与首次跑通:从装依赖到摄像头出画

3.1 系统前提与依赖清单

只需要一个能跑 X11 或 Wayland 的桌面环境,加上 Qt 和几个开发包。Debian/Ubuntu 系的机器上一条命令能搞定大半:

sudo apt update sudo apt install -y build-essential cmake \ qtbase5-dev qtbase5-dev-tools \ libjpeg-turbo8-dev \ v4l-utils

v4l-utils不是编译依赖,但强烈建议装,里面v4l2-ctl是排查摄像头问题的第一把工具,比你自己写测试代码快得多。Qt 版本上,Qt5.12 以上都能跑,Qt6 把find_package(Qt5 ...)换成Qt6就行,Widgets 模块的 API 没变。

依赖用途是否必需
qtbase5-devQWidget、QImage、信号槽必需
libjpeg-turbo8-devMJPEG 帧解码用 MJPEG 时必需
cmake + build-essential构建必需
v4l-utilsv4l2-ctl 命令行排查强烈建议

3.2 构建配置(CMake 与 qmake 二选一)

工程用 CMake,主要是为了后面加第三方库方便。核心就几行:

cmake_minimum_required(VERSION 3.16) project(usb-cam-viewer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 就必须开,否则链接期报 vtable 未定义 find_package(Qt5 REQUIRED COMPONENTS Widgets) find_package(JPEG REQUIRED) # 不用 MJPEG 分支可以删掉这行 add_executable(usb-cam-viewer src/main.cpp src/mainwindow.cpp src/v4l2device.cpp src/cameraworker.cpp src/frameconverter.cpp src/videowidget.cpp ) target_link_libraries(usb-cam-viewer PRIVATE Qt5::Widgets JPEG::JPEG )

CMAKE_AUTOMOC ON这行漏了是新手最常见的翻车点,症状是编译能过、链接时报undefined reference to vtable for CameraWorker,看着像 C++ 问题其实是 moc 没跑。如果你更习惯 qmake,.pro文件里对应的是QT += widgets和LIBS += -ljpeg,逻辑一样。

构建流程:

mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc) ./usb-cam-viewer

3.3 设备节点与权限确认

程序启动前先确认设备在不在、格式支不支持。这两条命令基本能覆盖八成问题:

# 列出所有视频设备及其归属 v4l2-ctl --list-devices # 看某个节点支持的分辨率和像素格式 v4l2-ctl -d /dev/video0 --list-formats-ext

第二条命令的输出里,[0]: 'YUYV' (YUYV 4:2:2)和[1]: 'MJPG' (Motion-JPEG)这种条目就是驱动实际支持的格式,下面的Size: Discrete 640x480是支持的分辨率列表。程序里协商格式时,最好就照着这个列表来,别自己臆想一个 1280×720 去设,驱动会悄悄给你降到最接近的,然后你按错误的分辨率去解析内存,画面就是斜的。

权限问题也常遇到。普通用户默认不在video组里,open("/dev/video0")直接返回 EACCES:

ls -l /dev/video0 # 看属组,一般是 root:video id # 看自己在不在 video 组 sudo usermod -aG video $USER

改完组要重新登录才生效,别改完立刻跑然后怀疑人生。临时验证的话sudo ./usb-cam-viewer也行,但长期这么干不是好习惯。

3.4 无硬件时的验证路径

手上暂时没有 USB 摄像头,又想把代码跑通,可以用 v4l2loopback 造一个虚拟设备节点,然后用 ffmpeg 往里灌一段视频流。这套组合在调试转换逻辑时特别有用,因为你可以精确控制输入是什么格式、什么内容,出问题能确定是自己的代码而不是摄像头固件。

sudo apt install v4l2loopback-dkms v4l2loopback-utils sudo modprobe v4l2loopback video_nr=10 card_label="FakeCam" exclusive_caps=1 # 此时会多出 /dev/video10,用 ffmpeg 循环推一段测试视频进去 ffmpeg -re -stream_loop -1 -i test.mp4 -pix_fmt yuyv422 -f v4l2 /dev/video10

程序里把设备路径改成/dev/video10就能出画。exclusive_caps=1这个参数别省,不加的话节点会同时报告 capture 和 output 能力,有些程序会认错方向。

第一次跑通的目标很朴素:窗口出来、画面在动、CPU 占用不超过一个核。如果画面出来了但很卡,先别改代码,用top看是采集线程吃满还是 UI 线程吃满,这两个方向的排查路径完全不同。

4. V4L2采集链路:一次open到DQBUF的完整调用

4.1 打开设备与能力查询

V4L2 的调用是有严格顺序的,跳步基本都会失败。第一步是 open 加 QUERYCAP,确认这个节点到底是采集设备还是输出设备、支不支持流式 IO。

// v4l2device.cpp bool V4l2Device::open(const std::string &path) { // O_NONBLOCK 让后续 DQBUF 不会死等,配合 select 使用 fd_ = ::open(path.c_str(), O_RDWR | O_NONBLOCK, 0); if (fd_ < 0) { lastError_ = "open " + path + " failed: " + strerror(errno); return false; } v4l2_capability cap{}; if (ioctl(fd_, VIDIOC_QUERYCAP, &cap) < 0) { lastError_ = "VIDIOC_QUERYCAP failed"; return false; } // 两个能力位缺一不可:能采集 + 支持 streaming IO const bool canCapture = cap.capabilities & V4L2_CAP_VIDEO_CAPTURE; const bool canStream = cap.capabilities & V4L2_CAP_STREAMING; if (!canCapture || !canStream) { lastError_ = "device does not support video capture streaming"; return false; } return true; }

V4L2_CAP_VIDEO_CAPTURE判断的是传统单平面采集,如果摄像头是 M2M 或者多平面设备,能力位会是V4L2_CAP_VIDEO_CAPTURE_MPLANE,这时候v4l2_buffer的结构用法都不一样。USB 摄像头基本都是单平面,按上面写没问题,但接手别人代码时看到MPLANE别懵。

O_NONBLOCK这个标志值得单独说。不加的话VIDIOC_DQBUF会在没有帧的时候一直阻塞,你的停止按钮就失效了;加了之后要自己用select等可读,否则就是 CPU 空转。这份程序选的是非阻塞加 select,控制粒度更细。

4.2 格式协商:VIDIOC_S_FMT 的谈判逻辑

设置格式这一步最容易想当然。你以为你设了 640×480 YUYV,驱动就一定给你这个,实际上驱动可能因为带宽或硬件限制改成别的值,而且它会通过同一个结构体把实际值返回给你。不读返回值就是给自己埋雷。

bool V4l2Device::setFormat(int &width, int &height, uint32_t &pixfmt) { v4l2_format fmt{}; fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = width; fmt.fmt.pix.height = height; fmt.fmt.pix.pixelformat = pixfmt; fmt.fmt.pix.field = V4L2_FIELD_NONE; // USB 摄像头都是逐行 if (ioctl(fd_, VIDIOC_S_FMT, &fmt) < 0) { lastError_ = "VIDIOC_S_FMT failed"; return false; } // 关键:把驱动实际接受的值回写给调用方 width = fmt.fmt.pix.width; height = fmt.fmt.pix.height; pixfmt = fmt.fmt.pix.pixelformat; bytesPerLine_ = fmt.fmt.pix.bytesperline; // MJPEG 下可能是 0 sizeImage_ = fmt.fmt.pix.sizeimage; return true; }

参数用引用传,是因为函数内部要改。调用方拿到回写后的pixfmt再去选转换分支,这才是正确姿势。bytesperline在 YUYV 下一般等于width * 2,但在 MJPEG 下经常是 0,因为压缩帧没有"行跨度"的概念,这时候只能靠buf.bytesused判断帧长度。

V4L2_FIELD_NONE表示不隔行。有些老摄像头默认 field 是V4L2_FIELD_INTERLACED,你设成 NONE 之后驱动可能拒绝并保留原值,画面就会出现横向撕裂条纹。这种情况在v4l2-ctl --list-formats-ext里能看到 field 标记,遇到撕裂先查这里。

4.3 缓冲区申请与mmap映射

格式定下来之后申请缓冲区。V4L2 支持几种内存模式,USB 摄像头场景用V4L2_MEMORY_MMAP最合适:驱动在内核里分配缓冲,用户态 mmap 到自己地址空间,零拷贝。

bool V4l2Device::requestBuffers(unsigned count) { v4l2_requestbuffers req{}; req.count = count; // 一般 4 个够用,太少会丢帧 req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(fd_, VIDIOC_REQBUFS, &req) < 0) return false; if (req.count < 2) return false; // 驱动至少给 2 个,否则没法周转 buffers_.resize(req.count); for (unsigned i = 0; i < req.count; ++i) { v4l2_buffer buf{}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; if (ioctl(fd_, VIDIOC_QUERYBUF, &buf) < 0) return false; // length 和 offset 必须来自 QUERYBUF,不能自己算 void *p = mmap(nullptr, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd_, buf.m.offset); if (p == MAP_FAILED) return false; buffers_[i].start = static_cast<uint8_t *>(p); buffers_[i].length = buf.length; } return true; }

req.count传进去是"我想要几个",出来是"实际给了几个",以出来的为准。buf.length和buf.m.offset一定要用VIDIOC_QUERYBUF填回来的值,自己按width*height*2算长度在某些驱动上会短一截,mmap 之后访问越界就是段错误,而且这种错误在调试器里极难定位。

缓冲数量选 4 是个经验值。少于 3 个,你在处理当前帧的时候驱动没地方写下一帧,就会丢帧;太多也没意义,只会增加内存占用和延迟。高帧率场景可以加到 6。

4.4 采集循环:QBUF、DQBUF、重新入队

缓冲区映射完之后,先把所有 buffer 全部入队,再开流,然后进入循环。

void CameraWorker::run() { // 1. 所有缓冲先入队,让驱动有地方写 for (unsigned i = 0; i < dev_.bufferCount(); ++i) { v4l2_buffer buf{}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; buf.index = i; ioctl(dev_.fd(), VIDIOC_QBUF, &buf); } // 2. 开流,摄像头开始出数据 v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(dev_.fd(), VIDIOC_STREAMON, &type); while (!stopRequested_) { fd_set fds; FD_ZERO(&fds); FD_SET(dev_.fd(), &fds); timeval tv{0, 200000}; // 200ms 超时,用来周期性检查停止标志 int r = select(dev_.fd() + 1, &fds, nullptr, nullptr, &tv); if (r < 0) { if (errno == EINTR) continue; break; } if (r == 0) continue; // 超时,没有帧,回去看停止标志 v4l2_buffer buf{}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(dev_.fd(), VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) continue; emit errorOccurred("VIDIOC_DQBUF failed"); break; } // buf.index 指向 buffers_[buf.index],buf.bytesused 是这一帧的有效字节数 QImage img; if (converter_.convert(dev_.buffers()[buf.index].start, buf.bytesused, width_, height_, pixfmt_, img)) { emit frameReady(img); // img 内部已 copy,安全跨线程 } // 处理完立刻还回去,让驱动继续填 ioctl(dev_.fd(), VIDIOC_QBUF, &buf); } ioctl(dev_.fd(), VIDIOC_STREAMOFF, &type); }

几个参数的意义得说清楚:bytesused是这一帧实际写入的字节数,YUYV 下通常等于width*height*2,MJPEG 下就是压缩后的大小,动态变化。buf.index是缓冲区下标,取值范围 0 到count-1,用它去索引自己维护的 buffers 数组。

select的超时设 200ms 是有讲究的。设成 nullptr 就一直阻塞,按停止按钮没反应;设太短比如 10ms,循环次数飙升,CPU 空转。200ms 意味着最坏情况下停止响应延迟 200 毫秒,人感觉不到,同时 CPU 也不会因为空转被吃掉。

循环退出之后一定要VIDIOC_STREAMOFF,否则下一次VIDIOC_STREAMON会返回 EBUSY。这个顺序在调试期间被反复打断时特别容易忘。

4.5 格式转换:YUYV转RGB与MJPEG解码分支

转换层的入口根据pixfmt分发。YUYV 走手写转换,MJPEG 走 libjpeg-turbo,其他格式返回失败让上层知道。

bool FrameConverter::convert(const uint8_t *src, size_t len, int w, int h, uint32_t pixfmt, QImage &out) { if (pixfmt == V4L2_PIX_FMT_YUYV) { // YUYV 每 2 像素占 4 字节,所以总长度是 w*h*2 if (len < static_cast<size_t>(w) * h * 2) return false; out = QImage(w, h, QImage::Format_RGB888); yuyvToRgb888(src, out.bits(), w, h); return true; } if (pixfmt == V4L2_PIX_FMT_MJPEG) { return decodeMjpeg(src, len, out); // 内部用 jpeg_mem_src + jpeg_read_scanlines } return false; } // 整数近似:1.402*256=359, 0.344*256=88, 0.714*256=183, 1.772*256=454 static inline uint8_t clamp8(int v) { return static_cast<uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v)); } void FrameConverter::yuyvToRgb888(const uint8_t *src, uint8_t *dst, int w, int h) { const int pixels = w * h; for (int i = 0, p = 0; i < pixels; i += 2, p += 4) { const int y0 = src[p + 0]; const int u = src[p + 1] - 128; const int y1 = src[p + 2]; const int v = src[p + 3] - 128; const int c0 = 359 * v; const int c1 = 88 * u + 183 * v; const int c2 = 454 * u; // 第一个像素 dst[i * 3 + 0] = clamp8(y0 + ((c0) >> 8)); dst[i * 3 + 1] = clamp8(y0 - ((c1) >> 8)); dst[i * 3 + 2] = clamp8(y0 + ((c2) >> 8)); // 第二个像素,复用同一组 UV dst[i * 3 + 3] = clamp8(y1 + ((c0) >> 8)); dst[i * 3 + 4] = clamp8(y1 - ((c1) >> 8)); dst[i * 3 + 5] = clamp8(y1 + ((c2) >> 8)); } }

转换公式里u、v先减 128 是因为 YUV 的色度分量以 128 为零点。用整数移位代替浮点乘,单帧 640×480 的转换耗时从 4 毫秒降到 1 毫秒左右,别小看这几毫秒,30fps 下就是 10% 的 CPU。

QImage(w, h, QImage::Format_RGB888)创建的时候每行会按 4 字节对齐,bytesPerLine可能大于w*3。上面代码直接按w*3步进写dst,在w是 4 的倍数时没问题(640、1280 都满足),但如果宽度是 641 这种就会错行。稳妥写法是每次用out.scanLine(y)取行首指针,这点在第 5 章讲显示层时再展开。

MJPEG 分支用 libjpeg-turbo 的jpeg_mem_src,输入是内存块而不是文件,返回的是 RGB 或 YCbCr。解出来的out也要保证内存对齐,通常是先解到一个std::vector<uint8_t>再拷贝进 QImage。

5. 黑屏、花屏、卡死排查:从显示层反推的5条踩坑记录

5.1 QImage包装与深拷贝:显示层最容易翻车的一处

显示控件本身很简单,paintEvent里把当前帧画到中央,按比例缩放。

void VideoWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::black); if (frame_.isNull()) return; // KeepAspectRatio 保证不拉伸,画面比例和实际一致 const QSize target = frame_.size().scaled(size(), Qt::KeepAspectRatio); const QRect dst(QPoint((width() - target.width()) / 2, (height() - target.height()) / 2), target); painter.setRenderHint(QPainter::SmoothPixmapTransform, false); // 关掉平滑,省 CPU painter.drawImage(dst, frame_); } void VideoWidget::onFrameReady(const QImage &img) { frame_ = img; // img 已在采集线程 copy 过,这里赋值只是引用计数 +1 update(); // 请求重绘,不要在这里直接 paintEvent }

SmoothPixmapTransform关掉是有意的。开着的话每次缩放都走双线性插值,1080p 缩到 800 宽在低端 ARM 板上能吃掉半个核;关掉走最近邻,画质差一点但预览够用。真要高质量显示,正确做法是在采集端就缩好再送过来。

update()只是标记需要重绘,Qt 会把多次调用合并成一次paintEvent,所以高频调用它本身不是灾难,但它会触发事件循环调度,每帧调一次还是有开销。

5.2 刷新节流:定时器与采集帧率解耦

采集帧率和显示刷新率没必要绑死。摄像头 30fps,屏幕 60Hz,你只需要在屏幕刷新的时候拿"最新的一帧"就行,中间那些帧该丢就丢。做法是维护一个带锁的最新帧槽位,UI 侧用 QTimer 定时取。

// 采集线程侧:只更新槽位,不直接触发重绘 void CameraWorker::onFrameConverted(const QImage &img) { QMutexLocker lock(&mutex_); latest_ = img; // 引用计数赋值,开销极小 hasNew_ = true; } // UI 侧:30ms 取一次,和采集帧率解耦 void VideoWidget::pullFrame() { QMutexLocker lock(&worker_->mutex()); if (!worker_->hasNewFrame()) return; frame_ = worker_->takeLatest(); update(); }

QMutexLocker是 RAII 写法,作用域结束自动解锁,比手动 lock/unlock 安全。锁的范围要尽可能小,只包住赋值和取值的几行,别把转换也包进去——那样采集线程就被 UI 线程的绘制节奏卡住了。

30ms 对应约 33fps 的上限,比摄像头 30fps 稍快,能保证不丢帧的同时又不会空转。如果你的采集是 15fps,这个值可以放到 60ms,省一半的事件循环唤醒。

5.3 五条踩坑记录:现象、原因、解决

一、打开设备报 Device or resource busy。现象是open返回 -1,errno 是 EBUSY。原因通常是别的进程已经占着这个节点,桌面环境自带的相机预览、浏览器里的视频会议页面、甚至之前没退干净的自己都有可能。解决:fuser -v /dev/video0看谁占着,kill掉;或者直接换个节点,很多机器上/dev/video0是笔记本内置摄像头,USB 外接的是/dev/video2。用v4l2-ctl --list-devices能看清每个节点对应哪个物理设备。

二、DQBUF 一直返回,但画面全黑。现象是循环正常跑,bytesused也有值,转换函数也不报错,就是画面黑的。原因多半是格式协商没读回实际值:你按 YUYV 解析,驱动实际给的是 MJPEG,解出来就是一片噪点或者全黑。解决:在setFormat之后打印一次实际返回的pixfmt和分辨率,和v4l2-ctl --list-formats-ext的输出对一遍。还有个可能是摄像头有镜头盖或者补光没开,这种低级错误真发生过,排查顺序上先看一眼物理设备再动代码。

三、画面花屏、撕裂、下半截是上一帧。现象是画面出现横向条纹或者上下两部分内容不一致。原因有两个层次:一是 QImage 没做深拷贝,采集线程已经把 buffer 还给驱动了,UI 线程还在读,读到的是驱动正在写的新数据;二是paintEvent里用了scanLine之外的裸指针步进,宽度不是 4 的倍数时错行。解决:转换完成后立即img.copy(),或者在转换函数内部就写进独立分配的 QImage,别拿 mmap 内存直接构造。宽度对齐的问题统一改用scanLine(y)。

四、跑几分钟之后 UI 卡死、内存一直涨。现象是启动正常,几分钟后窗口点不动,top看内存曲线一路上升。原因是队列连接的信号发得太快,主线程处理不过来,事件队列无限堆积,每个事件里还带着一个 QImage。解决:加 5.2 那套节流,采集线程只更新最新帧槽位,不 emit 到 UI;或者用Qt::DirectConnection加手动加锁,但那样就把绘制工作挤到采集线程了,不推荐。判断方法很简单,在onFrameReady里打个计数器,如果每秒调用次数远超摄像头帧率,就是堆积了。

五、停止按钮点了没反应,程序退不出去。现象是点关闭窗口,进程还在后台。原因是采集循环阻塞在select或者DQBUF上,stopRequested_标志改了但循环体没机会检查。解决:select一定要带超时(前面写的 200ms 就是干这个的),DQBUF用非阻塞模式,每次循环开头检查停止标志。另外退出时要保证VIDIOC_STREAMOFF和munmap都执行到,否则下次启动会报 EBUSY,这个错误信息会误导你去查权限,实际是上次没清理干净。

6. 二次开发:加录制、加参数面板、加多摄头的改造路径

拿到这套骨架之后,最常被提的三个需求是录制、调参数、多路。三个方向的改造点不一样,我按投入产出比排一下。

加录制最省事的做法不是接 FFmpeg,而是先把转换后的 RGB 帧按 y4m 裸流写文件——y4m 就是加了一个纯文本头的原始帧序列,播放器认,写起来只要几十行。等确认录制逻辑没问题了,再考虑上 libavcodec 压成 H.264。落盘一定要放独立线程,别在采集线程里fwrite,U 盘或者 SD 卡的写入抖动会直接把采集帧率拖垮。

加参数面板走 V4L2 的控制项接口,VIDIOC_QUERYCTRL枚举设备支持哪些控制(亮度、对比度、曝光模式、白平衡),VIDIOC_G_CTRL/VIDIOC_S_CTRL读写。注意不是所有摄像头都支持曝光手动调节,枚举的时候用V4L2_CTRL_FLAG_DISABLED过滤掉不可用的,界面上把滑块置灰,比让用户拖了没反应体验好得多。自动曝光和手动曝光是两个不同的 control id,切换的时候要先关自动再设手动,顺序反了会被驱动拒绝。

加多摄像头的结构改动最小:把CameraWorker的构造参数里加上设备路径,每个设备一个 worker 实例、一个VideoWidget,主窗口用网格布局排开。要注意的是 USB 控制器的带宽是共享的,两个 1080p MJPEG 挂在同一个 USB 2.0 控制器上大概率跑不满,v4l2-ctl --list-devices的输出里会显示设备挂在哪条 bus 上,同 bus 的设备要错开分辨率。多路场景下转换线程数也要控制,四路 1080p YUYV 转 RGB 能把四核 ARM 吃满。

验证改动是否正确的习惯,我一般是这样:先用v4l2-ctl --stream-mmap --stream-count=100 -d /dev/video0单独测采集,确认驱动层面没问题,再跑自己的程序。这样一旦出问题,能立刻判断是 V4L2 那层还是上层逻辑。另外每次改完转换函数,我都会拿一张标准色卡对着摄像头拍一下,看红绿蓝三个色块的颜色是否正,比盯着人脸看靠谱得多——色偏这种东西,肉眼对着自然场景根本判断不出来。

这套程序的价值不在于它现在能干什么,而在于它把 V4L2 那堆 ioctl 的调用顺序和参数依赖关系摊开摆在了你面前,改哪一步、错哪一步都有明确的错误码和现象对应。从那以后我每次接新的摄像头,都先用v4l2-ctl --list-formats-ext把能力摸清楚再动手写代码,这一步花两分钟,能省掉后面半天的瞎猜。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进