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

时间戳字段只有 3 字节,最多 16777215 毫秒,也就是 4.66 小时。 超过之后要把字段填满 1,再追加 4 字节真值——而且格式 3 也要带。

📝 本节和前面几节的关系

4.4.4 讲的四种格式都假设时间戳能塞进 3 字节。 这一节处理「塞不下」的情况,它只影响长时间直播。

一、4.66 小时那条线

3 字节最大 0xFFFFFF = 16777215 毫秒,折合 4.66 小时。 也就是说,一路直播连播超过 4.66 小时,时间戳就装不下了。 这不是理论问题:电商直播动辄播一整天。

标准给的办法很朴素: ① 时间戳字段全填 1(FF FF FF)。 ② 在消息头后面追加 4 字节,装真正的时间戳。 接收端看到全 1,就去读那 4 字节。

注意这个「全 1」是个哨兵值,不是时间戳本身。 所以 16777215 这个值不能被当成普通时间戳使用——一旦用上,接收端就会去找扩展字段。 这也是「为什么上限是 16777214」的技术原因。

二、扩展字段在哪儿

位置取决于格式号,因为消息头长度不一样: ① 格式 0:消息头 11 字节,扩展字段在偏移 11 处。 ② 格式 1:消息头 7 字节,扩展字段在偏移 7 处。 ③ 格式 2:消息头 3 字节,扩展字段在偏移 3 处。 ④ 格式 3:它本来什么都不带,但仍然要带这 4 字节。

第④条是最坑的。 格式 3 的意义是「与上一块相同」,但只要时间戳是扩展形式,它就必须再带一遍那 4 字节。 因为接收端要靠这 4 字节确认「这确实是同一个时间戳的后续块」。 漏掉它,后面所有字节的边界都会偏 4。

三、不发扩展会发生什么

如果实现偷懒,直接把「低 24 位」写进时间戳字段会怎样? 假设真实时间戳是 16789561(4.66 小时后再 12 秒),低 24 位是 12345,也就是 12 秒。 接收端读到 12345,会以为是「开播第 12 秒」。

表现是: 播放器把整条流的时间轴重置,画面像「刚开始播」——有的播放器会直接卡住,因为它检测到时间戳倒退。 更麻烦的是这个 bug 只在 4.66 小时后出现,测试时根本发现不了。

所以有一条测试建议: 做长连接测试时,别只跑十分钟就以为稳了。 要么真跑五小时,要么把时间戳的起点人为设到接近 0xFFFFFF,让扩展逻辑提前触发。 后者是工程上更常用的办法。

四、为什么这个坑很隐蔽

因为它同时满足三个「难查」的条件: ① 出现得晚:4.66 小时后才触发,短测试测不到。 ② 不报错:协议层不校验,接收端只是「理解错了」。 ③ 表现像别的问题:画面像重新开始、时间轴乱跳,容易被当成「播放器 bug」或「服务端重启」。

排查办法是抓包看时间戳字段: 如果某个块的时间戳字段突然变成 FF FF FF,而后面紧跟 4 字节——那就是扩展形式。 如果时间戳字段从一个大数突然变成一个小数,而中间没有 FF FF FF——那就是实现没做扩展,直接截断了。

前者正常,后者是 bug。这个判据一眼就能用。

五、动手 Demo:0xFFFFFF 之后的那 4 字节

真构造一个带扩展时间戳的块,再解回来确认。 顺便算一下「不做扩展会读到什么」。

import struct

big = 0x1000000 + 12345            # 4.66 小时后再 12 秒
payload = b"B" * 20

basic = struct.pack("!B", 4)
mh = (b"\xFF\xFF\xFF"                # 时间戳字段全填 1,表示看扩展
      + struct.pack("!I", len(payload))[1:]
      + struct.pack("!B", 9)
      + struct.pack("<I", 1))
ext = struct.pack("!I", big)          # 真时间戳,4 字节大端
pkt = basic + mh + ext + payload

print("块长 %d 字节(1 + 11 + 4 + %d)" % (len(pkt), len(payload)))
print("前 16 字节 %s" % pkt[:16].hex())

