🎬 第五章 HLS流媒体协议
📄 一句话概括

TS 的每个包都是 188 字节,第一个字节固定 0x47。 定长是它最大的特点,也是它能扛丢包的根本原因。

📝 本节和前面几节的关系

5.2.1 与 5.2.2 讲的是清单,这一节开始讲清单里指的那些文件。 5.3 会把 m3u8 的标签系统讲全,5.4 再回过头讲 TS 与 PS 的分工。

一、真封一个 TS 看对齐

真编 25 帧视频、封成 TS,量出来的数字很干净: 文件 9588 字节,正好 51 个 188 字节包,余数为 0。 第一个字节是 0x47——这个值叫「同步字节」,是 TS 的身份证。 逐包检查 51 个包,不合规 0 个。

定长是 TS 最大的特点。 定长的好处是「丢了一个包也能靠 188 对齐找回来」: 接收端只要连续找到几个 0x47 在同一位置,就能重新同步。 这让 TS 特别适合「会丢数据的传输环境」,比如广播与组播。

对比一下 PS:PS 的包是变长的,丢一个字节后面全乱。 所以 PS 只能用在「保证不丢」的场合,比如本地文件与 DVD。

二、包里的 PID 分布

188 字节的包里,4 字节包头 + 184 字节载荷。 包头里最重要的字段是 13 位的 PID:它决定这包属于哪一路。

实测这个只含视频的 TS 有四种 PID: 256 占 60.8%(视频本体)、0 占 17.6%(PAT)、 4096 占 17.6%(PMT)、17 占 3.9%(SDT,节目标识)。

PID 0 是 PAT,PAT 里写着「本节目用哪个 PID 发 PMT」。 所以解析顺序永远是:先找 PID 0,读 PAT,拿到 PMT 的 PID,再读 PMT。

注意 PAT 与 PMT 各占 17.6%,这个比例高得惊人。 原因是我只封了 25 帧、才 51 个包,表被重发了 9 次。 片越长,这个比例越低——长片的表开销通常不到 1%。

三、为什么偏偏是 188

188 = 4 字节包头 + 184 字节载荷。 这个数字不是随便定的,是几个历史约束撞出来的结果。 ① 与 ATM 的 47 字节载荷兼容:4 个 47 刚好 188, 早期传输网是 ATM,这样能直接塞进去。 ② 与纠错块对齐:188 是 188,前向纠错常按 204 字节一块, 188 加 16 字节校验正好 204。 ③ 包头要够放:4 字节刚好装下同步字节、PID、连续计数这些必需字段。

数字看起来怪,但它是广播行业几十年妥协的结果。 所以看到 188 不要想着「为什么不是 200」,它就是历史。

四、动手 Demo:TS 文件——188 字节一包,从头到尾对齐

真编 25 帧封成 TS,然后做三件事:验 188 对齐、验同步字节、数 PID。 PID 分布会告诉你「这个文件里都有哪些表、哪些流」。

import io, os, collections, tempfile
import av, numpy as np

tmp = os.path.join(tempfile.mkdtemp(), "a.ts")
c = av.open(tmp, "w", format="mpegts")
st = c.add_stream("libx264", rate=25)
st.width, st.height, st.pix_fmt = 320, 240, "yuv420p"
st.options = {"crf": "30", "preset": "veryfast", "g": "25"}

for i in range(25):
    f = av.VideoFrame(320, 240, "yuv420p")
    y = np.zeros((240, 320), np.uint8)
    y[:, :] = np.linspace(16, 200, 320, dtype=np.uint8)[None, :]
    f.planes[0].update(y.tobytes())
    for pi in (1, 2):
        f.planes[pi].update(bytes([128]) * f.planes[pi].buffer_size)
    f.pts = i
    for pk in st.encode(f):
        c.mux(pk)
for pk in st.encode(None):
    c.mux(pk)
c.close()

raw = io.open(tmp, "rb").read()
print("文件 %d 字节 = %d × 188 + %d" % (len(raw), len(raw) // 188, len(raw) % 188))

cnt = collections.Counter()
bad = 0
for off in range(0, len(raw) - 187, 188):
    if raw[off] != 0x47:
        bad += 1
        continue
    cnt[((raw[off + 1] & 0x1F) << 8) | raw[off + 2]] += 1
print("同步字节不合规 %d 个" % bad)
for pid, n in cnt.most_common():
    print("  PID %-5d %3d 包  %.1f%%" % (pid, n, n * 100.0 / (len(raw) // 188)))

实跑输出:

--------------------------------------------------------------------
TS 文件——188 字节一包,从头到尾对齐
--------------------------------------------------------------------
**① 真封一个 TS,看它是不是 188 的整数倍**

  文件 9588 字节 = 51 × 188 + 0
  第一个字节 0x47(必须是 0x47,叫同步字节)
  逐包检查同步字节:不合规 0 个

  **定长是 TS 最大的特点。**
  **定长的好处是「丢了一个包也能靠 188 对齐找回来」。**
  **接收端只要连续找到几个 0x47 在同一位置,就能重新同步。**
  **这让 TS 特别适合「会丢数据的传输环境」,比如广播与组播。**

**② 包里的 PID 分布**

  PID      包数     占比
  ---------------------------
  256      31       60.8%
  0        9        17.6%
  4096     9        17.6%
  17       2        3.9%

  **PID 0 是 PAT,PAT 里写着「本节目用哪个 PID 发 PMT」。**
  **所以解析顺序永远是:先找 PID 0,读 PAT,拿到 PMT 的 PID,再读 PMT。**

**③ 为什么是 188**

  **188 = 4 字节包头 + 184 字节载荷。**
  **选这个数是为了「与 ATM 的 47 字节、以及 8 的倍数的纠错块兼容」。**
  **数字看起来怪,但它是广播行业几十年妥协的结果。**

9588 = 51 × 188,余数 0——TS 文件长度永远是 188 的整数倍,没有例外。 PID 256 是视频本体,占比六成,剩下的四成全是表与元数据。 PID 17 是 SDT,它写「这个节目叫什么名字」,播放器显示台标就靠它。

30 秒实验

  1. 把帧数从 25 改成 100,看 PAT 的占比——从 17.6% 掉到 5% 左右,片越长表开销越显得小。
  2. 加一条音频流再封一次,看 PID 多出几个——多一个音频 PID,PMT 也会变长。
  3. 把 format 改成 mp4,看文件长度还能不能整除 188——不能,MP4 是变长结构,这就是「TS 面向传输」的意思。

常见误区

  1. 「TS 包长可以自定义」——不能,188 是硬规定。
  2. 「0x47 只是巧合」——不是,它是同步字节,用来重同步。
  3. 「PID 就是流编号,随便取」——有些 PID 是保留的,0 必须是 PAT。
自测三问(点开对答案) 1. TS 包长多少、同步字节是什么? —— **188 字节,首字节 0x47**;文件长度永远是 188 的整数倍。 2. 为什么定长能扛丢包? —— **丢了也能靠 0x47 重新对齐**,找到下一个包边界。 3. 为什么表开销在小文件里占比很高? —— **表是周期重发的,片越短重发占比越高**,长片通常不到 1%。

相关笔记