基于 FFmpeg 构建跨平台播放器系统时,其架构设计通常遵循模块化、高内聚低耦合的原则,核心目标是实现高效的音视频解码、渲染及同步。
很多人学 FFmpeg,都是从这段代码开始的:
ffplay input.mp4
然后自己照着抄一个:
av_read_frame()
avcodec_send_packet()
avcodec_receive_frame()
SDL_RenderCopy()
跑起来,能播,很开心。
但一旦加上:
-
暂停 / 继续
-
Seek
-
音视频同步
-
硬解
-
倍速播放
-
网络流 / 本地文件切换
👉 整个程序会瞬间崩塌。
这篇文章不讲 API 怎么调,而是站在工程视角 ,拆解一个可维护、可扩展、接近 mpv/ffplay 的播放器架构。
一、先给结论:播放器 = 多线程流水线
播放器不是"解码器 + 渲染器",而是一个"实时多媒体流水线系统"。
它的核心矛盾只有两个:
-
生产者 / 消费者速度不匹配
-
音视频时间轴必须对齐
因此,一个合格的播放器,至少包含 5 个模块 + 3 条队列 + 1 个时钟。
二、整体架构图(脑内先建立模型)
┌────────────┐
│ Input │ file / rtmp / srt
└─────┬──────┘
│ av_read_frame
┌─────▼──────┐
│ Demuxer │ 单线程
└─────┬──────┘
├───► PacketQueue(Audio)
└───► PacketQueue(Video)
│
┌──────────┴──────────┐
│ │
┌──▼───┐ ┌────▼──┐
│ Audio│ │ Video │ Decode Thread
│ Decoder│ │ Decoder│
└──┬───┘ └────┬──┘
│ FrameQueue(Audio) │ FrameQueue(Video)
│ │
└───────▶ Clock ◀─────┘
▲
│ A/V Sync
┌───────┴───────┐
│ Audio Output │
│ Video Render │
└───────────────┘
所有复杂问题,最终都收敛到这张图上。
三、模块一:Demuxer(读包线程)
职责
-
av_read_frame() -
识别流类型
-
将 packet 分发到对应队列
关键设计点
✅ 必须是独立线程
while (running) {
av_read_frame(fmt_ctx, &pkt);
queue_push(pkt);
}
✅ 暂停 ≠ sleep
-
暂停时:阻塞 packet queue
-
而不是停
av_read_frame
✅ Seek 是全局事件
-
清空 packet queue
-
清空 frame queue
-
avcodec_flush_buffers() -
avformat_seek_file()
四、Packet Queue:第一道缓冲
为什么需要它?
-
网络抖动
-
解码速度不稳定
-
Seek 后需要预读
核心字段
typedef struct {
AVPacketList *first, *last;
int nb_packets;
int size; // bytes
int64_t duration;
SDL_mutex *mutex;
SDL_cond *cond;
} PacketQueue;
重要策略
-
上限控制(防止 OOM)
-
音视频独立队列
-
时钟不在这里
五、Decoder:解码线程(音 / 视分离)
音频解码线程
-
通常 快于播放
-
受音频回调驱动
视频解码线程
-
通常 慢于显示
-
受 AVSync 控制
关键代码骨架
while (running) {
packet_queue_get(&pkt);
avcodec_send_packet(dec_ctx, &pkt);
while (ret >= 0) {
ret = avcodec_receive_frame(dec_ctx, &frame);
frame_queue_push(frame);
}
}
⚠️ MKV / 网络流特别注意:
-
EAGAIN不是错误 -
必须继续
read_frame -
硬解(DXVA2 / NVDEC)必须 reset
六、Frame Queue:第二道缓冲
为什么需要两层队列?
| 层级 | 作用 |
|---|---|
| PacketQueue | 平滑 IO |
| FrameQueue | 平滑解码 & 同步 |
视频 FrameQueue 特点
-
容量小(3--6 帧)
-
严格 FIFO
-
保存 PTS / duration
-
硬解 surface 生命周期在此管理
七、Clock:播放器的"心跳"
没有 Clock,就没有播放器,只有幻灯片。
三个时间源
-
Audio clock(最准)
-
Video clock
-
External clock(直播 / 无音频)
Clock 结构
typedef struct {
double pts;
double pts_drift;
double last_updated;
int serial;
} Clock;
核心思想
physical_time = get_monotonic_time()
clock = pts + drift
drift = last_pts - physical_time
八、AVSync:播放器最难的部分
视频同步到音频(最常见)
diff = video_clock - audio_clock;
if (diff > 0) {
// 视频快了 → 延迟显示
usleep(diff * 1e6);
} else if (diff < -threshold) {
// 视频慢太多 → 丢帧
drop_next_frame();
}
丢帧策略(非常关键)
-
只丢非参考帧
-
不破坏 B-frame 依赖
-
Seek 后禁止丢帧
九、渲染线程(Audio / Video)
音频
-
回调驱动(PortAudio / ALSA / WASAPI)
-
永不阻塞
-
不足补静音
视频
-
独立线程 or UI 线程
-
从 FrameQueue 取帧
-
按 Clock 显示
硬解渲染(重点)
-
DXVA2:
StretchRect → Present -
VA-API:
EGL + DRM -
NVDEC:
CUDA → OpenGL
⚠️ BeginScene / EndScene 不能包 StretchRect
十、状态机:播放器的灵魂
enum PlayerState {
STOPPED,
OPENING,
PLAYING,
PAUSED,
SEEKING,
BUFFERING,
ERROR
};
所有模块 只响应状态变化:
-
Seek → 全部 flush
-
Pause → 暂停 packet 消费
-
Buffering → 阻塞渲染
十一、为什么 ffplay 看起来很简单?
因为 ffplay:
-
把 queue / clock / sync 藏在全局变量里
-
大量
goto -
强耦合 SDL
-
不适合二次开发
👉 ffplay 是"教学代码",不是"架构范本"。
十二、一个真实的坑清单(避坑用)
-
Seek 后第一帧黑屏(没等到 keyframe)
-
暂停后 CPU 100%(不该忙等)
-
网络流卡死后不恢复(没超时 + retry)
-
硬解 surface 泄漏(FrameQueue 没 Release)
-
音频视频不同步(Clock 没更新)
-
MKV 进度条跳动(byte seek 没 scan)
十三、推荐的工程目录结构
player/
├── demux.c # Demuxer 线程
├── packet_queue.c
├── frame_queue.c
├── audio_dec.c
├── video_dec.c
├── audio_out.c
├── video_render.c
├── clock.c
├── sync.c
├── state.c
└── player.h
十四、总结
**FFmpeg 播放器的复杂度,不在 API,而在"并发 + 缓冲 + 同步 + 状态管理"。**
你不是在写播放器,而是在写一个 实时多媒体操作系统内核。