🎬 第四章 RTMP流媒体协议
📄 一句话概括

发送端把消息切块交错发,接收端按 csid 分流重组。 同一个 csid 上不许乱序,不同 csid 之间随便交错——这就是全部规则。

📝 本节和前面几节的关系

4.4.5 把所有头部字段拆完了,这一节把它们串成完整流程。 4.4.7 开始讲控制类的具体消息。

一、发送端的切块流程

一条消息要发出去,走四步: ① 看长度:消息比块大小短就一块发完,否则要切。 ② 写第一块:基本头(格式 0)+ 11 字节消息头 + 前 N 字节载荷。 ③ 写后续块:基本头(格式 3)+ 下一段载荷。 没有消息头,因为长度、类型、流 ID 都已经在接收端缓存里了。 ④ 交替下一路:如果同一时刻还有别的车道有数据,就换一条发。

实测:音频 300 字节切 3 块、视频 600 字节切 5 块,交错之后一共 8 个块、930 字节。 其中 8 字节是第一块各带的 12 字节头中的一部分,剩下的都是载荷。

二、接收端的分流重组

接收端要为每个 csid 维护三样东西: ① 缓冲区:已经收到的载荷字节。 ② 应收长度:从格式 0 的消息头里读出来的消息总长。 ③ 时间戳与类型:留着给后续的格式 1/2/3 块用。

判断逻辑只有一句: 「已收字节数 ≥ 应收长度」就说明上一条消息收完了,这一块是新消息的第一块。 否则就是继续,跳过 1 字节基本头直接接载荷。

这一步如果写错会怎样? 把「新消息的第一块」当成「继续」,就会把 12 字节头当成载荷拼进去——拼出来的数据长度对、内容错,解码器报「NAL 头非法」。 这种 bug 的特征是「长度看着对,就是解不出来」。

三、交错的两条纪律

① 同一个 csid 上的块必须按顺序发。 因为格式 1/2/3 的「沿用上一次」是按时序算的,乱序就对不上。 所以一条车道不能超车。 ② 不同 csid 之间可以任意交错。 这就是为什么音频能插在视频大帧中间——它们走不同车道。

还有一条实践建议:交错不能太碎。 每切一次就多一个块头,虽然只有 1 字节,但块数上去了解析开销就上去了。 实际实现常用 4096 的块大小,一帧视频通常只有一两块,交错粒度足够细,又不会碎成一地。

四、把流程缩成一句话

发送端:一条消息按块大小切开,第一块带完整头,后续块只带基本头。 接收端:每个 csid 一个缓冲区,按「收满没满」判断新消息还是继续。 交错:同车道保序,跨车道随意。

这三句话就是 RTMP 传输层的全部。 剩下的都是细节:csid 怎么编、消息头怎么压、时间戳怎么扩展。 先把这三句立住,再看细节就不会乱。

一个检验自己有没有理解的办法: 给你一段十六进制,问「这里面有几条消息、分别属于哪条流」,你能不能不用工具数出来。 能数出来,说明这套流程真的通了。

五、动手 Demo:两路交错,接收端怎么还原

造一条音频消息与一条视频消息,按块交错,再按 csid 分流重组。 最后比对重组结果与原消息是否逐字节一致。

import struct

csize = 128
ca, cv = b"AUDIO" * 60, b"VIDEO" * 120

def mk(csid, mtype, payload):
    basic = struct.pack("!B", csid)
    mh = (struct.pack("!I", 0)[1:]
          + struct.pack("!I", len(payload))[1:]
          + struct.pack("!B", mtype)
          + struct.pack("<I", 1))
    out = [basic + mh + payload[:csize]]
    rest = payload[csize:]
    while rest:
        out.append(basic + rest[:csize])
        rest = rest[csize:]
    return out

la, lv = mk(4, 8, ca), mk(6, 9, cv)
mixed = []
for k in range(max(len(la), len(lv))):
    if k < len(la):
        mixed.append(la[k])
    if k < len(lv):
        mixed.append(lv[k])
print("音频 %d 块、视频 %d 块,交错成 %d 块,共 %d 字节"
      % (len(la), len(lv), len(mixed), sum(len(x) for x in mixed)))

st = {}
for piece in mixed:
    cs = piece[0] & 0x3F
    buf, need = st.get(cs, (b"", 0))
    if len(buf) >= need:                    # 上一条收满了
        need = int.from_bytes(piece[4:7], "big")
        st[cs] = (piece[12:], need)
    else:
        st[cs] = (buf + piece[1:], need)

for cs, want in ((4, ca), (6, cv)):
    print("csid %d 重组 %d 字节,一致:%s"
          % (cs, len(st[cs][0]), st[cs][0] == want))

实跑输出:

--------------------------------------------------------------------
消息分块流程——两路交错,接收端怎么还原
--------------------------------------------------------------------
**① 造两路消息,交错发**

  音频消息 300 字节,视频消息 600 字节
  按 128 切:音频 3 块,视频 5 块

**② 接收端按 csid 分流重组**

  切完:音频 3 块、视频 5 块,交错成 8 个块
  交错后总长 930 字节

  收到 8 个块,涉及 2 条块流:4、6
  csid 4 重组 300 字节/应为 300,一致:是
  csid 6 重组 600 字节/应为 600,一致:是

**③ 交错的关键约束**

  **① 同一个 csid 上的块必须按顺序发。**
  **因为格式 1/2/3 的「沿用上一次」是按时序算的,乱序就对不上。**
  **② 不同 csid 之间可以任意交错。**
  **这就是为什么音频能插在视频大帧中间——它们走不同的块流。**
  **③ 交错不能太碎。**
  **每切一次就多一个块头,开销上升;所以实际实现常用 4096 的块大小,一帧视频通常只有一两个块。**

  **一句话概括:csid 是「车道」,块是「车厢」,消息是「货」。**
  **多车道可以并行跑,但同一车道上的车厢不能超车。**

音频 3 块、视频 5 块,交错成 8 块、930 字节。 重组时靠 len(buf) >= need 这一句判断「新消息还是继续」——这就是格式 3 两种含义的分界点。 两条 csid 各自重组的结果与原消息逐字节一致,说明「同车道保序、跨车道交错」这套规则确实可行。

30 秒实验

  1. 把视频消息改成 130 字节,看块数——变成 2 块,第二块只有 2 字节载荷。
  2. 把交错顺序改成「先发完全部音频再发视频」,看重组还对不对——对,说明交错只是优化,不影响正确性。
  3. 把音频块的顺序打乱,看还能不能重组——不能,同车道乱序就废了,这就是「不许超车」。

常见误区

  1. 「可以把音频块的顺序打乱」——同一 csid 上必须保序。
  2. 「交错越碎越好」——块数上去了解析开销也上去了。
  3. 「重组只需要缓冲区」——还要记「应收长度」,否则判断不出新消息。
自测三问(点开对答案) 1. 接收端每个 csid 要维护哪三样东西? —— **缓冲区、应收长度、时间戳与类型。** 2. 「新消息」与「后续块」怎么判断? —— **已收字节数是否达到应收长度。**达到了就是新消息。 3. 交错的两条纪律是什么? —— **同 csid 保序,不同 csid 可任意交错。**

相关笔记