简介:面向嵌入式Linux与Qt开发者的v4l2视频采集方案,基于V4L2框架拉取640×480分辨率视频流,转换为QImage后在QLabel/QWidget控件显示,并支持截图、录像及通过串口与下位机通信触发命令,适合需要快速落地摄像头应用的开发者。压缩包共225个文件,以C/C++头文件与源文件(.h/.c/.cpp)为主体,附带多个.so动态库、pkg-config配置(.pc)、Qt界面文件及工程文件,整体约68.74MB;其中.so库包含FFmpeg组件(如libavcodec/libavformat/libx264),便于理解视频编码与保存录像的实现。包内代码结构清晰,可直接学习V4L2采集→QImage转换→显示→截图→录像的完整链路,同时演示串口解析与多线程处理,已有272人下载学习,适合中高级开发者参考。
1. 为什么用 v4l2 拉流 + Qt 显示:这套组合到底解决了什么问题
你手上有一个 USB 摄像头或者某 ARM 板子上的 MIPI 摄像头,系统是 Linux,正要做一个带界面预览的采集工具。需求很具体:640×480 的分辨率,画面要实时显示在窗口里,点一下截图能存一张图,点一下录制能生成一段能拖进播放器看回放的视频。如果你去网上翻方案,会发现 Jump 到 OpenCV 高 GUI 窗口是一条路,但只要你后面要加按钮、要加状态栏、要调整布局,OpenCV 的窗口体系就会开始别扭。用 v4l2 直接拉流、用 Qt 做界面层,这两个东西一组合,无论是做设备调试工具还是产品原型,都算得上最不容易翻车的组合。v4l2 拉流拿到的是一帧一帧的裸数据,Qt 显示只负责把帧画出来,中间没有任何黑匣子,出了问题你能一层层查下去。
这套方案适合谁?适合那种不想被上层封装拖着走、想把采集链路控制在自己手里的人。你不需要理解内核驱动细节,但至少要明白 v4l2 的 ioctl 流程和 buffer 机制;你也不需要把 Qt 玩出花,但要知道信号槽跨线程怎么用。下面从数据通路开始,一步步把 640×480 的帧从设备节点搬到 QLabel 上,再把截图和录像加上去。
2. 先理清通路:V4L2 设备数据怎么变成 Qt 控件里的一帧图像
2.1 V4L2 的三种采集方式:read、mmap 与用户指针
V4L2 是 Linux 内核里视频采集设备的统一接口,它给用户态暴露的是/dev/videoX这样一个字符设备节点。你打开这个节点之后,所有的配置和采集动作都是靠 ioctl 和 mmap 完成的。要理解数据怎么从摄像头进到你的 Qt 窗口,第一个绕不开的问题就是:缓冲区到底用什么方式给你的用户态程序。
V4L2 提供三种常见的内存方式:read 方式、mmap 方式和用户指针方式。read 方式就像读普通文件一样,一帧一帧地从设备节点 read 出来,驱动在内核里帮你拷贝一次,结构最简单,但性能最差,640×480 的 YUYV 帧一秒钟 25 帧就是约 18MB 数据量,read 的拷贝开销会让你 CPU 占用很难看。mmap 方式是驱动在内核申请缓冲区,通过 mmap 映射到用户态,取帧时你拿到的指针直接指向这块映射内存,省掉了一次用户态拷贝,这是绝大多数采集程序的标准做法。用户指针方式是你自己申请内存,把地址告诉驱动,让驱动把数据填进你的内存里,听起来灵活,但很多摄像头驱动对它的支持并不好,实际项目里很少作为首选。
这张表是我做选型时的习惯用法:
| 方式 | 拷贝次数 | 实现难度 | 实际选择 |
|---|---|---|---|
| read | 至少 1 次 | 低 | 不建议,帧率上去就吃力 |
| mmap | 0 次(内核直接映射) | 中 | 首选,稳定且通用 |
| userptr | 0 次 | 高 | 驱动支持看脸,慎用 |
所以后面所有代码都基于 mmap 展开。你需要记住一个结论:在 640×480 这种小分辨率下,mmap 的性能冗余非常大,它带来的好处不只是速度,而是你不必在每次取帧时都自己去手动管理一块临时内存。
2.2 640×480 格式协商:像素格式与缓冲区大小
打开设备之后要做的第一件事是格式协商。摄像头不一定默认就是 640×480,也不一定是你想要的像素格式,你必须主动告诉驱动:我要这个尺寸、这个格式。这一步通过VIDIOC_S_FMT完成,它传递一个v4l2_format结构体,里面填的是期望值,驱动回填的是实际生效值。
结构体里最核心的几个字段是 width、height、pixelformat 和 field。pixelformat 在 640×480 这个档位上,我们最常遇到的是V4L2_PIX_FMT_YUYV和V4L2_PIX_FMT_MJPEG两种。YUYV 是原始 YUV 数据,一个像素占 2 字节,100% 兼容性好,任何设备都能输出;MJPEG 是摄像头内部把每帧 JPEG 压缩好了再给你,单帧数据量小,但对你的程序来说,拿到的是 JPEG 编码后的字节流,你得先解码才能显示。
我在写采集程序时一般会这样设计:优先尝试 YUYV,如果摄像头确实不支持,再退回 MJPEG。原因很简单,YUYV 转 RGB 的算法几十行代码就能搞定,而 MJPEG 要先接一个 JPEG 解码器,链路长一环就多一环出错的可能。当然,如果你的 CPU 非常紧张,MJPEG 是值得考虑的,因为 JPEG 解码用 libjpeg-turbo 可以做到非常快,而且网络传输或文件存储时体积小很多。
缓冲区大小有个容易被忽略的点:你在S_FMT里设置了 width=640、height=480,但驱动实际生效的bytesperline可能不是你想象的 640×2。某些驱动会按 4 字节或 8 字节对齐每行,导致行字节数大于理论值。所以格式协商之后,一定要马上VIDIOC_G_FMT回读一次,把实际的 width、height、bytesperline、sizeimage 存下来,后续所有取帧、转换、显示都用回读值,不要用自己写死的 640 和 480 去算偏移。
2.3 YUYV、MJPEG 与 QImage 的转换路径
V4L2 设备输出给我们的数据,到底怎么变成 Qt 的 QImage?QImage 支持的格式很多,但显示起来最直接的是 RGB 三通道或 RGBA 四通道。摄像头给的是 YUV 家族的数据,所以中间必须做一次颜色空间转换。
YUYV 的排布是:两个像素共享一对 UV 色度分量。具体说,每 4 字节表示两个像素:Y0、U、Y1、V。第一个像素的亮度是 Y0,色度是 U、V;第二个像素的亮度是 Y1,色度还是 U、V。转换公式有很多版本,我通常用整数近似式计算,避免浮点运算,速度更快。核心思路就是先算 C = Y - 16,D = U - 128,E = V - 128,然后三个颜色通道的线性组合,最后做一次超范围裁剪。
如果摄像头给的是 MJPEG 格式,那么每一帧缓冲区里存放的是完整的 JPEG 数据,不能直接做 YUV 到 RGB 转换。常见做法是用 libjpeg-turbo 解压,或者干脆把这段字节流交给 Qt 的 QImageReader 去解。QImageReader 可以从 QByteArray 构造,配合QBuffer使用,代码量小,但解码性能比 libjpeg-turbo 差一截,帧率要求不高时够用。选择哪条路取决于你的帧率和 CPU 余量:25fps 的 640×480 下,两者差别不大;如果是多路采集,我会直接上 libjpeg-turbo。
这里有一个很现实的问题:很多 USB 摄像头在 640×480 下并不输出 YUYV,只有 MJPEG。你在S_FMT里请求 YUYV 可能直接失败,或者驱动默默给你改成别的格式却不告诉你。这就是为什么上面强调要回读实际格式。程序里必须做成双分支:回读发现是 YUYV 就走 YUV 转换,发现是 MJPEG 就走 JPEG 解码,绝不能写死某一种。
3. 核心代码:在 Qt 工程里跑通 V4L2 取流并显示
3.1 初始化:打开设备、协商格式、申请缓冲区
先把第一步初始化代码写出来。下面的open_camera函数负责打开设备、查询能力、协商格式,并回读实际生效的参数。
// open_camera.cpp #include <linux/videodev2.h> #include <sys/ioctl.h> #include <sys/mman.h> #include <fcntl.h> #include <unistd.h> #include <cstdio> int open_camera(const char* dev, int w, int h, uint32_t pixelformat) { // 使用非阻塞方式打开,后续 DQBUF 没有帧时会返回 EAGAIN int fd = open(dev, O_RDWR | O_NONBLOCK); if (fd < 0) { perror("open camera"); return -1; } struct v4l2_capability cap; if (ioctl(fd, VIDIOC_QUERYCAP, &cap) < 0) { perror("QUERYCAP"); close(fd); return -1; } struct v4l2_format fmt; memset(&fmt, 0, sizeof(fmt)); fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; fmt.fmt.pix.width = w; fmt.fmt.pix.height = h; fmt.fmt.pix.pixelformat = pixelformat; fmt.fmt.pix.field = V4L2_FIELD_NONE; if (ioctl(fd, VIDIOC_S_FMT, &fmt) < 0) { perror("S_FMT"); close(fd); return -1; } // 关键:回读实际格式,bytesperline 要按驱动实际值使用 if (ioctl(fd, VIDIOC_G_FMT, &fmt) == 0) { printf("real format: %dx%d, bytesperline=%u, sizeimage=%u\n", fmt.fmt.pix.width, fmt.fmt.pix.height, fmt.fmt.pix.bytesperline, fmt.fmt.pix.sizeimage); } return fd; }这段代码里最需要注意的是两个细节。第一,O_NONBLOCK是刻意加上的,因为如果摄像头掉线或者驱动卡住,阻塞模式下 DQBUF 会让整个采集线程卡死,界面跟着没反应,非阻塞模式配合 EAGAIN 处理能让你有机会超时退出。第二,S_FMT返回成功并不代表一定是你要的格式,必须再G_FMT回读确认,尤其在某些 UVC 驱动上,像素格式可能被悄悄替换。
接下来是申请缓冲区、mmap、入队、开始流。这四步必须按顺序做,任何一步失败都要清理前面的资源。
// buffer_setup.cpp #include <vector> #include <cstring> struct FrameBuffer { void* start; size_t length; }; std::vector<FrameBuffer> g_buffers; int setup_buffers(int fd, int buffer_count) { struct v4l2_requestbuffers req; memset(&req, 0, sizeof(req)); req.count = buffer_count; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.memory = V4L2_MEMORY_MMAP; if (ioctl(fd, VIDIOC_REQBUFS, &req) < 0) { perror("REQBUFS"); return -1; } g_buffers.resize(req.count); for (int i = 0; i < (int)req.count; ++i) { struct v4l2_buffer buf; 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"); return -2; } g_buffers[i].length = buf.length; g_buffers[i].start = mmap(nullptr, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, fd, buf.m.offset); if (g_buffers[i].start == MAP_FAILED) { perror("mmap"); return -3; } // 入队,让驱动可以使用这块缓冲区 ioctl(fd, VIDIOC_QBUF, &buf); } enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; if (ioctl(fd, VIDIOC_STREAMON, &type) < 0) { perror("STREAMON"); return -4; } return 0; }缓冲区数量我一般申请 4 个。太少容易在帧率波动时出现丢帧,太多又会增加内存占用和延迟。4 个在 640×480 YUYV 下就是约 8MB 内存,完全可以接受。mmap 之后的QBUF入队是很多人忘掉的一步,缓冲区不入队,驱动根本不知道它可不可用。
3.2 抓帧循环:采集线程与 UI 线程的信号槽配合
采集是高频操作,绝对不能放在 Qt 的主线程里跑。主线程要处理界面事件,任何超过 16ms 的阻塞都会让界面卡顿。我把采集写在一个 QThread 子类里,让它自己循环 DQBUF、转换、发信号,界面线程只负责接收信号并绘制。
// grabthread.h #include <QThread> #include <QSharedPointer> #include <QImage> class GrabThread : public QThread { Q_OBJECT public: explicit GrabThread(int fd, QObject* parent = nullptr); void stop(); signals: void frameReady(QSharedPointer<QImage> frame); protected: void run() override; private: int m_fd; volatile bool m_stop = false; };// grabthread.cpp #include "grabthread.h" #include <linux/videodev2.h> #include <sys/ioctl.h> #include <cerrno> GrabThread::GrabThread(int fd, QObject* parent) : QThread(parent), m_fd(fd) {} void GrabThread::stop() { m_stop = true; } void GrabThread::run() { while (!m_stop) { struct v4l2_buffer buf; memset(&buf, 0, sizeof(buf)); buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; int ret = ioctl(m_fd, VIDIOC_DQBUF, &buf); if (ret < 0) { if (errno == EAGAIN) { // 非阻塞模式下暂时没有帧,给调度器一点时间 QThread::msleep(1); continue; } // 其他错误说明设备异常,直接退出采集循环 break; } QImage image = yuyv_to_qimage( static_cast<uchar*>(g_buffers[buf.index].start), 640, 480, g_bytesperline); emit frameReady(QSharedPointer<QImage>::create(image)); // 必须立刻把缓冲区归还给驱动 ioctl(m_fd, VIDIOC_QBUF, &buf); } }这个循环的核心逻辑是:DQBUF 取一帧,转成 QImage,通过信号发出去,再 QBUF 把缓冲区还给驱动。EAGAIN 的处理很关键,因为非阻塞模式下如果驱动还没有准备好帧,ioctl 会立即返回错误而不是等在那里,你需要在循环里稍微睡一下再重试。这里睡 1ms 是经验值,睡太短会空转烧 CPU,睡太长会丢失帧节奏。
emit frameReady这一行,信号参数用了QSharedPointer<QImage>。这么做的好处是跨线程传递时不用深拷贝图片数据,只有引用计数在传递,两个线程之间不会为同一帧数据争抢内存。QSharedPointer::create(image)会复制一次图像数据,这是不可避免的,因为缓冲区内存是驱动映射出来的,它的生命周期由 QBUF 决定,QImage 不能直接引用这块内存,否则一旦缓冲区被驱动重新填充,界面上的画面就花了。
3.3 画面显示:把帧转成 QImage 并渲染到控件上
主线程连接信号到槽函数,槽函数里做的事情很少:把 QSharedPointer 存成成员变量,然后用 setPixmap 更新 QLabel。
// mainwindow.cpp #include <QPixmap> #include <QLabel> #include <QDateTime> void MainWindow::onFrameReady(QSharedPointer<QImage> frame) { // 保留下最近一帧,截图功能要用 m_lastFrame = frame; if (!m_uiVideoLabel) { return; } QPixmap pixmap = QPixmap::fromImage(*frame) .scaled(m_uiVideoLabel->size(), Qt::KeepAspectRatio, Qt::SmoothTransformation); m_uiVideoLabel->setPixmap(pixmap); }这里有两个实践细节。第一,scaled配合Qt::SmoothTransformation能让画面在控件缩放时不过于毛糙,但你要知道,如果控件大小和图像大小一致,这个缩放操作其实是空转的。我一般会让 QLabel 的大小等于 640×480,或者干脆把 QLabel 设置为固定尺寸,避免每次显示都要做一次缩放。
第二,信号槽的连接方式。采集线程 emit 的信号和主线程槽函数之间,我默认用Qt::AutoConnection,也就是让 Qt 自己判断跨线程后转为队列连接。队列连接意味着帧数据不是立即进入槽函数,而是先放到事件队列里,主线程空闲了才处理。如果你的主线程还有别的耗时操作,画面就会出现延迟,这是正常的,解决的办法是把耗时操作放到别的线程或优化主线程事件循环。
显示效果符合预期之后,采集链路就算通了。接下来要给这个链路加上两个最常见的功能:截图和录像。这两者在工程上比你想的要复杂一点,核心问题不在于“保存”,而在于“保存过程中帧从哪里来、会不会影响实时采集”。
4. 截图与录像:在采集之上加一个可靠的保存功能
4.1 截图:把当前帧输出成图片文件
截图逻辑是最简单的。上面显示槽函数里已经把m_lastFrame保存下来了,截图按钮按下时,只需要把这个 QImage 转存成文件。但这里有一个很多人踩过的坑:你以为“当前最新的帧”是采集线程里刚出来的那帧,其实用户看到的画面是主线程最后一帧。如果你在按钮事件里直接从采集线程拿最新帧,保存下来的画面可能比用户看到的快了那么几帧。正确做法是保存m_lastFrame,也就是界面上正在显示的那一帧。
// 截图按钮的槽函数 void MainWindow::onSaveScreenshot() { if (!m_lastFrame) { return; } QString path = QString("snapshot_%1.jpg") .arg(QDateTime::currentDateTime().toString("yyyyMMdd_hhmmss_zzz")); bool ok = m_lastFrame->save(path, "JPG", 95); if (ok) { statusBar()->showMessage("截图已保存: " + path, 3000); } }保存格式用 JPG 还是 PNG 要按用途分。做设备调试或后续图像分析,PNG 无损更合适;做记录留档,JPG 体积小、带质量参数可调。save函数的第三个参数在 JPG 格式下表示压缩质量,95 是个比较稳妥的值,视觉无损且体积可控。如果你要连拍,建议在文件名里加上毫秒时间戳,至少短时间内不会同名覆盖。
4.2 录像:两条常见且可落地的路线
录像比截图复杂一个量级。你面对的已经不是“保存一帧”,而是“以固定帧率把一系列帧编码成一个可播放的视频文件”。我见过不少人在这一步自己写 AVI 封装,最后发现播放器不认、音频同步错位、文件长度字段对不上,做得非常痛苦。我的经验是:除非你有特殊需求,否则不要自己拼容器,用现成的编码链路。
路线一:引入 OpenCV 的 VideoWriter。这是最省事的路,它内部已经把编码器和容器封装好了。但这意味着你的 Qt 工程要链接 OpenCV 的 videoio 模块,编译环境和部署体积都会变大。如果产品对体积敏感,要慎重。
// opencv_recorder.cpp #include <opencv2/opencv.hpp> cv::VideoWriter g_writer; bool start_record(const QString& filepath) { g_writer.open(filepath.toStdString(), cv::VideoWriter::fourcc('M', 'J', 'P', 'G'), 25, cv::Size(640, 480)); return g_writer.isOpened(); } // 在帧回调里调用 void onRecordFrame(const QImage& frame) { if (!g_writer.isOpened()) return; cv::Mat mat(480, 640, CV_8UC3, (void*)frame.bits(), frame.bytesPerLine()); g_writer.write(mat); }路线二:如果你的摄像头输出本来就是 MJPEG,那录像这件事可以更轻量。你不需要解码再编码,直接把每次 DQBUF 拿到的 JPEG 字节流顺序写入一个文件。等录制结束,再用系统里现成的 ffmpeg 命令把它封装成标准文件。这个路线的优点是零编码开销,CPU 占用极低,缺点是中间产物是裸流文件,只有封装之后才能直接播放。
// mjpeg_recorder.cpp #include <QFile> #include <QProcess> QFile g_recFile; bool start_record_mjpeg(const QString& filepath) { g_recFile.setFileName(filepath); return g_recFile.open(QIODevice::WriteOnly | QIODevice::Append); } // 采集循环里,如果是 MJPEG 格式,直接写原始数据 void onMjpegFrame(const uchar* data, int size) { if (g_recFile.isOpen()) { g_recFile.write(reinterpret_cast<const char*>(data), size); } } // 录制结束,调用 ffmpeg 封装为 mp4 void stop_record_mjpeg(const QString& mjpgPath) { g_recFile.close(); QString outPath = mjpgPath; outPath.replace(".mjpg", ".mp4"); QString cmd = QString("ffmpeg -y -framerate 25 -i %1 -c copy %2") .arg(mjpgPath, outPath); QProcess::execute(cmd); }这两条路线怎么选?我一般会按一个原则来:如果采集格式是 YUYV,直接用 OpenCV VideoWriter,因为它能帮你做 YUV 到 MJPEG 的编码;如果采集格式本身就是 MJPEG,那走第二条裸流路线更划算,省去一次解压和一次再压缩的 CPU 开销。
4.3 帧数据生命周期的坑:录像与预览共用缓冲区时的处理
录像功能加上之后,你会立刻发现一个问题:同一帧数据,预览要用、录像也要用。如果录像走 OpenCV 路线,OpenCV 在writer.write内部会把cv::Mat的数据复制并编码,所以它不会长期占用你的 QImage 内存。但如果录像走裸流路线,你直接把缓冲区里的字节写入文件,QFile::write内部会做一次拷贝,写入完成后函数返回,也不会长期持有缓冲区内存。
真正的坑在这里:无论哪条路线,你都必须在QBUF归还缓冲区之前完成对缓冲区内数据的读取或写入。道理很简单,缓冲区还给了驱动,驱动随时可能往里写新帧,你如果还把缓冲区指针保留在录像模块里,下一帧数据一来,旧数据就被覆盖了。
我见过一个问题:录像录了 10 秒,播放器能打开,但画面中间经常出现横条撕裂。排查到最后发现,录制线程和采集线程不是同一个,录制线程在缓冲区归还之后才去读数据,读到的是被覆盖了一半的新帧。解决的办法只有一个:要么保证对数据的处理发生在 QBUF 之前,要么在 DQBUF 之后立刻把数据复制到自己的内存区域。前者的代价是处理耗时直接影响取帧节奏,后者的代价是多一次拷贝。640×480 帧如果走 MJPEG,单帧才几十 KB,复制代价可以忽略,所以我会选择立刻复制。
5. v4l2 与 Qt 配合中的避坑记录与排查
5.1 摄像头打不开或 ioctl 报错,先从设备权限查
现象:open("/dev/video0", O_RDWR)返回 -1,perror 显示 Permission denied;或者open成功了,但VIDIOC_QUERYCAP返回ENOTTY或EINVAL。
原因:绝大多数情况是当前用户不在 video 组里,或者设备节点权限不够。/dev/video0 通常属于 root:video,普通用户只有在 video 组里才能读写。还有一种可能是你打开了错误的节点,某些设备上 video0 并不是摄像头,而是别的视频设备。
解决:先用ls -l /dev/video*和v4l2-ctl --list-devices查看设备列表,确认哪个节点对应你要用的摄像头。然后把用户加入 video 组,或者用 chmod 临时给设备节点加权限。如果是嵌入式板子,还要确认内核里对应的驱动有没有 probe 成功,dmesg | tail可以看到驱动是否报错。另外,程序里要实现 QUERYCAP 失败的退出路径,不要带着一个无效 fd 继续往下走。
5.2 画面花屏、偏色,先看像素格式和行对齐
现象:画面能出,但颜色明显不对,比如整张图像发紫、发绿,或者图像像被斜着切了一样错位。发紫通常说明你把 YUYV 数据当成 RGB 直接显示了,错位则大概率是行字节数算错了。
原因:两类。第一,像素格式不匹配,摄像头实际输出的是你回读到的格式,但你的转换代码写死了另一种格式。第二,bytesperline不等于width * 2,某些驱动在一行末尾有对齐填充字节,如果你按width * 2去计算每行起始地址,后面的行会越来越偏。
解决:把G_FMT回读到的 bytesperline 传进 YUYV 转换函数,每行起始地址用y * bytesperline计算,不要用y * width * 2。同时在转换之前打印一次格式信息,开发期这行日志能救你很多次。颜色偏紫的另一个隐蔽来源是 YUV 的 BT.601 和 BT.709 系数差异,但 640×480 小分辨率下肉眼几乎看不出区别,不用太纠结。
5.3 UI 卡顿、掉帧严重,处理完线程优先级再查拷贝
现象:采集线程跑得好好的,但界面上画面一卡一卡,鼠标移动都不跟手。检查之后发现 CPU 占用并不高,但主线程事件循环经常被阻塞。
原因:最常见的是信号槽里做了重活,比如每次都把 QImage 深拷贝一遍再缩放。另一个常见原因是你在槽函数里做了文件写入,写文件这种阻塞操作虽然在 UI 线程里执行。
解决:把显示路径和数据路径分开。显示槽函数只做 setPixmap,不做任何磁盘操作;截图和录像的耗时环节放到独立线程或使用 QtConcurrent 异步执行。必要时把采集线程的优先级调高,但我不建议轻易动线程优先级,因为 Linux 下优先级设置不当可能造成采集线程饿死 UI 线程。先把不必要的拷贝去掉:确保 emit 用的是 QSharedPointer,而不是 QImage 直接作为信号参数。
5.4 录像文件打不开或时长不对,容器和编码器要匹配
现象:录制结束生成的 AVI 文件,VLC 能打开但系统自带播放器打不开,或者打开后时长显示为 0 和几秒的差别。
原因:OpenCV 的 VideoWriter 在写 AVI 时,fourcc 和文件后缀必须匹配。MJPG 编码配 .avi 后缀是标准组合,但如果 fourcc 用了 H264 配 .avi,系统播放器可能不认识。另外,如果想要精确时长,录制时传入的帧率必须和实际 DQBUF 的帧率一致,否则封装的 timebase 和实际帧数量对不上。
解决:统一用 MJPG + .avi 是最稳的组合。录制结束后,用ffprobe看一眼文件流的编码和时长,确认参数对不对。如果发现封装有问题,把中间的裸流文件用 ffmpeg 重新封装一次,通常能修好。不要试图自己改 AVI 头部的文件长度字段,那是对付播放器的临时办法,不是正经工程方案。
5.5 退出程序崩溃,多半是缓冲区和线程释放顺序混乱
现象:程序运行正常,但点退出时经常崩溃,或者出现段错误。偶尔正常退出,偶尔必崩。
原因:线程和缓冲区释放的顺序错了。如果你先munmap了缓冲区,采集线程还在 DQBUF,那线程访问的就是已经释放的记忆;如果你先关闭了设备 fd,再让线程退出,那线程在 ioctl 时就会对一个无效 fd 进行操作。
解决:退出顺序必须是:先STREAMOFF停止数据流,再停止采集线程并wait()等待它真正结束,最后munmap所有缓冲区并 close fd。注意,STREAMOFF 必须由采集线程自己执行,或者至少要在采集线程停止 ioctl 之后再执行。我习惯在采集线程的 run() 末尾自动完成 STREAMOFF,而不是从外部调用,这样能保证时序。退出代码写成这样:
void stop_camera(int fd) { enum v4l2_buf_type type = V4L2_BUF_TYPE_VIDEO_CAPTURE; ioctl(fd, VIDIOC_STREAMOFF, &type); // 先停流 } // 线程退出函数 void GrabThread::shutdown() { m_stop = true; wait(); // 等待线程完全退出 // 此时线程不会再访问缓冲区 } // 主程序退出流程 void MainWindow::closeEvent() { m_grabThread->shutdown(); for (auto& b : g_buffers) { munmap(b.start, b.length); } g_buffers.clear(); close(m_fd); }6. 进阶优化:让 640×480 视频流在低端设备上更流畅
如果这套采集程序要在低功耗 ARM 板子上跑,CPU 余量通常不大。前面已经用了 mmap 和 QSharedPointer,已经把不必要的拷贝减到了最低,但还有一个可以优化的点:QImage 的转换与创建。
我一般会做一个预分配 QImage 池。在初始化时创建三四张 640×480 的 RGB888 QImage,之后每一帧从池里取一张来填,用完归还。这样避免了每一帧都重新分配内存,也避免了 QSharedPointer 内部 refcount 的原子操作开销。在 ARM 平台,这个细节能让帧率提高 5 到 10 帧。但代价是画面延迟会稍微增加,因为池里的图像是复用的,录像模块不能持有 QImage 指针太久。
另一个值得做的验证是帧率和延迟测量。别凭感觉判断流畅不流畅,用 QElapsedTimer 统计实际帧率:
// fps_counter.cpp #include <QElapsedTimer> QElapsedTimer g_timer; int g_frameCount = 0; void onFrameShow() { g_frameCount++; if (g_timer.elapsed() >= 1000) { double fps = g_frameCount * 1000.0 / g_timer.elapsed(); g_frameCount = 0; g_timer.restart(); statusBar()->showMessage(QString("fps: %1").arg(fps), 1000); } }延迟测量则要在摄像头前放一个秒表,观察画面里秒表走字和实物秒表走字之间的差。这个方法很土,但非常有效,能直接量出“从镜头到屏幕”的整体延迟。如果延迟超过 100ms,检查槽函数里有没有磁盘写、有没有不必要的缩放、信号槽连接是否是阻塞式。
我在一套采集工具上就吃过这个亏:明明帧率统计显示 25fps,但操作者感觉画面有半秒延迟。后来发现是 QLabel 的 setPixmap 每次都触发了一个剪贴板事件,这个事件又引出了一个额外的格式转换。换成 QWidget 的 paintEvent 自己画之后,延迟立刻降下来了。所以我的习惯是:一旦发现帧率和延迟不符,就先在 paintEvent 里用 QPainter::drawImage 代替 QLabel,排除控件层的多余动作。
给你一个最实在的清单:第一,确保缓冲区数量不小于 4;第二,确保 QBUF 在每一帧处理之后立刻执行;第三,显示和录像不要共用同一个数据处理函数;第四,录制结束后主动用 ffmpeg 验证文件完整性。这套框架我反复用了很多次,每次遇到问题都是这四点里的某一点没做到位。保持采集线程做采集、UI 线程只做显示、磁盘写入异步化这个纪律,640×480 的 v4l2 采集在绝大多数平台上都能跑得很稳。希望帮到你。
本文还有配套的精品资源,点击获取