2. RTP 头部结构详解:从比特流到媒体时钟

RTP 头部,说白了就是每个媒体包的“身份证”。我刚开始接触 RTP 时,总觉得这 12 字节的固定头没啥看头。直到有一次排查音画不同步问题,才发现时间戳和序列号里藏着大学问。今天咱们就把这 12 字节掰开揉碎,看看每个字段到底在干什么。

核心要点:RTP 固定头部 12 字节,后面跟着可选的扩展头。所有字段都是网络字节序(大端),解析时一定要注意字节序转换。

2.1 头部结构总览

先看一张整体结构图,我习惯把它分成三块:标志与控制区、媒体标识区、同步源区。

RTP 固定头部结构(12 字节) V 2bit P 1bit X 1bit CC 4bit M 1bit PT 7bit 序列号 16bit 时间戳(Timestamp) 32bit SSRC(同步源标识符) 32bit CSRC 列表(0~15项,每项32bit) 0~480bit(由 CC 字段决定) 说明: 固定头 12 字节(V~SSRC),CSRC 列表长度由 CC 决定。 所有字段均为网络字节序(大端)。 标志与控制区 媒体标识区 同步源区 贡献源区

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,结果解码器直接崩溃。