3、RTP协议详解:RTP头部结构、负载类型、序列号与时间戳的作用

聊到WebRTC的流控,RTP协议是绕不开的基础。说白了,所有音视频数据在网络上传输时,都得穿上RTP这件“外衣”。我刚开始接触WebRTC时,总觉得RTP就是个简单的数据封装,后来在排查一个视频卡顿问题时才发现——RTP头部里的每个字段,都可能成为流控的关键线索。

这一节,我们就把RTP协议拆开来看。重点聚焦三个核心要素:头部结构、负载类型(PT)、序列号与时间戳。理解了它们,你就能明白WebRTC是如何在不可靠的网络上,把音视频数据“拼”回来的。

3.1 RTP头部结构:12字节的“身份证”

RTP头部固定为12字节。别小看这12个字节,它包含了接收端重建媒体流所需的全部元信息。我习惯把它分成三块来看:

  • 版本号(V, 2 bits):当前固定为2。如果收到非2的包,直接丢弃。
  • 填充位(P, 1 bit):置1时,包尾有填充字节。加密场景下常见。
  • 扩展位(X, 1 bit):置1时,头部后面紧跟着扩展头部。WebRTC的RTP头部扩展就用这个位。
  • CSRC计数(CC, 4 bits):表示CSRC标识符的数量。混音场景下用,WebRTC点对点通信中通常为0。
  • 标记位(M, 1 bit):应用自定义。视频中常用来标记帧边界,音频中标记讲话开始。
  • 负载类型(PT, 7 bits):标识编码格式。后面细讲。
  • 序列号(Sequence Number, 16 bits):每个RTP包唯一的递增序号。
  • 时间戳(Timestamp, 32 bits):采样时刻,单位由编码器决定。
  • SSRC(32 bits):同步源标识符。同一媒体流的所有包共享同一个SSRC。

核心要点:接收端通过SSRC区分不同的媒体流,通过序列号排序和丢包检测,通过时间戳实现播放同步。这三个字段是流控的“三驾马车”。

嗯,这里要注意:RTP头部扩展(X位)在WebRTC中非常常用。比如RTP头部扩展用于传输时间偏移、绝对时间等。我在做音画同步优化时,就靠扩展头部里的绝对捕获时间来对齐音视频时间线。

3.2 负载类型(PT):编码格式的“暗号”

PT字段占7位,取值范围0-127。它告诉接收端:“这个包里的数据是用什么编码器压缩的”。

PT值分为两类:

  • 静态分配(0-95):由IANA预定义。比如PT=0是PCMU,PT=8是PCMA,PT=111是Opus。
  • 动态分配(96-127):通过SDP协商确定。WebRTC几乎全用动态PT。

举个例子,在SDP中你会看到这样的行:

m=audio 5004 RTP/AVP 111
a=rtpmap:111 opus/48000/2

这表示:动态PT=111对应Opus编码,采样率48kHz,双声道。接收端收到PT=111的包,就知道用Opus解码器来处理。

个人经验:我在项目中遇到过一个问题——发送端和接收端的PT映射不一致。发送端把H264映射到PT=96,接收端却以为PT=96是VP8。结果画面全是花屏。排查了半天,最后发现是SDP协商时PT值冲突了。所以,PT映射必须在SDP中明确协商,不能硬编码。

另外,PT字段还影响流控策略。不同的编码器对丢包的敏感度不同。比如Opus有内置的PLC(丢包隐藏),丢个10%还能听;而H264的B帧丢了,画面可能直接卡住。流控模块需要根据PT值,动态调整丢包重传策略。

3.3 序列号:乱序与丢包的“侦察兵”

序列号是16位的无符号整数,初始值随机(防止攻击者猜测),每发送一个RTP包就加1。接收端用它来做三件事:

  1. 排序:网络可能乱序,接收端按序列号重排。
  2. 丢包检测:如果收到序列号100和102,没收到101,就认为101丢了。
  3. 包重复检测:如果收到两个序列号相同的包,丢弃后者。

你想想看,序列号只有16位,最大值65535。溢出怎么办?标准做法是回绕处理。比如当前序列号65535,下一个包应该是0(不是65536)。接收端需要能识别这种回绕。

避坑指南:我曾经在实现JitterBuffer时,忘了处理序列号回绕。结果在65535到0的边界上,JitterBuffer把新包当成了旧包直接丢弃,导致持续半秒的音频卡顿。修复方法很简单——用序列号算术比较,而不是直接比较数值大小。

在WebRTC的RTP解析代码中,序列号比较用的是这样的逻辑:

// 判断a是否在b之后(考虑回绕)
static bool IsNewerSequenceNumber(uint16_t a, uint16_t b) {
  return static_cast<int16_t>(a - b) > 0;
}

这段代码把差值转成有符号16位整数。如果差值在1到32767之间,说明a比b新;如果差值在-32768到-1之间,说明a比b旧。这样就能正确处理回绕了。

3.4 时间戳:播放节奏的“指挥棒”

时间戳是32位,单位由编码器采样率决定。比如:

  • 音频(Opus, 48kHz):每采样1个单位,1秒 = 48000个时间戳单位。
  • 视频(通常90kHz):1秒 = 90000个时间戳单位。

时间戳的作用有两个:

  1. 播放时序:接收端根据时间戳决定什么时候播放这个包。
  2. 帧边界识别:同一帧的视频包,时间戳相同。

举个例子,一个视频帧被拆成3个RTP包,它们的时间戳都是123456。接收端收到后,知道这3个包属于同一帧,等全部收齐后再解码。

时间戳的初始值也是随机的。接收端不能直接拿时间戳的绝对值来算延迟,而是通过相对差值来推算播放时间。

流控中的关键作用:时间戳是JitterBuffer的核心输入。JitterBuffer通过统计时间戳的到达间隔,估算网络抖动,然后动态调整播放延迟。如果时间戳不准,JitterBuffer要么延迟太大(卡顿),要么延迟太小(抖动导致丢帧)。

3.5 序列号 vs 时间戳:别搞混了

很多初学者会把序列号和时间戳搞混。我画个对比表:

属性 序列号 时间戳
位数 16 bits 32 bits
递增规则 每个包+1 每个采样点递增
回绕周期 65535个包 约13小时(90kHz)
主要用途 排序、丢包检测 播放时序、帧同步
同一帧的包 连续递增 相同

简单来说:序列号管“顺序”,时间戳管“时间”。网络乱序时,序列号帮你把包排好;播放时,时间戳告诉你什么时候放。

3.6 知识体系总览

下面这张图总结了RTP头部中与流控相关的核心字段及其关系:

RTP头部流控相关字段关系图 RTP头部流控相关字段关系图 RTP包 序列号 (16 bits) 每个包+1,检测丢包 时间戳 (32 bits) 采样时刻,控制播放 负载类型 (7 bits) 标识编码格式 流控模块(JitterBuffer + NACK + 码率估计) 说明: 序列号和时间戳共同驱动JitterBuffer的排序和播放逻辑。 负载类型影响重传策略(如Opus可容忍少量丢包,H264需要高可靠性)。 三者缺一不可,任何一个字段出错都会导致流控失效。

3.7 小结

这一节我们深入了RTP头部。序列号和时间戳是流控的“双眼”——序列号看顺序,时间戳看节奏。负载类型则告诉流控模块“该用什么策略”。

我个人习惯在调试流控问题时,先抓包看这三个字段。如果序列号连续但时间戳跳跃异常,说明编码器有问题;如果时间戳正常但序列号有空洞,说明网络丢包了。定位问题会快很多。