2、RTP头部结构深度解析

RTP头部,说白了就是音视频数据包的“快递面单”。没有它,接收端根本不知道这包数据是什么、从哪来、该往哪放。我刚开始接触WebRTC时,总觉得头部开销是浪费带宽,后来被一个丢包问题折磨了三天——才发现是序列号解析错了。嗯,从那以后我再也不敢小看这12个字节了。

核心认知:RTP固定头部只有12字节,但加上CSRC列表和扩展头后,最大可达72字节。每个比特都有明确职责。

2.1 头部结构总览

先看一张整体结构图,我习惯把它分成三块:前两个字节是控制位,中间四个字节是标识位,最后六个字节是源信息。

RTP固定头部结构(12字节) V(2bit) 版本号 P(1bit) 填充 X(1bit) 扩展 CC(4bit) CSRC计数 M(1bit) 标记 PT(7bit) 负载类型 序列号 SN (16bit) 第3-4字节 时间戳 Timestamp (32bit) 第5-8字节 SSRC (32bit) 第9-12字节 CSRC列表(可选,0-15项,每项32bit) 当CC > 0时存在,最多60字节 扩展头(可选,当X=1时存在) 长度可变,由扩展头中的长度字段指定

我的习惯:解析RTP包时,先读前两个字节的bit位,再读序列号和时间戳。SSRC最后确认——因为它是会话级别的标识,很少变。

2.2 逐个字段深度解析

2.2.1 版本号 (V, 2bit)

当前RTP版本号固定为2。你可能会问:版本0和1去哪了?

版本0是RTP草案阶段用的,早已废弃。版本1是RTPv1,但没大规模商用。现在所有WebRTC流量都是版本2。我在一次抓包分析中见过版本0的包——那是某老旧嵌入式设备没升级协议栈,直接导致互通失败。

避坑:如果收到V≠2的RTP包,直接丢弃。我曾经见过某些网关设备错误修改了版本号字段,导致接收端解析异常。

2.2.2 填充位 (P, 1bit)

P=1表示包尾有填充字节。填充字节的最后一个字节表示填充长度(包含自身)。

什么场景会用?最常见的是加密场景——某些加密算法要求明文长度是块大小的整数倍。比如AES-128-CBC要求16字节对齐,不够就补。

// 伪代码:处理填充位
if (rtpHeader.P == 1) {
    uint8_t padding_len = rtp_packet[packet_len - 1];
    actual_payload_len = packet_len - 12 - padding_len;
    // 注意:CSRC和扩展头也要算进去
}

2.2.3 扩展位 (X, 1bit)

X=1表示固定头部后面紧跟着扩展头。扩展头格式如下:

偏移 长度 含义
0-1 16bit 扩展头标识(由profile定义)
2-3 16bit 扩展长度(以32bit为单位)
4+ 可变 扩展数据

WebRTC中常用扩展头携带绝对发送时间、视频旋转角度等信息。我个人建议:非必要不开启扩展头,每多一个扩展字段就多一次解析开销。

2.2.4 CSRC计数器 (CC, 4bit)

CC表示CSRC列表中包含的贡献源数量。取值范围0-15。CC=0时,CSRC列表不存在,头部就是12字节。

这个字段在混音场景下特别重要。比如一个会议中有5个人同时说话,混音器会把5个源的SSRC都列出来。接收端看到CC=5,就知道要读5个CSRC。

关键点:CSRC列表紧跟在固定头部之后,每个CSRC占4字节。所以头部总长度 = 12 + CC × 4。如果X=1,扩展头在CSRC列表之后。

2.2.5 标记位 (M, 1bit)

M位的含义由profile文件定义。在视频流中,M=1通常表示一帧的结束。在音频流中,M=1表示语音活动开始。

我记得有一次排查视频花屏问题,发现接收端把M位当成了帧边界,但发送端每帧发了3个包才把M置1——结果接收端一直在等M位,缓存越积越多。后来改成按时间戳判断帧边界才解决。

2.2.6 负载类型 (PT, 7bit)

PT告诉接收端:这包数据是什么编码格式。WebRTC中常用的PT值:

PT值 编码格式 类型
96 VP8 视频
98 VP9 视频
100 H264 视频
111 Opus 音频
0 PCMU 音频
8 PCMA 音频

注意:动态PT(96-127)是通过SDP协商确定的。静态PT(0-95)是IANA预定义的。WebRTC几乎只用动态PT。

2.2.7 序列号 (SN, 16bit)

序列号是RTP头部最重要的字段之一。每发送一个RTP包,SN加1。接收端用它来:

  • 检测丢包:如果收到SN=100、102,中间缺了101,说明丢了一包
  • 排序:网络可能乱序,按SN重排
  • 去重:同一个SN收到两次,丢弃重复包

