从零写一个 FFmpeg 播放器:架构设计远比“解码显示”复杂

基于 FFmpeg 构建跨平台播放器系统时,其架构设计通常遵循‌模块化、高内聚低耦合‌的原则,核心目标是实现高效的音视频解码、渲染及同步。

很多人学 FFmpeg,都是从这段代码开始的:

复制代码
ffplay input.mp4

然后自己照着抄一个:

复制代码
av_read_frame()
avcodec_send_packet()
avcodec_receive_frame()
SDL_RenderCopy()

跑起来,能播,很开心。

但一旦加上:

  • 暂停 / 继续

  • Seek

  • 音视频同步

  • 硬解

  • 倍速播放

  • 网络流 / 本地文件切换

👉 整个程序会瞬间崩塌。

这篇文章不讲 API 怎么调,而是站在工程视角 ,拆解一个可维护、可扩展、接近 mpv/ffplay 的播放器架构


一、先给结论:播放器 = 多线程流水线

播放器不是"解码器 + 渲染器",而是一个"实时多媒体流水线系统"。

它的核心矛盾只有两个:

  1. 生产者 / 消费者速度不匹配

  2. 音视频时间轴必须对齐

因此,一个合格的播放器,至少包含 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,就没有播放器,只有幻灯片。

三个时间源

  1. Audio clock(最准)

  2. Video clock

  3. 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,而在"并发 + 缓冲 + 同步 + 状态管理"。**​

你不是在写播放器,而是在写一个 实时多媒体操作系统内核

相关推荐
淡淡的香烟1 天前
Android Camera开发详解
android·音视频
笨笨饿1 天前
#109_BK7258 蓝牙 A2DP 音频数据通路深度解析
python·stm32·单片机·mcu·物联网·编辑器·音视频
≮傷£≯√1 天前
图片合并成视频 img2video ffmpeg
ffmpeg·音视频
A13345551 天前
视频特效字幕怎么翻译?保姆级AI字幕与外挂字幕教程
人工智能·音视频
ViiTor_AI2 天前
从去字幕到 AI 配音:完整视频本地化流程
人工智能·音视频
明德扬2 天前
多路MIPI摄像头如何同步聚合?一文看懂FPGA视频汇聚方案
fpga开发·音视频
HY小宝F2 天前
树莓派 FFmpeg 实战笔记(二):命令行、源码编译与硬件编码的真相
学习·职场和发展·ffmpeg
折枝吖2 天前
音视频开发-PCM 原理与 AI 模块实战
音视频·pcm
HySpark2 天前
熙瑾·会悟 ASR 实战:Qwen-ASR 漏字导致转写不完整,如何从音频分段到二次校验解决?
音视频·熙瑾会悟
qq_316837752 天前
uniapp + Vite + ffmpeg-wasm 0.12.x 集成示例
ffmpeg·uni-app·wasm