4、RTP序列号(SN)调试:序列号的作用与递增规则、序列号回绕处理、乱序到达检测、丢包率计算基础
RTP 序列号,简称 SN。说白了,它就是每个 RTP 数据包的身份证号。
我刚开始调 RTP 协议栈的时候,总觉得序列号就是个简单的计数器,没什么好研究的。直到有一次线上视频会议频繁花屏,抓包一看,序列号乱成一锅粥。嗯,从那以后我再也不敢小看这 16 个比特了。
4.1 序列号的作用与递增规则
序列号的核心作用就三个:
- 包序重建:接收端根据 SN 把乱序的包排回正确顺序
- 丢包检测:发现 SN 跳变,就知道丢了包
- 报文去重:重复的 SN 直接丢弃
递增规则其实很简单——每发一个 RTP 包,SN 就加 1。初始值呢?RFC 3550 说必须是随机的。为什么?为了防止加密攻击。我见过不少新手直接写死从 0 开始,这在公网上很容易被猜出报文规律。
关键点:SN 是每包递增,不是每帧递增。一个视频帧可能拆成多个 RTP 包,它们的 SN 是连续的,但时间戳相同。
举个例子:
包1: SN=1000, Timestamp=3600
包2: SN=1001, Timestamp=3600 ← 同一帧
包3: SN=1002, Timestamp=3600 ← 同一帧
包4: SN=1003, Timestamp=3640 ← 下一帧
你看,SN 和时间戳的变化节奏不一样。调试时千万别搞混了。
4.2 序列号回绕处理
RTP 的 SN 字段只有 16 位。最大值是 65535。超过之后呢?回绕到 0 重新开始。
你想想看,一个 4.5Mbps 的视频流,每秒大概发 300 个包。65535 个包也就撑 3 分多钟。所以回绕是必然发生的。
回绕处理的核心问题:怎么判断新来的 SN=100 是回绕后的,还是乱序的旧包?
标准做法是用「序列号空间比较」算法。RFC 3550 附录 A 给出了参考实现,我把它简化成核心逻辑:
// 判断 a 是否比 b 更新(在回绕场景下)
bool isNewer(uint16_t a, uint16_t b) {
return (int16_t)(a - b) > 0;
}
// 使用示例
uint16_t last_sn = 65530;
uint16_t current_sn = 10;
if (isNewer(current_sn, last_sn)) {
// current_sn 是更新包(回绕后)
printf("新包到达,SN=%u\n", current_sn);
} else {
// 乱序或重复包
printf("旧包或重复包,丢弃\n");
}
我的经验:回绕判断一定要用有符号减法。直接用 current_sn > last_sn 比较,在回绕边界上必出 bug。我曾经在一个嵌入式项目里踩过这个坑,排查了两天才找到原因。
回绕窗口的概念也很重要。一般建议只维护最近 1/4 序列号空间(即 16384 个包)内的乱序缓冲区。超出这个范围的旧包,直接丢弃。为什么?因为超过 16000 个包的延迟,视频早就没法看了。
4.3 乱序到达检测
网络不是完美的。包 3 可能比包 2 先到。这就是乱序。
乱序检测的基本思路:维护一个「期望的下一序列号」expected_sn。
- 如果
current_sn == expected_sn:正常顺序,直接处理 - 如果
current_sn > expected_sn:可能有丢包,也可能只是乱序 - 如果
current_sn < expected_sn:乱序到达的旧包
但光靠这个还不够。我常用的方法是加一个「乱序容忍窗口」:
#define MAX_MISORDER 100 // 最多容忍100个包的乱序
int check_sequence(uint16_t sn, uint16_t expected) {
int16_t diff = (int16_t)(sn - expected);
if (diff == 0) {
return SEQ_NORMAL; // 正常顺序
} else if (diff > 0 && diff < MAX_MISORDER) {
return SEQ_GAP; // 可能有丢包,先缓存
} else if (diff < 0 && -diff < MAX_MISORDER) {
return SEQ_OUT_OF_ORDER; // 乱序包,插入缓存
} else {
return SEQ_INVALID; // 超出容忍范围,丢弃
}
}
注意:乱序容忍窗口不能设太大。设大了会引入额外延迟,设小了会误判丢包。我个人习惯视频流设 50-100,音频流设 10-20。音频对延迟更敏感。
乱序包怎么处理?放到一个排序缓冲区里,等缺失的包到了再一起提交给上层解码器。缓冲区大小就是上面说的容忍窗口。
4.4 丢包率计算基础
丢包率是衡量网络质量的核心指标。计算公式很简单:
丢包率 = (丢失包数 / 期望接收包数) × 100%
但「期望接收包数」怎么算?看个例子:
收到序列号: 100, 101, 102, 105, 106, 107
期望接收包数 = (107 - 100 + 1) = 8
实际接收包数 = 6
丢失包数 = 8 - 6 = 2
丢包率 = 2 / 8 = 25%
这里有个坑:重复包不能算进实际接收包数。重复包是网络镜像导致的,不是有效数据。
我一般用滑动窗口计算实时丢包率:
typedef struct {
uint16_t base_sn; // 窗口起始SN
uint32_t expected; // 窗口内期望包数
uint32_t received; // 窗口内实际接收包数(去重后)
uint32_t duplicated; // 重复包计数
} LossRateCalc;
// 每收到一个包,更新丢包率
void update_loss_rate(LossRateCalc *calc, uint16_t sn) {
if (calc->base_sn == 0) {
calc->base_sn = sn;
calc->expected = 1;
calc->received = 1;
return;
}
int16_t diff = (int16_t)(sn - calc->base_sn);
if (diff >= 0) {
// 新包
calc->expected = diff + 1;
calc->received++;
} else {
// 可能是重复包
calc->duplicated++;
}
// 计算当前丢包率
float loss_rate = 0.0;
if (calc->expected > 0) {
loss_rate = (1.0 - (float)calc->received / calc->expected) * 100;
}
printf("丢包率: %.2f%%, 重复包: %u\n", loss_rate, calc->duplicated);
}
避坑指南:我曾经在一个项目中直接用 RTCP 报告的丢包率做 QoS 决策,结果发现不准。为什么?RTCP 报告周期太长(默认 5 秒),等它报上来,网络状况早就变了。实时丢包率一定要在 RTP 层自己算,用滑动窗口,窗口大小建议 500-1000 个包。
丢包率的阈值怎么设?我一般这么用:
| 丢包率范围 | 网络质量 | 建议措施 |
|---|---|---|
| 0% - 1% | 优秀 | 正常传输 |
| 1% - 3% | 良好 | 启动 FEC 前向纠错 |
| 3% - 10% | 一般 | 降低码率 + 开启 NACK 重传 |
| 10% - 20% | 较差 | 大幅降码率,考虑切到音频优先 |
| > 20% | 极差 | 建议断开重连或切换网络 |
注意,这个表只是参考。实际项目中要根据业务场景调整。比如视频会议可以容忍 5% 丢包,但金融交易系统的屏幕共享可能 1% 丢包就受不了。
4.5 本章知识体系
下面这张图把序列号调试的核心逻辑串起来了:
这张图把整个处理链路串起来了。从收到包开始,先过回绕检测,再过乱序检测,最后算丢包率。每一步都有对应的参数需要调优。
调试序列号,说白了就是跟这三个问题打交道:包是不是新的?包是不是乱序?丢了多少包?把这三个问题搞清楚,RTP 传输的底层逻辑就通了。