资讯详情

Qt与FFmpeg实现RTSP拉流解码上屏:从环境搭建到低延迟重连实战

📅 2026/10/21 14:21:16 | 华诺云谱 👁 阅读
Qt与FFmpeg实现RTSP拉流解码上屏:从环境搭建到低延迟重连实战
简介Qt-FFmpeg-RTSP-Demo 是一份面向 C/Qt 开发者与多媒体编程学习者的实战示例工程聚焦在 Qt 环境中集成 FFmpeg 库实现 RTSP 实时流播放。它解决了开发者不熟悉 FFmpeg API 调用、Qt 事件循环与多线程配合、RTSP 连接与解码流程等常见痛点适合具备一定 C 基础、希望入门网络视频流处理的中级学习者。压缩包共 16 个文件约 14KB以 5 个 cpp 源文件、4 个头文件、3 个 ui 界面文件为主另含 Makefile、pro 工程文件与说明文档覆盖主入口、FFmpeg 封装、界面布局与构建配置等模块。目前已有 671 人学习。通过研读源码读者可掌握 avformat_open_input、av_read_frame 等关键函数的实际用法理解 RTSP 拉流、解码与 Qt 显示的完整链路并借助 Makefile 与 qmake 工程文件熟悉项目构建流程为后续开发监控、直播类应用打下基础。1. 从拉流到上屏Qt 与 FFmpeg 的 RTSP 演示到底解决了什么如果你做过安防、巡检机器人或者工业视觉上位机大概率绕不开一个需求把网络摄像头的 RTSP 流拉下来实时显示在自研的 Qt 界面上。听起来简单真动手就会发现纯 Qt 的QMediaPlayer在不少平台上对 RTSP 支持得并不好延迟高、断流不重连、解码格式受限最后往往还是得回到 FFmpeg 这条老路上。这个 Qt-FFmpeg-RTSP-Demo 就是干这件事的用 FFmpeg 负责拉流和解码用 Qt 负责界面和渲染中间靠信号槽把解码后的帧送到窗口上。它适合两类人一类是刚接触音视频、想找一个能跑通的最小闭环的新手另一类是做上位机、想拿它当骨架往业务里塞功能的熟手。整份工程用 Makefile 组织构建不依赖 qmake 或 CMake对想看清编译链路的人反而更友好。下面我按“能跑起来、能改参数、能排错”的顺序拆一遍。2. 环境搭建与 Makefile 构建把依赖和编译链路先理顺2.1 为什么这个 Demo 用 Makefile 而不是 qmakeQt 工程默认走 qmake 或 CMake但这个 Demo 偏偏用 Makefile原因不复杂它要显式链接 FFmpeg 的一堆库还要控制编译顺序和头文件路径。qmake 的.pro文件写LIBS -lavcodec -lavformat当然也行但一旦涉及不同平台库名差异、静态库顺序、pkg-config 探测Makefile 反而更透明。你可以一眼看到每个.o是怎么来的、链接了哪些.so。对于学习音视频集成的人来说这种“没有黑匣子”的构建方式比 IDE 一键生成更有价值。常见做法是先用pkg-config探测 FFmpeg 的编译和链接参数再拼进 Makefile。这样换机器、换 FFmpeg 安装路径时不用手改一堆-I和-L。2.2 依赖清单与安装在动手编译前先把三样东西备齐Qt 开发库含 Widgets、FFmpeg 开发库含 avcodec/avformat/swscale、以及 pkg-config。以常见的 Linux 发行版为例# 安装 Qt 基础开发包和 FFmpeg 开发库 # 注意不同发行版包名不同这里给的是通用思路 sudo apt install qtbase5-dev qt5-qmake sudo apt install libavcodec-dev libavformat-dev libavutil-dev libswscale-dev sudo apt install pkg-config build-essential # 验证 FFmpeg 开发库是否可被 pkg-config 找到 pkg-config --cflags --libs libavcodec libavformat libavutil libswscale这段命令的逻辑是先装 Qt 和 FFmpeg 的头文件与库文件再装编译工具链最后用pkg-config验证 FFmpeg 是否“可被发现”。如果最后一条命令报Package libavcodec was not found说明 FFmpeg 开发库没装全或者PKG_CONFIG_PATH没指向正确的.pc文件目录。参数上--cflags给的是头文件搜索路径--libs给的是链接库名两者在 Makefile 里分别对应CFLAGS和LDFLAGS。提示如果你是从源码编译的 FFmpeg记得make install后确认/usr/local/lib/pkgconfig在PKG_CONFIG_PATH里否则 pkg-config 找不到。2.3 Makefile 关键变量与编译目标一个能用的 Makefile 核心就三块变量、目标、清理规则。下面是我一般会写的结构你可以直接对照自己的工程改# 编译器与 Qt 相关路径 CXX g MOC moc QT_INC $(shell pkg-config --cflags Qt5Widgets) QT_LIB $(shell pkg-config --libs Qt5Widgets) # FFmpeg 编译与链接参数交给 pkg-config 探测 FF_CFLAGS $(shell pkg-config --cflags libavcodec libavformat libavutil libswscale) FF_LIBS $(shell pkg-config --libs libavcodec libavformat libavutil libswscale) # 最终可执行文件名与源文件 TARGET rtsp_demo SRCS main.cpp mainwindow.cpp decoder.cpp OBJS $(SRCS:.cpp.o) # 编译规则每个 cpp 生成对应 o %.o: %.cpp $(CXX) -c $ -o $ $(QT_INC) $(FF_CFLAGS) -fPIC # 链接规则把所有 o 和库拼起来 $(TARGET): $(OBJS) $(CXX) $(OBJS) -o $(TARGET) $(QT_LIB) $(FF_LIBS) -lpthread # 清理编译产物 clean: rm -f $(OBJS) $(TARGET)逻辑说明%.o: %.cpp是模式规则$代表依赖里的第一个文件即 cpp$代表目标文件即 o。-fPIC在链接动态库时通常需要避免位置无关代码报错。链接阶段把 Qt 库和 FFmpeg 库都放进去-lpthread是因为 FFmpeg 内部会用到线程。参数上如果你用的是 Qt6把Qt5Widgets换成Qt6Widgets即可其余不变。2.4 带 Q_OBJECT 的类别忘了 mocQt 的信号槽依赖 moc元对象编译器预处理。如果你的mainwindow.h里有Q_OBJECT宏就必须在 Makefile 里加一条 moc 规则否则链接时会报undefined reference to vtable。常见做法是# 对含 Q_OBJECT 的头文件生成 moc 源文件 moc_mainwindow.cpp: mainwindow.h $(MOC) $ -o $ # 把生成的 moc 文件也加入编译 OBJS moc_mainwindow.o现象是编译能过、链接报错原因就是 vtable 没生成解决就是把 moc 产物编进去。这一步新手最容易漏漏了会盯着链接错误看半天。3. 拉流与解码FFmpeg 的打开、读包、解码三步走3.1 打开 RTSP 流avformat_open_input 的参数与超时FFmpeg 拉 RTSP 的第一步是avformat_open_input。它负责建立连接、读取流信息。RTSP 和本地文件最大的区别是“网络会断、会慢”所以超时设置必须给否则界面会卡死。AVFormatContext *fmt_ctx NULL; AVDictionary *options NULL; // 设置 RTSP 传输方式为 TCP减少丢包导致的画面花屏 av_dict_set(options, rtsp_transport, tcp, 0); // 设置连接超时和读取超时单位微秒 av_dict_set(options, stimeout, 3000000, 0); int ret avformat_open_input(fmt_ctx, url, NULL, options); if (ret 0) { // 打开失败打印错误信息 char errbuf[128]; av_strerror(ret, errbuf, sizeof(errbuf)); fprintf(stderr, open input failed: %s\n, errbuf); return -1; } // 探测流信息拿不到流信息后面没法解码 ret avformat_find_stream_info(fmt_ctx, NULL); if (ret 0) { fprintf(stderr, find stream info failed\n); return -1; }逻辑说明rtsp_transport设成tcp是常见做法UDP 在丢包网络下容易花屏TCP 更稳但延迟略高。stimeout是 socket 超时单位微秒3000000 即 3 秒。avformat_find_stream_info会实际读一段数据来探测编码格式这一步不能省。参数上如果摄像头是 H.265FFmpeg 版本要够新否则可能探测不到。3.2 找到视频流并准备解码器打开流之后要从fmt_ctx-streams里找到视频流索引再根据codec_id找解码器。音频流在这个 Demo 里可以先忽略。int video_index -1; for (unsigned i 0; i fmt_ctx-nb_streams; i) { if (fmt_ctx-streams[i]-codecpar-codec_type AVMEDIA_TYPE_VIDEO) { video_index i; break; } } if (video_index -1) { fprintf(stderr, no video stream found\n); return -1; } // 根据流参数找解码器 AVCodecParameters *par fmt_ctx-streams[video_index]-codecpar; const AVCodec *codec avcodec_find_decoder(par-codec_id); if (!codec) { fprintf(stderr, decoder not found\n); return -1; } // 分配解码上下文并拷贝参数 AVCodecContext *codec_ctx avcodec_alloc_context3(codec); avcodec_parameters_to_context(codec_ctx, par); // 打开解码器 if (avcodec_open2(codec_ctx, codec, NULL) 0) { fprintf(stderr, open codec failed\n); return -1; }逻辑说明avcodec_find_decoder按 codec_id 找软解器找不到通常是 FFmpeg 编译时没开对应解码器。avcodec_parameters_to_context把流参数拷进解码上下文宽高、像素格式都从这里来。参数上如果你要开多线程解码可以在avcodec_open2前设codec_ctx-thread_count 4让解码吃满多核。3.3 读包与解码循环av_read_frame 与 avcodec_send_packet解码循环是整个 Demo 的心脏。FFmpeg 新 API 用send_packet/receive_frame分离的方式比老的avcodec_decode_video2更清晰。AVPacket *pkt av_packet_alloc(); AVFrame *frame av_frame_alloc(); while (av_read_frame(fmt_ctx, pkt) 0) { if (pkt-stream_index video_index) { // 把压缩包送进解码器 int ret avcodec_send_packet(codec_ctx, pkt); if (ret 0 ret ! AVERROR(EAGAIN)) { av_packet_unref(pkt); continue; } // 可能一次 send 对应多帧 receive while (avcodec_receive_frame(codec_ctx, frame) 0) { // 这里拿到的是解码后的 YUV 帧交给渲染 handle_frame(frame); av_frame_unref(frame); } } av_packet_unref(pkt); }逻辑说明av_read_frame每次读一个包可能是视频也可能是音频所以要用stream_index过滤。send_packet返回EAGAIN表示解码器缓冲区满了需要先receive_frame取走帧再继续送。receive_frame返回 0 表示取到一帧取完要av_frame_unref释放引用。参数上pkt和frame都要用av_packet_alloc/av_frame_alloc分配别用栈上变量否则内部引用计数会出问题。注意av_read_frame返回负值不一定是错误网络流断开时也会返回负值循环退出后要做重连处理否则界面就“死”在那了。4. 渲染上屏YUV 转 RGB 与 Qt 绘制4.1 为什么必须做 sws_scale 转换FFmpeg 解码出来的是 YUV420P多数情况而 Qt 的QImage要的是 RGB。两者之间必须用sws_scale转换。这一步不做画面颜色会完全错乱或者直接花屏。// 创建转换上下文源格式 YUV420P目标格式 RGB24 SwsContext *sws_ctx sws_getContext( codec_ctx-width, codec_ctx-height, codec_ctx-pix_fmt, codec_ctx-width, codec_ctx-height, AV_PIX_FMT_RGB24, SWS_BILINEAR, NULL, NULL, NULL); // 准备目标缓冲区 uint8_t *rgb_data[4] {0}; int rgb_linesize[4] {0}; av_image_alloc(rgb_data, rgb_linesize, codec_ctx-width, codec_ctx-height, AV_PIX_FMT_RGB24, 1); // 转换 sws_scale(sws_ctx, frame-data, frame-linesize, 0, codec_ctx-height, rgb_data, rgb_linesize);逻辑说明sws_getContext的参数依次是源宽高格式、目标宽高格式、缩放算法。SWS_BILINEAR是双线性插值速度和质量折中。av_image_alloc按目标格式分配对齐的内存。sws_scale的frame-data和frame-linesize是源rgb_data和rgb_linesize是目标。参数上如果源和目标尺寸一样可以不做缩放但格式转换仍必须走这一步。4.2 把 RGB 数据塞进 QImage 并刷新拿到 RGB 缓冲区后用QImage包一层再通过信号槽发给界面线程绘制。注意QImage不拷贝数据所以缓冲区生命周期要管好。// 在解码线程里构造 QImage注意深拷贝一份数据 QImage img(rgb_data[0], codec_ctx-width, codec_ctx-height, rgb_linesize[0], QImage::Format_RGB888); QImage copy img.copy(); // 深拷贝避免缓冲区被复用后画面撕裂 // 通过信号把图像发给主线程 emit frameReady(copy);逻辑说明QImage构造函数只是引用外部缓冲区不拥有数据。如果直接把这个QImage发到主线程而解码线程马上又用同一块缓冲区解下一帧画面就会撕裂。所以这里用img.copy()做一次深拷贝。参数上Format_RGB888对应 RGB24rgb_linesize[0]是每行字节数必须传对否则图像会错位。4.3 跨线程渲染信号槽与 QLabel/QWidget 绘制Qt 的界面必须在主线程更新解码在子线程所以要用信号槽跨线程传图像。常见做法是继承QWidget重写paintEvent把最新帧画上去。// 主线程窗口类里连接信号 connect(decoder, Decoder::frameReady, this, MainWindow::onFrameReady); void MainWindow::onFrameReady(const QImage img) { m_frame img; // 保存最新帧 update(); // 触发 paintEvent } void MainWindow::paintEvent(QPaintEvent *event) { QPainter painter(this); if (!m_frame.isNull()) { // 按窗口大小缩放绘制保持比例可自行处理 painter.drawImage(rect(), m_frame); } }逻辑说明connect默认是AutoConnection跨线程时自动变成队列连接图像会被拷贝到主线程事件循环里处理。update()是异步请求重绘不是立刻画。paintEvent里用drawImage把帧画到窗口矩形。参数上如果帧率很高update()调用太频繁会拖慢界面可以加一个定时器限流比如 30fps 刷一次。5. 避坑与排查断流、花屏、卡顿的常见原因5.1 现象画面卡住不动日志无报错原因av_read_frame阻塞在网络上RTSP 服务端没发数据或者 TCP 连接被中间设备静默断开。FFmpeg 默认没有读超时会一直等。解决在avformat_open_input的 options 里设stimeout并且把av_read_frame放在独立线程里主线程用定时器检测“最后一帧时间”超过阈值就主动关闭重连。重连时先avformat_close_input再重新open别复用旧的fmt_ctx。5.2 现象画面花屏、绿屏、下半部分错位原因sws_scale的目标linesize没对齐或者QImage构造时行字节数传错。YUV420P 的linesize和 RGB 的linesize不是一回事直接混用必花。解决始终用av_image_alloc分配目标缓冲区它返回的rgb_linesize[0]是对齐后的值构造QImage时用这个值不要自己用width * 3算。如果还花检查codec_ctx-pix_fmt是不是AV_PIX_FMT_YUV420P有些摄像头是YUVJ420P转换上下文要对应改。5.3 现象解码越来越慢内存持续上涨原因AVFrame或AVPacket没有及时unref引用计数不释放缓冲区越积越多。或者sws_ctx每帧都重建没复用。解决av_frame_unref和av_packet_unref要在每次循环末尾调用。sws_ctx只在分辨率或格式变化时重建正常情况复用同一个。如果分辨率会变比如摄像头切换码流要在receive_frame后检测frame-width是否变化变了就sws_freeContext再重建。5.4 现象链接报 undefined reference toavcodec_xxx原因Makefile 里库的顺序不对。GCC 链接静态库时从左到右解析被依赖的库要放在依赖它的库后面。FFmpeg 内部avformat依赖avcodecavcodec依赖avutil。解决链接顺序写成-lavformat -lavcodec -lavutil -lswscaleswscale放最后或按依赖关系排。用pkg-config --libs输出的顺序通常是对的别自己手动打乱。5.5 现象Qt 界面能显示但鼠标点不动、窗口无响应原因解码循环跑在主线程里av_read_frame阻塞导致事件循环被卡死。解决把整个拉流解码逻辑放到QThread或std::thread里主线程只负责界面。解码线程通过信号发帧主线程收帧重绘。注意信号槽跨线程传QImage时QImage的隐式共享在跨线程下是安全的但如果你传的是裸指针就要自己保证生命周期。6. 进阶技巧低延迟调优与重连策略的落地写法把 Demo 跑通只是第一步真正上线还要压延迟和保稳定。延迟这块我一般从三个地方下手。第一avformat_open_input时加av_dict_set(options, fflags, nobuffer, 0)和av_dict_set(options, flags, low_delay, 0)让 FFmpeg 尽量不缓冲。第二解码器开codec_ctx-flags | AV_CODEC_FLAG_LOW_DELAY并设codec_ctx-thread_count 1多线程解码虽然吞吐高但会引入帧重排序延迟低延迟场景反而单线程更稳。第三渲染端用定时器限流比如 25fps 刷一次别每解一帧就update()否则界面线程会被重绘拖垮。重连策略我踩过的坑最多。早期我直接在av_read_frame返回负值时goto回open结果旧fmt_ctx没释放跑一晚上内存就爆了。后来改成状态机IDLE - CONNECTING - PLAYING - RECONNECTING每次重连前先avformat_close_input(fmt_ctx)并把fmt_ctx置 NULL再走一遍完整的打开流程。重连间隔用指数退避第一次 1 秒第二次 2 秒最多退到 10 秒避免服务端没恢复时疯狂重试。下面这段是重连状态机的核心写法可以直接嵌到解码线程里// 解码线程主循环里的重连逻辑 while (!m_stop) { if (m_state IDLE || m_state RECONNECTING) { // 清理旧上下文避免内存泄漏 if (fmt_ctx) { avformat_close_input(fmt_ctx); fmt_ctx NULL; } m_state CONNECTING; if (open_stream() 0) { // 打开失败退避后重试 m_state RECONNECTING; std::this_thread::sleep_for( std::chrono::seconds(m_retry_delay)); m_retry_delay std::min(m_retry_delay * 2, 10); continue; } m_retry_delay 1; // 连上后重置退避 m_state PLAYING; } // 正常读包解码 int ret av_read_frame(fmt_ctx, pkt); if (ret 0) { // 读失败进入重连 m_state RECONNECTING; continue; } // ... 解码与渲染逻辑 }逻辑说明m_retry_delay初始为 1 秒每次失败翻倍上限 10 秒连上后重置。avformat_close_input会释放fmt_ctx内部资源并把指针置 NULL所以传的是fmt_ctx。参数上std::this_thread::sleep_for在解码线程里睡不会卡界面。如果你用 Qt 的QThread可以用QThread::msleep替代。验证延迟有个土办法但很有效拿手机秒表对着摄像头看界面上秒表读数和真实秒表的差值差值就是端到端延迟。TCP 传输下局域网通常能压到 300ms 以内公网看带宽和路由500ms 到 1 秒都算正常。如果超过 2 秒先查nobuffer和low_delay有没有生效再查渲染端是不是每帧都update()了。从那以后我每次接 RTSP 项目都强制先把重连状态机和超时参数写进去再谈画面效果。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