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 秒实验
- 把
SDL_CreateRenderer的第二个参数从-1改成指定的驱动序号,看画面有没有变化。 - 算一下:一帧 0.7 毫秒,放 60 帧每秒,渲染占 CPU 的百分之几?
- 判断一下:
RenderCopy只要 1 微秒,是不是说明「缩放」不要钱?
常见误区
- 以为 ffplay 是个能日常用的播放器。 它没有播放列表、没有字幕菜单,定位是「能跑的教学材料」。
- 以为 SDL 负责解码。 SDL 只管窗口、渲染、声音设备、事件;解码和同步都是 ffplay 自己的代码。
- 以为分线程是为了「快的等慢的」。 真正的理由是「阻塞来源不同」,网络卡了不该让画面停。
- 以为队列越大越流畅。 队列只抗抖,不加速;攒得越多,延迟越大,见 08.04 的两个实验。
自测三问(点开对答案)
1. ffplay 为什么要开三条线程? —— 因为三种阻塞来源不同:网络与磁盘、解码器、声卡。串成一条线程,任何一头卡住整条链都停。 2. 包队列和帧队列分别在谁和谁之间? —— 包队列在 read_thread 和解码线程之间,装 AVPacket;帧队列在解码线程和主线程之间,装 AVFrame。 3. 渲染四步里最贵的是哪两步? —— UpdateTexture 和 RenderPresent。前者搬数据进去,后者推结果出去; 中间的 Clear 和 Copy 几乎不花时间。知道了 ffplay 长什么样,下一步是把它跑起来。但先别上 MFC 和 Qt——先用最裸的控制台版跑通,把工程配置这道坎过掉。因为后面两节里一半的「跑不起来」,根子都在这一节的配置上。
- ☐ (本节完,下一节 08.02 VS 2015控制台开发FFplay+SDL 2播放器)