🎬 第九章 FFplay源码剖析及音视频同步
📄 一句话概括

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 秒实验

  1. 把包队列只按「包个数」限在 15,算一下最坏情况占多少内存。
  2. 算一下:如果时钟每帧都直接 set,两帧之间读出来会是什么值?
  3. 判断一下:serial 字段是干什么用的?去掉它会出什么问题?

常见误区

  1. 以为包队列只需要记个数。 还要记字节数和时长;一个 I 帧能顶十几个普通帧。
  2. 以为时钟返回的是「上一次的 pts」。 它返回的是「偏移量 + 当前墙钟」,两帧之间会自己走。
  3. 以为时钟可以用系统墙上时间。 必须用单调时钟,否则用户改一下系统时间,播放器就疯了。
  4. 以为帧队列「取走即销毁」。 它是环形缓冲,peek 和 next 分开, 因为同步逻辑要「先看一眼再决定」。
自测三问(点开对答案) 1. 包队列为什么除了包个数还要记字节数? —— 因为包大小能差几百倍,只按个数限,遇到大 I 帧就爆内存。 2. `get_clock()` 为什么能「自己走」? —— 因为它存的是「pts 减去当时墙钟」的偏移量,读的时候加上当前墙钟,于是两帧之间它也连续增长。 3. `serial` 字段解决什么问题? —— seek 之后队列里可能残留上一段的数据,靠 serial 区分新旧,旧数据一律作废,避免时钟倒退。

零件认全了,下一节看它们怎么装起来。重点是 read_thread 那个主循环——它看起来是个「死读」,其实里面有刹车、有分发、有退出检查。

相关笔记