C++基于Qt和FFmpeg音视频播放器源码解析:编译、解码与同步实现

C++基于Qt和FFmpeg音视频播放器源码解析:编译、解码与同步实现 简介这是一份基于Qt框架与FFmpeg库的C音视频播放器完整源码面向有一定C基础、正在学习桌面播放器开发的中级开发者可帮助理解UI交互、解码渲染、多线程调度等核心环节。压缩包共有112个文件约520KB构成上以h/cpp源文件为主辅以hpp、cc等扩展类实现以及pro/qrc工程与资源文件、txt说明文档分别对应核心逻辑、界面资源与工程配置。已有337人学习下载适合作为Qt与FFmpeg综合实践参考也可作为课程设计与毕业设计的选题蓝本。源码涵盖videopreviewwidget、hardwaredecode、videooutputrender等典型模块可观察Qt信号槽如何驱动播放控制、FFmpeg如何硬解码与转换帧格式、OpenGL渲染器如何输出画面并通过多线程机制处理音视频同步。结合帧同步、事件处理等设计可进一步理解播放器整体架构目录结构清晰便于按模块研读或二次开发。1. 一包 C 音视频播放器源码解压后先分清 FFmpeg 和 Qt 各自负责什么把名为 C基于Qt和Ffmpeg的音视频播放器源码.zip 的包下载到本地常见的第一反应是直奔 main.cpp 找播放按钮在哪。但我看过不少类似工程后建议反过来先去区分 FFmpeg 负责的解封装/解码层和 Qt 负责的界面/音频设备层。FFmpeg 做的事是把 mp4、mkv、ts 拆成音频包和视频包再解出 YUV、PCM 这样的原始数据Qt 做的事是把这些数据画到窗口、送到声卡顺便处理进度条和按键事件。两者一旦缠在一个类里出现黑屏、花屏、拖动进度条崩溃时很难定位问题。我不会去复述某个具体包的结构而是讲清楚这类视频播放器源码最常见的编译环境、解码线程、渲染和 A/V 同步实现以及拿到源码后按什么顺序排错。2. 搭建 C/Qt/FFmpeg 播放器源码的编译环境与工程骨架这类源码能不能跑起来通常不是播放逻辑难而是编译链上的三个点一个都不能松Qt 工具链能被 CMake 找到FFmpeg 的 dev 包与编译器位数一致运行时 DLL 能出现在程序目录。下面先按 Windows 上最常见的方式把环境理顺再把源码文件的边界划出来。2.1 安装Qt工具链并配好FFmpeg的include/lib路径Windows 下搜索“ffmpeg 不是内部或外部命令”的人绝大多数只是想跑ffmpeg -i命令行。做播放器源码则不同需要的是 FFmpeg 开发包解压后至少包含 include、lib、bin 三个目录。include 里的 avformat.h、avcodec.h、swresample.h 等头文件是编译期用的lib 里的导入库让链接器能找到 avformat、avcodec、avutil、swscale、swresample 这几个符号bin 里的 DLL 才是运行时真正加载的文件。只装命令行工具不会让你的 Qt 工程编译通过这是第一个要理解的差别。我的做法是先装 Qt 官方在线安装包选上当前 Qt 版本对应的编译器套件。用 MSVC 就选 MSVC 2019/2022 的 Kit用 MinGW 就选 MinGW 同版本工具链。之后再下载 FFmpeg 的 dev 包和 shared 包两个包的 release 版本必须完全一致且与编译器位数一致混用不同 release 的头文件和 DLL在链接期可能只给一个警告运行期却直接崩溃。最后把 shared 包的 bin 目录加入 PATH只是方便调试真正分发程序时需要把 avcodec-.dll、avformat-.dll、avutil-.dll、swresample-.dll、swscale-*.dll 复制到 exe 同目录。组件需要的内容容易踩的坑Qt 工具链qmake 或 CMake 对应编译器的 KitKit 与 FFmpeg 编译器不一致出现链接符号冲突FFmpeg dev 包include/、lib/只有命令行工具没有头文件和导入库FFmpeg shared 包bin/ 下的 DLL运行时找不到 DLL 或找到了旧版本VC 运行库vcruntime140.dll 等启动后立即闪退对话框都没有在这个表里最隐蔽的是第三行。FFmpeg 的 DLL 不是放在 dev 包里而是在 shared 包里。某些网上流传的源码压缩包会自带一份编译好的 FFmpeg 目录来源和版本不一定和你本地一致不要急着替换先让程序用自带的 FFmpeg 跑起来再考虑升级会更省时间。2.2 用CMake把FFmpeg的avformat/avcodec链进Qt工程一个最小可编译的 CMakeLists 是这样的。我习惯优先找 Qt 6找不到再回退到 Qt 5cmake_minimum_required(VERSION 3.16) project(QtFfmpegPlayer LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_AUTOMOC ON) # 优先使用 Qt 6环境里只有 Qt 5 时自动回退 find_package(Qt6 COMPONENTS Widgets Multimedia QUIET) if(NOT Qt6_FOUND) find_package(Qt5 COMPONENTS Widgets Multimedia REQUIRED) set(QT_TARGETS Qt5::Widgets Qt5::Multimedia) else() set(QT_TARGETS Qt6::Widgets Qt6::Multimedia) endif() # FFmpeg 库名统一写裸名CMake 会自动处理 avcodec.lib/libavcodec.a set(FFMPEG_ROOT $ENV{FFMPEG_ROOT} CACHE PATH FFmpeg dev 包根目录) find_path(AVFORMAT_INCLUDE_DIR libavformat/avformat.h HINTS ${FFMPEG_ROOT}/include) find_library(AVFORMAT_LIBRARY avformat HINTS ${FFMPEG_ROOT}/lib) find_library(AVCODEC_LIBRARY avcodec HINTS ${FFMPEG_ROOT}/lib) find_library(AVUTIL_LIBRARY avutil HINTS ${FFMPEG_ROOT}/lib) find_library(SWSCALE_LIBRARY swscale HINTS ${FFMPEG_ROOT}/lib) find_library(SWRESAMPLE_LIBRARY swresample HINTS ${FFMPEG_ROOT}/lib) add_executable(player main.cpp core/decode_thread.cpp render/video_widget.cpp render/audio_output.cpp ui/main_window.cpp ) target_include_directories(player PRIVATE ${AVFORMAT_INCLUDE_DIR} ${CMAKE_CURRENT_SOURCE_DIR} ) target_link_libraries(player PRIVATE ${QT_TARGETS} ${AVFORMAT_LIBRARY} ${AVCODEC_LIBRARY} ${AVUTIL_LIBRARY} ${SWSCALE_LIBRARY} ${SWRESAMPLE_LIBRARY} )找头文件的 find_path 只写了 avformat.h 一层实际上 FFmpeg 的头文件包含格式是#include libavformat/avformat.h因此目录要定位到 FFmpeg 解压后的根目录而不是 include 子目录。find_library 的裸名写法在 Windows/Unix 上通用MSVC 会找 avformat.libMinGW 会找 libavformat.a。target_link_libraries 中的顺序也有讲究静态链接时 avformat、avcodec、avutil 按依赖关系从外向里摆swscale 和 swresample 放在最后否则可能报 unresolved external symbol。2.3 源码包里的目录怎么规划core/render/ui三块的职责边界播放器源码解压后即使文件名和我现在写的不一样我也会按 core/render/ui 这个边界去重排代码。core 只管读媒体、解包、解码和时钟render 接收已经解码的 AVFrame 和 PCMui 只关心窗口、按钮和进度条。这个做法不是为了好看而是排查线程问题的时候你只需要看 core 里的 packet 队列和 frame 队列不需要在 ui 和 Qt 的事件循环里找解码调用。player/ ├── CMakeLists.txt ├── main.cpp ├── core/ │ ├── media_demuxer.h/.cpp │ ├── decode_thread.h/.cpp │ └── av_sync.h/.cpp ├── render/ │ ├── video_widget.h/.cpp │ └── audio_output.h/.cpp └── ui/ ├── main_window.h/.cpp └── play_controls.h/.cpp文件职责不该做的事media_demuxer打开文件、找流、读 packet不要在这里调用 QImagedecode_thread消费 packet、产出 AVFrame不要直接写 UIvideo_widget转换像素格式并绘制不要持有 AVFormatContextaudio_output重采样并写 PCM不要把音频数据存成 QByteArray 成员main_window控制播放状态、进度条不要在槽里做耗时解码这个划分还有一个肉眼可见的好处播放新的文件格式时大多只需改 media_demuxer 和 decode_thread调整画面缩放和音量时只碰 render。新手把解码放在按钮点击的槽函数里就会导致拖动进度条时界面假死因为 UI 线程在等待解码器吐帧。3. FFmpeg 解封装解码与 Packet 队列的 C 实现播放器源码里最容易看晕的不是某个函数而是数据到底从哪个线程流到哪个线程。解码层把这条链路理清楚UI 层就只是画图和发声。下面按“打开→拆包→排队→解码”的顺序展开。3.1 初始化AVFormatContext时三个容易漏掉的返回值先看打开文件的最基本代码extern C { #include libavformat/avformat.h #include libavcodec/avcodec.h } AVFormatContext *fmt_ctx nullptr; int ret avformat_open_input(fmt_ctx, url.toUtf8().constData(), nullptr, nullptr); if (ret 0) { char err[AV_ERROR_MAX_STRING_SIZE] {0}; av_strerror(ret, err, sizeof(err)); qCritical() open input failed: err; return false; } ret avformat_find_stream_info(fmt_ctx, nullptr); if (ret 0) { qCritical() find stream info failed; avformat_close_input(fmt_ctx); return false; } int video_index av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0); int audio_index av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_AUDIO, -1, -1, nullptr, 0); if (video_index 0 audio_index 0) { avformat_close_input(fmt_ctx); return false; }三个容易漏掉的返回值分别来自 open_input、find_stream_info、find_best_stream。第一个失败通常是路径不存在或文件被占用第二个失败说明媒体文件的 metadata 不完整input 已经打开但没有拿到流信息继续解码会拿空包第三个失败常见的场景是只有视频没有音频轨或只有音频的录音文件。源码里的处理应该把 audio_index 允许为负但 video_index 不能为负除非你是做纯音频播放器。av_strerror 把负数错误码解释成可读文本不要在日志里只写一个 ret 数字。接着用 codecpar 初始化解码上下文而不是直接从 stream-codec 取宽高后者在 4.0 以后已经废弃const AVCodec *video_dec avcodec_find_decoder( fmt_ctx-streams[video_index]-codecpar-codec_id); AVCodecContext *video_ctx avcodec_alloc_context3(video_dec); ret avcodec_parameters_to_context(video_ctx, fmt_ctx-streams[video_index]-codecpar); if (ret 0) { avcodec_free_context(video_ctx); avformat_close_input(fmt_ctx); return false; } video_ctx-thread_count 0; // 让 FFmpeg 按核心数自动选 ret avcodec_open2(video_ctx, video_dec, nullptr); if (ret 0) { avcodec_free_context(video_ctx); avformat_close_input(fmt_ctx); return false; }thread_count 设为 0 表示自动设置解码线程数对 H.264 这类多线程解码能明显提速但前提是你的后续代码不依赖“帧顺序严格等于输入包顺序”否则要改成 1。codecpar 中的 extradata 会被 parameters_to_context 自动拷进 AVCodecContext这里不要再手动给 extradata 赋值。3.2 std::deque还是环形缓冲packet队列的线程安全封装读线程与解码线程之间需要一个队列。原始av_read_frame拿到的 AVPacket 是复用同一块缓冲的每次循环都要av_packet_unref否则内存不断增长。队列里不能直接塞裸 AVPacket至少用 unique_ptr 包一层struct PacketDeleter { void operator()(AVPacket *p) const { av_packet_free(p); } }; using PacketPtr std::unique_ptrAVPacket, PacketDeleter; class PacketQueue { public: void push(PacketPtr pkt) { { std::lock_guardstd::mutex lock(mutex_); queue_.push_back(std::move(pkt)); } cond_.notify_one(); } bool pop(PacketPtr out, int timeout_ms 20) { std::unique_lockstd::mutex lock(mutex_); if (queue_.empty()) { cond_.wait_for(lock, std::chrono::milliseconds(timeout_ms)); } if (queue_.empty()) return false; out std::move(queue_.front()); queue_.pop_front(); return true; } void clear() { std::lock_guardstd::mutex lock(mutex_); while (!queue_.empty()) queue_.pop_front(); } private: std::mutex mutex_; std::condition_variable cond_; std::dequePacketPtr queue_; };这里首选 deque 而不是环形缓冲AVPacket 内部的数据块大小不定环形缓冲适合定长元素deque 的双向开口让 push_back 和 pop_front 都是 O(1)seek 时 clear 整个队列也很直接。timeout_ms 设为 20 毫秒是为了让 pop 不会无限期阻塞解码线程可以在这个间隔里检查 stop 标志并退出。另一个常见处理是给队列加最大长度比如 256 个包文件播放时push 前发现队列已满就 sleep 10 毫秒直播播放时则可以直接丢旧包保证延迟不增长。3.3 send_packet/receive_frame状态机与解码循环骨架struct FrameDeleter { void operator()(AVFrame *f) const { av_frame_free(f); } }; using FramePtr std::unique_ptrAVFrame, FrameDeleter; PacketPtr pkt; while (!stop_flag_ queue_.pop(pkt)) { int ret avcodec_send_packet(video_ctx_, pkt.get()); pkt.reset(); // 送进去后立刻释放引用 if (ret 0 ret ! AVERROR(EAGAIN)) { qWarning() send_packet error: ret; continue; } AVFrame *frame av_frame_alloc(); while (true) { ret avcodec_receive_frame(video_ctx_, frame); if (ret AVERROR(EAGAIN) || ret AVERROR_EOF) { av_frame_free(frame); break; } if (ret 0) { av_frame_free(frame); break; } frame_queue_.push(FramePtr(frame)); frame av_frame_alloc(); // 下一轮必须换新帧 } }send_packet 与 receive_frame 不是一对一。一个 packet 可能解出 0 帧因为 B 帧要等后面的包一个 packet 也可能解出多帧。EAGAIN 表示解码器中还有旧数据没吐完需要立刻回到 receive 而不是继续 send。AVERROR_EOF 不是错误一般发生在avcodec_send_packet(video_ctx_, nullptr)冲刷之后。播放结束时需要单独做一次 flushavcodec_send_packet(video_ctx_, nullptr); while (avcodec_receive_frame(video_ctx_, frame) 0) { // 这里拿到的都是延迟帧也要按顺序送进帧队列 }返回码含义处理方式0成功产出一帧取走后继续下一次 receiveEAGAIN需要更多 packet跳出 receive继续 read_frameAVERROR_EOF输入已经冲刷完退出解码循环并回收资源其他负值包损坏或不支持丢弃当前包不要让线程死循环av_frame_alloc 在循环里必须换新是因为 receive_frame 会对传入的 frame 做复用。如果一直用同一个 AVFrame 指针往帧队列里丢前一个画面的数据会被下一次解码覆盖最终界面上看到的全是最后一帧或花屏。4. 用Qt渲染视频帧和输出音频并做A/V同步解码线程拿到 AVFrame 之后剩下的事情全部交给 Qt。视频帧先转 QImage再在 paintEvent 里绘制音频帧要先统一成 S16 交错格式再写进 QAudioOutput。最后还要有一把时钟去校正两边速度否则画面和声音不会对齐。4.1 用sws_scale把AVFrame转成QImage的像素格式选择#include libswscale/swscale.h SwsContext *sws_ctx_ nullptr; int last_w_ 0; int last_h_ 0; void VideoWidget::showFrame(const AVFrame *frame) { int w frame-width; int h frame-height; if (sws_ctx_ nullptr || w ! last_w_ || h ! last_h_) { sws_freeContext(sws_ctx_); sws_ctx_ sws_getContext( w, h, (AVPixelFormat)frame-format, w, h, AV_PIX_FMT_RGB24, SWS_BILINEAR, nullptr, nullptr, nullptr); last_w_ w; last_h_ h; } QImage image(w, h, QImage::Format_RGB888); uint8_t *dst_data[1] { image.bits() }; int dst_linesize[1] { static_castint(image.bytesPerLine()) }; sws_scale(sws_ctx_, frame-data, frame-linesize, 0, h, dst_data, dst_linesize); QMetaObject::invokeMethod(this, [this, image]() { image_ image; update(); }); }为什么选 RGB24 而不是 RGBA8888因为 QImage::Format_RGB888 的内存布局和 FFmpeg 的 AV_PIX_FMT_RGB24 一致省一次 alpha 通道的拷贝。sws_getContext 第一组参数是输入帧的原始宽高和像素格式frame-format 在解码后可能是 AV_PIX_FMT_YUV420P 或 AV_PIX_FMT_YUVJ420P不能写死否则花屏。第三组参数是输出格式这里写 AV_PIX_FMT_RGB24 对应 QImage::Format_RGB888。还要注意 dst_linesize 用的是 image.bytesPerLine()而不是 w3因为 QImage 内部可能做 4 字节对齐直接把 w3 传给 sws_scale 会导致每行错位画面出现斜向的色带。4.2 QPainter画帧与QAudioOutput写PCM的最小通路绘制侧只需要在 paintEvent 里把 image_ 按比例画出来void VideoWidget::paintEvent(QPaintEvent *) { QPainter painter(this); painter.fillRect(rect(), Qt::black); if (!image_.isNull()) { QRect target image_.rect().scaled(size(), Qt::KeepAspectRatio); painter.drawImage(target, image_); } }音频则用 Qt Multimedia 的 QAudioOutput。下面以 Qt 6 风格为例Qt 5 的构造参数略有不同QAudioFormat format; format.setSampleRate(48000); format.setChannelCount(2); format.setSampleSize(16); format.setSampleType(QAudioFormat::SignedInt); format.setCodec(audio/pcm); auto *audioOutput new QAudioOutput(this); audioOutput-setFormat(format); QIODevice *audioIO audioOutput-start();字段取值说明sampleRate48000 或源文件一致不匹配会变调或明显卡顿channelCount2 或源文件一致单声道需要重采样到双声道sampleSize16大多数 PCM 场景用 16bitsampleTypeSignedInt对应 S16 交错格式codecaudio/pcm不是音频压缩格式解码得到的 AVFrame 音频数据大多数是 fltp 浮点平面格式不能直接送 QAudioOutput。我会统一交给 swr_convert把输出设置成 AV_SAMPLE_FMT_S16采样率和声道数都固定为目标值。没有这层重采样出来的声音要么是白噪音要么完全无声这是播放器源码里最常见的声音问题来源。进度条也在这里接上QSlider 用 setRange(0, duration_ms) 和 setValue(current_ms) 驱动拖拽时通过 sliderMoved 信号调用 seek。不要用 setStyleSheet 做过于复杂的皮肤很多播放器 UI 的疑难问题都出在“自定义 progress bar 样式”和实际值没对齐。4.3 用PTS和audio_clock做同步替代固定sleep的校准公式很多初版播放器在显示完一帧后直接QThread::msleep(40)这是拿 25fps 猜测的结论。真实视频帧率可能是 29.97、50、23.976I 帧解码耗时也不一样固定 sleep 会让同步误差不断累积。正确做法是拿帧的 PTS 换算成秒再和音频时钟相减double VideoPlayer::getVideoClock(const AVFrame *frame) { int64_t pts frame-pts ! AV_NOPTS_VALUE ? frame-pts : av_frame_get_best_effort_timestamp(frame); return pts * av_q2d(videoStream_-time_base); } void VideoPlayer::syncAndRender() { double videoTime getVideoClock(frame); double audioClock audioOutput_-getClock(); // processedUSecs 换算的秒数 double diff videoTime - audioClock; if (diff 0.015) { QThread::msleep(qMinint(diff * 1000, 100)); } else if (diff -0.08) { skipNextFrame_ true; // 落后太多丢帧而不是延迟 } }阈值 15ms 以内不用睡眠视觉上察觉不到落后超过 80ms 就丢帧避免越积越慢。音频时钟通常取 QAudioOutput 的 processedUSecs 除以 1e6再加上音频起始时间不要用 QElapsedTimer 从进第一帧开始计因为声卡缓冲会让时间走得比真实播放速度更快。策略参考时钟适用场景音频主时钟QAudioOutput 的 processedUSecsmp4 这种有声音的本地文件视频主时钟当前帧 pts无声监控录像外部时钟QElapsedTimer 单调时间直播/实时播放这个公式在面试里也常被问这题的要点不是“延时多少毫秒”而是“什么时候该丢帧、什么时候该等待”能答出这两个方向A/V 同步的追问基本就算过关。5. 黑屏花屏声音断续读源码验证解码器的顺序与一个省事技巧黑屏时不要先怀疑 paintEvent。把解码线程里的 qDebug 打开如果连 frame-width 都没有打印说明 packet 队列根本没把数据送进 avcodec_send_packet。下面是这类源码最常见的断点位置和一个测试素材生成方法。5.1 黑屏时先验证解码器线程有没有吐出AVFrame我一般会在 showFrame 最前面加三行日志qDebug() frame: frame-width x frame-height format: frame-format pts: frame-pts;能打印但画面全黑问题就在 sws_scale 的目标格式或 QImage 的字节序不能打印问题就在解码线程的 while 条件。注意 avcodec_open2 失败时不要直接 return先 emit 一个错误信号让主窗口停下来否则解码线程会进入一个只报一次错误就静默的空转循环。每帧都打印会把日志刷爆可以定义一个帧计数器只在 frameCount_ % 30 0 时输出这还能顺带看出帧率是否掉到个位数两次日志间隔超过 1 秒先查解码还是渲染。5.2 用ffmpeg命令生成测试素材并解决qt.qpa.platform插件报错排查 A/V 同步时别拿真实电影测试。用 FFmpeg 自带信号源生成一个 10 秒短视频参数完全可控ffmpeg -f lavfi -i testsrc2size640x480:rate30 \ -f lavfi -i sinefrequency440:sample_rate48000 \ -c:v libx264 -pix_fmt yuv420p -c:a aac -shortest test.mp4testsrc2 生成带时间戳的彩色测试画面sine 生成 440Hz 正弦波播放时如果画面和声音能对上说明同步链路没坏。运行 exe 时如果出现 qt.qpa.plugin 找不到平台插件的提示多半是 Qt 的 platforms 目录没有跟随 exe把Qt安装目录/plugins/platforms拷到 exe 旁边或者设置 QT_QPA_PLATFORM_PLUGIN_PATH 指向该目录。同时记得把 FFmpeg 的 DLL 和 VC 运行库放到同目录否则播放器会在 main 还没执行时闪退。本文还有配套的精品资源点击获取