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

PAT 与 PMT 都是 section,套路一样:table_id、长度、正文、CRC32。 区别只在正文:PAT 列节目,PMT 列流。

📝 本节和前面几节的关系

5.5.3 走通了路线,这一节把两张表的字段逐字节拆开。 5.6 转去讲 PS——那是另一种把 PES 装起来的方式。

一、PAT 的结构

它是一段 section,固定套路: ① table_id = 0x00:标识「这是 PAT」。 ② 长度字段 2 字节:低 12 位是后面还有多少字节。 ③ 传输流 ID 2 字节、版本 1 字节、节号 1 字节、末节号 1 字节。 ④ 节目循环:每 4 字节一组,节目号 2 字节 + PMT PID 2 字节。 ⑤ CRC32 4 字节。

实测 PAT 16 字节:table_id 0x00、节长度字段 13、传输流 ID 1、 版本 0、当前节 0、末节 0,节目号 1 对应 PMT PID 4096。

算一下就能对上:3 字节头 + 13 字节 = 16,正好。 13 字节里:传输流 ID 2 + 版本 1 + 节号 1 + 末节号 1 + 一组节目 4 + CRC 4 = 13。 所以一个单节目流的 PAT 一定是 16 字节。

二、PMT 的结构

实测 PMT 21 字节,table_id 0x02。 节目号 1、PCR PID 256、节目信息长度 0 字节。

PMT 里最关键的是「流循环」: 每组 5 字节起:「流类型 1 + PID 2 + 描述长度 2」,再跟描述符。 解析时一定要按「描述长度」跳过描述符,否则下一组的起点就错了。

21 字节怎么来的? 3 字节 section 头 + 9 字节固定段 + 5 字节流循环 + 4 字节 CRC = 21。 9 字节固定段是:节目号 2、版本 1、节号 1、末节号 1、PCR PID 2、信息长度 2。

「节目信息长度」这个字段经常被读错: 它是 12 位,占「1 字节的低 4 位 + 下 1 字节」。 只读一个字节会得到 0xF0 之类的高位垃圾值。 正确写法是 ((b[10] & 0x0F) << 8) | b[11]。

三、两张表都有 CRC32

PAT 与 PMT 末尾都是 4 字节 CRC32,用的是 MPEG-2 那一套: 多项式 0x04C11DB7、初值 0xFFFFFFFF、不反转、最后不异或。

这一点必须强调:它和 zlib 的 CRC32 不是同一个算法。 zlib 用的是反转多项式 0xEDB88320,算出来的值完全不同。 所以拿 zlib.crc32() 去校验 TS 的表,永远对不上。

实测 PAT 里写的 CRC32 是 0x2AB104B2,用 MPEG-2 算法自己算也是 0x2AB104B2,一致。 一致就说明这一节是完整的、没被截断。

CRC 是 TS 的「section 级校验」,比包级更强。 包级只能靠连续计数发现「少了包」,发现不了「包里的字节错了」。 所以解析 PAT/PMT 之前先校验 CRC,是标准做法。

四、动手 Demo:PAT 与 PMT——两张表决定一切

把 PAT 的 16 字节与 PMT 的 21 字节逐段拆开,打印每个字段。 最后用 MPEG-2 的 CRC32 校验一遍——注意不是 zlib 那个。

import io, os, tempfile, struct
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()

