序列号每包加一,16 位,跨过 65535 回绕到 0。 时间戳每帧加「时钟 ÷ 帧率」,同一帧切开的几个包共用一个时间戳。
上一节把静态的格式讲完了,这一节讲它怎么动起来。 序列号和时间戳是 RTCP 统计的全部原料——丢包率靠序列号算,抖动靠时间戳算。 播放器里这两个数怎么用, 看 34 课 的同步章节。
一、序列号:16 位,会回绕
每发一个 RTP 包,序列号加一,初值是随机的。 为什么要随机初值:防止同一对端之间前后两次会话的包被混淆。
它是 16 位,最大值 65535,加完就回绕到 0。 回绕不是错误,是设计。 但写统计代码时必须处理,否则「65535 之后变 0」会被算成丢了几万个包。
| 收到 | 上一包 | 差值 | 结论 |
|---|---|---|---|
| 100 | 99 | 1 | 正常 |
| 103 | 100 | 3 | 丢了 101、102 |
| 0 | 65535 | 1 | 回绕,没丢 |
第三行是关键:差值要用「模 65536 的无符号运算」算,(0 - 65535) & 0xFFFF 得到 1,
不是负六万多。
二、时间戳:每帧加多少
步长 = 时钟频率 ÷ 帧率,整数除法。
| 帧率 | 步长 | 一秒多少帧 |
|---|---|---|
| 25 | 3600 | 25 |
| 30 | 3000 | 30 |
| 50 | 1800 | 50 |
| 60 | 1500 | 60 |
注意「同一帧的几个包共用同一个时间戳」。一帧切成三个包发,这三个包的时间戳完全一样, 只有序列号在递增。
这样接收方就能靠时间戳判断「这几个包属于同一帧」,不用等 M 位。 两个信息是冗余的,冗余是为了容错。
三、M 位:一帧结束的标记
M(Marker)位只在「这一帧的最后一个包」上置 1。 接收方靠它判断:这一帧收齐了,可以送去解码了。
| 场景 | 一帧几个包 | M 位在哪 |
|---|---|---|
| 小帧(I 帧的一部分) | 1 | 唯一那个包 |
| 普通 P 帧 | 2~3 | 最后一个 |
| 大 I 帧 | 几十个 | 最后一个 |
为什么不能只靠时间戳判断帧结束:因为不知道这一帧该有几个包。 M 位是显式的「到此为止」,时间戳是隐含的「同属一帧」。
丢包时会发生什么:M 位那个包丢了,接收方就永远等不到帧结束,这一帧只能丢掉。 所以关键帧的最后一个包丢了特别伤。
四、抖动和丢包都是从这里算的
RTCP 的两个核心统计量,原料就是序列号和到达时间。
| 统计量 | 用哪些数 | 怎么算 |
|---|---|---|
| 丢包数 | 序列号 | 期望收到 − 实际收到 |
| 抖动 | 序列号 + 到达时间 | 到达间隔 − 发送间隔,取绝对值平滑 |
抖动的算法值得说一下:对每个包算「这一包和前一个包的到达时间差」减去「它们的时间戳差」, 取绝对值,再做指数平滑。 平滑系数是 1/16,所以它反映近期变化,不是全程平均。
这意味着网络刚变差,抖动值会很快爬上来;网络恢复了,它也要一段时间才降回去。 看这个数要记得它是有惯性的。
五、动手 Demo:30 帧的流,时间戳每包加 3000
这段模拟发 100 个包(每 3 个包一帧),看序列号和时间戳怎么走。 起点特意选在 65530,就是为了让回绕发生在中间。
fps, per_frame = 30, 3
step = 90000 // fps # 3000
first = 65530
for i in range(100):
s = (first + i) & 0xFFFF
t = (1000000 + (i // per_frame) * step) & 0xFFFFFFFF
m = 1 if (i + 1) % per_frame == 0 else 0
T([u"%d" % i, u"%d" % s, u"%d" % t, u"%d" % m], [0, 0, 0, 0])
实跑输出:
--------------------------------------------------------------------
30 帧的流,时间戳每包加 3000
--------------------------------------------------------------------
**RTP 的时间戳步长 = 时钟频率 ÷ 帧率。**
视频时钟固定 90000 Hz,所以:
帧率 步长 一秒多少个包(一帧一包)
------------------------------------------------
25 fps 3600 25
30 fps 3000 30
50 fps 1800 50
60 fps 1500 60
90000 这个数不是随便定的:它能被 24、25、30、50、60 整除,常见帧率算下来都是整数,不用四舍五入。
**真发 100 个包,看序列号和时间戳怎么走(每 3 个包一帧):**
包序号 序列号 时间戳 M
---------------------------------------
0 65530 1000000 0
1 65531 1000000 0
2 65532 1000000 1
3 65533 1003000 0
4 65534 1003000 0
5 65535 1003000 1
6 0 1006000 0
7 1 1006000 0
… … … …
62 56 1060000 1
63 57 1063000 0
64 58 1063000 0
65 59 1063000 1
66 60 1066000 0
67 61 1066000 0
68 62 1066000 1
69 63 1069000 0
**两件事一眼能看出来:**
一、序列号从 65530 开始,跨过 65535 后回绕到 0 继续——**16 位字段,回绕是正常的,不是错误。**
二、时间戳三个包才加 3000,因为这三个包是同一帧切开的,**同一帧共用时间戳**。
M 位只在每帧第 3 个包上置 1,接收端靠它判断「这一帧收齐了」。
**抖动和丢包也是用这两个数算的:**
收到序列号 100、101、103,就知道 102 丢了;
时间戳的到达间隔忽大忽小,就是抖动(jitter),RTCP 会统计它,见 D18。
输出里最值得看的是第 6 行:序列号从 65535 变成 0,时间戳正常往前走。
这一下就是回绕,而 & 0xFFFF 让它自动发生,不用写 if 判断。
时间戳那列的模式是「三行一样,然后跳 3000」:因为每三个包属于同一帧,共用时间戳。 M 位那列是「三个 0,然后一个 1」,正好对上。
这两个信息配合的好处:就算 M 位那个包丢了,接收方看到时间戳变了, 也知道上一帧该结束了——这就是「冗余设计」,代价是几乎为零,收益是容错。
实战里的一个陷阱:有的设备一帧切很多包(大 I 帧能到几十个),这时序列号在帧内快速递增, 丢一个包整帧就废。 所以关键帧的丢包代价远高于普通帧——这也是为什么不少方案会给关键帧加冗余(FEC)。
30 秒实验
- 算出 25 fps 的步长,和 30 fps 的比一下差多少。
- 把起点设成 65533,数到第几包时发生回绕。
- 把每帧包数从 3 改成 1,看 M 位变成什么样。
常见误区
- 以为序列号回绕是错误。 16 位字段必然回绕,统计时要用模 65536 的运算,不能直接相减。
- 以为时间戳是毫秒。 是采样时钟。30 fps 每帧加 3000,不是加 33。
- 以为每个包的时间戳都不同。 同一帧切开的几个包共用时间戳,只有序列号在递增。
自测三问(点开对答案)
1. 30 fps 的流,时间戳每帧加多少?为什么? —— 加 3000。视频时钟是 90000 Hz,90000 ÷ 30 = 3000。 2. 序列号从 65535 变成 0,是丢包吗? —— 不是,是正常的 16 位回绕。算差值要用 `(new - old) & 0xFFFF`,得到 1 而不是负六万多。 3. M 位和「时间戳相同」都表示同一帧,为什么要两个? —— 冗余容错。时间戳丢了还能看 M 位,M 位丢了还能看时间戳变化,两边互相兜底。下一节进 RTCP:五种包类型,以及那个「length 字段写 6 代表 28 字节」的坑。
- ☐ (本节完,下一节 01.07.01 RTCP的5种分组类型)