简介:面向Linux音视频与摄像机接入开发场景的实战工程包,适合具备一定C++/Qt基础、希望打通GUI、设备SDK与视频解码链路的学生和初中级工程师。内容围绕Qt 5.9.1界面构建、大华摄像机SDK设备控制及ffmpeg视频解码展开,将三者整合到DH_Camera项目中,涉及实时预览、抓图录像、解码数据显示与依赖构建等典型问题。整包约156.52MB,共2000个文件,类型以C源码、头文件、编译产物、多媒体测试样本、构建脚本与配置说明为主,既能反映ffmpeg相关源码与样本组织,也能看到Qt工程的构建目录和资源文件。已有1036人学习,可作为理解Linux下多媒体应用工程结构、SDK调用边界和Qt与ffmpeg协同方式的参考,便于对照梳理模块划分、编译依赖及常见集成环节。
1. 从一台总是卡顿的工控机说起:Linux Qt 客户端为什么要自己接 FFmpeg
去年帮人排过一个现场:工控机上跑着自研的预览界面,接了两路网络摄像机,画面不是卡成 PPT,就是隔一会儿绿一片。厂商给的示例程序是 Windows 版的,Linux 侧只丢过来一个.so加几个头文件。最初的做法是直接调 SDK 自带的解码显示接口,把窗口句柄传进去让它自己画,结果整个 UI 线程被拖住,按钮点下去半秒才有反应。后来改成 SDK 只负责取裸流,FFmpeg 解码,Qt 自己渲染,问题一次性消掉——这套Linux Qt + 摄像机 SDK 取流 + FFmpeg 解码的组合,就是这份项目的骨架。
它要做的事情很具体:在一个 Qt 窗口里同时预览多路视频,主码流和子码流能切,SDK 私有协议和标准 RTSP 两种来源都要吃得下,还能把码流落成本地文件、按时间回放。适合谁?做安防上位机、工业视觉工位机、机器人图传终端的 Linux C++ 开发者。前置要求不高,会写 Qt 信号槽、看得懂 CMake 就能上手;真正需要花时间的是线程边界和 FFmpeg 那套 API 的脾气。
2. 整体架构:三条取流链路怎么在 Qt 事件循环里共存
这个项目最容易被低估的地方不是解码,而是“东西往哪儿放”。摄像机 SDK 的回调线程、FFmpeg 的解码线程、Qt 的 UI 线程,三者在同一个进程里跑,任何一个环节乱来都会以“界面莫名其妙卡一下”的形式报应回来。整套架构的核心约定只有一条:数据单向流动,跨线程只传值不传指针。SDK 回调线程只负责把字节拷进队列,解码线程只负责把字节变成图像,UI 线程只负责把最新的一张图贴到窗口上,谁都不许越界去碰别人的东西。
2.1 目录结构:从 src/ 到 third_party/ 的职责切分
工程按“来源—解码—呈现”三段切,third_party里只放厂商给的东西,绝不改它一行代码。厂商 SDK 的头文件基本是 C 风格、命名全大写,直接散落在业务代码里会污染整个工程,所以统一裹一层sdk_wrapper。
camera-client/ ├── CMakeLists.txt ├── config/ │ └── cameras.json # 摄像机 IP、端口、通道、账号,运行时可改 ├── src/ │ ├── main.cpp │ ├── ui/ │ │ ├── main_window.cpp # 布局、码流切换、录像按钮 │ │ └── preview_widget.cpp# QWidget 子类,只负责画 QImage │ ├── core/ │ │ ├── sdk_wrapper/ # 厂商 so 的唯一入口,对外只暴露 C++ 接口 │ │ ├── stream/ # 取流抽象:SDK 源 / RTSP 源,同一套接口 │ │ ├── decode/ # H264Decoder,parser + avcodec │ │ └── record/ # 裸流落盘、按时间戳索引 │ └── common/ │ ├── frame_queue.h # 有界帧队列,带丢帧策略 │ └── logger.h └── third_party/ ├── ipc_sdk/{include,lib} # 厂商提供,只读 └── ffmpeg/ # 版本固定的头文件,避免和生产机不一致stream这一层是整个架构的关键:StreamSource是个纯虚基类,SdkStreamSource和RtspStreamSource各自实现,上层解码器完全不知道自己吃的是私有协议还是标准流。现场遇到只支持 RTSP 的第三方球机时,只要新增一个实现类,解码和 UI 一行都不用改。这种“多一层抽象”的写法在 Demo 阶段看着啰嗦,但接入第二家设备时能省下至少两天。
config/cameras.json单独抽出来也是有原因的。现场调试时改 IP、改通道号是高频操作,如果编译进二进制,每次都要重新交叉编译一遍再拷到工控机上,一天下来什么正事都干不了。配置文件热加载,改完重启进程即可。
2.2 三线程模型:谁干活、谁等待、谁绝不阻塞
把线程职责列清楚,后面写代码时就不容易犯迷糊:
| 线程 | 归属 | 职责 | 绝对不能做的事 |
|---|---|---|---|
| SDK 回调线程 | 厂商库内部创建 | 收到一帧回调,深拷贝进队列 | 解码、new QWidget、调用任何 Qt 界面 API |
| 解码线程 | 自己std::thread起 | 从队列取帧,parser 拆包,avcodec 解码,转 RGB | 直接update()界面、长时间持锁 |
| UI 线程 | Qt 主线程 | 定时器驱动重绘,只取最新一帧 | 等待网络、等待解码、任何阻塞调用 |
队列用固定容量的有界队列,满了丢最旧的帧。这一点是血泪经验:早期用的是无界std::deque,网络抖动时 SDK 回调猛灌数据,队列涨到几万帧,内存直接飙到 2G,同时延迟累积到十几秒,画面里的人在动,屏幕上还是十秒前的样子。
// core/stream/frame_queue.h // 有界帧队列:SDK 回调线程写,解码线程读。 // 满了丢最旧的一帧 —— 实时预览里“最新”比“完整”重要得多。 class FrameQueue { public: explicit FrameQueue(size_t cap = 90) : cap_(cap) {} void push(Frame&& f) { std::lock_guard<std::mutex> lk(m_); if (q_.size() >= cap_) q_.pop_front(); // 丢弃最旧,不阻塞回调 q_.push_back(std::move(f)); cv_.notify_one(); } bool pop(Frame& out, int timeoutMs = 200) { std::unique_lock<std::mutex> lk(m_); if (!cv_.wait_for(lk, std::chrono::milliseconds(timeoutMs), [&] { return !q_.empty() || stop_; })) return false; // 超时返回,让解码线程有机会检查退出标志 if (q_.empty()) return false; out = std::move(q_.front()); q_.pop_front(); return true; } void stop() { std::lock_guard<std::mutex> lk(m_); stop_ = true; cv_.notify_all(); } private: std::deque<Frame> q_; size_t cap_; bool stop_ = false; std::mutex m_; std::condition_variable cv_; };cap取 90 是按 25fps 算的,约等于 3.6 秒缓冲,现场网络瞬断一下不会立刻花屏,但也不会积压太久。pop的timeoutMs不能省,否则stop_置位后解码线程还卡在wait上,进程退出要等好几秒甚至退不掉。Frame里就三个字段:std::vector<uint8_t> data、int64_t ptsUs、bool key,全部是值语义,跨线程传着放心。
2.3 编译与运行:把 FFmpeg 和厂商 so 都对上
依赖版本对不上是新人第一天最容易卡住的地方。Ubuntu 上apt install拿到的 FFmpeg 是发行版锁定的版本,头文件和运行库必须来自同一套,千万不能出现“编译用 58 的头、运行链到 60 的 so”,那种崩溃没有任何提示,进程直接消失。
cmake_minimum_required(VERSION 3.16) project(camera_client LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_AUTOMOC ON) # Q_OBJECT 类必须开,忘了就是 vtable 未定义 find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED libavformat libavcodec libavutil libswscale) set(IPC_SDK_ROOT ${CMAKE_SOURCE_DIR}/third_party/ipc_sdk) add_executable(camera_client src/main.cpp src/core/sdk_wrapper/ipc_sdk.cpp src/core/stream/stream_source.cpp src/core/decode/h264_decoder.cpp src/ui/preview_widget.cpp ) target_include_directories(camera_client PRIVATE ${CMAKE_SOURCE_DIR}/src ${IPC_SDK_ROOT}/include # 厂商头文件 ${FFMPEG_INCLUDE_DIRS}) target_link_directories(camera_client PRIVATE ${IPC_SDK_ROOT}/lib) # 厂商只给 so 所在目录 target_link_libraries(camera_client PRIVATE Qt5::Widgets ipc_sdk # 链接时写 ipc_sdk,实际找 libipc_sdk.so ${FFMPEG_LIBRARIES})target_link_directories需要 CMake 3.13 以上,这行省不掉——厂商通常不提供.cmake配置文件,只能手动指定so目录。链接名去掉了lib前缀和.so后缀,写全名反而会失败。
# 1. 装编译依赖(Ubuntu / Debian 系) sudo apt install -y build-essential cmake pkg-config qtbase5-dev \ libavformat-dev libavcodec-dev libavutil-dev libswscale-dev # 2. 编译 mkdir -p build && cd build cmake .. -DCMAKE_BUILD_TYPE=Release make -j$(nproc) # 3. 厂商 so 不在系统路径里,运行时手动指一下,别去改 ld.so.conf export LD_LIBRARY_PATH=$PWD/../third_party/ipc_sdk/lib:$LD_LIBRARY_PATH ./camera_client用LD_LIBRARY_PATH而不是往/etc/ld.so.conf.d/里塞配置,是因为工控机现场往往要同时装好几个版本的客户端,改全局搜索路径迟早出事。-DCMAKE_BUILD_TYPE=Release也别省,Debug 版下sws_scale的耗时能到 Release 的三倍,1080p 四路直接跑不动。
3. 摄像机 SDK 取流:从登录到回调,每一步都在跟线程打交道
厂商 SDK 的接口风格通常是“一次初始化、一次登录、一次订阅、剩下全靠回调”,回调里给一个指向内部缓冲区的指针。这套模式本身没毛病,毛病出在很多人拿到指针就地开始干活——在里面做解码、算时间戳、发信号给 UI。正确姿势只有一句:回调里除了 memcpy 和入队,什么都别做。下面按调用顺序拆一遍。
3.1 初始化与登录:参数错一个就是超时
登录参数里最容易填错的是端口和通道号。私有协议端口不一定是 HTTP 的 80,也不一定是 RTSP 的 554,具体数值以厂商文档为准;通道号从 0 还是从 1 开始,不同厂差异极大,接错了表现是登录成功但拉不到流,很容易误判成网络问题。
| 参数 | 典型值 | 说明 |
|---|---|---|
ip | 192.168.1.64 | 设备地址,注意工控机要同网段 |
port | 按文档 | 私有协议端口,填错就是连接超时 |
user/passwd | 出厂账号 | 密码错误和端口错误的返回码常常是同一个 |
channel | 0 或 1 | 起始索引因厂而异,接不到流先查这个 |
streamType | 0 主 / 1 子 | 主码流 1080p,子码流 720p 或 D1,多路预览优先子码流 |
// core/sdk_wrapper/ipc_sdk.cpp // 函数名按厂商头文件替换,这里只保留调用顺序和参数形态 bool IpcSdk::login(const CameraInfo& info) { // 1. 全局初始化,进程内只做一次,重复调用多数厂商会返回错误 if (!sdkInited_) { if (IPC_Init() != IPC_OK) { LOG_E("sdk init failed"); return false; } sdkInited_ = true; } // 2. 登录,拿到设备句柄 IPC_LOGIN_PARAM p{}; std::snprintf(p.ip, sizeof(p.ip), "%s", info.ip.c_str()); p.port = info.port; p.user = const_cast<char*>(info.user.c_str()); p.passwd = const_cast<char*>(info.passwd.c_str()); handle_ = IPC_Login(&p); if (handle_ <= 0) { // 厂商一般用 <=0 表示失败 LOG_E("login failed, ret=%d", handle_); return false; } // 3. 订阅实时流,把回调注册进去 IPC_REALPLAY_PARAM rp{}; rp.channel = info.channel; rp.streamType = info.subStream ? 1 : 0; rp.cb = &IpcSdk::onStreamCallback; // 必须是静态函数或自由函数 rp.user = this; // 把自己传回去,回调里还原对象 playHandle_ = IPC_RealPlay(handle_, &rp); return playHandle_ > 0; }回调必须是静态函数,这是 C 风格 SDK 的通病:它们只认函数指针,不认成员函数。惯用解法是登录时通过user字段把this塞进去,在静态回调里static_cast回来。另外IPC_Init要在所有登录之前完成,如果程序里有多路摄像机,务必用一个std::once_flag或者原子标志保护,重复初始化在部分厂商的库里会直接导致后续所有登录失败。
3.2 回调里只做一件事:深拷贝进队列
回调函数是被厂商库的内部线程调用的,线程优先级、调用频率、是否会重入全部不可控。最保险的写法是:校验参数、拷贝数据、入队、立刻返回。整个函数控制在微秒级,绝不做任何可能阻塞的事情。
// 静态回调,运行在 SDK 内部线程上 void IpcSdk::onStreamCallback(int playHandle, IPC_FRAME_INFO* f, void* user) { auto* self = static_cast<IpcSdk*>(user); if (!f || f->dataSize <= 0) return; // 心跳包、空帧直接丢 Frame frame; frame.ptsUs = f->ptsUs; // 厂商一般给微秒,没有就用本地时钟补 frame.key = (f->frameType == IPC_FRAME_I); // 必须深拷贝:f->data 指向 SDK 复用的内部缓冲区, // 回调返回后随时可能被下一帧覆盖,这是花屏的头号来源 frame.data.assign(reinterpret_cast<uint8_t*>(f->data), reinterpret_cast<uint8_t*>(f->data) + f->dataSize); self->queue_->push(std::move(frame)); // 内部加锁,临界区只有 deque 操作 }assign这一行看着平淡,其实是整个取流链路的命门。早期为了省一次内存拷贝,直接把f->data指针存进队列,解码线程拿到的往往是“下一帧已经写了一半”的缓冲区,画面表现是随机位置的花屏,一天出现两三次,查了整整一周。内存拷贝 1080p 一帧约 200KB,25fps 下每秒 5MB 的拷贝量,对现代 CPU 来说完全不值一提,省这一下毫无意义。
ptsUs要留意:部分厂商在首帧给0或AV_NOPTS_VALUE之类的无效值,需要自己用std::chrono::steady_clock补一个单调递增的时间戳,否则后面做录像索引时排序会乱。
3.3 RTSP 兼容分支:超时和 TCP 传输必须设死
现场不可能只有一家设备。甲方后来加了两台第三方球机,只支持标准 RTSP,这时候StreamSource那层抽象就派上用场了——解码器代码一行没改,只新增了一个RtspStreamSource。
// core/stream/rtsp_stream_source.cpp bool RtspStreamSource::open(const std::string& url) { AVDictionary* opts = nullptr; // 默认走 UDP,丢包后花屏能持续好几秒,局域网也建议强制 TCP av_dict_set(&opts, "rtsp_transport", "tcp", 0); av_dict_set(&opts, "stimeout", "3000000", 0); // 3 秒,单位微秒;新版本改叫 timeout av_dict_set(&opts, "max_delay", "500000", 0); fmt_ = avformat_alloc_context(); // 中断回调,拔网线时靠它把 av_read_frame 从阻塞里拽出来 fmt_->interrupt_callback.callback = &RtspStreamSource::interruptCb; fmt_->interrupt_callback.opaque = this; if (avformat_open_input(&fmt_, url.c_str(), nullptr, &opts) < 0) { av_dict_free(&opts); return false; } av_dict_free(&opts); // 用完必须释放,哪怕内容为空 if (avformat_find_stream_info(fmt_, nullptr) < 0) return false; videoIdx_ = av_find_best_stream(fmt_, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); return videoIdx_ >= 0; } // 返回 1 表示中断,FFmpeg 会立刻让当前阻塞调用返回 AVERROR_EXIT int RtspStreamSource::interruptCb(void* opaque) { auto* self = static_cast<RtspStreamSource*>(opaque); return self->stopFlag_.load() ? 1 : 0; }stimeout这个键名是历史遗留,老版本 FFmpeg 用它,较新版本改成了timeout,两个都设一遍最省事,多余的键会被忽略。中断回调里只能用原子变量,不能加锁、不能调av_*函数,因为它可能在 FFmpeg 内部的任意位置被调用。
RTSP 分支和 SDK 分支最后都往同一个FrameQueue里塞数据,只是 RTSP 侧需要先av_read_frame拿到AVPacket再取pkt->data,PTS 换成pkt->pts * av_q2d(stream->time_base) * 1e6转成微秒。上层解码器对此一无所知,这正是分层带来的好处。
4. FFmpeg 解码与渲染:从裸流到屏幕上的那张 QImage
拿到 H.264 裸流之后,剩下的活是把它变成QImage。这里踩的坑比取流还多,因为 FFmpeg 的 API 在 3.x 到 6.x 之间改过好几轮,网上搜到的示例一半跑不通。整套链路拆开是四步:拆包、送解码器、取帧、转 RGB。
4.1 解码器初始化:裸流必须过 parser
关键区别在于数据来源。RTSP 拿到的AVPacket已经带了 SPS/PPS,解码器自己能搞定;而 SDK 回调给的是纯裸流,SPS/PPS 可能夹在第一帧里,也可能根本没给。这时候必须用av_parser先把字节流切成完整的 NAL 单元,它顺手会把 SPS/PPS 拼到第一个 packet 前面。
// core/decode/h264_decoder.cpp bool H264Decoder::open() { codec_ = avcodec_find_decoder(AV_CODEC_ID_H264); if (!codec_) return false; ctx_ = avcodec_alloc_context3(codec_); ctx_->flags |= AV_CODEC_FLAG_LOW_DELAY; // 预览场景不要帧重排缓冲 ctx_->thread_count = 4; // 多路时按核数分摊,别每路都 0(自动) ctx_->thread_type = FF_THREAD_SLICE; if (avcodec_open2(ctx_, codec_, nullptr) < 0) return false; // 裸流专用:没有容器提供 extradata,靠 parser 拼出完整 NAL parser_ = av_parser_init(AV_CODEC_ID_H264); return parser_ != nullptr; }如果厂商 SDK 会单独给出 SPS/PPS(有些接口会在流开始前回调一帧FRAME_TYPE_SPS_PPS),那就把这几十个字节手动塞进ctx_->extradata,长度写extradata_size,再加AV_CODEC_FLAG_GLOBAL_HEADER。这两种做法二选一,不要都做,重复塞会导致解码器报 “SPS unavailable” 之类的错误。
thread_count设 4 而不是 0,是因为 8 路预览时如果每路都让 FFmpeg 自动决定,它会按机器核数开满,几十个线程互相抢 CPU,帧率反而掉得厉害。按“核数 / 路数”手工分配,实测更稳。
4.2 send / receive 循环与时间戳处理
新版 FFmpeg 只有一套正确用法:avcodec_send_packet送入,avcodec_receive_frame取帧,两个函数各要循环到返回AVERROR(EAGAIN)为止。裸流的时间戳只能自己造,用采集时刻的微秒值灌进pkt->pts和pkt->dts,不要试图从字节流里解析。
void H264Decoder::feed(const uint8_t* data, int size, int64_t ptsUs) { while (size > 0) { uint8_t* out = nullptr; int outSize = 0; // parse2 返回消耗掉的字节数,out/outSize 是切出来的完整 NAL int used = av_parser_parse2(parser_, ctx_, &out, &outSize, data, size, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0); if (used <= 0) break; // 防止脏数据导致死循环 data += used; size -= used; if (outSize > 0) decodePacket(out, outSize, ptsUs); } } void H264Decoder::decodePacket(const uint8_t* data, int size, int64_t ptsUs) { AVPacket* pkt = av_packet_alloc(); av_new_packet(pkt, size); std::memcpy(pkt->data, data, size); pkt->pts = pkt->dts = ptsUs; // 用采集时间,别信裸流里的值 if (avcodec_send_packet(ctx_, pkt) == 0) { AVFrame* f = av_frame_alloc(); while (avcodec_receive_frame(ctx_, f) == 0) { if (sink_) sink_(toImage(f)); // 转 RGB,交给 UI 线程 av_frame_unref(f); // 复用结构体,别每次都 free/alloc } av_frame_free(&f); // 循环结束才真正释放 } av_packet_free(&pkt); // pkt 和 pkt->data 一起回收 }av_frame_unref和av_frame_free的区别是新手最常搞混的:前者只清空内容、保留结构体,可以反复用;后者彻底释放。一轮receive_frame里可能吐出多帧(B 帧重排),也可能一帧都没有,所以unref必须放在循环内。
时间戳统一用微秒是刻意的选择。SDK 给微秒,RTSP 的AVStream::time_base换算过来也是微秒,录像索引和回放跳转时全部用同一个单位,省掉一堆整数除法带来的取整误差。
4.3 YUV 转 RGB:sws_scale 与 QImage 的生命周期
解码出来的AVFrame是AV_PIX_FMT_YUV420P,Qt 画不了,得转成Format_RGB32。SwsContext的创建相当贵,不能每帧都建,要按分辨率缓存——摄像机切主辅码流时分辨率会变,这时候才重建。
QImage H264Decoder::toImage(AVFrame* f) { if (!sws_ || lastW_ != f->width || lastH_ != f->height) { sws_freeContext(sws_); sws_ = sws_getContext(f->width, f->height, (AVPixelFormat)f->format, f->width, f->height, AV_PIX_FMT_RGB32, SWS_BILINEAR, nullptr, nullptr, nullptr); lastW_ = f->width; lastH_ = f->height; } QImage img(f->width, f->height, QImage::Format_RGB32); uint8_t* dst[4] = { img.bits(), nullptr, nullptr, nullptr }; int dstStride[4] = { static_cast<int>(img.bytesPerLine()), 0, 0, 0 }; // dstStride 必须传 bytesPerLine,宽度*4 在非对齐宽度下会错行 sws_scale(sws_, f->data, f->linesize, 0, f->height, dst, dstStride); return img; }bytesPerLine()这行坑过很多人。QImage为了内存对齐,每行字节数不一定等于width * 4,直接传width * 4会让画面整体斜着错位,看起来像“图像撕裂”,其实是 stride 对不上。
SWS_BILINEAR是画质和速度的平衡点,如果要更省 CPU 可以换SWS_FAST_BILINEAR,在 1080p 缩到 400x300 的小窗口里肉眼几乎看不出差别。四路 1080p 全用SWS_BICUBIC的话,CPU 能吃掉两个核。
4.4 渲染与丢帧:UI 线程只认最新一帧
解码再快也没意义,屏幕只有 60Hz。UI 侧用一个 30ms 的QTimer驱动重绘,加上“只保留最新帧”的策略,解码线程吐多少帧都无所谓,画的时候永远拿的是最新那张。
// ui/preview_widget.cpp void PreviewWidget::onFrameReady(const QImage& img) { QMutexLocker lk(&m_); pending_ = img; // 覆盖旧值,中间帧自动丢弃 hasNew_ = true; } void PreviewWidget::paintEvent(QPaintEvent*) { QImage img; { QMutexLocker lk(&m_); if (!hasNew_) return; // 没有新帧就保持上一帧,别清屏 img = pending_; hasNew_ = false; } QPainter p(this); p.setRenderHint(QPainter::SmoothPixmapTransform, false); // 关掉,缩放开销不小 p.drawImage(rect(), img.scaled(size(), Qt::KeepAspectRatio, Qt::FastTransformation)); }onFrameReady必须通过QMetaObject::invokeMethod(..., Qt::QueuedConnection)或者信号槽跨到 UI 线程,绝不能直接在解码线程里调update()。paintEvent的锁范围要尽量小,只保护pending_和hasNew_两个字段,图像缩放放到锁外面做,否则 4 路同时刷新时会互相等锁,界面肉眼可见地发涩。
SmoothPixmapTransform关掉是有意的:这个开关打开后 Qt 会走平滑采样,1080p 缩到小窗口时单帧多花 2~3ms,四路就是十几毫秒,帧率直接掉一档。预览窗口本来就不追求印刷级画质。
5. 避坑与排查:这套组合在 Linux 上最容易翻车的五个点
下面五条都是现场真出过的,按“现象 → 原因 → 解决”记下来,遇到时能少走弯路。
5.1 画面绿屏或上半部分花屏
现象:程序刚起来第一帧是绿的,或者画面下半截是花的,过几秒自己好了;有时候一直不好。
原因:解码器拿到的第一个 packet 里没有 SPS/PPS。裸流模式下,如果 SDK 的回调从 P 帧开始给(中途订阅经常这样),avcodec根本不知道分辨率和帧参数,只能吐垃圾。另一种情况是 GDR(gradual decoder refresh)编码,关键帧信息分几帧才拼完整。
解决:一是确认av_parser_init那条路径生效,且第一帧确实是 IDR;二是订阅后主动丢弃所有非关键帧,直到收到frameType == IPC_FRAME_I为止再开始解析。这个“等首帧”的逻辑一定要加,否则每次切码流都会绿一下。
// 订阅后的前处理:等到第一个 I 帧再开闸 if (!gotKeyFrame_) { if (!frame.key) return; // 非关键帧直接丢,别送进 parser gotKeyFrame_ = true; }5.2 跑十分钟内存涨到 2G
现象:top里 RES 从 200M 一路涨,成了阶梯状,到最后被 OOM Killer 干掉;画面本身看不出来异常。
原因:三个地方最常见。AVFrame只用av_frame_free释放了最外层没有unref;AVPacket用av_packet_alloc之后忘了free;SwsContext每次toImage都新建一个。还有一种隐蔽的:QImage包装了外部缓冲区但没copy(),QImage的隐式共享让那块外部内存被反复引用,谁都没释放。
解决:把unref和free的位置固定下来(一轮 receive 结束av_frame_free,一个 packet 处理完av_packet_free),SwsContext按分辨率缓存,QImage一定返回拥有自己 buffer 的实例。验证方式很土但有效:用valgrind --leak-check=full跑十分钟,看definitely lost是不是零。
5.3 回调线程直接刷 UI,偶发段错误
现象:平时正常,切通道或者关窗口时程序直接崩,gdb的栈一半在 Qt 的绘制代码里,一半在厂商 SDK 的线程里。
原因:在 SDK 回调里调了update()、setPixmap(),或者用Qt::DirectConnection连了跨线程的信号槽。Qt 的绘图必须在主线程,跨线程调用属于未定义行为,崩不崩全看运气。
解决:所有跨线程通信统一走Qt::QueuedConnection,信号参数用值类型(QImage是可隐式共享的值类型,可以直接传)。窗口关闭时先stop()队列、join解码线程、再IPC_Logout,顺序反了就会出现“对象已析构但回调还在跑”。
5.4 拔网线后程序卡死不退出
现象:网络断掉后界面卡住,点关闭按钮没反应,Ctrl+C也退不掉,只能kill -9。
原因:av_read_frame在等 socket 数据,没有超时会一直阻塞;SDK 的IPC_StopRealPlay如果内部在等回调返回,也会挂住。这两种阻塞都不在 Qt 的控制范围内。
解决:RTSP 侧靠interrupt_callback置位中断,SDK 侧在退出前先注销回调再停流。另外给整个退出流程加一个 5 秒的兜底定时器,超时就_exit(),虽然粗暴但比现场打电话让你远程处理要好。
5.5 编译通过,运行时报找不到 so 或符号未定义
现象:./camera_client直接报error while loading shared libraries: libxxx.so: cannot open shared object file;或者链接阶段一堆undefined reference to ...。
原因:前者是LD_LIBRARY_PATH没设,或者是 32/64 位不匹配(厂商只给了 32 位库,你编了 64 位程序);后者常见于厂商库用老版 GCC 编译,std::string的 ABI 和你这边不一致。
解决:先file libipc_sdk.so看架构,再ldd看依赖有没有缺;ABI 问题在编译时加-D_GLIBCXX_USE_CXX11_ABI=0试试,注意这个宏必须对整个工程统一,不能只加在一个文件上,否则链接期会出现更诡异的符号错位。厂商只给 32 位库的话,编译时加-m32并装齐gcc-multilib,别想着混编。
6. 二次开发:把单路 Demo 改成四路预览需要动哪些地方
单路跑通之后,几乎所有人下一步都是“我想同时看四路”。这件事在架构上不难,CameraSession这个类就是为这个准备的:把IpcSdk、FrameQueue、H264Decoder、PreviewWidget四件东西打包成一个自洽的单元,构造时传一份CameraInfo,析构时自己清理干净。四路就是new四个CameraSession,主窗口用QGridLayout摆四个PreviewWidget。
真正要注意的是资源分配。四路 1080p 主码流在 i5 上解码占用约 60% CPU,如果每路thread_count都设 4,FFmpeg 会开 16 个解码线程,上下文切换的损耗比并行收益还大。我的做法是按路数动态算:thread_count = std::max(1, cores / sessionCount),四路时每路 2,八路时每路 1,实测总吞吐比每路 4 高出 15% 左右。
| 改造点 | 改动位置 | 大致工作量 |
|---|---|---|
| 单路 → 多路 | 主窗口布局 +CameraSession列表 | 半天 |
| 主辅码流热切换 | IpcSdk::switchStream+ 队列重建 | 一天,需处理首帧等待 |
| 本地录像 | 新增Recorder,在入队前落盘 | 一天,注意加文件头 |
| 时间轴回放 | 按ptsUs建索引,顺序读文件喂解码器 | 两到三天 |
| 画面叠加 OSD | paintEvent里drawText | 两小时 |
录像这块有个容易忽略的点:裸流落盘的文件没有时间戳索引,回放时无法定位。惯常做法是写一个自建的.idx伴生文件,每收到一个 I 帧就记一行ptsUs + 文件偏移,回放拖动进度条时二分查最近的关键帧偏移,再seek过去。这套索引格式自己定就行,不用兼容任何标准。
验证改造成果有个很省事的办法:用ffmpeg -re -i test.mp4 -c copy -f rtsp rtsp://127.0.0.1:8554/live起一路本地测试流(需要先起一个 RTSP 服务),把客户端指向它,这样可以稳定复现各种分辨率和码率,不用每次跑去机房插网线。压测时把码率调到 8Mbps 以上,能很快暴露队列积压和内存泄漏问题。
最后说个自己的教训。早期做这类项目,我总想着“先把功能跑通,参数以后再说”,结果每次到了现场才发现超时值、队列容量、线程数全都是拍脑袋定的,一出问题就得靠猜。从那以后我每次接新的摄像机 SDK,都强制先把三件事写死并记在配置里:回调线程里的最大拷贝耗时、队列上限对应的秒数、断线重连的退避间隔。这三组数字定下来,后面 80% 的“偶发卡顿”都不会发生。希望帮到你。
本文还有配套的精品资源,点击获取