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 序列号调试核心流程 收到 RTP 包 回绕检测 (isNewer) 乱序检测 (容忍窗口) 丢包率计算 (滑动窗口) 输出排序后的包 + 丢包率 关键参数 • 回绕窗口: 1/4 空间 • 乱序容忍: 50-100 包 • 滑动窗口: 500-1000 包 避坑提醒 • 回绕用有符号减法 • 重复包不计入接收数

这张图把整个处理链路串起来了。从收到包开始,先过回绕检测,再过乱序检测,最后算丢包率。每一步都有对应的参数需要调优。

调试序列号,说白了就是跟这三个问题打交道:包是不是新的?包是不是乱序?丢了多少包?把这三个问题搞清楚,RTP 传输的底层逻辑就通了。