🎬 第八章 FFplay+SDL 2开发音视频流媒体播放器
📄 一句话概括

ffplay 是 FFmpeg 源码树里的一个文件,fftools/ffplay.c,三千多行。 它的价值不在「能用」,而在「能读完」——整个播放流程被压在这一份文件里,没有一个多余的分层。

📝 本节和前面几节的关系

上一章把 API 一段段拆开了:解封装、解码、像素转换、声卡输出。 但「把它们连起来跑」这件事,书上是放到 ffplay 里讲的。 第八章先认识这个程序,再把它搬到三个壳里:控制台、MFC、Qt。 第九章才真正一行行读它的源码。 所以这一章的位置是「先会用,再会拆」。

一、四个播放器,四种活法

名字 定位 大致体量 你拿它干什么
ffplay FFmpeg 自带的示例 三千多行 C 读源码、验证码流
VLC 重装全功能播放器 上百万行 直接放、用 LibVLC
mplayer 老牌命令行播放器 十几万行 命令行批处理
商业播放器 闭源 不公开 只能用

这张表里只有第一行是「能读完的」。 三千多行对一个人来说是一两周的事,上百万行则是「一辈子也只熟悉自己改过的那几处」。

但 ffplay 也就因此不是给用户用的:它没有播放列表、没有字幕选择菜单、没有设置界面, 全部交互只有「一个窗口 + 几个键盘按键」。 你按 空格 暂停、按 q 退出、按 ← → 拖动十秒——就这样。

所以对它的期待要摆正:它不是产品,是一份「能跑起来的教学材料」。 你读它,是为了知道「一个播放器最少需要什么」,不是为了拿它和 PotPlayer 比功能。

二、它在源码树的哪个位置

FFmpeg 源码里的 fftools 目录只有三个程序:

程序 干什么 读什么进去 写什么出来
ffmpeg.c 转码、转封装、滤镜 输入文件/流 输出文件/流
ffprobe.c 只探测不处理 输入文件/流 文本报告
ffplay.c 播放 输入文件/流 窗口与声卡

三者的共同点是「都直接调 libav*,不依赖任何界面框架」,区别只在最后一步: 一个写文件、一个写文本、一个写屏幕。

这也是为什么想学播放器的人都从 ffplay 开始:它把「引擎」和「壳」的边界切得最干净。 引擎是 libav*,壳是 SDL。 第八章后面三节做的事,全是换壳——把 SDL 那个壳换成 MFC 的、换成 Qt 的。

三、三条线程,各自卡在自己的地方

ffplay 一启动就开三条线程,这不是为了「快」,是为了「不被彼此拖死」:

线程 干什么 通常卡在哪 交出什么
read_thread 读文件或网络,解封装 网络、磁盘 包队列
video_thread 解视频包 解码器 帧队列
audio_thread 解音频包、重采样 解码器 音频缓冲
主线程 取帧、同步、渲染、收事件 垂直同步 屏幕

为什么要分开,看「卡在哪」这一列就明白了:三种阻塞的来源完全不同。 网络断了,画面不该跟着停;声卡满了,解码不该跟着停。 串成一条线程的话,任何一个环节抖一下,整条链都停在那里。

这里有个反直觉的点要提前说: 分线程的理由不是「解码比渲染贵」——在本课的实测里,小分辨率下解一帧和贴一帧是同一个量级,谁也没贵多少。 真正的理由是「两者会互相打扰」,不是「一快一慢」。

四、队列:三条线程之间的缓冲带

线程之间不能直接递数据,因为一边快一边慢,必须有地方存。 ffplay 用了两级队列:

队列 两头是谁 装什么 默认上限
包队列 read_thread → 解码线程 AVPacket 15 个
帧队列 解码线程 → 主线程 AVFrame 按时间算,约 1 秒

队列的作用不是「加速」,是「吸收抖动」——这一点很多人搞反。 它不会让解码变快,只会让「偶尔慢一下」不至于立刻卡画面。

代价是延迟:队列里攒着几帧,你看到的画面就落后几帧。 所以「缓冲多大」永远是「抗抖」和「迟钝」之间的取舍——08.04 会用两个真实实验把这个取舍量出来,结论比想象中微妙。

五、SDL 在 ffplay 里只干三件事

很多人以为 ffplay 靠 SDL 播放,其实 SDL 只负责「最后一步和最早一步」:

SDL 子系统 在 ffplay 里对应什么
Video 建窗口、建渲染器、把一帧贴上去
Audio 打开声卡、按回调往里送 PCM
Events 键盘、鼠标、关闭窗口

解码、同步、排队,SDL 一概不管。 这三件事全是 ffplay 自己写的代码。

所以「换壳」这件事才成立:要换掉的只是「建窗口 + 贴图 + 收事件」这一层, 中间那一大坨解码与同步逻辑可以原样保留。 这是第八章三节移植能写成一章的根本原因。

