DongGu
播放器开发--音视频解码(2)

播放器开发--音视频解码(2)

解码

上一篇博客我们讲了 Demuxer 如何从文件中提取出一个个 AVPacket。但 packet 里装的还是压缩数据——比如 H.264 编码的视频流、AAC 编码的音频流。解码这一步,就是把这些压缩数据解压成原始的图像帧(YUV)和音频采样(PCM),交给后续的渲染和音频输出模块。


解码流程概览

视频解码和音频解码的整体逻辑几乎一致:

  1. init:根据 Demuxer 传来的 AVCodecParameters 初始化解码器
  2. start:启动解码线程
  3. decodeLoop:线程主循环——从 PacketQueue 取包 → 喂给解码器 → 接收解码后的 Frame → 归一化格式 → 推入 FrameQueue
  4. 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_idAVCodecParameters 中的一个枚举值,标识编码格式——比如 AV_CODEC_ID_H264AV_CODEC_ID_AACavcodec_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;  // 包时间基,用于 PTS/DTS 换算
_streamTimeBase = streamTimeBase; // 保存一份供 decodeLoop 使用
_codecContext->thread_count = 0; // 0 = 让 FFmpeg 自动选择线程数
_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 刷新

为什么要用多线程?

在整个播放器里,解封装、视频解码、音频解码、音频输出、渲染各自运行在独立的线程中。分开的理由很明确:

  1. 阻塞隔离av_read_frame()avcodec_receive_frame() 都可能阻塞(等 I/O、等解码器内部缓冲区)。如果全放在主线程,UI 会卡死。
  2. 性能:解码是 CPU 密集型操作。视频和音频各自一个线程,可以并行解码不同的帧,充分利用多核。
  3. 自然背压:队列满时生产者线程自动挂起,不需要额外逻辑协调速度。
  4. 独立的生命周期: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()) {
// ========== 阶段 0:seek 保护 ==========
if (_seekFlag && _seekFlag->load()) {
std::this_thread::sleep_for(std::chrono::milliseconds(2));
continue;
}

// ========== 阶段 1:从队列取一个 packet ==========
AVPacketPtr pkt = PacketQueue.pop();
if (!pkt) {
if (!_running.load()) {
break;
}
if (_seekFlag && _seekFlag->load()) {
continue;
}
// 队列空了(EOF),进入 draining 模式
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 {
// ========== 阶段 2:将 packet 送入解码器 ==========
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;
}
// EAGAIN:解码器内部缓冲区满,先取一帧腾空间
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();
}
}
}

// ========== 阶段 3:从解码器接收帧 ==========
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; // 解码器没有更多帧了,回去取下一个 packet
}
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 模式:文件读完了,队列不会再收到新数据。此时需要:

  1. frameQueue.markDraining() —— 通知 FrameQueue 进入排空状态,视频渲染端的尾部保护逻辑据此工作(尾部保护将在 A/V 同步 文章中讲解)
  2. 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; }
// EAGAIN:先取一帧腾空间,再重试 send
...
}

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(解码器完全排空)。

对每一帧的处理:

  1. 取 PTSframe->best_effort_timestamp 是 FFmpeg 的”最佳猜测”时间戳,即使源文件 DTS/PTS 不完整,FFmpeg 也会尽量推算出一个合理值
  2. 设置 time_base:继承从 Demuxer 传入的流时间基,用于后续将 PTS 转换为秒
  3. 归一化:调用 normalizeFrame() 将各种像素格式统一转为 YUV420p(具体怎么转换的,见后续 OpenGL 渲染 文章)
  4. 推入 FrameQueue:成功则回调 onFrameReady() 通知 UI 有新帧可渲染;FrameQueue 满时 push 会阻塞,形成背压

一句话总结

decodeLoop 就是不断做三件事:取包 → 喂解码器 → 收帧。EAGAIN 的重试、draining 的排空、seek 的跳过,都是为了让这三件事在各种边界情况下(缓冲满、流结束、跳转)正常工作。


这次的插图来自画师inuyama
图片地址:https://www.pixiv.net/artworks/114620604
看到神社想到了新海诚呀,新作快端上来吧

本项目源码请看:https://github.com/DongGuZhengHuaJi/VideoPlayer

本文作者:DongGu
本文链接:https://donggu.xyz/2026/05/10/音视频学习/音视频解码-解码篇/
版权声明:本文采用 CC BY-NC-SA 3.0 CN 协议进行许可