SN是16位无符号整数,范围0-65535。溢出后回绕到0。处理回绕是必考题——你不能简单用if (sn2 > sn1)判断新包,要考虑回绕。

// 正确的序列号比较(处理回绕)
bool isNewer(uint16_t new_sn, uint16_t old_sn) {
    return (int16_t)(new_sn - old_sn) > 0;
}

我的经验:用int16_t差值判断回绕,百试不爽。别用uint16_t直接比大小,否则回绕时全乱套。

2.2.8 时间戳 (Timestamp, 32bit)

时间戳反映的是采样时刻,不是发送时刻。音频和视频的时间戳单位不同:

媒体类型 时钟频率 时间戳增量含义
音频(Opus) 48000Hz 每采样点增1
视频(VP8/VP9/H264) 90000Hz 每像素行?不,固定90000

为什么视频用90000Hz?因为这是MPEG-TS标准定的,RTP沿用了这个传统。90000能被多种帧率整除(24、25、30、50、60),计算方便。

时间戳的差值可以计算实际时长:

// 计算两包之间的时间差(毫秒)
uint32_t ts_diff = ts2 - ts1;  // 假设ts2 > ts1
uint32_t clock_rate = 90000;   // 视频
float duration_ms = (float)ts_diff / clock_rate * 1000.0f;

2.2.9 同步源标识符 (SSRC, 32bit)

SSRC唯一标识一个RTP流。同一个会话中,每个SSRC都是唯一的。如果两个SSRC冲突(概率极低但存在),RTP有冲突检测和解决机制。

SSRC是随机生成的,但WebRTC实现中通常基于某些种子(如网卡MAC、进程ID)生成,降低冲突概率。我在测试中故意构造过SSRC冲突——两个流用同一个SSRC,接收端直接崩溃。嗯,从那以后我写代码都会检查SSRC是否变化。

2.2.10 贡献源列表 (CSRC, 每项32bit)

CSRC列表由混音器填充,列出所有参与混音的源。每个CSRC就是一个SSRC值。接收端可以通过CSRC知道:当前这包数据是哪些源的混合结果。

举个例子:

  • 源A的SSRC=0x12345678
  • 源B的SSRC=0x87654321
  • 混音器发出的RTP包:SSRC=混音器自己的ID,CC=2,CSRC[0]=0x12345678,CSRC[1]=0x87654321

接收端看到CC=2,就知道要读两个CSRC。这在多方通话的统计和渲染中很有用。

注意:CSRC列表只在混音场景存在。WebRTC的SFU(选择性转发单元)通常不混音,所以CSRC列表很少出现。如果你在WebRTC中看到CC>0,多半是网关或MCU转发的流。

2.3 头部解析实战

最后给一段完整的RTP头部解析代码。我习惯用位操作直接读,不依赖任何库:

typedef struct {
    uint8_t  version;      // 2bit
    uint8_t  padding;      // 1bit
    uint8_t  extension;    // 1bit
    uint8_t  cc;           // 4bit
    uint8_t  marker;       // 1bit
    uint8_t  payload_type; // 7bit
    uint16_t sequence;     // 16bit
    uint32_t timestamp;    // 32bit
    uint32_t ssrc;         // 32bit
    uint32_t csrc[15];     // 最多15个
} RtpHeader;

void parse_rtp_header(const uint8_t* data, RtpHeader* hdr) {
    // 第1字节
    hdr->version   = (data[0] >> 6) & 0x03;
    hdr->padding   = (data[0] >> 5) & 0x01;
    hdr->extension = (data[0] >> 4) & 0x01;
    hdr->cc        = data[0] & 0x0F;

    // 第2字节
    hdr->marker       = (data[1] >> 7) & 0x01;
    hdr->payload_type = data[1] & 0x7F;

    // 第3-4字节:序列号(网络字节序)
    hdr->sequence = (data[2] << 8) | data[3];

    // 第5-8字节:时间戳
    hdr->timestamp = (data[4] << 24) | (data[5] << 16) |
                     (data[6] << 8)  | data[7];

    // 第9-12字节:SSRC
    hdr->ssrc = (data[8] << 24) | (data[9] << 16) |
                (data[10] << 8) | data[11];

    // CSRC列表(如果有)
    int offset = 12;
    for (int i = 0; i < hdr->cc; i++) {
        hdr->csrc[i] = (data[offset] << 24) |
                       (data[offset+1] << 16) |
                       (data[offset+2] << 8)  |
                       data[offset+3];
        offset += 4;
    }
}

调试技巧:用Wireshark抓包时,直接看RTP头部各字段的值,和自己代码解析的结果对比。我每次写新平台的RTP解析器,都会先抓一包对照验证。

RTP头部就这么12个字节(不含扩展),但每个bit都有它的使命。理解了这些字段,你就掌握了RTP协议的基石。下一层,我们该看看负载数据怎么组织了——那是RTP payload格式的范畴。