4. RTP时间戳生成机制

聊到 RTP 的同步机制,有两个标识符你绕不开——SSRC 和 CSRC。说白了,它们就是 RTP 流里每个参与者的「身份证」。但身份证也有不同类型,SSRC 是主卡,CSRC 是副卡。怎么用?什么时候用?我结合项目里的坑跟你聊聊。

4.1 SSRC:同步源的唯一身份证

SSRC(Synchronization Source)是每个 RTP 流的唯一标识。一个 SSRC 对应一个独立的 RTP 流,比如一个摄像头、一个麦克风、一个屏幕共享。它是个 32 位的随机数,由发送端在会话开始时生成。

关键点:SSRC 必须在同一个 RTP 会话内唯一。如果两个流用了同一个 SSRC,接收端会直接丢弃其中一个,或者产生混乱。

我在项目中遇到过一个问题:两个编码器实例不小心配了相同的 SSRC,结果接收端只显示一路视频,另一路完全黑屏。排查了半天才发现是 SSRC 冲突。从那以后,我习惯在代码里加一个 SSRC 冲突检测机制。

SSRC 的生成规则

RFC 3550 建议用随机算法生成 SSRC,但实际工程中,我更喜欢用哈希算法,比如把 IP + 端口 + 时间戳做一次 MD5,取低 32 位。这样冲突概率极低,而且可复现。

// 伪代码示例:生成 SSRC
uint32_t generate_ssrc(const char* ip, uint16_t port) {
    char input[64];
    snprintf(input, sizeof(input), "%s:%u:%lu", ip, port, time(NULL));
    uint8_t hash[16];
    md5(input, strlen(input), hash);
    return (hash[0] << 24) | (hash[1] << 16) | (hash[2] << 8) | hash[3];
}

4.2 CSRC:贡献源的贡献列表

CSRC(Contributing Source)是 RTP 头里的一个列表,用来标识哪些源参与了当前 RTP 包的内容。它只在混合器场景下出现。

举个例子:一个视频会议里,4 个人在说话。混合器把 4 路音频混成 1 路,然后发送。接收端需要知道这路音频里包含了谁的贡献。CSRC 列表就干这个事。

字段 长度 说明
CC 4 bits CSRC 数量,范围 0-15
CSRC[0..CC-1] 32 bits each 每个贡献源的 SSRC

我的习惯:CSRC 列表的顺序很重要。第一个 CSRC 通常是音量最大的源,或者最近发言的源。接收端可以根据这个顺序做 UI 高亮。

4.3 混合器与翻译器:两种拓扑场景

RTP 定义了两种中间节点:混合器(Mixer)和翻译器(Translator)。它们处理 SSRC/CSRC 的方式完全不同。

混合器(Mixer)

混合器把多路 RTP 流合并成一路。它重新生成 SSRC,并在 RTP 头里插入 CSRC 列表。接收端看到的是混合器的 SSRC,而不是原始源的 SSRC。

  • 优点:节省带宽,接收端只需要处理一路流。
  • 缺点:丢失了原始源的同步信息,比如时间戳和序列号。
  • 典型场景:音频会议、MCU 视频会议。

翻译器(Translator)

翻译器不合并流,它只是转发或转换格式。它保留原始源的 SSRC,不修改 RTP 头。接收端看到的是原始源的 SSRC。

  • 优点:保持原始同步信息,适合需要独立解码的场景。
  • 缺点:带宽消耗大,接收端需要处理多路流。
  • 典型场景:网关、转码器、防火墙穿透。
特性 混合器 翻译器
SSRC 处理 重新生成 保留原始
CSRC 使用 必须 可选
带宽消耗 低 高
同步信息 丢失 保留
典型延迟 高(需要混音) 低(直接转发)

我曾经踩过的坑:在做一个网关项目时,我错误地把翻译器当混合器用,导致接收端收到了多个相同 SSRC 的流,直接崩溃。后来我加了一个 SSRC 映射表,才解决问题。

4.4 同步上下文管理

同步上下文(Synchronization Context)是接收端用来关联不同 RTP 流的一组信息。它包含 SSRC、CNAME、时间戳基准等。没有它,你没法把音频和视频对齐。

上下文的核心组成

  1. SSRC 表:记录当前会话中所有活跃的 SSRC。
  2. CNAME 映射:把 SSRC 映射到 Canonical Name,用于关联同一源的多个流。
  3. 时间戳基准:每个 SSRC 的 RTP 时间戳与 NTP 时间的对应关系。
  4. 序列号窗口:用于丢包检测和重排序。

上下文管理的工程实践

我建议用哈希表来管理同步上下文。键是 SSRC,值是一个结构体,包含 CNAME、时间戳基准、序列号状态等。

// 同步上下文结构体
typedef struct {
    uint32_t ssrc;
    char cname[256];
    uint32_t timestamp_base;
    uint32_t seq_num_base;
    int64_t ntp_timestamp;
    int64_t last_received_time;
} SyncContext;

// 哈希表管理
SyncContext* sync_table[1024];

SyncContext* find_or_create_context(uint32_t ssrc) {
    int idx = ssrc % 1024;
    if (sync_table[idx] == NULL) {
        sync_table[idx] = malloc(sizeof(SyncContext));
        memset(sync_table[idx], 0, sizeof(SyncContext));
        sync_table[idx]->ssrc = ssrc;
    }
    return sync_table[idx];
}

我的经验:同步上下文需要定期清理。如果一个 SSRC 超过 30 秒没有收到任何包,我建议把它标记为过期,然后释放资源。否则内存会一直增长。

4.5 核心逻辑流程图

下面这张图展示了 SSRC/CSRC 在混合器和翻译器场景下的处理流程。你可以看到,混合器重新生成 SSRC,而翻译器保留原始 SSRC。

SSRC/CSRC 处理流程 源 A (SSRC=1) 源 B (SSRC=2) 源 C (SSRC=3) 混合器 重新生成 SSRC 翻译器 保留原始 SSRC 混合输出 SSRC=100, CSRC=[1,2,3] 转发输出 SSRC=1,2,3 (不变) 说明:混合器把多路流合并成一路,CSRC 列表记录贡献源;翻译器保持原始流不变,不修改 SSRC。 接收端根据 SSRC 和 CSRC 信息,决定如何解码和渲染。

4.6 避坑指南

最后,我总结几个实际项目中容易踩的坑:

  • SSRC 冲突:两个源用了相同的 SSRC,接收端会丢弃其中一个。解决方案:用哈希算法生成,或者加一个冲突检测。
  • CSRC 列表溢出:CC 字段只有 4 位,最多 15 个 CSRC。如果混合器有 16 个源,只能记录前 15 个。解决方案:按音量或优先级排序,只保留最重要的。
  • 混合器时间戳不一致:混合器重新生成时间戳,可能导致接收端无法对齐。解决方案:混合器需要维护一个统一的时间戳基准。
  • 翻译器 SSRC 泄漏:翻译器如果错误地修改了 SSRC,接收端会认为是一个新流。解决方案:翻译器只转发,不修改 RTP 头。

我曾经犯过的错:在一个大型会议系统中,我忘了处理 CSRC 列表的排序问题。结果接收端总是把静音的人高亮显示,而真正说话的人反而被忽略了。后来我加了一个音量检测逻辑,才解决这个问题。