ts_field = int.from_bytes(pkt[1:4], "big")
print("时间戳字段 0x%06X,是哨兵值吗:%s"
      % (ts_field, ts_field == 0xFFFFFF))
print("扩展字段 %d,与目标一致:%s"
      % (int.from_bytes(pkt[12:16], "big"),
         int.from_bytes(pkt[12:16], "big") == big))

print("若直接截断低 24 位,接收端会读到 %d" % (big & 0xFFFFFF))
print("3 字节上限 %d 毫秒,折合 %.2f 小时"
      % (0xFFFFFF, 0xFFFFFF / 3600000.0))

实跑输出:

--------------------------------------------------------------------
扩展时间戳——0xFFFFFF 之后的那 4 字节
--------------------------------------------------------------------
**① 时间戳只有 3 字节**

  3 字节最大 0xFFFFFF = 16777215 毫秒
  折合 4.66 小时

  **也就是说,一路直播连播 4.66 小时之后,时间戳就装不下了。**
  **标准给的办法是:时间戳字段全填 1,然后在后面追加 4 字节真值。**

**② 真构造一个大时间戳**

  目标时间戳 16789561(0x01003039)
  块长 36 字节(基本头 1 + 消息头 11 + 扩展 4 + 载荷 20)
  前 16 字节:04ffffff000014090100000001003039

  时间戳字段 = 0xFFFFFF(全 1,要看后面的扩展)
  扩展字段 = 16789561,与目标一致:是
  载荷起点 = 16(跳过 12 + 4 字节)

  **如果不发扩展、直接把低 24 位写进时间戳字段,接收端会读到 12345。**
  **它以为是第 12 秒,实际已经是第 4.66 小时之后再 12 秒。**
  **表现是播放器把整条流的时间轴重置,画面像「刚开始播」。**

**③ 扩展字段在哪儿,取决于格式**

  格式     扩展字段位置              是否重复
  -------------------------------------------------
  0        消息头之后(偏移 11)     只出现一次
  1        消息头之后(偏移 7)      只出现一次
  2        消息头之后(偏移 3)      只出现一次
  3        仍然要带 4 字节           要重复出现

  **格式 3 那一行是最坑的:它本来「什么都不带」。**
  **但只要时间戳是扩展形式,它就必须再带一遍那 4 字节。**
  **因为接收端靠这 4 字节才知道「这是同一个时间戳的后续块」。**

  **这也解释了为什么长时间直播会突然出问题:**
  **前 4.66 小时一切正常,过了之后所有块的布局都变了 4 字节**——**写死的解析代码会从此全乱。**
  **所以测试长连接时,别只跑十分钟就以为稳了。**

时间戳字段是 FFFFFF,这就是「去看扩展」的哨兵。 扩展字段 16789561 与目标一致,载荷起点从 12 变成 16——整整多了 4 字节。 如果偷懒截断,接收端只会读到 12345(12 秒),这就是「时间轴被重置」的物理原因。

30 秒实验

  1. 把 big 改成 0xFFFFFF - 1,看时间戳字段——不是全 1,说明没触发扩展,这是边界内的正常情况。
  2. 把 big 改成 0xFFFFFF,看会怎样——它正好等于哨兵值,接收端会误以为要看扩展,所以这个值不能用。
  3. 把载荷长度改成 130,看块怎么切——第一块带扩展,后续块按块大小继续,格式 3 的块也要带扩展字段。

常见误区

  1. 「时间戳能装 32 位」——只有 24 位,上限 4.66 小时。
  2. 「格式 3 不带任何头」——扩展时间戳时它仍要带 4 字节。
  3. 「16777215 可以当时戳用」——它是哨兵值,用了会被当成扩展。
自测三问(点开对答案) 1. 时间戳字段占几字节、上限多少? —— **3 字节,16777215 毫秒,约 4.66 小时。** 2. 扩展时间戳怎么标记? —— **时间戳字段填 `FF FF FF`,后面追加 4 字节真值(大端)。** 3. 格式 3 的块需要带扩展时间戳吗? —— **需要。**它是「什么都不带」的唯一例外。

相关笔记