TS 包头只有 4 字节,里面塞了八个字段。 最实用的是 PID 与连续计数:一个定位流,一个查丢包。
5.4 讲清了 TS 的地位,这一节开始逐个字节拆包头。 5.5.2 讲怎么用工具看,5.5.3 讲 PAT/PMT 怎么把流串起来。
一、包头四个字节的字段
第 1 字节是同步字节,固定 0x47。 这是整个 TS 唯一可以硬编码的假设。 第 2 字节的三位标志:传输错误(1 位)、载荷单元起始 PUSI(1 位)、 传输优先级(1 位)。 剩下 5 位与第 3 字节合起来是 13 位 PID。 第 4 字节:加扰控制 2 位、适配字段控制 2 位、连续计数 4 位。
适配字段控制这四个值要记住: 0 是保留、1 是纯载荷、2 是纯适配字段、3 是两者都有。 实测里最常见的是 1 与 3。
13 位 PID 意味着上限 8191。 但 0x1FFF(8191)是空包专用,所以真正能用的是 0 到 8190。 空包用来填满恒定码率,内容全是 0xFF,接收端直接丢弃。
二、真读一批包看取值
实测 51 个包:PUSI 为 1 的有 45 个。 45 接近 25 帧的两倍——因为每个关键帧还会额外带一个 SPS/PPS 单元。 适配字段控制分布:纯载荷(1)24 包、两者都有(3)27 包。 含适配字段的包超过一半,说明有不少包需要填充或带 PCR。
连续计数这个字段很实用:它是 4 位循环,正常每包加一。 所以「跳了」就说明中间丢了包——这是 TS 层唯一的内建丢包检测手段。
实测 PID 256 的前 8 个连续计数是 0 到 7,跳变 0 次。 注意「跳变」要按「差 1 以外」判,而不是按「是否等于上一个加一」判。 因为 15 之后会回 0,直接比较会误报。
三、适配字段是干什么的
它有两个用途: ① 补齐:最后一包不足 184 字节时用它填。 ② 塞 PCR:节目参考时钟,6 字节,用来做音视频同步。
PCR 是 TS 独有的东西: 它不是时间戳,而是「参考时钟采样」,接收端靠它校准本地时钟频率。 标准要求 PCR 至少每 100 毫秒出现一次。
为什么需要 PCR?因为接收端的晶振和编码端的晶振不一样快。 播放几小时后,两边会差出几百毫秒。 PCR 就是「对表」用的:接收端拿它算出频率偏差,再调整自己的时钟。
适配字段还有第三种用途:塞「不连续指示」。 时间轴跳变时置这一位,效果与 m3u8 的 DISCONTINUITY 类似, 只是发生在 TS 层而不是列表层。
四、动手 Demo:TS 包格式——4 字节包头加 184 字节载荷
先把八个字段列清楚,再真读 51 个包统计 PUSI 与适配字段分布。 最后盯连续计数:它是查丢包最省事的办法。
import io, os, tempfile, collections
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()
n = len(raw) // 188
pusi = 0
afc = collections.Counter()
cc = []
for k in range(n):
p = raw[k * 188:(k + 1) * 188]
if p[1] & 0x40:
pusi += 1
afc[(p[3] >> 4) & 0x03] += 1
if ((p[1] & 0x1F) << 8) | p[2] == 256:
cc.append(p[3] & 0x0F)
print("总包数 %d,PUSI 为 1 的 %d 个" % (n, pusi))
print("适配字段控制:%s" % dict(afc))
jump = sum(1 for a, b in zip(cc, cc[1:]) if (b - a) % 16 != 1)
print("PID 256 连续计数前 8 个 %s,跳变 %d 次" % (cc[:8], jump))
实跑输出:
--------------------------------------------------------------------
TS 包格式——4 字节包头加 184 字节载荷
--------------------------------------------------------------------
**① 包头四个字节的字段**
字段 位宽 作用
--------------------------------------------------------------
同步字节 8 固定 0x47
传输错误标志 1 传输中出错置 1
载荷单元起始 1 PES 第一包置 1
传输优先级 1 高优先级置 1
PID 13 标识这包属于哪一路
加扰控制 2 是否加密
适配字段控制 2 0 无、1 纯载荷、2 纯适配、3 都有
连续计数 4 同 PID 内循环 0~15,用来查丢包
**② 真读一批包,看字段的实际取值**
总包数 51,其中 PUSI 为 1 的 45 个
适配字段控制分布:1→24 包、3→27 包
PID 256 的前 8 个连续计数:0、1、2、3、4、5、6、7
跳变次数 0(0 表示没有丢包)
**连续计数这个字段很实用:它是 4 位循环,正常每包加一。**
**所以「跳了」就说明中间丢了包**——**这是 TS 层唯一的内建丢包检测手段。**
**③ 适配字段是干什么的**
**它有两个用途:**
**① 补齐**:**最后一包不足 184 字节时用它填。**
**② 塞 PCR**:**节目参考时钟,6 字节,用来做音视频同步。**
**PCR 是 TS 独有的东西**:**它不是时间戳,而是「参考时钟采样」,接收端靠它校准本地时钟频率。**
**标准要求 PCR 至少每 100 毫秒出现一次。**
PUSI 45 个、包 51 个——大多每个包都开了一个新的载荷单元。
0x03 掩码取第 4 字节高 2 位,就是适配字段控制那两位。
跳变判定要用 (b - a) % 16 != 1,直接比较会在 15 回 0 时误报。
30 秒实验
- 把帧数改成 100,看 PUSI 个数——大约是帧数的 1.8 倍,比例稳定。
- 把
(b - a) % 16 != 1改成b != a + 1,看跳变次数——会多出几次,全是 15 回 0 造成的误报。 - 统计 PID 8191 的包数,看有几——这个文件里是 0,因为码率不是恒定的。
常见误区
- 「PID 可以有 65535 个」——只有 13 位,上限 8191,且 8191 是空包。
- 「连续计数能发现字节错误」——只能发现丢包,字节损坏要靠 CRC。
- 「PCR 就是时间戳」——不是,它是时钟采样,用来校准频率。
自测三问(点开对答案)
1. 包头 4 字节里最重要的两个字段是什么? —— **13 位 PID 与 4 位连续计数**:一个定位流,一个查丢包。 2. 适配字段控制四个值分别是什么? —— **0 保留、1 纯载荷、2 纯适配、3 都有**;常见是 1 与 3。 3. PCR 为什么必要? —— **两端晶振不同步**,靠 PCR 校准接收端时钟频率。- ☐ (本节完,下一节 5.5.2 TS 码流分析工具)