2. RTP 头部结构:从比特到音画同步的基石

RTP 头部,说白了就是音视频数据的“快递面单”。我刚开始接触 RTP 时,总觉得这 12 字节的固定头部没什么了不起。直到有一次排查丢包问题,才发现序列号和时间戳的配合有多精妙。嗯,咱们今天就把它拆开揉碎了讲清楚。

核心认知:RTP 头部是每个数据包的开销,但它承载了排序、同步、负载识别等关键信息。没有它,接收端就是一盘散沙。

2.1 头部结构总览

RTP 固定头部 12 字节,后面可能跟着 CSRC 列表和扩展头部。我习惯把它画成一张图,这样看着更直观。

RTP 固定头部结构(12 字节) RTP 固定头部结构(12 字节) V 2 bit P 1 bit X 1 bit CC 4 bit M 1 bit PT 7 bit 序列号 16 bit 时间戳 32 bit SSRC(同步源标识符) 32 bit CSRC 列表(0~15 项,每项 32 bit,可选) 字段说明 V: 版本号,当前为 2 P: 填充位,尾部有填充字节 X: 扩展位,头部后有扩展 CC: CSRC 计数器 M: 标记位,帧边界标记 PT: 负载类型,编码格式 序列号: 丢包检测 & 排序 时间戳: 播放时序 & 同步 SSRC/CSRC: 源标识

2.2 逐个字段拆解

2.2.1 版本号(V, 2 bit)

当前 RTP 版本号固定为 2。你可能会问,版本 1 去哪了?其实 RTPv1 是实验性协议,后来被 v2 完全取代。我个人习惯在抓包时先看一眼版本号,如果不是 2,那多半是解析器配置错了。

2.2.2 填充位(P, 1 bit)

填充位为 1 时,表示包尾有填充字节。填充的最后一个字节表示填充长度。我在做加密传输时遇到过这个字段——某些加密算法要求明文长度是块大小的整数倍,这时候就得靠填充位来对齐。

小技巧:如果你看到 P=1,记得把填充字节去掉再计算负载长度。否则解码器会吃到脏数据。

2.2.3 扩展位(X, 1 bit)

扩展位为 1 时,固定头部后面紧跟着一个扩展头部。扩展头部包含 16 位的扩展类型和 16 位的扩展长度。我记得有一次做视频会议系统,需要在 RTP 里携带自定义的 QoS 信息,就是用扩展头部实现的。

2.2.4 CSRC 计数(CC, 4 bit)

CC 表示 CSRC 标识符的数量,范围 0~15。每个 CSRC 占 32 位,所以 CSRC 列表总长度为 CC × 4 字节。这个字段在混音场景下特别重要——比如多方通话时,混音器会把多个源的 SSRC 填进 CSRC 列表。

2.2.5 标记位(M, 1 bit)

标记位的含义由负载类型决定。对于视频,M=1 通常表示一帧的结束;对于音频,M=1 表示一个 talkspurt 的开始。我曾经踩过一个坑:把 M 位当成帧边界,结果在 H.264 的 NAL 单元边界判断上出了 bug,画面花了好一阵。

注意:M 位的语义不是协议强制的,而是由具体的负载格式规范(如 RFC 6184 for H.264)定义的。不要想当然。

2.2.6 负载类型(PT, 7 bit)

PT 标识 RTP 负载的编码格式。常见的如 PT=96 表示 H.264,PT=0 表示 PCMU。7 位理论上支持 128 种类型,但 0~95 是静态分配,96~127 是动态分配。我建议你在做系统设计时,优先使用动态 PT 范围,避免和静态类型冲突。

PT 值 编码格式 时钟频率 (Hz) 说明
0 PCMU 8000 G.711 μ-law
8 PCMA 8000 G.711 A-law
96 H.264 90000 动态分配,视频常用
97 OPUS 48000 动态分配,音频常用

2.3 序列号:丢包检测的利器

序列号是 16 位无符号整数,每个 RTP 包递增 1。接收端通过序列号可以检测丢包、乱序和重复包。我做过一个统计:在 Wi-Fi 环境下,RTP 序列号跳变超过 10 的情况并不罕见。

序列号初始值是随机的,不是从 0 开始。为什么?为了防止攻击者猜测后续序列号。我个人习惯在初始化时用 rand() 生成一个随机起点。

// 伪代码:序列号处理逻辑
uint16_t expected_seq = first_seq;
while (receive_packet(&pkt)) {
    if (pkt.seq == expected_seq) {
        // 正常顺序
        process(pkt);
        expected_seq++;
    } else if (pkt.seq > expected_seq) {
        // 丢包了
        uint16_t lost = pkt.seq - expected_seq;
        report_loss(lost);
        expected_seq = pkt.seq + 1;
    } else {
        // 乱序或重复包
        handle_out_of_order(pkt);
    }
}

2.4 时间戳:音画同步的灵魂

时间戳是 32 位整数,表示 RTP 数据包中第一个字节的采样时刻。时钟频率由负载类型决定——视频通常用 90 kHz,音频用 8 kHz 或 48 kHz。时间戳的增量反映了媒体数据的真实时间流逝。

举个例子:一段 48 kHz 的音频,每帧 20 ms,那么每帧的采样点数为 48000 × 0.02 = 960。所以相邻 RTP 包的时间戳差值就是 960。

关键点:时间戳不是系统时钟,而是采样时钟。它用于播放时序控制,而不是用于计算 RTT。RTCP 的 SR 包会建立 RTP 时间戳和 NTP 时间戳的映射关系,这才是音画同步的桥梁。

2.5 SSRC 与 CSRC:谁在说话?

SSRC(同步源标识符)是 32 位随机数,唯一标识一个 RTP 会话中的同步源。每个参与者生成一个 SSRC,冲突时通过 RTCP 解决。CSRC(贡献源标识符)则用于混音场景,标识哪些源混合到了当前包中。

我记得有一次做多方通话测试,两个客户端偶然生成了相同的 SSRC,结果接收端把两个流当成一个来处理,音频全乱了。后来我们加上了 SSRC 冲突检测,才彻底解决。

字段 长度 作用 典型场景
SSRC 32 bit 标识同步源 每个 RTP 流一个
CSRC 32 bit × N 标识贡献源 混音器输出包

2.6 避坑指南

  • 序列号回绕:16 位序列号最大 65535,回绕后从 0 开始。我曾经没处理回绕,导致丢包检测在回绕瞬间误报。解决方案是用有符号差或模运算。
  • 时间戳回绕:32 位时间戳在 90 kHz 下约 13 小时回绕一次。长时间流媒体会话必须处理回绕,否则播放时序会乱。
  • SSRC 冲突:随机生成 SSRC 有极小概率冲突。RTCP 的 BYE 和 SDES 包可以协助检测,但最好在应用层也做一层校验。
  • 扩展头部解析:X=1 时,扩展头部紧跟在固定头部之后。解析时先读 16 位类型和 16 位长度,再跳过对应字节数。别把扩展数据当成负载。

2.7 小结

RTP 头部虽然只有 12 字节,但每个字段都有它的使命。序列号负责网络传输的可靠性,时间戳负责媒体播放的时序,SSRC/CSRC 负责源的标识。理解这些字段,是掌握 RTP 协议的第一步,也是实现音画同步的基础。