Demo 里把 SDL 的「家底」打了出来:本机可选视频驱动两个、渲染驱动六个。 这两个数字不大,但它解释了一个常见现象——为什么照着书上的代码跑,画面表现却跟书上不一样: 因为 SDL_CreateRenderer 传 -1 时,SDL 自己挑,挑到谁是不确定的。

六、2026 现状:SDL 2 与 FFmpeg 8

书上成于 2023 年,用的是 FFmpeg 4.x 加 SDL 2.0.x。 现在(2026 年)是 FFmpeg 8.1、SDL 2.32。

东西 书上 现在 影响
FFmpeg 4.x 8.1.2 老 API 已删,见第七章
SDL 2.0.x 2.32.10 API 基本没变
VS 2015 2022 工程配置要重做
Qt 5.x 6.x 模块与编译方式都变了

一个好消息:SDL 2 的 API 从 2.0 到 2.32 几乎没变,书上的 SDL 代码照抄能跑。

一个坏消息:SDL 3 已经出了正式版,事件结构体、窗口创建、渲染都改了名字,还要改一批。 所以「学 SDL 2 会不会白学」这个问题,答案是「不会,但升级时要重读一遍文档」。

最后交代本课的实测边界:沙箱里没有 ffplay 可执行文件, 所以本课所有 Demo 都不是「跑 ffplay」,而是「用 PyAV 加 pysdl2 复现 ffplay 的同一段逻辑」。 结论能对上,数字是本机量出来的,但别把它当成 ffplay 自己的输出。

七、动手 Demo:ffplay 启动时先问 SDL 三件事,再算一帧的价码

ffplay 的第一行是 SDL_Init,第二步是选驱动。 它到底选到了什么,用几行就能问清楚。 问完之后再量一件事:把一帧贴到屏幕上,要多少时间。 这个数就是「渲染这条路的上限」,后面的同步全在它的余量里做文章。

sdl2.SDL_Init(sdl2.SDL_INIT_VIDEO)
v = sdl2.SDL_version(); sdl2.SDL_GetVersion(ctypes.byref(v))
print(v.major, v.minor, v.patch)
print(sdl2.SDL_GetCurrentVideoDriver())
print(sdl2.SDL_GetNumVideoDrivers(), sdl2.SDL_GetNumRenderDrivers())

# 一帧的完整成本:贴图 + 清屏 + 拷贝 + 呈现
t0 = time.perf_counter()
for _ in range(300):
    SDL_UpdateTexture(tex, None, ptr, pitch)
    SDL_RenderClear(ren)
    SDL_RenderCopy(ren, tex, None, None)
    SDL_RenderPresent(ren)
dt = (time.perf_counter() - t0) / 300

实跑输出:

--------------------------------------------------------------------
ffplay 启动时先问 SDL 三件事,再算一帧的价码
--------------------------------------------------------------------
**ffplay 的第一行代码就是 `SDL_Init`,第二步就是选驱动**。
**它选到的到底是什么,用三行就能问清楚。**

  SDL 运行期版本:**2.32.10**
  当前视频驱动:**dummy**
  本机可选视频驱动:**2 个**
    - windows
    - dummy

  本机可选渲染驱动:**6 个**
    - direct3d(硬件加速/垂直同步/可渲到纹理)
    - direct3d11(硬件加速/垂直同步/可渲到纹理)
    - direct3d12(硬件加速/垂直同步/可渲到纹理)
    - opengl(硬件加速/垂直同步/可渲到纹理)
    - opengles2(硬件加速/垂直同步/可渲到纹理)
    - software(软件/垂直同步/可渲到纹理)

**这两个「个数」是本节最该记住的数**:**视频驱动只有两三个,渲染驱动也只有两三个**,
**ffplay 的行为差异全在这里——它用 `-1` 让 SDL 自己挑,挑到谁就跟书上的说明不一样。**

**先单独量「画一帧」要多少时间**:一直往同一张纹理上贴同一幅图,
**循环 300 次,去掉第一次的分配开销。**

  贴图 + 清屏 + 拷贝 + 呈现,一步不少:**每帧 0.77 毫秒**
  照这个速度,理论上限 **1301 帧/秒**

  步骤              每帧耗时(微秒)     占四步合计
  -----------------------------------------------------
  UpdateTexture     225.3                39%
  RenderClear       0.7                  0%
  RenderCopy        1.1                  0%
  RenderPresent     355.8                61%

**四步加起来 582.9 微秒**,
比整轮实测的 768.4 微秒少一截——**差的这部分是循环本身和「读时钟」的开销。**

**再把解码这一步摆进来对比**(320×240、40 帧):

  解一帧(含解封装):**中位 0.09 毫秒,最慢 0.44 毫秒**
  贴一张同尺寸的图:**0.056 毫秒**
  解码的抖动倍数:**4.9 倍**

