🎬 第三章 RTP与RTCP流媒体协议
📄 一句话概括

把 H.264 发到 RTP 上要三步:切 NAL、按大小决定单包还是 FU-A、末尾按序号重组。 实测 30 帧重组后 CRC32 与原始码流完全一致,说明每一步都没错。

📝 本节和前面几节的关系

3.1.2 讲了 FU-A 的格式,3.4.3 讲了会话骨架,这一节把两者接起来。 接完之后你就有了一条能真发视频的通道。

一、第一步:把码流切成 NAL

编码器吐出来的是 Annex-B 字节流,靠起始码 00 00 01 分隔 NAL。 所以切分就是「找起始码、取两码之间的字节」。

实测 30 帧 720p,切出 245 个 NAL、共 34925 字节。 平均一个 NAL 只有 142 字节——但最大的那个有两千多字节。 这个分布很关键: 「平均数」在这里毫无意义,必须看最大值,因为决定切不切的正是最大值。

切的时候有个坑: 第一个 NAL 前面可能带 00 00 00 01(四字节起始码),切完记得把多出来的那个 0 去掉,否则 NAL 头就不是你以为的那个字节。 表现是「类型解出来是 0」,一看就不对。

二、第二步:按 MTU 决定怎么装

判断式只有一句:len(NAL) + 12 <= MTU 就单包发,否则 FU-A 切。 这里的 +12 是 RTP 固定头,别忘了它——MTU 是「整个 IP 包」的上限,RTP 头也算在里面。

FU-A 的切法是: 把 NAL 头之后的字节按 MTU - 12 - 2 分块,每块前面加两字节 FU 头。 第一块置 S 位,最后一块置 E 位,中间两块都不置。 重组时反过来: 看到 S 就开一个新缓冲区,看到 E 就收尾,中间的一律追加。 拼出来的第一个字节要自己造:(FU indicator & 0xE0) | 原类型。

实测 245 个 NAL 变成 261 个 RTP 包——只有 16 个 NAL 需要切,而它们全是关键帧切片。 所以「一帧几十个包」这个说法,准确讲是「一个关键帧几十个包」。

三、第三步:先排序再重组

FU-A 的拼法完全依赖「S 是第一片、E 是最后一片」。 一旦乱序到达,按到达顺序直接拼就会拼反—— 拼出来长度对、CRC 错,解码器一帧都出不来。

所以顺序必须是:按序号排序 → 遍历 → 重组。 UDP 本身不保证顺序,这一步不能省。 这一步也正是 PollData 在库里替你做的事——它内部维护一个窗口,把乱序的包按序号排好再交给你。 所以用库的时候你不写排序,自己写 socket 的时候必须写。

实测的验收标准是 CRC32: 原始码流与重组结果都是 0x469DD876,逐字节一致的 NAL 是 245 个 / 245 个。 这个比对比「看起来能播」严格得多——长度对但内容错的 bug,只有 CRC 能抓出来。

四、丢了包之后怎么办

这是 UDP 传视频最现实的问题:丢了中间一片,整个 NAL 就废了。

有三条路,按代价从小到大: ① 整帧丢弃:把这一帧剩下的片全扔掉,等下一个关键帧。 代价是卡一下,但不会花屏。 ② 请求关键帧:发 PLI 或 FIR,让发送端立刻补一个 IDR。 代价是码率突增,带宽紧张时会雪上加霜。 ③ 交给解码器容错:把残片塞进去,让解码器自己遮。 代价是花屏一大片,而且错误会沿参考帧往后传好几帧。

工业上默认走①,配合②——这也是「丢一个包,糊半秒」的物理原因: 不是解码器不行,是它拿到的数据本身就是残的。 所以关键帧间隔(GOP)不要设太长,GOP 越长,一次丢包影响的恢复时间越久。

五、动手 Demo:切 FU-A、真发、真重组、CRC 校验

真编 30 帧 720p,切成 NAL,按 MTU=1200 发到一个 UDP 端口。 接收端按序号排好再重组,最后用 CRC32 与原始码流比对。

import socket, struct, zlib

MTU, SSRC = 1200, 0x1A2B3C4D

# ns = 从 Annex-B 切出来的 NAL 列表(此处省略编码与切分,见 3.4.5 正文)
# 假设已经把每一帧的码流塞进 ns

srv = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
srv.bind(("127.0.0.1", 0))
srv.settimeout(3.0)
dest = srv.getsockname()

cli = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
seq, sent = 0, 0

