简介:这是一份面向Linux嵌入式与桌面开发者的USB摄像头采集显示工程源码,围绕V4L2设备操作与Qt界面联调,帮助读者打通视频采集、图像格式转换和实时预览链路,适合课程设计、项目原型及驱动与GUI交叉学习。压缩包共56个文件、约164KB,以30个C源文件和16个头文件为主体,另有C++、ui、pro工程配置及libv4l相关库文件,并附README、ChangeLog与TODO说明,便于按设备管理、取流封装、界面显示和库适配几个层次阅读代码。目前已有1890人学习下载。工程覆盖/dev/video0初始化、分辨率与帧率设置、mmap取流、YUV转RGB以及QTimer定时刷新显示,也包含摄像头参数调整等交互处理;对V4L2 ioctl调用、缓冲区管理和Qt信号槽刷新等关键环节均有源码可查,并复现实验现象,便于快速定位问题与裁剪移植,可作为联合调试、排错和二次开发的对照样例。
1. 从一块开发板上的雪花屏说起:linux V4L2 Qt usb 摄像头采集链路能解决什么
一块开发板插上 USB 摄像头,/dev/video0明明在,Qt 窗口却只给雪花屏。linux 下用 V4L2 抓 usb 摄像头原始帧,再用 Qt 把画面贴到窗口里,这条链路看着短,真跑起来常卡在格式协商、mmap 和线程刷新上。这份工程把采集、转换、显示拆成三层,适合刚接触 V4L2 的 Qt 开发者,也适合要把摄像头接进工控界面的老手做底稿。
它不是只跑一次的玩具 Demo:缓冲区队列、EAGAIN 处理、拔插释放都留了可改的位置。你拿到源码后,先确认摄像头支持哪种像素格式,再决定是直接 YUYV 转 RGB,还是先走 JPEG 解码。下面按工程骨架、采集核心、显示层、避坑和二次开发拆开讲。
2. 工程骨架与数据流:V4L2 取帧、Qt 渲染、线程边界怎么划
2.1 目录结构与构建方式:qmake 和 CMake 各留一条路
这份工程不大,但模块边界要清。采集相关代码只碰 V4L2 和缓冲区,显示相关代码只碰 QWidget 和 QImage,中间用信号槽传一帧图像。目录我一般按下面这样放,后面加录像、加多路摄像头都不容易乱。
| 路径 | 作用 |
|---|---|
src/main.cpp | 创建 QApplication,拉起主窗口 |
src/mainwindow.h/.cpp | 主窗口、工具栏、显示区域、打开/关闭设备 |
src/v4l2camera.h/.cpp | V4L2 打开、格式设置、缓冲区、采集循环 |
src/yuyv.h/.cpp | YUYV 转 RGB,单独放便于替换 |
CMakeLists.txt | CMake 构建入口 |
usb_cam.pro | qmake 构建入口 |
qmake 的.pro文件保持最小依赖,V4L2 的头文件在 Linux 内核头里,不需要额外链接库。Qt6 和 Qt5 的写法只差QT += widgets和find_package的目标名,源码里用QWidget、QImage、QPainter这些基础类,不碰 QtMultimedia,移植时少一层坑。
QT += core gui widgets CONFIG += c++17 TARGET = usb_cam_viewer TEMPLATE = app SOURCES += \ src/main.cpp \ src/mainwindow.cpp \ src/v4l2camera.cpp \ src/yuyv.cpp HEADERS += \ src/mainwindow.h \ src/v4l2camera.h \ src/yuyv.h这段.pro只做三件事:声明 Qt 模块、指定 C++17、把采集和显示分开编译。QT += widgets不能省,QApplication和QWidget都在里面;CONFIG += c++17是为了用std::vector、std::atomic这些标准库设施,不依赖编译器默认值。V4L2 的ioctl、mmap、select来自系统头,不需要在.pro里写-lv4l2,写了反而在部分环境里找不到库。
CMake 版本对应写一份,方便在 Qt6 环境里直接构建:
cmake_minimum_required(VERSION 3.16) project(usb_cam_viewer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) find_package(Qt6 COMPONENTS Widgets REQUIRED) add_executable(usb_cam_viewer src/main.cpp src/mainwindow.cpp src/v4l2camera.cpp src/yuyv.cpp ) target_link_libraries(usb_cam_viewer PRIVATE Qt6::Widgets)CMake 里find_package(Qt6 COMPONENTS Widgets REQUIRED)是硬门槛,找不到就直接停,不要让它悄悄编出一个没有界面的空壳。target_link_libraries只链Qt6::Widgets,V4L2 部分仍然走系统调用。Qt5 环境把Qt6换成Qt5,Qt6::Widgets换成Qt5::Widgets即可,源码不用改。
提示:先跑
cmake -S . -B build看 Qt 版本是否匹配,再cmake --build build -j。如果报linux/videodev2.h找不到,装内核头文件包,不要急着改源码。
2.2 从/dev/video0到 QImage:一次采集的数据流
一次完整的取帧链路是:打开设备节点,查询能力,设置格式,申请内核缓冲区,mmap到用户态,入队,开流,select等可读,DQBUF取出一帧,转成 QImage,发信号给 UI,最后QBUF把缓冲区还给驱动。这个顺序不能乱,尤其是QBUF必须在使用完数据之后,否则驱动会复写同一块内存,画面就会撕裂或花屏。
采集类对外只暴露几个动作:openDevice()、start()、stop()、closeDevice(),以及一个frameReady(QImage)信号。UI 不需要知道 V4L2 的缓冲区索引,也不需要知道mmap指针。这样后面换像素格式、换分辨率,只改采集类内部。
class V4L2Camera : public QObject { Q_OBJECT public: explicit V4L2Camera(QObject *parent = nullptr); ~V4L2Camera() override; bool openDevice(const QString &devPath); bool start(); void stop(); void closeDevice(); signals: void frameReady(const QImage &image); void errorText(const QString &msg); private: int m_fd = -1; bool m_running = false; int m_width = 640; int m_height = 480; int m_bytesPerLine = 0; quint32 m_pixelFormat = 0; struct Buffer { void *start = nullptr; size_t length = 0; }; std::vector<Buffer> m_buffers; };这段声明把状态收在类里:m_fd是设备句柄,m_buffers保存每个mmap的起始地址和长度,m_pixelFormat记录驱动实际协商出来的格式。frameReady用QImage传值,Qt 的隐式共享会让拷贝很轻,但跨线程时仍然建议在发送前确保数据已经脱离mmap区域。errorText单独走信号,避免在采集线程里直接弹窗。
数据流的关键是“谁拥有这一帧”。mmap出来的内存在QBUF之前归用户态使用,QBUF之后归驱动。把QImage直接绑到mmap指针上,看起来省一次拷贝,实际很容易翻车:UI 还没画,采集线程已经把缓冲区还回去了。稳妥做法是在转换函数里新建QImage,把像素写进去,再发信号。
2.3 线程模型:采集线程和 UI 线程各干什么
V4L2 的DQBUF是阻塞或半阻塞的,放在 UI 线程里会让窗口拖不动。工程里把采集对象moveToThread到一个QThread,线程入口跑采集循环,UI 线程只负责paintEvent和按钮响应。跨线程信号默认走队列连接,QImage会被复制到接收线程的事件循环里,不需要手动加锁。
m_camera = new V4L2Camera; m_cameraThread = new QThread(this); m_camera->moveToThread(m_cameraThread); connect(m_cameraThread, &QThread::started, m_camera, [this] { if (!m_camera->openDevice("/dev/video0")) { emit m_camera->errorText("open /dev/video0 failed"); return; } m_camera->start(); }); connect(m_camera, &V4L2Camera::frameReady, this, &MainWindow::onFrameReady, Qt::QueuedConnection); connect(m_camera, &V4L2Camera::errorText, this, &MainWindow::showError, Qt::QueuedConnection); m_cameraThread->start();这段代码的重点在moveToThread和Qt::QueuedConnection。moveToThread之后,V4L2Camera的槽函数在采集线程执行,构造和析构仍然要回到对象所属线程处理。frameReady用队列连接,UI 线程拿到的是复制后的QImage,不会和采集线程抢mmap缓冲区。关闭时先让采集循环退出,再quit()、wait(),最后deleteLater(),顺序反过来会出现线程还在跑、对象已经销毁的崩溃。
缓冲区别开“内核队列”和“用户队列”。内核队列由VIDIOC_QBUF维护,用户态只需要记住每个index对应的mmap地址。采集循环里不要对同一个index连续DQBUF两次,也不要在没QBUF的情况下重新STREAMON。我一般把m_running做成std::atomic_bool,UI 点关闭时置false,采集线程在下一次select超时或EAGAIN时退出,避免强杀线程。
3. V4L2 采集核心:open、S_FMT、REQBUFS、mmap、DQBUF 逐段拆
3.1 打开设备与能力检查:QUERYCAP 里看什么
打开设备用O_RDWR | O_NONBLOCK,非阻塞模式配合select使用,避免DQBUF永久卡住。VIDIOC_QUERYCAP返回的能力位里,至少要看三件事:是不是视频采集设备、是不是支持流式 I/O、设备节点是不是 capture 类型。USB 摄像头通常支持V4L2_CAP_VIDEO_CAPTURE和V4L2_CAP_STREAMING,如果只支持READWRITE,这套mmap队列就跑不通,需要换读取方式。
bool V4L2Camera::openDevice(const QString &devPath) { m_fd = ::open(devPath.toLocal8Bit().constData(), O_RDWR | O_NONBLOCK, 0); if (m_fd < 0) { emit errorText(QString("open failed: %1").arg(QString::fromLocal8Bit(strerror(errno)))); return false; } v4l2_capability cap{}; if (::ioctl(m_fd, VIDIOC_QUERYCAP, &cap) < 0) { emit errorText("VIDIOC_QUERYCAP failed"); closeDevice(); return false; } const quint32 caps = (cap.capabilities & V4L2_CAP_DEVICE_CAPS) ? cap.device_caps : cap.capabilities; if (!(caps & V4L2_CAP_VIDEO_CAPTURE)) { emit errorText("not a video capture device"); closeDevice(); return false; } if (!(caps & V4L2_CAP_STREAMING)) { emit errorText("device does not support streaming I/O"); closeDevice(); return false; } return true; }这段代码先拿到fd,再用QUERYCAP判断设备类型。cap.capabilities和cap.device_caps的区别在于:前者是物理设备能力,后者是当前节点能力,多节点设备上以device_caps为准。V4L2_CAP_VIDEO_CAPTURE对应单平面采集,V4L2_CAP_VIDEO_CAPTURE_MPLANE对应多平面,USB 摄像头多数是前者。失败时不要只打印“打开失败”,把errno和strerror带出来,权限问题、设备被占用、节点不存在才能分得清。
注意:
O_NONBLOCK打开后,DQBUF可能返回EAGAIN,这不是错误,表示当前没有新帧。采集循环里遇到EAGAIN应该继续等,不要直接退出。
3.2 设置格式:宽高、像素格式与帧率
格式设置是整条链路最容易出分歧的一步。你请求 640×480 的 YUYV,驱动不一定照单全收,它可能改成 640×480 的 MJPEG,也可能把bytesperline对齐到更大值。所以VIDIOC_S_FMT之后必须回读v4l2_format,以驱动返回的width、height、pixelformat、bytesperline为准,不能拿请求值硬算。
bool V4L2Camera::setFormat(int width, int height) { v4l2_format 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_ANY; if (::ioctl(m_fd, VIDIOC_S_FMT, &fmt) < 0) { emit errorText("VIDIOC_S_FMT failed"); return false; } m_width = fmt.fmt.pix.width; m_height = fmt.fmt.pix.height; m_pixelFormat = fmt.fmt.pix.pixelformat; m_bytesPerLine = fmt.fmt.pix.bytesperline; if (m_bytesPerLine <= 0) { m_bytesPerLine = m_width * 2; // YUYV 每像素 2 字节 } return true; }这段代码请求 YUYV、宽高由调用方传入,field用V4L2_FIELD_ANY让驱动自己选。S_FMT成功后回读四个值:实际宽、实际高、实际像素格式、每行字节数。bytesperline可能大于width * 2,尤其是宽度不是 16 的倍数时,转换函数必须按行跨度走,不能假设像素紧挨着。pixelformat回读后要判断是不是V4L2_PIX_FMT_YUYV,如果不是,后面的转换函数就不能直接用。
帧率用VIDIOC_S_PARM设置,但 USB 摄像头对帧率的支持参差不齐,设不上就接受驱动默认值。更稳的做法是先VIDIOC_ENUM_FMT和VIDIOC_ENUM_FRAMESIZES枚举支持项,再挑一个最接近的。命令行里用v4l2-ctl --list-formats-ext -d /dev/video0能直接看到设备支持的分辨率和帧率,比在代码里猜快得多。
3.3 申请缓冲区与 mmap:内核队列和用户态指针
缓冲区数量我一般取 4。太少容易丢帧,太多占内存且增加延迟。VIDIOC_REQBUFS里memory必须写V4L2_MEMORY_MMAP,count是请求数量,驱动可能返回比请求少的数量,后面按实际req.count循环。每个缓冲区先VIDIOC_QUERYBUF拿到length和m.offset,再mmap,最后立刻VIDIOC_QBUF入队,等STREAMON之后驱动才有帧可填。
bool V4L2Camera::initBuffers() { v4l2_requestbuffers req{}; req.count = 4; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (::ioctl(m_fd, VIDIOC_REQBUFS, &req) < 0 || req.count < 2) { emit errorText("VIDIOC_REQBUFS failed"); return false; } m_buffers.resize(req.count); for (uint32_t 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(m_fd, VIDIOC_QUERYBUF, &buf) < 0) { emit errorText("VIDIOC_QUERYBUF failed"); return false; } m_buffers[i].length = buf.length; m_buffers[i].start = ::mmap(nullptr, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, m_fd, buf.m.offset); if (m_buffers[i].start == MAP_FAILED) { m_buffers[i].start = nullptr; emit errorText("mmap failed"); return false; } if (::ioctl(m_fd, VIDIOC_QBUF, &buf) < 0) { emit errorText("VIDIOC_QBUF failed"); return false; } } return true; }这段代码做四件事:申请内核缓冲区、逐个查询长度和偏移、映射到用户态、把缓冲区入队。req.count小于 2 就没法形成队列,直接失败。mmap的offset必须用buf.m.offset,不能自己算;MAP_SHARED不能换成MAP_PRIVATE,否则驱动写进去的帧用户态看不到。每个mmap成功后要立刻QBUF,否则STREAMON之后驱动手里没有可用缓冲区,select一直不返回。
释放顺序反过来:先STREAMOFF,再munmap,最后close(fd)。munmap的长度用保存下来的length,不要用当前分辨率算出来的sizeimage。如果中途某一步失败,已经映射的缓冲区要逐个释放,不能直接return false把fd和映射漏掉。
3.4 采集循环:QBUF/DQBUF 的配对与 EAGAIN 处理
采集循环是整套代码的心脏。select等设备可读,超时时间给 2 秒,超时后检查m_running决定是否继续。DQBUF取出一帧后,用buf.index找到对应的mmap地址,buf.bytesused是这一帧实际字节数,转换时以它和bytesperline为准。处理完立刻QBUF归还,保证队列里始终有缓冲区可用。
void V4L2Camera::captureLoop() { v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; if (::ioctl(m_fd, VIDIOC_STREAMON, &type) < 0) { emit errorText("VIDIOC_STREAMON failed"); return; } while (m_running) { fd_set fds; FD_ZERO(&fds); FD_SET(m_fd, &fds); timeval tv{2, 0}; int ret = ::select(m_fd + 1, &fds, nullptr, nullptr, &tv); if (ret < 0) { if (errno == EINTR) continue; break; } if (ret == 0) continue; // 超时,回到循环顶部检查 m_running v4l2_buffer buf{}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (::ioctl(m_fd, VIDIOC_DQBUF, &buf) < 0) { if (errno == EAGAIN) continue; if (errno == ENODEV) break; // 设备被拔掉 break; } QImage image = yuyvToRgb( static_cast<const uchar *>(m_buffers[buf.index].start), m_width, m_height, m_bytesPerLine); if (!image.isNull()) { emit frameReady(image); } if (::ioctl(m_fd, VIDIOC_QBUF, &buf) < 0) { break; } } ::ioctl(m_fd, VIDIOC_STREAMOFF, &type); }这段循环先STREAMON,然后反复select、DQBUF、转换、发信号、QBUF。select返回 0 表示超时,不是错误,继续下一轮;返回负数且errno是EINTR就重试。DQBUF遇到EAGAIN继续等,遇到ENODEV说明设备已经不在,必须跳出循环并走清理。frameReady发出去的QImage是转换函数新建的,不依赖mmap指针,QBUF之后驱动复写缓冲区也不会影响 UI 正在画的帧。
QBUF失败通常意味着队列状态已经乱了,继续采集没有意义,直接退出循环。退出前STREAMOFF是必须的,否则重新打开设备时可能报设备忙。清理函数里再统一munmap和close,不要在循环里顺手关fd,否则后面的STREAMOFF会作用在无效句柄上。
4. Qt 显示层:QImage 绑定、YUYV 转 RGB 与刷新节奏
4.1 YUYV 转 RGB:查表与逐像素的取舍
YUYV 每两个像素共用一组 U、V,排列是 Y0 U Y1 V。转换公式不复杂,但每帧 640×480 要算三十万次,直接写浮点乘加在低端开发板上会吃掉不少 CPU。工程里先用直观的浮点实现保证正确,再按需换成整数近似或查表。无论哪种写法,都要按bytesperline逐行跳,不能假设每行正好width * 2字节。
static inline uchar clamp8(int v) { return static_cast<uchar>(v < 0 ? 0 : (v > 255 ? 255 : v)); } QImage yuyvToRgb(const uchar *src, int width, int height, int bytesPerLine) { QImage out(width, height, QImage::Format_RGB888); for (int y = 0; y < height; ++y) { const uchar *line = src + y * bytesPerLine; uchar *dst = out.scanLine(y); for (int x = 0; x < width; x += 2) { const int y0 = line[0]; const int u = line[1] - 128; const int y1 = line[2]; const int v = line[3] - 128; line += 4; const int r0 = y0 + (1.370705 * v); const int g0 = y0 - (0.337633 * u) - (0.698001 * v); const int b0 = y0 + (1.732446 * u); dst[0] = clamp8(r0); dst[1] = clamp8(g0); dst[2] = clamp8(b0); const int r1 = y1 + (1.370705 * v); const int g1 = y1 - (0.337633 * u) - (0.698001 * v); const int b1 = y1 + (1.732446 * u); dst[3] = clamp8(r1); dst[4] = clamp8(g1); dst[5] = clamp8(b1); dst += 6; } } return out; }这段函数新建QImage,格式用Format_RGB888,每个像素 3 字节。外层按行走,src + y * bytesPerLine定位到每一行起点,内层每次读 4 字节产出一个 YUYV 对,写出两个 RGB 像素。clamp8防止 YUV 转 RGB 后溢出到 0 以下或 255 以上。u、v先减 128 再参与公式,少一次减法在循环里,读起来也更贴近公式原样。
如果设备实际输出的是 MJPEG,这段函数不能直接用。S_FMT回读的pixelformat不是V4L2_PIX_FMT_YUYV时,要么换一个支持 YUYV 的分辨率,要么走 JPEG 解码。命令行v4l2-ctl --list-formats-ext能列出每个分辨率支持的格式,先看清再改代码,比在转换函数里瞎试省时间。
4.2 QImage 生命周期:别让 mmap 指针裸奔
QImage有一个构造函数可以包装外部缓冲区,不拥有内存。很多人为了省一次拷贝,直接把mmap指针传进去,结果画面偶尔撕裂。原因是QImage还指着那块内存,采集线程已经把缓冲区QBUF还给驱动,驱动下一帧直接覆盖。UI 画的时候拿到的是半新半旧的像素,看起来就是花屏。
稳妥做法是转换函数内部new一块QImage自己的内存,逐像素写进去。上面yuyvToRgb返回的QImage就是独立内存,发信号跨线程时 Qt 会再做一次隐式共享拷贝,代价可控。如果追求零拷贝,可以把QImage的释放挂钩到“UI 画完”再QBUF,但这就变成引用计数和缓冲区生命周期管理,代码复杂度会上去。先用拷贝把功能跑稳,再谈优化。
QImage::Format_RGB888在部分平台绘制时还要转一次RGB32,如果发现paintEvent里缩放耗时,可以改成Format_RGB32,转换函数写 4 字节像素。代价是内存多四分之一,换绘制路径更顺。这个取舍在开发板上比较明显,先在目标板上跑一遍再定。
4.3 刷新与绘制:paintEvent、QTimer 与丢帧策略
显示层不要在采集线程里直接调update(),也不要在paintEvent里做重活。主窗口收到frameReady后只保存最新一帧,然后update()触发重绘。paintEvent里用QPainter::drawImage的目标矩形直接缩放,避免每帧先scaled()生成一张中间图。宽高比用Qt::KeepAspectRatio算一次,黑边填满窗口。
void MainWindow::onFrameReady(const QImage &image) { m_lastFrame = image; // 隐式共享,主线程持有最新帧 update(); } void MainWindow::paintEvent(QPaintEvent *event) { Q_UNUSED(event); QPainter painter(this); painter.fillRect(rect(), Qt::black); if (m_lastFrame.isNull()) return; const QSize target = m_lastFrame.size().scaled(size(), Qt::KeepAspectRatio); const QRect dstRect( (width() - target.width()) / 2, (height() - target.height()) / 2, target.width(), target.height()); painter.drawImage(dstRect, m_lastFrame); }这段代码把“收帧”和“画帧”分开:onFrameReady只更新成员变量并请求重绘,paintEvent只做一次带目标矩形的绘制。drawImage的目标矩形会自动缩放,省掉中间QImage。如果采集帧率高于屏幕刷新率,UI 事件循环里会堆积多次update(),Qt 会合并重绘请求,实际画的是最新一帧,旧帧被自然丢弃。这个丢帧策略对预览场景够用,对录像场景不适用,录像要在采集线程里单独落盘。
提示:窗口尺寸变化频繁时,
paintEvent里算目标矩形很便宜,不要把它挪到采集线程。UI 相关对象只在主线程碰。
5. 避坑与排查:设备能识别但 Qt 界面黑屏的五个现场
5.1 权限与设备节点:/dev/video0在,open 却失败
现象是终端里ls /dev/video0能看到节点,程序启动后直接报open failed: Permission denied。原因通常是当前用户不在video组,或者 udev 规则给节点的权限是root:root 660。临时改权限能跑通,但重启或重新插拔后又会恢复,不适合作为长期方案。
解决方式是把用户加进video组,重新登录后生效;或者在 udev 规则里给这个设备加MODE="0660"、GROUP="video"。程序里不要把设备路径写死成/dev/video0,有些机器上摄像头是/dev/video1或/dev/video2。打开失败时把errno打出来,EACCES是权限,ENOENT是节点不存在,EBUSY是被占用,三种情况处理方式完全不同。
5.2 格式协商失败:S_FMT成功但画面花屏
现象是VIDIOC_S_FMT返回成功,窗口也有画面,但颜色发绿、图像错位或者只显示半屏。原因多半是驱动没有接受请求的像素格式,实际返回了 MJPEG,而转换函数仍按 YUYV 解析;或者bytesperline大于width * 2,转换时按紧凑排列读了错行。
解决方式是S_FMT之后强制回读fmt.fmt.pix.pixelformat和fmt.fmt.pix.bytesperline,格式不匹配就报错退出,不要硬转。转换函数按bytesPerLine逐行定位,内层只处理width个像素。排查时先用v4l2-ctl --list-formats-ext -d /dev/video0看设备支持哪些格式,再决定请求哪一种。分辨率也不是越大越好,低端摄像头的最高分辨率往往帧率很低,预览用 640×480 更稳。
5.3 缓冲区与内存映射:REQBUFS或mmap报错
现象是VIDIOC_REQBUFS返回EINVAL,或者mmap返回MAP_FAILED。常见原因是memory没写V4L2_MEMORY_MMAP,count请求得太大,或者没有先QUERYBUF就直接mmap。还有一种情况是设备只支持READWRITE,不支持STREAMING,走这套队列必然失败。
解决方式是req.count先从 4 开始,memory固定V4L2_MEMORY_MMAP,每个缓冲区先QUERYBUF拿到length和m.offset再mmap。mmap失败时检查errno,EINVAL多半是 offset 或 length 不对,ENOMEM是内核缓冲区不够。失败后把已经映射的缓冲区逐个munmap,不要直接退出进程留下泄漏。
5.4 线程与刷新:画面卡死或偶发撕裂
现象是拖动窗口时画面卡住,或者画面下半截是上一帧、上半截是新帧。原因有两个:一是在采集线程里直接操作QWidget,Qt 的 UI 对象不是线程安全的;二是QImage包装了mmap指针,QBUF之后驱动复写,UI 还在画旧数据。
解决方式是把采集对象moveToThread,跨线程只发QImage信号,UI 更新放在主线程的槽里。转换函数内部新建QImage,不引用mmap内存。paintEvent里不做耗时转换,只做绘制。关闭窗口时先停采集循环,再quit()、wait(),最后销毁对象,避免线程还在跑而窗口已经没了。
5.5 拔插与资源释放:第二次打开设备失败
现象是摄像头拔掉再插上,程序要么崩溃,要么重新打开时报Device or resource busy。原因是拔掉时select或DQBUF返回ENODEV,采集循环没有正确退出,STREAMOFF、munmap、close没有按顺序执行,fd或映射泄漏。还有一种情况是重复调用openDevice,旧fd没关就覆盖了成员变量。
解决方式是在采集循环里识别ENODEV,跳出后统一走清理函数:先STREAMOFF,再munmap所有缓冲区,最后close(fd)并把m_fd置 -1。重新打开前检查m_fd是否已经有效,有效就先关。界面上加一个“重新连接”按钮,内部做一次完整清理再打开,不要指望热插拔自动恢复。这类问题最玄学的地方在于日志很少,清理顺序错一步,下一次打开就失败。
6. 二次开发:从能看画面到能录像、能切分辨率、能跑多路
6.1 加录像:在 DQBUF 后分叉,不要动显示链路
录像最省事的改法是在采集循环里DQBUF之后、QBUF之前,把同一帧同时送给显示转换和编码队列。显示照旧走QImage,编码走另一条线程,队列满了就丢旧帧,不要阻塞采集。这样预览和录像互不拖累,编码慢的时候最多录像丢帧,窗口不会卡死。
// 采集线程内:先转换给预览,再把原始帧投递到编码队列 QImage preview = yuyvToRgb(src, m_width, m_height, m_bytesPerLine); emit frameReady(preview); if (m_recording) { FramePacket pkt; pkt.data.assign(src, src + m_buffers[buf.index].length); pkt.width = m_width; pkt.height = m_height; pkt.format = m_pixelFormat; m_encodeQueue.tryPush(std::move(pkt)); // 队列满则丢弃,不阻塞采集 }这段分叉的关键是“原始帧拷贝出去再入队”。QBUF之后mmap内存会被复用,编码线程不能直接持有指针。tryPush用非阻塞入队,队列满就丢,保证采集节奏不被编码拖垮。编码器接口按项目需要接,文件头、时间戳、封装格式都要在编码线程里处理,不要塞回采集循环。
6.2 切分辨率与多路:S_FMT之后必须重建缓冲区
运行中切分辨率不能只调一次S_FMT。格式变了,bytesperline、sizeimage都可能变,旧缓冲区长度对不上。正确顺序是:STREAMOFF、munmap旧缓冲区、S_FMT新格式、REQBUFS、重新mmap、STREAMON。少一步就会出现DQBUF成功但数据长度不对,或者mmap越界。
多路摄像头建议一路一个V4L2Camera实例、一个采集线程,UI 侧用网格布局分别显示。不要在一个线程里轮询多个fd再串行转换,USB 带宽和 CPU 都容易顶不住。先保证单路 640×480 稳定 30 帧,再往上加路数;加路之前把转换函数换成查表或硬件转换,否则 CPU 先成为瓶颈。
6.3 验证习惯:先命令行,再 Qt
我现在的习惯是每接一个新摄像头,先跑三条命令:v4l2-ctl --list-devices看节点,v4l2-ctl --list-formats-ext -d /dev/video0看格式和帧率,v4l2-ctl --stream-mmap --stream-count=30 -d /dev/video0看能不能稳定出帧。命令行能出帧,Qt 里黑屏就是代码问题;命令行都出不来,先查线和权限,别在 Qt 里绕。
| 检查项 | 命令或观察点 | 通过标准 |
|---|---|---|
| 设备节点 | ls -l /dev/video* | 节点存在且当前用户可读写 |
| 支持格式 | v4l2-ctl --list-formats-ext | 有目标分辨率和像素格式 |
| 取帧能力 | v4l2-ctl --stream-mmap --stream-count=30 | 30 帧内不报错 |
| 程序日志 | errno和strerror | 失败点能定位到 open/S_FMT/DQBUF |
从那以后我每次接新摄像头,
本文还有配套的精品资源,点击获取