def section(pid):
    buf = b""
    for k in range(len(raw) // 188):
        p = raw[k * 188:(k + 1) * 188]
        if ((p[1] & 0x1F) << 8) | p[2] != pid:
            continue
        pos = 4
        if ((p[3] >> 4) & 3) == 3:
            pos += 1 + p[4]
        pos += 1 + p[pos]
        buf += p[pos:]
    return buf[:3 + (((buf[1] & 0x0F) << 8) | buf[2])]

pat = section(0)
print("PAT %d 字节,table_id 0x%02X,节长度 %d"
      % (len(pat), pat[0], ((pat[1] & 0x0F) << 8) | pat[2]))
print("传输流 ID %d,版本 %d,当前节 %d,末节 %d"
      % (struct.unpack("!H", pat[3:5])[0], (pat[5] >> 1) & 0x1F, pat[6], pat[7]))
print("节目号 %d,PMT PID %d"
      % (struct.unpack("!H", pat[8:10])[0], ((pat[10] & 0x1F) << 8) | pat[11]))

def mpeg_crc32(d):
    crc = 0xFFFFFFFF
    for byte in d:
        crc ^= byte << 24
        for _ in range(8):
            crc = (((crc << 1) ^ 0x04C11DB7) if crc & 0x80000000
                   else (crc << 1)) & 0xFFFFFFFF
    return crc

print("表内 CRC 0x%08X,自己算 0x%08X"
      % (struct.unpack("!I", pat[-4:])[0], mpeg_crc32(pat[:-4])))

实跑输出:

--------------------------------------------------------------------
PAT 与 PMT——两张表决定一切
--------------------------------------------------------------------
**① PAT 的结构**

  **它是一段 section,固定套路:**
  **① table_id = 0x00**:**标识「这是 PAT」。**
  **② 长度字段 2 字节**:**低 12 位是后面还有多少字节。**
  **③ 传输流 ID 2 字节、版本 1 字节、节号 1 字节、末节号 1 字节。**
  **④ 节目循环**:**每 4 字节一组,节目号 2 字节 + PMT PID 2 字节。**
  **⑤ CRC32 4 字节**。

  实测 PAT 16 字节
  table_id 0x00,节长度字段 13
  传输流 ID 1,版本 0,当前节 0,末节 0
  第一个节目:节目号 1,PMT PID 4096

**② PMT 的结构**

  实测 PMT 21 字节,table_id 0x02
  节目号 1,PCR PID 256
  节目信息长度 0 字节(后面才是流循环)

  **PMT 里最关键的是「流循环」:**
  **每组 5 字节起:「流类型 1 + PID 2 + 描述长度 2」,再跟描述符。**
  **解析时一定要按「描述长度」跳过描述符,否则下一组的起点就错了。**

**③ 两个表都有 CRC32**

  PAT 里写的 CRC32:0x2AB104B2
  自己算的 CRC32:    0x2AB104B2
  **一致:是**——**说明这一节是完整的、没被截断。**

  **CRC 是 TS 的「section 级校验」,比包级更强。**
  **包级只能靠连续计数发现「少了包」,发现不了「包里的字节错了」。**
  **所以解析 PAT/PMT 之前先校验 CRC,是标准做法。**

PAT 16 字节 = 3 头 + 13,节长度字段的值就是 13,正好对上。 版本号取 (pat[5] >> 1) & 0x1F——它塞在 1 个字节里,前后都有保留位。 CRC 两边都是 0x2AB104B2,说明 MPEG-2 的算法对了;用 zlib 会得另一个值。

30 秒实验

  1. 把 & 0x0F 去掉、直接读 pat[10],看 PMT PID 变成多少——高位混进保留位,读出来是错的。
  2. 用 zlib.crc32(pat[:-4]) 代替,看两个值差多少——完全对不上,这就是算法不同的直接证据。
  3. 打印 pat[6] 与 pat[7],看节号与末节号——单节目流都是 0,多节目流会不一样。

常见误区

  1. 「CRC32 都是同一个算法」——TS 用 MPEG-2 的,跟 zlib 不同。
  2. 「节目信息长度读一个字节」——12 位,要读两个字节拼。
  3. 「PAT 长度会随节目数变长」——会,每多一个节目加 4 字节。
自测三问(点开对答案) 1. PAT 与 PMT 的 table_id 各是什么? —— **0x00 与 0x02**;解析时先验这两个值再往下读。 2. 流循环里每组几个字节? —— **5 字节起**:流类型 1 + PID 2 + 描述长度 2,再跟描述符。 3. TS 的 CRC32 与 zlib 的有什么区别? —— **多项式不同**:MPEG-2 用 0x04C11DB7 且不反转,结果完全不同。

相关笔记