2. RTP 头部结构详解:从比特流到媒体时钟
RTP 头部,说白了就是每个媒体包的“身份证”。我刚开始接触 RTP 时,总觉得这 12 字节的固定头没啥看头。直到有一次排查音画不同步问题,才发现时间戳和序列号里藏着大学问。今天咱们就把这 12 字节掰开揉碎,看看每个字段到底在干什么。
核心要点:RTP 固定头部 12 字节,后面跟着可选的扩展头。所有字段都是网络字节序(大端),解析时一定要注意字节序转换。
2.1 头部结构总览
先看一张整体结构图,我习惯把它分成三块:标志与控制区、媒体标识区、同步源区。
2.2 标志与控制区(第 1 个 32 位字)
2.2.1 版本号(V, 2 bits)
当前 RTP 版本号固定为 2。我记得早期有些设备还在用版本 0 或 1,现在已经基本绝迹了。解析时如果 V 不等于 2,直接丢包——这不是你该处理的流量。
2.2.2 填充位(P, 1 bit)
这个位很有意思。如果 P=1,表示包尾有填充字节。填充的最后一个字节的值表示填充的总长度。我曾在加密场景中用过这个字段——某些加密算法要求明文长度是块大小的整数倍,填充位正好派上用场。
实战技巧:解析时先读 P 位,如果为 1,记得从包尾减掉填充长度。我曾经见过一个实现忘了处理填充,导致后续所有 CSRC 解析都错位了。
2.2.3 扩展位(X, 1 bit)
X=1 表示固定头后面紧跟着一个扩展头。扩展头的格式是:2 字节的扩展标识 + 2 字节的扩展长度(以 32 位字为单位)。我个人建议尽量少用扩展头——很多老旧的解码器会直接丢弃带扩展头的包。
2.2.4 CSRC 计数器(CC, 4 bits)
CC 表示后面跟着多少个 CSRC 标识符。取值范围 0~15。为什么是 15?因为 4 位二进制最大是 15。嗯,这里要注意:CC=0 时,CSRC 列表不存在,固定头就是 12 字节。
2.2.5 标记位(M, 1 bit)
M 位的含义由具体的负载类型(PT)定义。对于视频流,M 位通常标记一帧的结束;对于音频流,M 位标记一个 talkspurt 的开始。我习惯在调试时把 M 位打印出来——它能帮你快速定位帧边界。
2.2.6 负载类型(PT, 7 bits)
PT 告诉接收端这个包承载的是什么类型的媒体。常见的值有:
| PT 值 | 媒体类型 | 时钟频率 | 说明 |
|---|---|---|---|
| 0 | PCMU | 8000 Hz | G.711 μ-law 音频 |
| 8 | PCMA | 8000 Hz | G.711 A-law 音频 |
| 96 | H.264 | 90000 Hz | 动态类型,常见于视频 |
| 97 | OPUS | 48000 Hz | 动态类型,WebRTC 常用 |
注意:PT 值 0~95 是静态分配(RFC 3551),96~127 是动态类型,需要通过 SDP 协商确定。我曾经踩过一个坑:直接硬编码 PT=96 当作 H.264,结果对方用的是 VP8,画面全是马赛克。
2.3 媒体标识区(第 2 个 32 位字)
2.3.1 序列号(Sequence Number, 16 bits)
序列号从随机值开始,每发送一个 RTP 包就加 1。接收端用它来检测丢包和恢复包序。为什么从随机值开始?为了防止某些攻击者猜出序列号。16 位会溢出,但没关系——接收端应该用序列号差值来判断,而不是绝对值。
我排查过一个诡异的问题:视频偶尔卡顿,但没丢包。后来发现是发送端序列号实现有 bug,在溢出时跳过了几个值。接收端误以为丢包,触发了 FEC 重传,反而增加了延迟。
2.3.2 时间戳(Timestamp, 32 bits)
时间戳是 RTP 里最容易被误解的字段。它表示媒体数据的采样时刻,单位是时钟频率的倒数。时钟频率由 PT 决定:音频通常是 8000 Hz 或 48000 Hz,视频固定 90000 Hz。
举个例子:对于 48000 Hz 的音频,时间戳每增加 480,就代表过去了 10 毫秒。对于 90000 Hz 的视频,时间戳每增加 900,也是 10 毫秒。时间戳的初始值也是随机的,接收端只关心差值。
关键区别:序列号反映包的发送顺序,时间戳反映媒体的采样时间。一个 RTP 包可能包含多个音频采样(时间戳不变),也可能一个视频帧被拆成多个 RTP 包(序列号递增,时间戳相同)。
2.4 同步源区(第 3 个 32 位字)
2.4.1 SSRC(同步源标识符, 32 bits)
SSRC 唯一标识一个 RTP 会话中的同步源。比如在一个视频会议中,每个人的摄像头和麦克风各自有一个 SSRC。SSRC 是随机生成的,但必须保证在同一个 RTP 会话中不冲突。
我记得有一次做多方通话测试,两个参与者的 SSRC 竟然冲突了——接收端把两个人的音频混在了一起。后来我们加了个 SSRC 冲突检测机制:收到 RTCP BYE 或 SDES 时,检查 SSRC 是否重复。
2.5 贡献源区(CSRC 列表)
2.5.1 CSRC(贡献源标识符, 每项 32 bits)
CSRC 列表只在混音场景中使用。比如一个 MCU(多点控制单元)把 3 个人的音频混在一起发送,CC=3,CSRC 列表里就放着这 3 个人的 SSRC。接收端可以通过 CSRC 知道当前包是哪些人的声音混合而成的。
CSRC 列表的长度由 CC 字段决定。CC=0 时没有 CSRC,固定头就是 12 字节。CC=15 时,CSRC 列表占 60 字节,整个头部变成 72 字节。
2.6 实战:手撕一个 RTP 头
光说不练假把式。咱们用一段 C 代码来解析一个 RTP 包的头。假设我们已经从网络上收到了一个完整的 RTP 包。
// RTP 固定头结构体
typedef struct {
unsigned int version:2; // 版本号
unsigned int padding:1; // 填充位
unsigned int extension:1; // 扩展位
unsigned int cc:4; // CSRC 计数器
unsigned int marker:1; // 标记位
unsigned int pt:7; // 负载类型
unsigned int sequence:16; // 序列号
unsigned int timestamp; // 时间戳
unsigned int ssrc; // 同步源标识符
} rtp_header_t;
// 解析函数
void parse_rtp_header(const unsigned char *packet) {
rtp_header_t *hdr = (rtp_header_t *)packet;
// 检查版本号
if (hdr->version != 2) {
printf("非 RTP 包,版本号=%d\n", hdr->version);
return;
}
// 打印基本信息
printf("版本: %d\n", hdr->version);
printf("填充: %s\n", hdr->padding ? "是" : "否");
printf("扩展: %s\n", hdr->extension ? "是" : "否");
printf("CSRC 数量: %d\n", hdr->cc);
printf("标记: %d\n", hdr->marker);
printf("负载类型: %d\n", hdr->pt);
printf("序列号: %d\n", ntohs(hdr->sequence));
printf("时间戳: %u\n", ntohl(hdr->timestamp));
printf("SSRC: 0x%08x\n", ntohl(hdr->ssrc));
// 如果有 CSRC,打印它们
if (hdr->cc > 0) {
unsigned int *csrc_list = (unsigned int *)(packet + 12);
for (int i = 0; i < hdr->cc; i++) {
printf("CSRC[%d]: 0x%08x\n", i, ntohl(csrc_list[i]));
}
}
}
避坑指南:注意代码中的 ntohs 和 ntohl——网络字节序转主机字节序。我见过有人直接强转结构体后打印,结果序列号和时间戳全是反的。另外,位域的顺序在不同编译器下可能不同,生产代码建议用移位操作。
2.7 常见问题与排查思路
- 序列号乱序:检查网络是否有多路径转发,或者发送端是否多线程发送。接收端需要维护一个重排序缓冲区。
- 时间戳跳跃:可能是发送端时钟漂移,或者采样率配置错误。用 Wireshark 的 RTP 分析器可以直观看到时间戳变化曲线。
- SSRC 冲突:接收端收到两个相同 SSRC 的包时,应该触发 RTCP BYE 并重新协商。WebRTC 内部有完善的冲突处理机制。
- PT 不匹配:检查 SDP 协商结果。我曾经遇到过编码器输出 H.264,但 SDP 里写的是 MPEG4,结果解码器直接崩溃。