for idx, nu in enumerate(ns):
    ts = 3600 * (idx // 2)
    if len(nu) + 12 <= MTU:
        seq = (seq + 1) & 0xFFFF
        hdr = struct.pack("!BBHII", 0x80, 96, seq, ts, SSRC)
        cli.sendto(hdr + nu, dest)
        sent += 1
    else:
        ind = (nu[0] & 0xE0) | 28      # FU-A 的类型号是 28
        t = nu[0] & 31
        body = nu[1:]
        step = MTU - 12 - 2
        parts = [body[i:i + step] for i in range(0, len(body), step)]
        for k, p in enumerate(parts):
            fh = (0x80 if k == 0 else 0) | (0x40 if k == len(parts) - 1 else 0) | t
            seq = (seq + 1) & 0xFFFF
            hdr = struct.pack("!BBHII", 0x80, 96, seq, ts, SSRC)
            cli.sendto(hdr + bytes([ind, fh]) + p, dest)
            sent += 1

got = []
while len(got) < sent:
    try:
        got.append(srv.recvfrom(4096)[0])
    except socket.timeout:
        break

# 先按序号排序,再重组
got.sort(key=lambda d: struct.unpack("!H", d[2:4])[0])
out, cur = [], None
for d in got:
    pay = d[12:]
    t = pay[0] & 31
    if t == 28:
        fu = pay[1]
        if fu & 0x80:
            cur = [bytes([(pay[0] & 0xE0) | (fu & 31)])]
        if cur is not None:
            cur.append(pay[2:])
        if fu & 0x40 and cur is not None:
            out.append(b"".join(cur))
            cur = None
    else:
        out.append(pay)

print("发 %d 包,收 %d 包" % (sent, len(got)))
print("重组 %d 个 NAL" % len(out))
print("原始 CRC32 = 0x%08X" % (zlib.crc32(b"".join(ns)) & 0xFFFFFFFF))
print("重组 CRC32 = 0x%08X" % (zlib.crc32(b"".join(out)) & 0xFFFFFFFF))

实跑输出:

--------------------------------------------------------------------
发一路 H.264——切 FU-A、真发、真重组、CRC 校验
--------------------------------------------------------------------
  真编 30 帧,得到 245 个 NAL 单元,共 34925 字节

**① 发送与接收**

  发 261 个 RTP 包,收 261 个,共 34973 字节载荷

**② 按序号排好再重组**

  重组出 245 个 NAL 单元
  与原始 NAL 逐字节一致的有 245 个 / 245 个

  项             值
  -----------------------------
  原始 CRC32     0x469DD876
  重组 CRC32     0x469DD876
  是否一致       是

**③ 重组为什么要先排序**

  **FU-A 的拼法完全依赖「S 是第一片、E 是最后一片」,一旦乱序到达,直接按到达顺序拼就会拼反。**
  **而 UDP 不保证顺序,所以必须先把包按序号排好再拼。**

  **更难的是丢包:丢了中间一片,整个 NAL 就废了。**
  **这时候只能整帧丢弃并请求关键帧(PLI/FIR),而不能把残片交给解码器——交过去会花屏一大片。**
  **这就是「丢一个包,糊半秒」的物理原因。**

245 个 NAL 变成 261 个包——只有 16 个需要切,全是关键帧切片。 重组出的 245 个 NAL 与原始逐字节一致 245 个 / 245 个,一个都没差。 CRC32 两边都是 0x469DD876——这才是「发对了」的硬证据,比肉眼看到画面稳得多。

30 秒实验

  1. 把 MTU 从 1200 改成 600,看包数涨多少——片小了片数就多,头开销跟着涨。
  2. 把排序那行注释掉,看 CRC 还对不对——沙箱环回上很可能仍然对,因为基本不乱序;这说明「不排序也能过」是环境太好的假象。
  3. 故意把某个中间片的 S/E 位都置 1,看重组出多少 NAL——会多切出一个假 NAL,长度对不上但 CRC 就露出来了。

常见误区

  1. 「一帧就是一个包」——一帧几十个 NAL,关键帧还要切几十片。
  2. 「不用排序也能重组」——UDP 不保证顺序,沙箱不乱序不代表公网不乱序。
  3. 「CRC 对了就一定没问题」——CRC 只保证字节一致,不保证时间戳、序号也编对了。
自测三问(点开对答案) 1. FU-A 切片的载荷上限怎么算? —— **MTU 减去 12 字节 RTP 头、再减 2 字节 FU 头。**MTU=1200 时是 1186。 2. 为什么必须先按序号排序再重组? —— **FU-A 靠 S/E 判断边界,乱序会把片拼反。**UDP 不保证顺序。 3. 丢了中间一片怎么办? —— **整帧丢弃,再发 PLI 请求关键帧。**把残片交给解码器会花屏一片。

相关笔记