ffplay 的全部状态挂在一个 VideoState 上,其余三个结构体是它的零件:包队列、帧队列、
时钟。
这四个结构体的设计思路,比它们的字段更值得学。
09.01 讲了架构,但架构是「关系」,这一节讲「容器」。
读源码时看到满屏的 is->videoq、is->audclk,不知道它们是什么,就读不下去。
更要紧的是设计意图:
为什么包队列要记字节数、为什么时钟要存偏移量——这两处是整份代码里最有「工程经验」味道的地方。
一、VideoState:一个结构体装下全部
VideoState 有七八十个字段,但它们只分五类,分类之后就不吓人了:
| 类 | 典型字段 | 作用 |
|---|---|---|
| 输入 | ic、filename |
那个 AVFormatContext |
| 队列 | videoq、audioq、subtitleq |
三个包队列 |
| 时钟 | vidclk、audclk、extclk |
三个时钟 |
| 线程 | read_tid、vid_tid |
线程句柄 |
| 状态与开关 | abort_request、paused、muted |
控制位 |
这五类正好对应架构图上的五个部分——所以一个 VideoState 就是「一个播放器实例」。
ffplay 只有一个全局的,而你要做多路播放,就得把它变成数组或者链表。
字段名里有个细节值得学:abort_request 用 int 而不是 bool,
因为它要在多条线程之间读写,而 C 里没有原子布尔,只能靠「一个机器字」来保证读写不会撕裂。
这类「类型选择背后有并发考虑」的细节,是读源码最大的收获。
二、PacketQueue:为什么还要记字节数
普通队列只需要「进」和「出」,而 ffplay 的包队列多了两个字段:
| 字段 | 记什么 | 为什么要它 |
|---|---|---|
nb_packets |
包个数 | 按个数限流,防一手拿太多 |
size |
所有包的字节和 | 防内存炸 |
duration |
所有包的时长和 | 防延迟过大 |
三个字段对应三种「上限」,而 ffplay 实际用的是第一个和第二个:
MAX_QUEUE_SIZE 是 15,MAX_QUEUE_SIZE_BYTES 是一大笔字节数。
为什么「按个数」不够: 因为一个包的大小可以差几百倍——一个 I 帧可能是几十万字节,一个 P 帧只有几百字节。 只按个数限,遇到「15 个大 I 帧」就爆内存了。
这跟本课 Demo 里量到的数对得上: 同一个文件里,包的平均大小才一百多字节,而关键帧能顶十几个普通帧。 所以「按个数限」只是个粗略的刹车,真正的保险是字节数。
三、FrameQueue:环形缓冲与「已显示」标记
帧队列比包队列复杂,因为它多了一个「消费语义」: 帧不是「取走就没了」,而是「显示完了才算用完」。
| 字段或机制 | 作用 |
|---|---|
rindex / windex |
读写下标,环形推进 |
queue[max_size] |
定长数组,不动态分配 |
frame->pts 与 duration |
算这一帧该显示多久 |
width/height/format |
入队时就固定,之后不再变 |
「定长数组 + 两个下标」这个设计有两个好处:一是不会有内存分配失败,
二是消费者可以「先看一眼再决定要不要推进」——这就是 peek 与 next 两个动作分开的原因。
为什么需要「先看一眼」:因为同步逻辑要算「这一帧该什么时候显示」, 算完之后才知道「现在该不该显示它」。 如果队列是「取走即销毁」的,就没法算了。
这个细节是「数据结构迁就算法」的典型例子: 不是先有队列再想怎么用,而是先有同步算法,再设计出「可窥视」的队列。
四、Clock:存偏移量而不是存时间
时钟只有四个字段,但它的存法反直觉:
| 字段 | 含义 |
|---|---|
pts |
上一次收到的时间戳 |
last_updated |
上一次更新的墙钟 |
pts_drift |
pts 减去当时的墙钟 |
serial |
用来识别「这是新一轮播放的时钟」 |
get_clock() 只有一行:返回 pts_drift 加上当前墙钟。
换成大白话:「上次我们差了这么多,现在就按这个差继续推」。
为什么不能直接返回 pts:因为 pts 只在有新帧时才更新,两帧之间是常数,
读出来是「跳变」的。
加了墙钟外推之后,中间这段时间它是连续涨的。
serial 这个字段容易被忽略,但它解决一个真问题:
seek 之后,队列里可能还残留着上一段的数据,
如果这些数据的 pts 跟新一轮混在一起,时钟就会倒退。
所以每 seek 一次就换一个 serial,旧包一律作废。
五、三个队列的容量,为什么都这么小
看一眼就知道这几个数都不大:
| 队列 | 上限 | 折算成时间 |
|---|---|---|
| 包队列 | 15 个包 | 约 0.5 秒 |
| 帧队列 | 按个数 + 按时间 | 约 1 秒 |
| 音频缓冲 | 按样本数 | 约 0.1 秒 |
为什么不做大一点,因为队列越大、延迟越大——09.07 会用两个实验证明这件事。
为什么不做小一点,因为没有缓冲就抗不住抖动: 网络偶尔卡一下、解码偶尔慢一帧,全靠这点余量吸收。
所以这三个数字是「折中出来的经验值」,不是推导出来的最优解。 你在自己的播放器里可以调,但要知道每调一格在换什么。
六、API 的签名为什么长这样
ffplay 里几乎所有函数都带一个 is 参数,因为它们是「对一个播放器实例的操作」。
这是标准的「上下文传递」写法,跟 libav* 一致。
| 函数 | 干什么 | 返回值为什么不重要 |
|---|---|---|
packet_queue_put |
塞包 | 只看「成没成」 |
frame_queue_peek |
看下一帧 | 返回指针 |
frame_queue_next |
推进读指针 | void |
get_clock |
读时钟 | 返回秒(浮点) |
第三行值得看一眼:peek 和 next 分开,是为了让调用者「看过之后再决定」,
这一点前面讲过。
第四行的「返回秒」也有讲究:FFmpeg 里所有时间在内部都用「整数 + 时间基」表示, 只在需要比较和显示时才换成浮点秒。 ffplay 的时钟是「给同步算法用的」,所以它用浮点。 这个分工要记清:整数准,浮点好算。
七、动手 Demo:两个结构体撑起 ffplay——包队列与时钟
四个结构体里,前两个是「装东西的」,第四个是「串起来的」,只有时钟是「有算法的」。 所以 Demo 挑这两处量:包队列的三个统计量,和时钟的外推。
# 包队列:三个统计量一起维护
qsize += pk.size
qdur += pk.duration * stream.time_base
q.pts_max = max(q.pts_max, pk.pts)
# 时钟:存偏移量,读的时候外推
def set_clock_at(c, pts, now):
c.pts = pts; c.last_updated = now
c.pts_drift = c.pts - now
def get_clock(c):
return c.pts_drift + now()
实跑输出:
--------------------------------------------------------------------
两个结构体撑起 ffplay——包队列与时钟
--------------------------------------------------------------------
**ffplay 里有四个核心结构体**:
**`PacketQueue` 存包、`FrameQueue` 存帧、`Clock` 记时间、`VideoState` 串全局。**
**前两个本质一样,都是「带字节计数的队列」;`Clock` 最值得单独看,因为它反直觉。**
**先看包队列**。它比普通队列多两个字段:**字节数**和**时长累计**。
把整段 60 个视频包「全塞进队列」会是:
**8068 字节、2.000 秒、pts 从 0 到 30208**
**三个数各有各的用处**:**字节数防内存炸,时长防延迟过大,pts 用来算「队列里排到哪儿了」。**
**ffplay 的包队列上限是 15 个包**,
**看着很小,但它是「按包数」而不是「按时间」限的**——**这就有个隐患:一个大关键帧能顶十几个小帧。**
**按 15 个包算,平均每包 134 字节,队列大约 2 KB**
**再看时钟,这里有个反直觉的设计**。
**时钟不是「每帧更新一次」,而是「更新一次,之后自己走」**:
```text
set_clock_at(c, pts, now)
c.pts = pts
c.last_updated = now
c.pts_drift = c.pts - now
get_clock(c)
return c.pts_drift + now
关键就在 pts_drift 这一行:它把「时间戳」翻译成「偏移量」——时钟比墙钟差多少。
翻译完之后,get_clock() 只是「偏移量加当前墙钟」,得到的是一个跟着墙钟平滑前进的估计值。
为什么要这么绕,直接返回 pts 不行吗:不行。因为 pts 只在「有新帧」时才更新一次,
两帧之间它是个常数,读出来是「跳变」的。
加了墙钟外推之后,中间这段时间它是连续涨的。
设成 10.000 秒之后,一秒内不去动它,读三次: 第 1 次读取:10.050 秒 第 2 次读取:10.101 秒 第 3 次读取:10.152 秒
它自己在走,而且走得跟墙钟一样快。 这就是「用墙钟外推」的好处:不需要解码器每帧都来报告。
代价是「它会漂」:
墙钟是单调的,音视频的播放速率却可能跟墙钟有细微差异(声卡时钟不是水晶)。
所以 ffplay 还有 sync_clock_to_slave 这一步,把视频时钟往音频时钟上拉。
09.08 整节都在处理这个「拉」的力度问题。
```
包队列那三个数,本课量到的具体值是这样:整段 60 个包一共几 KB、2 秒、pts 从 0 到三万多。 三个数分别对应「内存」「延迟」「进度」。
平均每个包一百多字节这个数,很能说明问题: 因为它是「平均」,而单个包可以差几百倍——一个 I 帧几十万字节,一个 P 帧几百字节。 所以只按「包个数」限流是不够的。
时钟那一段是本节的重点:「设成 10 秒之后、不去动它、读三次」, 三次读出来的值在稳定上涨,间隔跟实际等待时间一致。 这就是「用墙钟外推」的效果,也是「时钟不需要每帧更新」的原因。
但外推有个前提:墙钟必须单调。
所以 ffplay 用的是 av_gettime_relative(),不是「墙上时间」——后者会被系统校时、夏令时、用户改表影响,一改就跳。
这个坑很隐蔽:换个系统时间,播放器就疯了。
最后一句要说清代价:外推出来的时钟会「漂」。
因为声卡的石英跟主板晶振不是一个,两者速率有极小的差异,累积起来就是音画不同步。
所以 ffplay 还有一步 sync_clock_to_slave,把视频时钟往音频时钟上拉——09.08 讲的就是这个「拉」的力度。
30 秒实验
- 把包队列只按「包个数」限在 15,算一下最坏情况占多少内存。
- 算一下:如果时钟每帧都直接
set,两帧之间读出来会是什么值? - 判断一下:
serial字段是干什么用的?去掉它会出什么问题?
常见误区
- 以为包队列只需要记个数。 还要记字节数和时长;一个 I 帧能顶十几个普通帧。
- 以为时钟返回的是「上一次的 pts」。 它返回的是「偏移量 + 当前墙钟」,两帧之间会自己走。
- 以为时钟可以用系统墙上时间。 必须用单调时钟,否则用户改一下系统时间,播放器就疯了。
- 以为帧队列「取走即销毁」。 它是环形缓冲,
peek和next分开, 因为同步逻辑要「先看一眼再决定」。
自测三问(点开对答案)
1. 包队列为什么除了包个数还要记字节数? —— 因为包大小能差几百倍,只按个数限,遇到大 I 帧就爆内存。 2. `get_clock()` 为什么能「自己走」? —— 因为它存的是「pts 减去当时墙钟」的偏移量,读的时候加上当前墙钟,于是两帧之间它也连续增长。 3. `serial` 字段解决什么问题? —— seek 之后队列里可能残留上一段的数据,靠 serial 区分新旧,旧数据一律作废,避免时钟倒退。零件认全了,下一节看它们怎么装起来。重点是 read_thread 那个主循环——它看起来是个「死读」,其实里面有刹车、有分发、有退出检查。
- ☐ (本节完,下一节 09.03 FFplay的核心框架及流程)