时间戳字段只有 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 秒实验
- 把
big改成0xFFFFFF - 1,看时间戳字段——不是全 1,说明没触发扩展,这是边界内的正常情况。 - 把
big改成0xFFFFFF,看会怎样——它正好等于哨兵值,接收端会误以为要看扩展,所以这个值不能用。 - 把载荷长度改成 130,看块怎么切——第一块带扩展,后续块按块大小继续,格式 3 的块也要带扩展字段。
常见误区
- 「时间戳能装 32 位」——只有 24 位,上限 4.66 小时。
- 「格式 3 不带任何头」——扩展时间戳时它仍要带 4 字节。
- 「16777215 可以当时戳用」——它是哨兵值,用了会被当成扩展。
自测三问(点开对答案)
1. 时间戳字段占几字节、上限多少? —— **3 字节,16777215 毫秒,约 4.66 小时。** 2. 扩展时间戳怎么标记? —— **时间戳字段填 `FF FF FF`,后面追加 4 字节真值(大端)。** 3. 格式 3 的块需要带扩展时间戳吗? —— **需要。**它是「什么都不带」的唯一例外。- ☐ (本节完,下一节 4.4.6 消息分块流程解析)