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 秒实验
- 把帧数从 25 改成 100,看 PAT 的占比——从 17.6% 掉到 5% 左右,片越长表开销越显得小。
- 加一条音频流再封一次,看 PID 多出几个——多一个音频 PID,PMT 也会变长。
- 把
format改成mp4,看文件长度还能不能整除 188——不能,MP4 是变长结构,这就是「TS 面向传输」的意思。
常见误区
- 「TS 包长可以自定义」——不能,188 是硬规定。
- 「0x47 只是巧合」——不是,它是同步字节,用来重同步。
- 「PID 就是流编号,随便取」——有些 PID 是保留的,0 必须是 PAT。
自测三问(点开对答案)
1. TS 包长多少、同步字节是什么? —— **188 字节,首字节 0x47**;文件长度永远是 188 的整数倍。 2. 为什么定长能扛丢包? —— **丢了也能靠 0x47 重新对齐**,找到下一个包边界。 3. 为什么表开销在小文件里占比很高? —— **表是周期重发的,片越短重发占比越高**,长片通常不到 1%。- ☐ (本节完,下一节 5.3 m3u8 格式讲解)