**这里的结果跟直觉相反,得照实说**:
**两者是同一个量级**——**在这个小分辨率上,「解一帧」和「把一帧交给显卡」谁也不比谁贵多少。**
**在 320×240 这个尺寸上,一帧的码流只有几 KB**,**解码就是几十微秒的事**;
**贴图也是几十微秒,大头是内存拷贝和驱动的固定开销,跟画面内容无关。**

**那 ffplay 为什么还要把解码和渲染分到两条线程?两个理由**:
  **第一,两者的耗时都在抖**——**上面这个「4.9 倍」就是抖动的量级**,
  **而抖动没法靠「平均一下」消掉,只能靠队列吸收。**
  **第二,两者的阻塞来源不同**——**解码等网络或磁盘,渲染等人和垂直同步。**
  **串成一条线,任何一头卡住,另一头都只能干等。**

**这里还藏着一个「分辨率一变结论就变」的陷阱**:
**换成 1920×1080,解码耗时按像素涨几十倍,贴图也要涨,但涨幅不同。**
**所以「解码贵还是贴图贵」没有固定答案,得按你的分辨率和机器重新量。**
**书上的顺序是「先解后画」,这句话任何时候都对;但「哪个更贵」这句话,书上也给不出通用答案。**

**最后给一个能看到天花板的数**:**上一轮量出的 0.77 毫秒/帧,换算成 1301 帧/秒**。
**这是「软件渲染器 + 隐藏窗口」的上限,不是真实播放的上限。**
**真正到屏幕上还要过合成器、过垂直同步**——**60 帧/秒的屏上,上限就锁死在 60。**

先看那两个「个数」:视频驱动两个、渲染驱动六个。 驱动这一层少得可怜,所以「换个驱动试试」这件事,可试的选项就这么几个。

再看四步的拆解: UpdateTexture 和 RenderPresent 吃掉几乎全部时间,RenderClear 和 RenderCopy 加起来还不到 2 微秒。 这个分布很说明问题: 贵的是「把数据搬进去」和「把结果推出去」两件事,中间那些绘制指令本身几乎不要钱。

所以优化渲染的方向只有两个: 少搬(比如直接给 YUV,让显卡自己转)、少推(比如只在画面真的变了才 Present)。 在 RenderCopy 上打主意是白费力气。

第三个结果是本节最该记住的:解一帧和贴一帧,在 320×240 上是同一个量级。 书上给的印象常常是「解码很贵、渲染很便宜」,但那是 1080p 时代之前的账。 小画面下,贴图的固定开销反而压过了解码。

但请注意这里有个真正有价值的数:抖动倍数。 中位数几十微秒,最慢那一帧却是中位的四到七倍——这个「偶尔慢一下」才是队列存在的理由,也是「平均值」这种指标在播放器里没用的原因。

最后一句要打个预防针:这里的上限是「软件渲染器 + 隐藏窗口」,换算出来一千多帧每秒。 这个数不代表你能放一千帧的视频——真到屏幕上还要过合成器、过垂直同步,60 赫兹的屏上就是 60。 测得出来的是「CPU 这侧够不够快」,测不出来的是「屏幕给不给你」。

30 秒实验

  1. 把 SDL_CreateRenderer 的第二个参数从 -1 改成指定的驱动序号,看画面有没有变化。
  2. 算一下:一帧 0.7 毫秒,放 60 帧每秒,渲染占 CPU 的百分之几?
  3. 判断一下:RenderCopy 只要 1 微秒,是不是说明「缩放」不要钱?

常见误区

  1. 以为 ffplay 是个能日常用的播放器。 它没有播放列表、没有字幕菜单,定位是「能跑的教学材料」。
  2. 以为 SDL 负责解码。 SDL 只管窗口、渲染、声音设备、事件;解码和同步都是 ffplay 自己的代码。
  3. 以为分线程是为了「快的等慢的」。 真正的理由是「阻塞来源不同」,网络卡了不该让画面停。
  4. 以为队列越大越流畅。 队列只抗抖,不加速;攒得越多,延迟越大,见 08.04 的两个实验。
自测三问(点开对答案) 1. ffplay 为什么要开三条线程? —— 因为三种阻塞来源不同:网络与磁盘、解码器、声卡。串成一条线程,任何一头卡住整条链都停。 2. 包队列和帧队列分别在谁和谁之间? —— 包队列在 read_thread 和解码线程之间,装 AVPacket;帧队列在解码线程和主线程之间,装 AVFrame。 3. 渲染四步里最贵的是哪两步? —— UpdateTexture 和 RenderPresent。前者搬数据进去,后者推结果出去; 中间的 Clear 和 Copy 几乎不花时间。

知道了 ffplay 长什么样,下一步是把它跑起来。但先别上 MFC 和 Qt——先用最裸的控制台版跑通,把工程配置这道坎过掉。因为后面两节里一半的「跑不起来」,根子都在这一节的配置上。

相关笔记