☰
Qt+ffmpeg+IPC SDK:Linux 多路视频预览链路搭建与避坑
2026/10/31 8:38:09 网站建设 项目流程

简介:面向Linux平台多媒体应用开发的Qt与大华摄像机SDK、ffmpeg解码整合工程包,适合具备C++与Qt基础、希望打通GUI、设备取流和视频解码链路的中级开发者参考。压缩包共2000个文件,约156.52MB,包含大量C/H源码、汇编与头文件、o目标文件、so/a库、pc配置、mak与makefile构建脚本,以及texi文档和多格式测试样本,覆盖Qt5.9.1工程构建、SDK依赖与ffmpeg编译产物等模块。已有1036人学习。资源以DH_Camera项目为线索,呈现Qt界面工程、大华摄像机SDK接入及ffmpeg解码显示相关代码与依赖组织,便于读者理解设备控制、实时视频流处理、YUV/RGB数据呈现和工程依赖管理中的关键环节,也可作为类似摄像机与多媒体项目的整合参考。

1. 凌晨两点,工控机上的预览窗口全是黑的

摄像机在线,网线绿灯在闪,ping通,可 Linux 桌面上那个 Qt 窗口就是一片黑。这种现场问题我遇到过太多次,最后定位下来,八成不是摄像机坏了,而是「取流 → 解封装 → 解码 → 上屏」这条链路上某一环悄悄断了。

这套工程干的就是把链路接通:Qt负责界面、线程调度和图像呈现,摄像机厂商的IPC SDK负责登录设备、拉实时码流,ffmpeg负责把 SDK 吐出来的私有封装数据解复用、解码成图像。整套东西跑在 Linux 上,目标机器往往是工控机或者国产化桌面,不是开发机。

适合谁看:做安防上位机、工业视觉预览、多路视频墙的开发者;手上已经有一份能跑的 demo,但一到多路、一到长时间运行就开始黑屏、花屏、内存涨、退出崩溃的人。下面按「工程怎么搭 → 流怎么进来 → 帧怎么出来 → 坑在哪 → 怎么改」的顺序拆。

2. 工程骨架:sdk、ffmpeg、业务代码三层怎么切

先把目录定下来,后面所有问题都好查。这类项目最常见的失败姿势是:SDK 的头文件和系统里的 ffmpeg 头文件混在一个 include 路径里,编译期没事,运行期找不到.so,现场装机直接翻车。

2.1 目录分层:三块东西物理隔开

我的习惯是彻底分开,厂商 SDK 一个目录、业务源码一个目录、构建产物一个目录,谁也别碰谁:

ipc_preview/ ├── CMakeLists.txt ├── sdk/ # 厂商 SDK,整包丢进来,不改一行 │ ├── include/ # 头文件 │ └── lib/ │ ├── libipc_sdk.so │ └── libipc_sdk.so.1 -> libipc_sdk.so.1.2.3 ├── src/ │ ├── main.cpp │ ├── MainWindow.h/.cpp # 界面、分屏布局、按钮 │ ├── StreamWorker.h/.cpp # SDK 登录/起流/回调,跑在子线程 │ ├── FrameQueue.h/.cpp # 回调线程 → 解码线程的字节队列 │ └── FfmpegDecoder.h/.cpp # ffmpeg 解复用 + 解码 + 转 RGB └── resources/ └── style.qss

StreamWorker和FfmpegDecoder一定分开。我见过把 SDK 回调和avcodec_send_packet写进同一个函数的代码,结果解码耗时把 SDK 的回调线程堵死,SDK 内部缓冲区溢出,表现就是「预览三秒后画面卡住,日志里没有任何报错」。分层之后,SDK 回调只管把字节塞进队列,解码线程自己按节奏消费,两边互不拖累。

FrameQueue是这个设计的核心。它是个带互斥锁和条件变量的环形字节队列,写端是 SDK 回调线程,读端是 ffmpeg 的 IO 回调。容量要设上限(我一般给 4~8 MB),满了就丢最老的数据而不是阻塞——实时预览丢几帧没人看得出来,阻塞住整条链路就是灾难。

2.2 CMake 链接与运行时找库:$ORIGIN必配

编译能过、运行报error while loading shared libraries: libipc_sdk.so: cannot open shared object file,这是现场部署第一大坑。系统里没有这个库,你也不能保证目标机器上有 root 权限去改/etc/ld.so.conf。

