播放器开发--音视频解码(2)
解码 上一篇博客我们讲了 Demuxer 如何从文件中提取出一个个 AVPacket。但 packet 里装的还是压缩数据——比如 H.264 编码的视频流、AAC 编码的音频流。解码这一步,就是把这些压缩数据解压成原始的图像帧(YUV)和音频采样(PCM),交给后续的渲染和音频输出模块。
解码流程概览 视频解码和音频解码的整体逻辑几乎一致:
init :根据 Demuxer 传来的 AVCodecParameters 初始化解码器
start :启动解码线程
decodeLoop :线程主循环——从 PacketQueue 取包 → 喂给解码器 → 接收解码后的 Frame → 归一化格式 → 推入 FrameQueue
stop / flush :停止解码线程 / 清空解码器内部缓冲区
区别只在对解码后帧的归一化处理 上:
视频:通过 SwsContext 统一转为 YUV420p(方便 OpenGL 渲染)
音频:通过 SwrContext 统一转为 S16 交错格式(QAudioSink 的要求)
下面以视频解码为例讲解共同逻辑,帧归一化的细节在后续渲染/输出文章中展开。
init() 函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 bool VideoDecoder::init (AVCodecParameters* codecpar, AVRational streamTimeBase) { if (_codecContext) { std::cerr << "VideoDecoder: Codec context is already initialized. Reinitializing..." << std::endl; avcodec_free_context (&_codecContext); _codecContext = nullptr ; } const AVCodec* codec = avcodec_find_decoder (codecpar->codec_id); if (!codec) { std::cerr << "VideoDecoder: Unsupported codec!" << std::endl; return false ; } _codecContext = avcodec_alloc_context3 (codec); if (!_codecContext) { std::cerr << "VideoDecoder: Could not allocate video codec context!" << std::endl; return false ; } int ret = avcodec_parameters_to_context (_codecContext, codecpar); if (ret < 0 ) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror (ret, errbuf, sizeof (errbuf)); std::cerr << "VideoDecoder: Could not copy codec parameters to context: " << errbuf << std::endl; return false ; } _codecContext->pkt_timebase = streamTimeBase; _streamTimeBase = streamTimeBase; _codecContext->thread_count = 0 ; _codecContext->thread_type = FF_THREAD_FRAME; ret = avcodec_open2 (_codecContext, codec, nullptr ); if (ret < 0 ) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror (ret, errbuf, sizeof (errbuf)); std::cerr << "VideoDecoder: Could not open codec: " << errbuf << std::endl; return false ; } return true ; }
init() 的五步流程 第 1 步:清理旧上下文
和解封装篇一样,FFmpeg 解码操作也需要一个 Context——解码器用的是 AVCodecContext。如果之前打开过文件,旧上下文还存在,需要先释放:
1 avcodec_free_context (&_codecContext);
注意:函数名是 avcodec_free_context,不是 avcodec_close。后者是旧版 API,新版直接用 free 一步完成关闭 + 释放。
第 2 步:根据 codec_id 查找解码器
1 const AVCodec* codec = avcodec_find_decoder (codecpar->codec_id);
codec_id 是 AVCodecParameters 中的一个枚举值,标识编码格式——比如 AV_CODEC_ID_H264、AV_CODEC_ID_AAC。avcodec_find_decoder() 在 FFmpeg 内部注册的解码器表中查找对应的解码器实现。
这里有个容易忽略的命名困惑:**avcodec_find_decoder 是查找解码器,不要和 avcodec_free_context 混淆。** 前者根据 ID 定位解码器,后者释放上下文内存。
第 3 步:为解码器分配上下文
1 _codecContext = avcodec_alloc_context3 (codec);
avcodec_alloc_context3() 分配一个 AVCodecContext 并与指定的解码器绑定。这个函数内部会按解码器类型的默认值初始化上下文。
第 4 步:将流参数拷贝到上下文
1 int ret = avcodec_parameters_to_context (_codecContext, codecpar);
Demuxer 传来的 codecpar 包含分辨率、像素格式、码率等元数据,avcodec_parameters_to_context() 把这些信息写入 _codecContext,补全解码环境。注意这一步只是拷贝元数据,解码器还未启动。
随后设置几个额外参数:
1 2 3 4 _codecContext->pkt_timebase = streamTimeBase; _streamTimeBase = streamTimeBase; _codecContext->thread_count = 0 ; _codecContext->thread_type = FF_THREAD_FRAME;
FF_THREAD_FRAME 意味着 FFmpeg 会以帧为粒度并行解码,大幅提升多核 CPU 上的解码速度。设为 0 表示由 FFmpeg 根据 CPU 核心数自动决定线程数。
第 5 步:启动解码器
1 ret = avcodec_open2 (_codecContext, codec, nullptr );
调用 avcodec_open2() 正式初始化解码器。此时 FFmpeg 会根据上下文中的参数分配内部缓冲区、加载硬件加速(如果有的话)等,为后续的 send_packet / receive_frame 做好准备。
start() 函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 void VideoDecoder::start (PacketQueue& PacketQueue, FrameQueue& frameQueue, std::function<void ()> onFrameReady) { if (_running.load ()) { std::cerr << "VideoDecoder: Decoder is already running!" << std::endl; return ; } if (_decodeThread.joinable ()) { _decodeThread.join (); } _running.store (true ); _decodeThread = std::thread (&VideoDecoder::decodeLoop, this , std::ref (PacketQueue), std::ref (frameQueue), std::move (onFrameReady)); }
这个函数负责启动解码线程。三个参数的作用:
PacketQueue:输入——解封装线程往里塞压缩包,解码线程从这里取
FrameQueue:输出——解码后的帧往里推,渲染/音频线程从这里取
onFrameReady:回调——每解码出一帧就触发,通知 UI 刷新
为什么要用多线程? 在整个播放器里,解封装、视频解码、音频解码、音频输出、渲染各自运行在独立的线程中。分开的理由很明确:
阻塞隔离 :av_read_frame() 和 avcodec_receive_frame() 都可能阻塞(等 I/O、等解码器内部缓冲区)。如果全放在主线程,UI 会卡死。
性能 :解码是 CPU 密集型操作。视频和音频各自一个线程,可以并行解码不同的帧,充分利用多核。
自然背压 :队列满时生产者线程自动挂起,不需要额外逻辑协调速度。
独立的生命周期 :stop/seek 时可以独立控制每个线程的暂停、清空、重启,互不干扰。
decodeLoop() 函数——解码的心脏 这是整个解码模块最核心的函数,也是最长的一段。我们先看完整代码,再拆解逻辑。
阅读提示 :代码中出现了 _seekFlag(seek 跳转)、frameQueue.markDraining()(尾部保护)、normalizeFrame()(帧格式转换)这些概念,它们会在后续的 Seek 机制 、A/V 同步 、OpenGL 渲染 文章中详细讲解。这里只需要关注解码的核心循环:取包 → 喂解码器 → 收帧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 void VideoDecoder::decodeLoop (PacketQueue& PacketQueue, FrameQueue& frameQueue, std::function<void ()> onFrameReady) { while (_running.load ()) { if (_seekFlag && _seekFlag->load ()) { std::this_thread::sleep_for (std::chrono::milliseconds (2 )); continue ; } AVPacketPtr pkt = PacketQueue.pop (); if (!pkt) { if (!_running.load ()) { break ; } if (_seekFlag && _seekFlag->load ()) { continue ; } frameQueue.markDraining (); if (onFrameReady) { onFrameReady (); } int ret = avcodec_send_packet (_codecContext, nullptr ); if (ret < 0 && ret != AVERROR_EOF) { std::cerr << "VideoDecoder: Error flushing decoder." << std::endl; } } else { for (;;) { if (_seekFlag && _seekFlag->load ()) break ; int ret = avcodec_send_packet (_codecContext, pkt.get ()); if (ret >= 0 ) break ; if (ret != AVERROR (EAGAIN)) { std::cerr << "VideoDecoder: Error sending packet for decoding." << std::endl; break ; } FramePtr drainFrame (av_frame_alloc(), AVFrameDeleter()) ; ret = avcodec_receive_frame (_codecContext, drainFrame.get ()); if (ret < 0 ) break ; drainFrame->pts = drainFrame->best_effort_timestamp; drainFrame->time_base = _streamTimeBase; FramePtr normalized = normalizeFrame (std::move (drainFrame)); if (normalized) { if (!frameQueue.push (std::move (normalized))) { if (_running.load ()) { std::cerr << "VideoDecoder: Failed to push frame to queue." << std::endl; } break ; } if (onFrameReady) onFrameReady (); } } } while (_running.load () && !(_seekFlag && _seekFlag->load ())) { FramePtr frame (av_frame_alloc(), AVFrameDeleter()) ; int ret = avcodec_receive_frame (_codecContext, frame.get ()); if (ret == AVERROR (EAGAIN)) { break ; } if (ret == AVERROR_EOF) { _running.store (false ); break ; } else if (ret < 0 ) { char errbuf[AV_ERROR_MAX_STRING_SIZE]; av_strerror (ret, errbuf, sizeof (errbuf)); std::cerr << "VideoDecoder: Error during decoding: " << errbuf << std::endl; break ; } frame->pts = frame->best_effort_timestamp; frame->time_base = _streamTimeBase; FramePtr normalizedFrame = normalizeFrame (std::move (frame)); if (!normalizedFrame) { std::cerr << "VideoDecoder: Dropping frame because normalization failed." << std::endl; continue ; } if (!frameQueue.push (std::move (normalizedFrame))) { if (_running.load ()) { std::cerr << "VideoDecoder: Failed to push frame to queue." << std::endl; } break ; } if (onFrameReady) { onFrameReady (); } } } }
逐段拆解 整段逻辑可以看作 “一个死循环、对外三层” 的嵌套结构:
1 2 3 4 5 6 7 while (_running) { // 外层:整个解码生命周期 pkt = queue.pop() // 层 1:取包 send_packet(pkt) // 层 2:送入解码器 while (true) { // 层 3:接收解码后的帧 receive_frame() } }
阶段 0:seek 保护(最外层) 1 2 3 4 if (_seekFlag && _seekFlag->load ()) { std::this_thread::sleep_for (std::chrono::milliseconds (2 )); continue ; }
当主线程正在进行 seek 操作时,_seekFlag 被置为 true。解码线程此时不应该继续取包或解码——因为队列已被清空,解码器已被 flush,旧的 packet 和 frame 都没意义了。所以直接 sleep(2ms) 然后 continue,等待 seek 完成后 _seekFlag 被清除,循环恢复正常。
提示 :_seekFlag 的置位/清除、I/O 中断、队列 abort/reset、解码器 flush 这些操作串联起来才构成一次完整的 seek。这里只需要知道解码线程在 seek 期间会主动让路即可,完整流程将在 Seek 机制 文章中展开。
阶段 1:从 PacketQueue 取包 1 AVPacketPtr pkt = PacketQueue.pop ();
调用阻塞 pop()——如果队列空且没被 abort,线程挂起等待。这个阻塞是正常的,说明解码比解封装快,正在等 Demuxer 生产更多数据。
取到空指针(!pkt)有三种可能:
原因
判断方式
处理
_running 变为 false(stop)
!_running.load()
break 退出循环
队列被 abort(seek 中)
_seekFlag->load()
continue 重新进入循环,等 seek 完成
流结束时队列排空
其他
进入 draining 模式
Draining 模式 :文件读完了,队列不会再收到新数据。此时需要:
frameQueue.markDraining() —— 通知 FrameQueue 进入排空状态,视频渲染端的尾部保护逻辑据此工作(尾部保护将在 A/V 同步 文章中讲解)
avcodec_send_packet(_codecContext, nullptr) —— 向解码器送入一个 空包 ,告诉解码器”输入结束了,把内部缓冲的帧全部吐出来”
阶段 2:将 packet 送入解码器(含 EAGAIN 处理) 1 2 3 4 5 6 7 for (;;) { int ret = avcodec_send_packet (_codecContext, pkt.get ()); if (ret >= 0 ) break ; if (ret != AVERROR (EAGAIN)) { break ; } ... }
FFmpeg 新式解码 API 是 “send 一个包,receive 若干帧” 的模型:
1 2 avcodec_send_packet() → 将压缩数据喂给解码器 avcodec_receive_frame() → 从解码器取出一帧已解码的图像
这俩函数不一定一一对应。一个 packet 可能解出多帧(比如音频),也可能多个 packet 才解出一帧(比如 B 帧需要等后续帧,这是压缩算法导致的。
AVERROR(EAGAIN) 表示解码器内部缓冲区已满,暂时无法接受新数据。需要先调用 avcodec_receive_frame() 取走至少一帧,腾出空间后再重试 send_packet。
这个 for (;;) 循环做的事就是:反复尝试 send,遇到 EAGAIN 就 receive 一帧(经过归一化后推入 FrameQueue),再继续尝试 send,直到 send 成功或遇到真正的错误。
阶段 3:从解码器批量取帧 1 2 3 4 5 while (_running.load () && !(_seekFlag && _seekFlag->load ())) { FramePtr frame (av_frame_alloc(), AVFrameDeleter()) ; int ret = avcodec_receive_frame (_codecContext, frame.get ()); ... }
packet 送入解码器后,解码器可能同时产生多帧。这个内层 while 循环一口气全部取出来,直到返回 EAGAIN(暂时没有了,回去取下一个 packet)或 AVERROR_EOF(解码器完全排空)。
对每一帧的处理:
取 PTS :frame->best_effort_timestamp 是 FFmpeg 的”最佳猜测”时间戳,即使源文件 DTS/PTS 不完整,FFmpeg 也会尽量推算出一个合理值
设置 time_base :继承从 Demuxer 传入的流时间基,用于后续将 PTS 转换为秒
归一化 :调用 normalizeFrame() 将各种像素格式统一转为 YUV420p(具体怎么转换的,见后续 OpenGL 渲染 文章)
推入 FrameQueue :成功则回调 onFrameReady() 通知 UI 有新帧可渲染;FrameQueue 满时 push 会阻塞,形成背压
一句话总结 decodeLoop 就是不断做三件事:取包 → 喂解码器 → 收帧 。EAGAIN 的重试、draining 的排空、seek 的跳过,都是为了让这三件事在各种边界情况下(缓冲满、流结束、跳转)正常工作。
这次的插图来自画师inuyama 图片地址:https://www.pixiv.net/artworks/114620604 看到神社想到了新海诚呀,新作快端上来吧
本项目源码请看:https://github.com/DongGuZhengHuaJi/VideoPlayer