RTP固定头部:流媒体的身份证
做流媒体开发这么多年,我见过太多音画不同步的惨案。有一次线上直播,用户投诉声音比画面快了整整两秒——查到最后,就是RTP头部里的时间戳字段被人为篡改了。嗯,从那天起我就明白,搞懂RTP固定头部,是做好音画同步的第一道门槛。
RTP头部其实不复杂,一共12字节固定部分。你想想看,这12个字节里塞了版本、填充、扩展、CSRC计数、标记、负载类型、序列号、时间戳、SSRC、CSRC——整整10个字段。每个字段都有它的脾气,我们一个一个来拆。
核心观点:RTP固定头部是音画同步的基石。序列号保证包序,时间戳保证播放节奏,SSRC保证流身份。这三个字段,缺一个都会出大问题。
2.1 头部结构总览
先看一张整体结构图,我习惯把RTP头部想象成一个快递面单——版本号是快递公司,序列号是运单号,时间戳是发货时间,SSRC是寄件人ID。这样记起来就轻松多了。
这张图我建议你保存下来。每次调试RTP流的时候,对照着看,哪个字段在哪个位置,心里就有数了。
2.2 逐字段详解
版本(V, 2位)
目前RTP只有两个版本:版本2是主流,版本1已经没人用了。我建议你直接写死为2。曾经有个外包团队把版本号写成了0,结果抓包工具直接忽略了这个流——排查了整整一天才发现。
填充(P, 1位)
这个位很有意思。置1表示包尾有填充字节,填充长度由最后一个字节的值决定。什么时候用?加密场景。有些加密算法要求明文长度是块大小的整数倍,这时候就需要填充。不过说实话,我在实际项目中很少见到有人用这个位。
扩展(X, 1位)
置1表示头部后面紧跟着一个扩展头部。扩展头部是干什么的?给RTP加自定义信息用的。比如有些厂商会在扩展里塞时间同步信息。我个人不太建议滥用扩展,因为不是所有接收端都能正确解析。
CSRC计数(CC, 4位)
这个字段告诉你有多少个CSRC(贡献源)跟在固定头部后面。取值范围0-15。在混音场景下特别有用——比如视频会议里5个人同时说话,CC就是5,后面跟着5个CSRC标识符。
标记(M, 1位)
标记位的含义由负载类型决定。对于视频流,通常用M位标记帧边界——置1表示这是一个视频帧的最后一个包。对于音频流,M位可以标记静音期的开始或结束。我记得有一次调试音频卡顿,发现是发送端忘了在静音结束后置M位,导致接收端一直处于静音抑制状态。
负载类型(PT, 7位)
这个字段告诉接收端:包里装的是什么编码格式的数据。比如96表示H.264视频,0表示PCMU音频,8表示PCMA音频。取值范围0-127,其中96-127是动态类型,可以自定义。
| PT值 | 编码格式 | 典型应用 |
|---|---|---|
| 0 | PCMU | G.711 μ-law 音频 |
| 8 | PCMA | G.711 A-law 音频 |
| 96 | H.264 | 视频(动态类型) |
| 97 | OPUS | 音频(动态类型) |
小技巧:调试时如果发现接收端无法解码,先检查PT值是否匹配。我曾经遇到过发送端用PT=96发H.264,接收端却用PT=96解析成VP8——结果当然是花屏。
序列号(16位)
这是RTP头部里最容易被忽视但又最重要的字段之一。发送端每发一个包,序列号加1。接收端靠它来检测丢包、乱序、重复包。
序列号是循环的——从0到65535,然后回到0。为什么是16位?因为RTP设计时认为65535个包足够覆盖一个会话周期。但在高码率场景下,比如4K视频每秒60帧,每帧可能拆成10个包,序列号几秒钟就循环一次了。
注意:序列号循环时,接收端必须正确处理回绕。不能用简单的「新序列号 > 旧序列号」来判断顺序。正确的做法是用有符号16位整数的差值来判断——差值在-32768到32767之间,正数表示新包,负数表示旧包。
时间戳(32位)
时间戳是音画同步的灵魂。它表示RTP数据包中第一个字节的采样时刻。注意,不是系统时钟,而是采样时钟——对于音频,采样时钟就是采样率(如8000Hz、44100Hz);对于视频,采样时钟通常是90000Hz。
为什么视频用90000Hz?因为90kHz是视频常用帧率(24、25、30、60)的最小公倍数,能保证每个帧的时间戳都是整数。这个设计很巧妙,我每次想到都觉得RTP的设计者确实考虑得很周全。
时间戳也是32位循环的。在90000Hz下,大约13小时循环一次。对于大多数会话来说够用了。
SSRC(32位)
SSRC是同步源标识符,用来唯一标识一个RTP流。同一个会话中,每个SSRC必须唯一。怎么保证唯一?随机生成。32位的随机数,碰撞概率很低,但不是零。
我在项目中遇到过SSRC冲突——两个编码器实例用了相同的随机种子,导致接收端把两个流当成一个流来处理。从那以后,我习惯在SSRC里嵌入进程ID和启动时间戳,确保唯一性。
CSRC(每个32位,0-15个)
CSRC是贡献源列表,由混音器插入。比如在视频会议中,混音器把5个人的音频混合成一个流,CSRC列表就记录这5个人的SSRC。接收端可以根据CSRC列表知道:当前这个混合包里有谁的声音。
2.3 实战:解析RTP头部
光说不练假把式。下面这段代码演示如何解析RTP固定头部。我习惯用C语言写底层解析,因为效率高、控制力强。
// RTP固定头部解析示例
#include <stdint.h>
typedef struct {
uint8_t version:2;
uint8_t padding:1;
uint8_t extension:1;
uint8_t csrc_count:4;
uint8_t marker:1;
uint8_t payload_type:7;
uint16_t sequence_number;
uint32_t timestamp;
uint32_t ssrc;
} rtp_header_t;
void parse_rtp_header(uint8_t *buffer) {
rtp_header_t *hdr = (rtp_header_t *)buffer;
printf("版本: %d\n", hdr->version);
printf("负载类型: %d\n", hdr->payload_type);
printf("序列号: %u\n", ntohs(hdr->sequence_number));
printf("时间戳: %u\n", ntohl(hdr->timestamp));
printf("SSRC: 0x%08x\n", ntohl(hdr->ssrc));
// 注意:CSRC在固定头部之后
if (hdr->csrc_count > 0) {
uint32_t *csrc_list = (uint32_t *)(buffer + 12);
for (int i = 0; i < hdr->csrc_count; i++) {
printf("CSRC[%d]: 0x%08x\n", i, ntohl(csrc_list[i]));
}
}
}
调试建议:用Wireshark抓包时,直接看RTP头部解析结果。Wireshark会把每个字段都标出来,对照着看,很快就能上手。我刚开始学RTP时,就是靠抓包+对照RFC 3550,一个包一个包地看,慢慢就熟了。
2.4 常见坑点
- 字节序问题:序列号、时间戳、SSRC都是网络字节序(大端)。在x86机器上解析必须用ntohs/ntohl转换。我见过有人忘了转换,结果序列号65535变成了65535,但65536却变成了0——排查起来非常痛苦。
- 时间戳单位混淆:音频用采样率,视频用90000Hz。有人把音频时间戳也按90000Hz算,结果播放速度变成了11倍(44100/90000≈0.49)。
- SSRC冲突:随机生成不是万能的。建议用哈希(IP+端口+进程ID)来生成SSRC,碰撞概率几乎为零。
- CSRC数量溢出:CC字段只有4位,最多15个CSRC。混音器如果超过15个参与者,必须丢弃一些CSRC或者分多个流发送。