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

一个块由四段拼成:基本头、消息头、扩展时间戳、载荷。 最短 2 字节,最长是「3 + 11 + 4 + 块大小」。

📝 本节和前面几节的关系

4.4.1 讲为什么要切,这一节讲切开之后每一块长什么样。 4.4.3 到 4.4.5 把前三段各自拆开。

一、四段的顺序

顺序是固定的,不能换: ① 基本头:1 到 3 字节,装块流 ID 与「格式号」。 ② 消息头:0 到 11 字节,装时间戳、长度、类型、流 ID。 长度取决于格式号。 ③ 扩展时间戳:0 或 4 字节,只在时间戳超过 24 位时才出现。 ④ 载荷:这一块承载的消息字节,最大等于块大小。

所以最短的块是 2 字节:1 字节基本头(格式 3、无扩展)+ 1 字节载荷。 最长的块是「3 + 11 + 4 + 块大小」。 理解这个区间,解析器的边界条件就不会写错。

二、消息头里的四个字段

格式 0 的消息头 11 字节,装四样东西: ① 时间戳:3 字节,大端。 超过 24 位就走扩展。 ② 消息长度:3 字节,大端。 注意这是「整条消息」的长度,不是这一块的载荷长度。 ③ 消息类型:1 字节,8 音频、9 视频、18 元数据、20 命令。 ④ 消息流 ID:4 字节,小端。

最后那个小端是 RTMP 的历史包袱: 前三个字段照抄 FLV 的大端,流 ID 却是后来加的小端。 手写解析时这里错一次,后面全错——而且错得很隐蔽:流 ID 小的时候(0 或 1)大小端看起来一样,流 ID 大了才露馅。

还有一点:流 ID 只有 4 字节里的低 28 位有效,高 4 位必须为 0。 有些实现直接按 32 位读,遇到脏数据会解出天文数字。

三、为什么消息长度在头里

因为接收端要靠它判断「这条消息收满了没有」。 而「收满没满」又决定了下一块该怎么解——收满了,下一块就是新消息的第一块;没满,下一块是继续。

这个判断是解析器最核心的一个状态: 每个 csid 维护一个「已收字节数/应收字节数」,两边相等就重置为 0。 漏了这一步,格式 3 的两种含义就分不开,整条流都会错位。

顺带说一句:消息长度是 3 字节,上限 16777215,也就是 16 MB。 一帧关键帧远远到不了,所以够用。 但如果有人想用它传文件,就会撞上这个天花板。

四、典型块的字节长相

拿一个真实的视频块来对: 块流 4、类型 9、流 1、时间戳 1000、载荷 128 字节。 ① 基本头:04——格式 0(高 2 位 00)、csid 4。 ② 时间戳:0003E8 = 1000。 ③ 长度:000080 = 128。 ④ 类型:09。 ⑤ 流 ID:01000000(小端,即 1)。 ⑥ 载荷:128 字节。

整块 140 字节 = 12 字节头 + 128 字节载荷。 把这串字节记住,抓包时一眼能认出「这是格式 0 的视频块」。 关键特征就是第 8 个字节是 09。

五、动手 Demo:基本头、消息头、载荷

手工拼一个完整块,再逐段解回来。 重点看流 ID 的小端与「长度是整条消息的长度」。

import struct

csid, mtype, sid, ts = 4, 9, 1, 1000
payload = b"A" * 128

basic = struct.pack("!B", csid)              # 格式 0 + csid
mh = (struct.pack("!I", ts)[1:]              # 时间戳 3 字节
      + struct.pack("!I", len(payload))[1:]  # 长度 3 字节
      + struct.pack("!B", mtype)             # 类型 1 字节
      + struct.pack("<I", sid))              # 流 ID 4 字节,小端
pkt = basic + mh + payload

print("总长 %d 字节(12 头 + %d 载荷)" % (len(pkt), len(payload)))
print("前 12 字节 %s" % pkt[:12].hex())

print("基本头 0x%02X → 格式 %d,csid %d"
      % (pkt[0], pkt[0] >> 6, pkt[0] & 0x3F))
print("时间戳 %d" % int.from_bytes(pkt[1:4], "big"))
print("消息长度 %d" % int.from_bytes(pkt[4:7], "big"))
print("消息类型 %d" % pkt[7])
print("流 ID(小端)%d" % struct.unpack("<I", pkt[8:12])[0])
print("载荷 %d 字节,全 0x41:%s" % (len(pkt[12:]), pkt[12:13].hex()))

实跑输出:

--------------------------------------------------------------------
消息块格式——基本头、消息头、载荷
--------------------------------------------------------------------
**① 一个块的组成**

  部分           最小     最大       说明
  -------------------------------------------------------------
  基本头         1        3          块流 ID,变长编码
  消息头         0        11         四种格式,逐级压缩
  扩展时间戳     0        4          时间戳超 24 位时才出现
  载荷           1        块大小     消息的一部分字节

  **所以一个块最短 2 字节:1 字节基本头加 1 字节载荷(格式 3 无消息头)。**
  **最长是「3 + 11 + 4 + 块大小」:基本头 3、消息头 11、扩展 4。**

**② 真构造一个完整的块**

  块流 ID 4、消息类型 9(视频)、流 1、时间戳 1000、载荷 128 字节
  总长 140 字节(12 头 + 128 载荷)
  前 12 字节:040003e80000800901000000

**③ 逐段解回来**

  基本头 1 字节 = 0x04,格式 0,块流 ID 4
  消息头 11 字节:时间戳 1000、长度 128、类型 9、流 ID 小端 1
  载荷 128 字节,全为 0x41

  **注意流 ID 是小端,时间戳与长度是大端。**
  **这个混用是 RTMP 的历史包袱:前三个字段照抄 FLV 的大端,流 ID 却是后来加的小端。**
  **手写解析时这里错一次,后面全错。**

  **还有一点:流 ID 只有 4 字节里的低 28 位有效,高 4 位必须为 0。**
  **有些实现直接按 32 位读,遇到脏数据就会解出天文数字。**

前 12 字节是 04 0003e8 000080 09 01000000,六段一一对应:基本头、时间戳、长度、类型、流 ID。 流 ID 写成 01000000 就是小端的证据——如果按大端读会得到 16777216,错得离谱。 「长度」写的是 128,正好等于这一块的载荷,因为这条消息本身就只有 128 字节;消息更大时这里会是全长。

30 秒实验

  1. 把 sid 改成 2,看那 4 个字节怎么变——变成 02000000,小端特征很明显。
  2. 把 mtype 改成 18,看第 8 个字节——它单独占一个字节,改它不影响别的字段。
  3. 把 payload 改成 300 字节,看长度字段与总长——长度变成 300,但仍是一条消息的第一块(后面还要接着切)。

常见误区

  1. 「消息长度是这一块的长度」——是整条消息的长度。
  2. 「流 ID 也是大端」——只有它是小端,这是历史包袱。
  3. 「块最短 12 字节」——最短 2 字节,12 是格式 0 的头。
自测三问(点开对答案) 1. 一个块由哪四段组成? —— **基本头、消息头、扩展时间戳、载荷。** 2. 消息头里哪个字段是小端? —— **消息流 ID(4 字节)。**其余都是大端。 3. 为什么接收端要靠消息长度判断状态? —— **收满了说明下一块是新消息的第一块,没满就是继续。**这是格式 3 两种含义的分界。

相关笔记