2、RTP头部结构深度解析
RTP头部,说白了就是音视频数据包的“快递面单”。没有它,接收端根本不知道这包数据是什么、从哪来、该往哪放。我刚开始接触WebRTC时,总觉得头部开销是浪费带宽,后来被一个丢包问题折磨了三天——才发现是序列号解析错了。嗯,从那以后我再也不敢小看这12个字节了。
核心认知:RTP固定头部只有12字节,但加上CSRC列表和扩展头后,最大可达72字节。每个比特都有明确职责。
2.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格式的范畴。