2. RTP头部结构详解:从比特到流的关键
RTP头部,说白了就是12个字节的固定开销。但就是这12个字节,承载了音视频传输中最核心的信息。我刚开始接触RTP时,总觉得头部结构很枯燥,直到有一次排查丢包问题,才发现每一个bit都可能成为定位问题的关键。
先看一张整体结构图,把RTP头部的各个字段串起来:
2.1 版本号(V)—— 2bit,固定为2
版本号就2个比特,值固定为2。为什么不是1?因为RTP v1早就被淘汰了。我见过一些老旧设备还在发v1的包,现在的解码器直接丢弃。嗯,这里要注意:如果你在抓包时看到版本号不是2,那基本可以断定这个流有问题。
2.2 填充位(P)—— 1bit,对齐的代价
填充位为1时,表示包尾有填充字节。填充字节的最后一个字节表示填充长度。为什么要填充?说白了就是为了对齐。有些加密算法要求明文长度是16字节的倍数,这时候就得靠填充。
2.3 扩展位(X)—— 1bit,头部的扩展口
扩展位为1时,固定头部后面紧跟着一个扩展头。扩展头的结构是:2字节的扩展标识 + 2字节的扩展长度(以4字节为单位)。
什么时候会用扩展头?举个例子,我在做视频会议系统时,需要在RTP层传递一些自定义的元数据,比如视频的旋转角度、屏幕共享的坐标信息。这时候扩展头就派上用场了。
2.4 CSRC计数器(CC)—— 4bit,混音的见证者
CC字段表示CSRC列表中有多少个标识符,取值范围0~15。CSRC是干嘛的?它用于标识RTP包的贡献源。
举个场景:你在开电话会议,3个人同时说话。混音器把3路音频合成一路RTP流,这时候SSRC是混音器的标识,而CSRC列表里就是那3个人的SSRC。接收端看到CSRC列表,就知道这包数据是谁贡献的。
| CC值 | CSRC数量 | 头部总长度(字节) | 典型场景 |
|---|---|---|---|
| 0 | 0 | 12 | 点对点单流 |
| 1 | 1 | 16 | 简单混音 |
| 3 | 3 | 24 | 小型会议 |
| 15 | 15 | 72 | 大型混音 |
2.5 标记位(M)—— 1bit,帧边界的哨兵
标记位只有1个bit,但它的作用可不小。M位的含义由具体的负载类型(PT)定义。最常见的用法是:M=1表示一帧数据的结束。
比如在H.264视频流中,M=1通常表示一个NAL单元的结束。在G.711音频流中,M=1可能表示一个talk spurt的开始。我个人习惯在实现中把M位当作帧边界的标志,这样接收端可以更准确地判断何时开始解码。
2.6 负载类型(PT)—— 7bit,告诉你怎么解码
PT字段标识RTP负载的编码格式。7个比特可以表示128种类型,其中0~96是静态分配的,96~127是动态分配的。
常见的静态PT值:
| PT值 | 编码格式 | 时钟频率(Hz) | 说明 |
|---|---|---|---|
| 0 | PCMU | 8000 | G.711 μ-law |
| 3 | GSM | 8000 | GSM 6.10 |
| 8 | PCMA | 8000 | G.711 A-law |
| 96 | H.264 | 90000 | 动态分配 |
| 97 | OPUS | 48000 | 动态分配 |
2.7 序列号(SN)—— 16bit,丢包检测的利器
序列号是RTP头部中最重要的字段之一。每个RTP包发送时,序列号递增1。接收端通过序列号可以:
- 检测丢包: 如果收到的序列号不连续,说明中间有包丢失
- 恢复顺序: 网络可能乱序,接收端按序列号重新排序
- 统计质量: 计算丢包率、抖动等QoS指标
序列号是16bit的,范围0~65535。溢出后从0重新开始。我曾经在项目中遇到过一个问题:序列号溢出后,接收端误判为乱序,导致大量包被丢弃。解决方案是使用序列号回绕检测算法,比如RFC 3550中推荐的比较方法。
// 序列号回绕安全比较
// 返回 true 如果 a 在 b 之后(考虑回绕)
bool sequenceNumberLater(uint16_t a, uint16_t b) {
const uint16_t HALF_RANGE = 32768;
return ((a - b) & 0xFFFF) < HALF_RANGE;
}
2.8 时间戳(Timestamp)—— 32bit,同步的基石
时间戳反映的是RTP数据包中第一个字节的采样时刻。它的单位不是毫秒,而是由负载类型的时钟频率决定的。比如:
- 音频(8kHz):每125μs递增1
- 视频(90kHz):每11.11μs递增1
时间戳的作用:
- 播放同步: 接收端根据时间戳决定何时播放
- 抖动消除: 结合序列号计算网络抖动
- 音视频同步: 通过RTCP SR包中的NTP时间戳关联
2.9 同步源标识(SSRC)—— 32bit,流的身份证
SSRC唯一标识一个RTP流。在同一个RTP会话中,每个流的SSRC必须是唯一的。SSRC是随机生成的,但冲突的概率很低(32bit空间)。
SSRC的生成规则:
- 使用随机数生成器,避免冲突
- RFC 3550建议使用MD5算法生成
- 冲突时通过RTCP的BYE或SDES报文处理
我记得有一次调试一个多路视频会议系统,发现两路视频的SSRC竟然冲突了。结果就是画面交替闪烁,解码器一会儿用这个流,一会儿用那个流。排查了半天,最后发现是随机数种子没初始化好。
2.10 贡献源列表(CSRC)—— 0~480bit,混音的功劳簿
CSRC列表是可选字段,长度由CC字段决定。每个CSRC是32bit的SSRC标识符,最多15个。CSRC列表紧跟在固定头部之后,在扩展头之前。
CSRC的使用场景:
- 音频混音: 混音器将多路音频合成一路,CSRC列出所有原始源
- 视频合成: 比如画中画场景,CSRC标识各个子画面
- 数据转发: 中间节点转发数据时,保留原始CSRC信息
2.11 头部解析实战:从字节流到结构体
最后,我们来看一个实际的RTP头部解析代码。假设我们从网络中收到一个RTP包,需要解析它的头部:
typedef struct {
uint16_t version:2;
uint16_t padding:1;
uint16_t extension:1;
uint16_t cc:4;
uint16_t marker:1;
uint16_t payload_type:7;
uint16_t sequence_number;
uint32_t timestamp;
uint32_t ssrc;
uint32_t csrc[15];
} rtp_header_t;
// 解析RTP头部
void parse_rtp_header(const uint8_t *data, rtp_header_t *hdr) {
// 第一个字节:V(2) | P(1) | X(1) | CC(4)
hdr->version = (data[0] >> 6) & 0x03;
hdr->padding = (data[0] >> 5) & 0x01;
hdr->extension = (data[0] >> 4) & 0x01;
hdr->cc = data[0] & 0x0F;
// 第二个字节:M(1) | PT(7)
hdr->marker = (data[1] >> 7) & 0x01;
hdr->payload_type = data[1] & 0x7F;
// 序列号(网络字节序转主机字节序)
hdr->sequence_number = (data[2] << 8) | data[3];
// 时间戳
hdr->timestamp = (data[4] << 24) | (data[5] << 16) |
(data[6] << 8) | data[7];
// SSRC
hdr->ssrc = (data[8] << 24) | (data[9] << 16) |
(data[10] << 8) | data[11];
// CSRC列表(如果有)
for (int i = 0; i < hdr->cc; i++) {
int offset = 12 + i * 4;
hdr->csrc[i] = (data[offset] << 24) |
(data[offset+1] << 16) |
(data[offset+2] << 8) |
data[offset+3];
}
}
这段代码看起来简单,但有几个细节要注意:
- 网络字节序是大端(Big-Endian),x86平台需要转换
- 位域的顺序要严格按照RTP规范,不同编译器可能有差异
- CSRC列表的长度由CC字段决定,不要越界读取