2. RTP头部结构详解:从比特到流的关键

RTP头部,说白了就是12个字节的固定开销。但就是这12个字节,承载了音视频传输中最核心的信息。我刚开始接触RTP时,总觉得头部结构很枯燥,直到有一次排查丢包问题,才发现每一个bit都可能成为定位问题的关键。

先看一张整体结构图,把RTP头部的各个字段串起来:

RTP固定头部结构(12字节) V 2bit P 1bit X 1bit CC 4bit M 1bit PT 7bit 序列号(Sequence Number) 16bit 时间戳(Timestamp) 32bit 同步源标识(SSRC) 32bit CSRC列表(0~15项,每项32bit) 0~480bit 字段说明 V(版本号):固定为2,标识RTP版本 P(填充位):为1时尾部有填充字节 X(扩展位):为1时头部后有扩展头 CC(CSRC计数器):CSRC标识的数量 M(标记位):应用自定义,如帧边界 PT(负载类型):标识编码格式 SN(序列号):检测丢包、排序 Timestamp:采样时刻,用于播放同步 SSRC:唯一标识一个RTP流 CSRC:混音时标识多个贡献源 固定头部12字节,CSRC可选

2.1 版本号(V)—— 2bit,固定为2

版本号就2个比特,值固定为2。为什么不是1?因为RTP v1早就被淘汰了。我见过一些老旧设备还在发v1的包,现在的解码器直接丢弃。嗯,这里要注意:如果你在抓包时看到版本号不是2,那基本可以断定这个流有问题。

2.2 填充位(P)—— 1bit,对齐的代价

填充位为1时,表示包尾有填充字节。填充字节的最后一个字节表示填充长度。为什么要填充?说白了就是为了对齐。有些加密算法要求明文长度是16字节的倍数,这时候就得靠填充。

我的经验: 填充位在实际的音视频流中很少用到。我做了这么多年RTP,只在SRTP(安全RTP)的场景下见过它。如果你不是做加密传输,基本可以忽略这个字段。

2.3 扩展位(X)—— 1bit,头部的扩展口

扩展位为1时,固定头部后面紧跟着一个扩展头。扩展头的结构是:2字节的扩展标识 + 2字节的扩展长度(以4字节为单位)。

什么时候会用扩展头?举个例子,我在做视频会议系统时,需要在RTP层传递一些自定义的元数据,比如视频的旋转角度、屏幕共享的坐标信息。这时候扩展头就派上用场了。

避坑指南: 我曾经遇到过一个坑——某些硬件解码器不支持扩展头,遇到X=1的包直接丢帧。所以,除非你的接收端明确支持,否则尽量别用扩展头。能用负载传递的信息,就别塞到扩展头里。

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 动态分配
关键点: 动态PT值(96~127)需要通过SDP协商确定。比如在WebRTC中,双方通过offer/answer交换SDP,约定PT=96对应H.264,PT=97对应OPUS。如果协商不一致,解码就会失败。

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时间戳关联
我的建议: 时间戳的初始值最好是随机的,不要固定为0。这样可以防止某些实现把时间戳为0的包当作无效包丢弃。另外,视频的时间戳时钟频率是90kHz,这是从MPEG-TS时代传下来的老规矩,别问为什么,记住就行。

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信息
避坑指南: 我曾经在实现混音器时,忘记更新CSRC列表,导致接收端无法正确识别说话人。记住:只要你的RTP包包含了多个源的数据,就必须在CSRC中列出它们。否则,接收端会认为所有数据都来自SSRC标识的那个源。

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字段决定,不要越界读取