4. RTP数据包结构

时间戳是RTP协议里最容易被忽视、却又最容易出问题的字段。我见过太多项目,音画不同步的根因最后都指向时间戳处理不当。说白了,时间戳就是媒体数据的“时间标签”,告诉接收端“这个包应该在什么时候播放”。

4.1 时间戳的核心作用

RTP时间戳不是用来计时的,它标记的是媒体采样时刻。接收端靠它来:

  • 重建播放时序:把乱序到达的包按正确顺序播放
  • 实现音画同步:音频和视频流各自的时间戳,配合RTCP的SR包,算出相对播放时间
  • 检测丢包和抖动:时间戳不连续,说明有包丢了或者网络抖动

关键认知:RTP时间戳是单调递增的,但它不代表绝对时间。绝对时间映射靠RTCP的Sender Report(SR)包里的NTP时间戳来完成。

举个例子。你收到两个视频包,时间戳分别是160和320。你只知道它们间隔160个时间单位,但不知道这对应多少毫秒。要换算成毫秒,得知道时钟频率。

4.2 时钟频率的选择

时钟频率怎么选?我直接说结论:时钟频率必须能精确表示采样间隔。

为什么?你想想看,如果音频采样率是8000Hz,每个采样间隔是125微秒。时钟频率选1000Hz,那每个时间单位是1毫秒,根本没法精确表示125微秒。时间戳误差会累积,最终导致音画不同步。

常见的时钟频率如下:

媒体类型 常用时钟频率 原因
PCM音频(8kHz) 8000 Hz 等于采样率,每个采样对应一个时间戳增量
G.711(8kHz) 8000 Hz 同上,一帧一个采样
AAC音频 48000 Hz 或 44100 Hz 等于采样率,但注意帧包含多个采样
H.264/H.265视频 90000 Hz 历史原因,MPEG标准沿用,能精确表示NTSC/PAL帧率
Opus音频 48000 Hz Opus内部采样率,虽然实际编码可能用48/24/16kHz

我的经验:视频用90000Hz是行业惯例。为什么是90000?因为它是常见帧率(24、25、30、50、60)的最小公倍数。90000/24=3750,90000/30=3000,都是整数。这样每个视频帧的时间戳增量都是整数,不会出现小数误差。

4.3 时间戳与采样率的关系

这里有个容易搞混的点。时间戳的增量不等于采样率,而是等于每个RTP包包含的采样数。

公式很简单:

时间戳增量 = 时钟频率 × 包时长(秒)

或者反过来:

包时长(秒) = 时间戳增量 / 时钟频率

举个例子:

  • PCM音频,8kHz采样率,每包20ms,包含160个采样。时钟频率8000Hz,时间戳增量 = 8000 × 0.02 = 160
  • H.264视频,30fps,每帧33.33ms。时钟频率90000Hz,时间戳增量 = 90000 × 0.03333 = 3000

注意:音频的时间戳增量通常是固定的(因为包时长固定),但视频的时间戳增量可能变化。为什么?因为视频编码是VBR,I帧可能比P帧大,但时间戳增量只跟帧率有关,跟帧大小无关。不过如果用了帧内预测、场景切换导致帧率波动,时间戳增量也会变。

我在项目中遇到过一个问题:有人把音频时间戳增量直接设成了采样率(8000),结果每包时间戳跳了8000。接收端一算,包时长 = 8000/8000 = 1秒。这显然不对,实际包只有20ms。接收端缓冲了1秒的数据才开始播放,延迟直接爆炸。

4.4 时间戳的初始值与回绕处理

时间戳初始值不是0。RTP协议规定,初始时间戳应该是一个随机数。为什么?安全考虑。如果初始值固定为0,攻击者可以预测时间戳,伪造RTP包。

我习惯用随机数生成器产生初始值,但要注意:同一会话中,音频和视频流的初始时间戳必须不同。否则接收端没法区分两个流的时间基准。

接下来是回绕问题。RTP时间戳是32位无符号整数,最大值是4294967295。如果时钟频率是90000Hz,大约13.3小时就会回绕一次。回绕后时间戳从0重新开始。

接收端怎么处理回绕?不能简单比较时间戳大小,因为回绕后新包的时间戳可能比旧包小。

正确的做法是:

  1. 使用无符号比较:判断时间戳差值是否在合理范围内。如果差值很大(比如超过2^31),说明发生了回绕。
  2. 维护一个扩展时间戳:在32位时间戳基础上,加一个高位计数器。每次检测到回绕,高位加1。这样内部用64位时间戳,永远不会回绕。

代码实现大概是这样的:

uint32_t last_ts = 0;
uint32_t current_ts = rtp_packet->timestamp;
uint64_t extended_ts;

// 检测回绕
if (current_ts < last_ts && (last_ts - current_ts) > 0x7FFFFFFF) {
    // 发生了回绕,高位加1
    extended_ts = ((uint64_t)(high_bits + 1) << 32) | current_ts;
    high_bits++;
} else {
    extended_ts = ((uint64_t)high_bits << 32) | current_ts;
}

避坑指南:我曾经在嵌入式设备上遇到一个bug。设备运行了14个小时后,视频突然卡死。排查了半天,发现是时间戳回绕导致接收端认为所有新包都是“过期包”,直接丢弃了。修复方法就是加上高位扩展,用64位时间戳做比较。

还有一个细节:回绕检测不能只看时间戳大小,还要考虑网络抖动。如果网络延迟很大,旧包可能在新包之后到达。这时候不能简单认为“时间戳小的就是旧包”。正确的做法是维护一个滑动窗口,窗口内的时间戳按序排列,超出窗口范围的才判定为回绕或乱序。

4.5 知识体系总览

下面这张图把时间戳机制的核心逻辑串起来了:

RTP时间戳机制核心逻辑 时间戳核心作用 重建播放时序 音画同步 丢包/抖动检测 时钟频率选择 音频:8000/48000Hz 视频:90000Hz 必须精确表示采样间隔 时间戳增量与回绕处理 增量 = 时钟频率 × 包时长 64位扩展时间戳 初始时间戳必须随机化,音频/视频流初始值不同

这张图把时间戳机制拆成了三层:顶层是作用,中间是频率选择,底层是增量计算和回绕处理。每一层都依赖下一层。频率选不对,增量就算不准;增量算不准,回绕检测就会出bug。