cmake_minimum_required(VERSION 3.16) project(ipc_preview LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 有 Q_OBJECT 就必须开 # 关键:让程序优先从自己所在目录的 lib/ 找 .so,装到哪都能跑 set(CMAKE_BUILD_WITH_INSTALL_RPATH TRUE) set(CMAKE_INSTALL_RPATH "$ORIGIN/lib;$ORIGIN") find_package(Qt5 REQUIRED COMPONENTS Core Gui Widgets) find_package(PkgConfig REQUIRED) pkg_check_modules(FFMPEG REQUIRED libavformat libavcodec libavutil libswscale) add_executable(ipc_preview src/main.cpp src/MainWindow.cpp src/StreamWorker.cpp src/FrameQueue.cpp src/FfmpegDecoder.cpp ) target_include_directories(ipc_preview PRIVATE ${CMAKE_SOURCE_DIR}/sdk/include ${FFMPEG_INCLUDE_DIRS} ) target_link_directories(ipc_preview PRIVATE ${CMAKE_SOURCE_DIR}/sdk/lib) target_link_libraries(ipc_preview PRIVATE Qt5::Widgets ${FFMPEG_LIBRARIES} ipc_sdk # 对应 libipc_sdk.so )

$ORIGIN是 ELF 的动态链接器占位符,表示「可执行文件自身所在目录」。写成$ORIGIN/lib之后,只要把libipc_sdk.so拷到可执行文件旁边的lib/目录,任何路径启动都能找到它。

CMAKE_BUILD_WITH_INSTALL_RPATH TRUE这行容易被忽略:默认情况下构建产物的 rpath 是构建目录路径,只有make install之后才写成$ORIGIN。你要是不加这行,直接从build/目录拿二进制去现场跑,一样找不到库。

参数说明:pkg_check_modules(FFMPEG REQUIRED ...)会从系统 pkg-config 里取 ffmpeg 的头文件和链接参数;如果目标机的 ffmpeg 是自编译装在/opt下,需要额外给PKG_CONFIG_PATH=/opt/ffmpeg/lib/pkgconfig,这一点在交叉编译或者国产化发行版上尤其常见,因为系统源自带的 ffmpeg 版本往往偏老。

2.3 依赖体检:上线前跑一遍 ldd

程序在开发机上跑得好好的,拷到工控机上就起不来,九成是动态库缺失或者版本对不上。上线前把这三条命令跑一遍,比在现场猜快得多:

# 1) 看所有依赖是否都能解析,缺的会显示 not found ldd ./ipc_preview | grep -E "not found|=>" # 2) 确认 ffmpeg 的实际版本,4.x 和 5.x 在解码接口上有差异 pkg-config --modversion libavcodec # 3) 确认 SDK 的 ABI 架构跟主程序一致(x86_64 / aarch64 别搞混) file ./ipc_preview sdk/lib/libipc_sdk.so

第一条里出现not found就别急着上现场了。第二条用来判断代码该用哪套解码接口:avcodec_send_packet/avcodec_receive_frame这套 send/receive 模型在新版本里是唯一推荐路径,老版本里还能见到avcodec_decode_video2,两套写法不能混用。第三条是交叉编译时的血泪经验——给 ARM 工控机编译,结果 SDK 是 x86 的,编译能过,运行直接Exec format error。

提示:如果目标机器上的 SDK 只有.so没有.so.1这类版本软链,target_link_libraries里不要写绝对路径,写库名ipc_sdk,靠target_link_directories找,否则换一台机器路径一变就要重新改 CMake。

3. 取流链路:从设备登录到回调拿到裸码流

链路的前半段全在厂商 SDK 里,代码不多但顺序极重要。句柄的生命周期管不好,退出崩溃、重连失败、句柄泄漏全从这里来。

3.1 登录与起流:句柄必须成对出现

SDK 的调用顺序基本固定:初始化 → 登录 → 起流 → 停流 → 登出 → 反初始化。每一步都可能失败,失败后要把已经成功的步骤按逆序回退。

// 函数名按你手上 SDK 的头文件替换,逻辑一致即可 bool StreamWorker::start(const StreamConfig &cfg) { // 1) 全局初始化,进程内只需一次;有些 SDK 内部按引用计数,重复调用无害 if (!IPC_Init(nullptr)) { emit errorOccurred(QStringLiteral("SDK 初始化失败")); return false; } // 2) 登录设备,拿长连接句柄 IPC_DEVICE_INFO dev{}; dev.nPort = static_cast<IPC_WORD>(cfg.port); strncpy(dev.szIP, cfg.ip.toUtf8().constData(), sizeof(dev.szIP) - 1); strncpy(dev.szUser, cfg.user.toUtf8().constData(), sizeof(dev.szUser) - 1); strncpy(dev.szPwd, cfg.pwd.toUtf8().constData(), sizeof(dev.szPwd) - 1); m_loginId = IPC_Login(&dev, /*timeoutMs=*/5000, /*retry=*/3); if (m_loginId == 0) { emit errorOccurred(QStringLiteral("登录失败,错误码 %1").arg(IPC_GetLastError())); IPC_Cleanup(); return false; } // 3) 起实时预览:channel 通道号,0 表示主码流 m_playId = IPC_StartRealPlay(m_loginId, cfg.channel, /*streamType=*/0, &StreamWorker::onRealData, this); if (m_playId == 0) { emit errorOccurred(QStringLiteral("起流失败,错误码 %1").arg(IPC_GetLastError())); IPC_Logout(m_loginId); // 起流失败也要回退登录 m_loginId = 0; IPC_Cleanup(); return false; } return true; }

参数说明:timeoutMs是 TCP 连接超时,内网给 3000~5000 就够,跨网段或者无线链路给到 8000 更稳妥;retry是重试次数,别设太大,否则界面会长时间卡在「连接中」而没有任何反馈。cfg.channel是通道号,单设备从 0 开始,接 NVR 时按 IPC 挂载顺序递增,这里一定要让用户可配,写死是二次开发的死穴。

this作为 user 参数传进回调,是整个设计里最需要小心的地方:SDK 会把指针原样回传,如果对象先析构了、SDK 还在回调,就是标准的野指针访问。所以停流必须在对象析构函数的第一行执行,而且要等回调真正停下来(后面避坑章节细讲)。

3.2 回调线程只做两件事:拷贝和入队

SDK 的数据回调跑在自己的线程里,这个线程和 Qt 主线程没有任何关系。在里面做解码、做 UI 更新、做日志刷盘,轻则卡顿,重则死锁。

// 这个函数运行在 SDK 内部线程,不是 Qt 主线程 void StreamWorker::onRealData(IPC_LONG playId, IPC_DWORD dataType, unsigned char *buf, IPC_DWORD size, void *user) { auto *self = static_cast<StreamWorker *>(user); if (!self || !buf || size == 0) { return; } // dataType 常见取值: // 0 = 私有封装数据(PS 流或带 SPS/PPS 的裸流)—— 工程里只接这个 // 1 = 帧数据(裸 H.264/H.265 NAL,缺 SPS/PPS,见 3.3) // 2 = YUV 原始数据 3 = PCM 音频 if (dataType != 0) { return; } // push 内部加锁 + 条件变量唤醒解码线程;队列满时丢最老的帧而不是阻塞 self->m_queue.push(buf, static_cast<size_t>(size)); }

这里的两个参数值得展开。buf是 SDK 内部缓冲区的指针,回调返回后随时可能被复用,所以push里必须做深拷贝,不能只存指针。size是一包数据的字节数,单包通常几 KB 到几十 KB,PS 流的 I 帧可能超过 100 KB,队列单槽容量别按 4 KB 估。

playId在多路场景下用来区分是哪一路的流。如果你们是多通道共用一个回调(有些 SDK 支持),一定要在回调开头按playId分发到对应的队列,否则多路数据混进同一个队列,解码器会疯掉——表现就是画面剧烈撕裂、时间戳乱跳。

3.3 码流类型判断:为什么必须接私有流

这是新手最容易踩的坑,也是「回调里有数据但画面全黑」的头号原因。

摄像机 SDK 一般能给你三种数据:私有封装流(PS/TS 之类)、裸 NAL 帧数据、解码后的 YUV。三种数据的处理路径完全不同:

dataType数据形态后续处理适用场景
0 私有封装PS 流,含 SPS/PPS 和完整封装头直接喂 ffmpeg 的avformat解复用本工程采用
1 帧数据裸 NAL 单元,通常不含 SPS/PPS需要自己拼 extradata 或周期性缓存 SPS/PPS低延迟场景
2 YUV已解码的原始图像直接 sws_scale 转 RGB,不需要 ffmpeg 解码极低路数、CPU 富余

接 dataType=0 的好处是省事:ffmpeg 自己会从 PS 流里解析出 H.264 的extradata(也就是 SPS/PPS),解码器初始化时能拿到完整参数集,第一帧就能正常解出来。

接 dataType=1 就麻烦了。裸 NAL 里通常不带 SPS/PPS,解码器没有参数集,avcodec_send_packet会一直返回错误,画面永远黑着。要用这条路,你得自己在回调里识别 NAL 类型(H.264 是nal_unit_type == 7/8),把 SPS/PPS 缓存下来,塞进AVCodecContext::extradata,并且处理「中途设备重连、参数集变化」的情况。为了省那点解复用开销,不值得。

注意:不同厂商对 dataType 的编号定义可能不一样,有的把私有流标成 0,有的标成 4。拿到 SDK 后第一件事是写个 demo,把回调里的dataType和size全打出来跑十分钟,看清楚到底给了什么,再决定接哪个分支。

4. ffmpeg 解码模块:内存喂流到 QImage 上屏

后半段是纯 ffmpeg 的活。难点不在 API 本身,而在「数据源不是文件也不是网络 URL,而是一个随时可能断流的回调队列」。这块用自定义 AVIO 解决。

4.1 内存 AVIO:把回调队列伪装成一个文件

avformat_open_input默认只认文件名或 URL。数据在我们自己的队列里,就得用avio_alloc_context造一个自定义 IO 上下文,让 ffmpeg 以为它在读一个文件。

bool FfmpegDecoder::open() { // 1) AVIO 的内部缓冲区,由 ffmpeg 读写,但内存由我们分配 m_avioBuf = static_cast<uint8_t *>(av_malloc(kIoBufferSize)); // 建议 32KB if (!m_avioBuf) return false; // 2) readPacket 是我们的静态回调,opaque 原样回传(这里传队列指针) m_avioCtx = avio_alloc_context(m_avioBuf, kIoBufferSize, /*write_flag=*/0, &m_queue, &FfmpegDecoder::readPacket, nullptr, // 不写 nullptr); // 不支持 seek if (!m_avioCtx) return false; // 3) 把 AVIO 挂到 format context 上,必须打 CUSTOM_IO 标记 m_fmtCtx = avformat_alloc_context(); m_fmtCtx->pb = m_avioCtx; m_fmtCtx->flags |= AVFMT_FLAG_CUSTOM_IO; // 4) PS 流头部信息少,探测窗口给小了会认不出格式 m_fmtCtx->probesize = 512 * 1024; if (avformat_open_input(&m_fmtCtx, nullptr, nullptr, nullptr) < 0) { return false; } if (avformat_find_stream_info(m_fmtCtx, nullptr) < 0) { return false; } m_videoIdx = av_find_best_stream(m_fmtCtx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); return m_videoIdx >= 0; }

AVFMT_FLAG_CUSTOM_IO这个标记不能漏。不打的话,avformat_close_input时会尝试去关闭你并没有打开的文件,行为未定义,运气不好就是 double free。

probesize调到 512 KB 是为了对抗「探测失败」:PS 流的封装头很紧凑,默认探测窗口偏小时,ffmpeg 可能判断不出这是 MPEG-PS,直接返回Invalid data found when processing input。这条错误在现场日志里出现,先加 probesize,别急着怀疑码流。

读回调本身很短,关键是阻塞语义要对:

int FfmpegDecoder::readPacket(void *opaque, uint8_t *buf, int bufSize) { auto *q = static_cast<FrameQueue *>(opaque); // 阻塞直到凑到数据,或者解码器被要求停止 const int n = q->readBlocking(buf, static_cast<size_t>(bufSize)); if (n < 0) { return AVERROR_EOF; // 只有主动 stop() 时才会走到这里 } return n; // 返回实际读取字节数 }

返回值语义:正数是读到的字节数,0 会被 ffmpeg 当成文件末尾(av_read_frame直接返回 EOF,解码循环退出),负数是错误。所以绝对不能在没有数据时返回 0,否则流刚起就被判定结束。正确做法是阻塞在条件变量上等,只有用户点「停止」时才让readBlocking返回 -1,转成AVERROR_EOF优雅收尾。

bufSize是 ffmpeg 传进来的目标缓冲区大小,readBlocking里要按这个上限做截断,绝不能写得比它多。

4.2 解码循环:send 和 receive 的节奏不能乱

拿到AVFormatContext之后就是标准解码流程,但 send/receive 的配对关系一定要理清:一个 packet 送进去,可能吐出 0 帧、1 帧或多帧。

void FfmpegDecoder::decodeLoop() { AVPacket *pkt = av_packet_alloc(); AVFrame *frm = av_frame_alloc(); m_sws = sws_getContext(m_codecCtx->width, m_codecCtx->height, m_codecCtx->pix_fmt, m_codecCtx->width, m_codecCtx->height, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); if (!m_sws) { /* 清理后返回 */ } const int w = m_codecCtx->width; const int h = m_codecCtx->height; std::vector<uint8_t> rgb(static_cast<size_t>(w) * h * 3); while (!m_stop.load(std::memory_order_relaxed)) { // 1) 从自定义 IO 里读一个 packet;返回负值说明流结束或已停止 if (av_read_frame(m_fmtCtx, pkt) < 0) { break; } if (pkt->stream_index != m_videoIdx) { av_packet_unref(pkt); // 音频/数据流直接丢弃,但必须 unref continue; } // 2) 送包失败通常是非关键帧缺参考帧,丢掉继续,不要 break if (avcodec_send_packet(m_codecCtx, pkt) < 0) { av_packet_unref(pkt); continue; } av_packet_unref(pkt); // 送完立刻释放,pkt 可复用 // 3) 一个包可能吐出多帧,用 while 全部取干净 while (avcodec_receive_frame(m_codecCtx, frm) == 0) { uint8_t *dst[4] = { rgb.data(), nullptr, nullptr, nullptr }; int dstLs[4] = { w * 3, 0, 0, 0 }; sws_scale(m_sws, frm->data, frm->linesize, 0, h, dst, dstLs); // QImage 这里是浅包装,rgb 缓冲下一帧就被覆盖,必须 copy() QImage img(rgb.data(), w, h, w * 3, QImage::Format_RGB888); emit frameReady(img.copy()); av_frame_unref(frm); } } av_frame_free(&frm); av_packet_free(&pkt); }

几个参数和返回值的含义必须说清楚。avcodec_receive_frame返回AVERROR(EAGAIN)表示「当前没有帧可出,继续送包」,返回AVERROR_EOF表示解码器彻底结束,返回 0 才是拿到一帧。上面用== 0做循环条件,把 EAGAIN 当退出条件是安全写法;如果你在循环里对 EAGAIN 也做错误处理,就会在每次正常送包时打一堆假错误日志。

avcodec_send_packet失败不 break 是有意为之:网络抖动丢一个 P 帧,后面的 P 帧都会因为缺参考帧报错,但如果直接跳出循环,整路流就断了。丢包继续,等下一个 I 帧回来自动恢复。

m_stop用std::atomic<bool>,不用volatile。有些老代码用bool加 mutex,在回调线程里频繁加锁解锁,CPU 会莫名其妙飙高。

4.3 像素转换与上屏:QImage 的生命周期陷阱

sws_scale那三行里藏着一个非常经典的坑:QImage用外部缓冲区构造时不接管内存所有权,它只是引用。rgb是循环外复用的 vector,第二帧一来,第一帧的像素数据就被覆盖了。如果你在信号槽里跨线程传这个 QImage,接收端拿到的极可能是撕裂的画面。

所以必须img.copy(),它做一次深拷贝,把像素数据搬进 QImage 自己的内存。

跨线程传递还有一步:QImage要注册成元类型,不然队列连接会失败,槽函数根本不执行,但没有任何报错。

// 放在 main() 里 QApplication 构造之后 qRegisterMetaType<QImage>("QImage");

接收端用Qt::QueuedConnection(跨线程默认就是队列连接),槽里只做m_label->setPixmap(QPixmap::fromImage(img))或者update()触发paintEvent。

如果分辨率是 1920×1080,QPixmap::fromImage每帧都要做一次格式转换,四路以上就开始吃 CPU。优化思路是在sws_scale里直接输出AV_PIX_FMT_BGRA,配合QImage::Format_RGB32,让 QImage 和 pixmap 走同一条快路径,省掉一次转换。参数改两处:sws_getContext的目标格式换成AV_PIX_FMT_BGRA,缓冲区大小从w*h*3变成w*h*4,dstLs从w*3变成w*4。

5. 避坑与排查:黑屏、花屏、内存涨、退出崩

这一章是拿事故换来的,每条都按「现象 → 原因 → 解决」写,遇到对应症状直接对号入座。

5.1 黑屏花屏类:回调有数据,画面就是不出来

现象:日志里 SDK 回调一直在打印,size也不为 0,但窗口始终黑的,或者闪过一两帧马赛克就没了。

原因:接了 dataType=1 的裸 NAL 数据。裸流里通常没有 SPS/PPS,avformat_find_stream_info找不到参数集,avcodec_send_packet一直返回AVERROR_INVALIDDATA,解码器吐不出帧。

解决:改接 dataType=0 的私有封装流,让 ffmpeg 从 PS 头里自己解析参数集。如果非要用裸流,必须在AVCodecContext里手动填extradata:

// SPS/PPS 需要从码流里识别 NAL type 7/8 后缓存下来,按 AV_INPUT_BUFFER_PADDING_SIZE 对齐 m_codecCtx->extradata = static_cast<uint8_t *>(av_malloc(spsPps.size() + AV_INPUT_BUFFER_PADDING_SIZE)); memcpy(m_codecCtx->extradata, spsPps.data(), spsPps.size()); memset(m_codecCtx->extradata + spsPps.size(), 0, AV_INPUT_BUFFER_PADDING_SIZE); m_codecCtx->extradata_size = static_cast<int>(spsPps.size());

现象:画面能出,但底部一条绿边,或者画面被拉成斜的。

原因:sws_scale的目标linesize写成了w*3,但实际缓冲区按 4 字节对齐分配,越界写或者读错行。另外sws_getContext的源宽高用了codecCtx的尺寸,而某些流有裁剪信息,应该用frame->width/height。

解决:linesize严格等于目标格式的实际每行字节数(RGB24 是w*3,BGRA 是w*4),缓冲区按linesize * h申请,不要图省事混用。

5.2 卡顿与内存类:跑十分钟就涨到几个 G

现象:单路预览没问题,开四路跑半小时,内存从 300 MB 涨到 3 GB,最后 OOM 被杀。

原因:av_read_frame成功之后忘记av_packet_unref。这个函数内部会引用计数,不 unref 的话每包都泄漏;avcodec_receive_frame拿到帧后不av_frame_unref同理。更隐蔽的是avformat_open_input失败时没释放m_avioCtx和m_avioBuf。

解决:每条路径都保证 unref,失败分支也一样。open 失败时的清理要写全:

void FfmpegDecoder::close() { if (m_codecCtx) avcodec_free_context(&m_codecCtx); // 内部会释放 extradata if (m_fmtCtx) avformat_close_input(&m_fmtCtx); if (m_avioCtx) { // 必须在 close_input 之后 av_freep(&m_avioCtx->buffer); avio_context_free(&m_avioCtx); } }

顺序很重要:avformat_close_input内部会碰m_fmtCtx->pb,所以 AVIO 必须在它之后释放。反过来的话就是 use-after-free,崩溃位置还会飘在完全无关的代码里,查起来非常费劲。

现象:界面一卡一卡的,鼠标拖动窗口时预览直接冻住。

原因:在 SDK 回调里直接调了emit到主线程的槽,而那个槽里做了QPixmap::fromImage加update()。回调线程被主线程的重绘拖着走,SDK 缓冲区堆满。

解决:回调只入队,解码在独立线程,上屏用队列连接并且做丢帧——主线程忙不过来时直接丢弃旧帧,而不是排队。

5.3 退出与部署类:点关闭按钮就段错误

现象:关窗口时进程直接Segmentation fault,gdb 栈里一半是 SDK 内部函数。

原因:析构顺序错了。窗口或 worker 对象先被销毁,SDK 的取流回调还在跑,回调里的user指针已经是野指针。

解决:停流必须放在析构第一行,并且要等回调真正停止。很多 SDK 的StopRealPlay是同步返回的,但内部回调可能还在队列里,稳妥做法是停流之后加一个短延时(100~200 ms)再登出:

StreamWorker::~StreamWorker() { m_stop.store(true); // 先让解码线程准备退出 m_queue.wakeAll(); // 唤醒阻塞在 readBlocking 的线程 if (m_playId) { IPC_StopRealPlay(m_playId); // 先停流 m_playId = 0; } if (m_loginId) { IPC_Logout(m_loginId); // 再登出 m_loginId = 0; } IPC_Cleanup(); // 最后反初始化 m_decodeThread.join(); // 线程 join 必须在句柄释放之后、成员析构之前 }

IPC_StopRealPlay之后如果立刻IPC_Cleanup,SDK 内部可能正处在回调函数中,会崩在第三方库里,栈上什么都看不出来。延时 100 ms 是我试过代价最小、最稳的做法。

现象:开发机好好的,拷到现场机器启动就黑屏,日志里avformat_open_input返回Invalid data。

原因:现场那台机器上装着系统源里的老版本 ffmpeg,probesize默认值更小,同样的 PS 流探测不出来。

解决:把probesize显式设成 512 KB 甚至 1 MB,别依赖默认值。同时用ldd确认实际加载的libavformat.so是从哪来的,避免出现「以为用的是自己编的,实际加载的是系统旧版」。

提示:现场排查黑屏,第一步永远是打日志确认「回调有没有数据 → 队列里有没有堆积 →av_read_frame返回什么 →avcodec_receive_frame返回什么」。这四个点定位下来,问题基本跑不掉。

6. 二次开发:从单路 demo 改成能交付的工具

这套骨架直接当产品用是不够的,但改造成本很低。最常见的两个方向是多路分屏和抓图录像。

6.1 多路分屏:从单实例改成 N 实例

单路代码改成四路,不要试图在一个StreamWorker里塞四个句柄。正确做法是每个通道一个StreamWorker加一个FfmpegDecoder,各自跑在自己的QThread上,主线程只负责布局和渲染。四路 1080p 在 i5 上 CPU 大约 60%~80%,线程数上去之后解码器的内部线程数要压下来,否则 4 路 × 8 线程 = 32 个解码线程,上下文切换的开销比解码本身还大:

// 每路解码器线程数按总核数 / 路数分配,别用默认值 m_codecCtx->thread_count = std::max(1, QThread::idealThreadCount() / channelCount); m_codecCtx->thread_type = FF_THREAD_SLICE; // 切片并行,延迟比帧级并行低

FF_THREAD_SLICE对实时预览更友好,帧级并行(FF_THREAD_FRAME)虽然吞吐高,但会引入额外缓冲延迟。

6.2 加抓图和直存录像

抓图最简单:在解码循环里拿到img之后,如果抓图标志位被置上,img.save(path)就完事,注意别在 UI 线程做。

录像有个省事的做法:不要重新编码,直接把 SDK 的原始码流写文件。SDK 大多提供「原始码流存盘」接口,或者你自己用 ffmpeg 的avformat_alloc_output_context2建一个 mp4 muxer,把AVPacket原样av_interleaved_write_frame写进去。这样 CPU 几乎零开销,缺点是文件只能被标准播放器播,做不了二次转码。

// 只写视频流的最小 muxer 骨架 AVFormatContext *ofmt = nullptr; avformat_alloc_output_context2(&ofmt, nullptr, "mp4", path); AVStream *st = avformat_new_stream(ofmt, nullptr); avcodec_parameters_from_context(st->codecpar, m_codecCtx); // 直接复用解码器参数集 avio_open(&ofmt->pb, path, AVIO_FLAG_WRITE); avformat_write_header(ofmt, nullptr); // 之后每拿到一个视频 packet 就 av_interleaved_write_frame(ofmt, pkt)

avcodec_parameters_from_context这行把 SPS/PPS 一并写进 mp4 的 avcC box,播放器才认。漏了这步,录出来的文件在 VLC 里能播,在别的播放器里黑屏。

6.3 验证清单与一个习惯

改完之后别急着交付,按这几条过一遍:连续跑 8 小时看内存曲线是否平稳;拔网线 30 秒再插回去,看能否自动恢复(不能恢复就在av_read_frame返回负值时重建整个解码链路,别指望原地复活);四路同时开关窗口二十次,看句柄数是否回到初始值;把 CPU 压到 90% 看会不会丢帧到卡死。

从那以后我每次改完取流或解码相关代码,都会强制走一遍「拔网线 → 关窗口 → 重开」这三步,因为真正让项目翻车的从来不是正常流程,而是这些边角状态。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询