4. RTP数据包结构

时间戳,是 RTP 协议里最容易被忽视、却又最容易出问题的字段。我见过太多项目,码流能通、画面能出,但一到多人通话就音画不同步,或者播放器快进后声音对不上嘴型。说白了,都是时间戳没玩明白。

这一章,我们就把它彻底讲透。

4.1 RTP 时间戳到底在干什么?

RTP 头里有个 32 位的 timestamp 字段。它不记录“几点几分”,而是记录这个包里的第一个采样点在整个媒体流中的位置。

你可以把它想象成电影胶片上的帧号。播放器拿到包以后,不是看“这个包是 14:30:01 到的”,而是看“这个包的 timestamp = 1600,上一个包是 800,中间差了 800 个采样单位”——于是播放器就知道:这两个包之间应该间隔 800 / 时钟频率 秒。

核心原则:RTP 时间戳是媒体时钟的刻度,不是墙上时钟。它只负责告诉接收端“这个样本该什么时候播放”,不负责“这个样本是什么时候发出的”。

我在调试一个视频会议系统时,发现远端画面每隔几秒就卡一下。抓包一看,发送端把系统时间直接填进了 RTP timestamp,结果网络抖动导致时间戳忽快忽慢,播放器根本没法做 jitter buffer 平滑。嗯,这就是典型的时间戳滥用。

4.2 时钟频率怎么选?采样率说了算

时钟频率,就是 RTP timestamp 每秒钟增加的步数。它必须和媒体采样率挂钩,否则时间戳就失去了物理意义。

媒体类型 典型采样率 RTP 时钟频率 备注
PCMU / PCMA 8000 Hz 8000 Hz 每个采样点对应一个时间戳增量
Opus 48000 Hz 48000 Hz 推荐用 48k,兼容性好
H.264 (90kHz) N/A 90000 Hz 视频固定用 90kHz,与采样率无关
AAC 44100 / 48000 Hz 与采样率相同 通常等于音频采样率

为什么视频固定用 90kHz?这其实是个历史遗留问题。早期的 MPEG 标准里,90kHz 刚好能被常见的帧率(24、25、30、50、60)整除,计算时间戳增量时不会出现小数。我个人习惯在写视频编码器时,直接硬编码 clock_rate = 90000,省心。

避坑指南:我曾经在集成 Opus 编码器时,误把时钟频率设成了 8000 Hz。结果播放器按 48k 采样率去算,音调直接变了,声音像开了慢速播放。记住:时钟频率必须和编码器实际使用的采样率一致。

4.3 时间戳与采样率的关系:一个公式搞定

时间戳增量 = 每帧采样数 × (时钟频率 / 采样率)

举个例子:20ms 的 Opus 帧,采样率 48000 Hz,时钟频率 48000 Hz。

  • 每帧采样数 = 0.02 × 48000 = 960 个采样点
  • 时间戳增量 = 960 × (48000 / 48000) = 960

如果时钟频率和采样率不同呢?比如视频:帧率 30 fps,时钟频率 90000 Hz。

  • 每帧时间戳增量 = 90000 / 30 = 3000

你看,公式其实很简单。但我在代码审查时经常看到有人写死增量值,一旦帧率或采样率变了,时间戳就全乱套。所以我的建议是:永远用公式计算,不要硬编码。

// 伪代码示例:计算 RTP 时间戳增量
uint32_t calculate_timestamp_increment(
    double frame_duration_ms,   // 每帧时长,单位毫秒
    uint32_t clock_rate,        // RTP 时钟频率
    uint32_t sample_rate        // 媒体采样率(音频用)
) {
    // 每帧的采样点数
    uint32_t samples_per_frame = (uint32_t)(frame_duration_ms * sample_rate / 1000.0);
    // 时间戳增量
    return samples_per_frame * (clock_rate / sample_rate);
}

// 使用示例:Opus 20ms 帧,48kHz 采样率
uint32_t inc = calculate_timestamp_increment(20.0, 48000, 48000);
// inc = 960

// 使用示例:H.264 30fps,90kHz 时钟
uint32_t inc_video = calculate_timestamp_increment(33.333, 90000, 90000);
// inc_video ≈ 3000

4.4 RTP 与 NTP 时间同步:音画同步的基石

光有 RTP 时间戳还不够。为什么?因为 RTP 时间戳是相对值,它只告诉播放器“这两个包之间隔了多少采样点”,但不知道“第一个包应该对应墙上时间的哪个点”。

这时候就需要 NTP(Network Time Protocol)来帮忙了。RTCP 的 Sender Report(SR)包里,同时携带了 NTP 时间戳和 RTP 时间戳。接收端拿到 SR 后,就能建立一条映射关系:

NTP 时间 ↔ RTP 时间戳

有了这个映射,播放器就能算出每个 RTP 包对应的绝对播放时间,从而实现音画同步。

同步原理:音频流和视频流各自有独立的 RTP 时间戳。但通过 RTCP SR 包,它们都映射到同一个 NTP 时间轴上。播放器只要保证“NTP 时间相同的音频和视频包同时播放”,音画就自然同步了。

我遇到过最典型的场景是:在一个直播系统中,音频和视频分别由两台不同的服务器编码发送。结果音频的 NTP 时钟和视频的 NTP 时钟差了 2 秒,导致画面比声音快了整整两秒。排查到最后,发现是两台服务器的系统时间没有同步。解决方案很简单:所有编码器统一用同一个 NTP 服务器校时。

4.5 RTP 时间戳的生成与处理:实战细节

生成时间戳时,有几个细节必须注意:

  1. 初始值随机化:RFC 3550 建议 RTP 时间戳的初始值随机生成,不要从 0 开始。这是为了防止某些攻击者通过时间戳推测媒体内容。
  2. 单调递增:同一个 SSRC 的 RTP 时间戳必须严格单调递增。如果编码器丢帧或跳帧,时间戳也要跳过对应的增量,不能回退。
  3. 处理回绕:32 位时间戳,以 90kHz 为例,大约 13 小时回绕一次。接收端必须用模运算处理回绕,不能简单比较大小。
// 时间戳回绕处理示例
bool is_timestamp_newer(uint32_t new_ts, uint32_t old_ts) {
    // 如果差值小于 2^31,说明 new_ts 是更新的
    return (int32_t)(new_ts - old_ts) > 0;
}

// 使用示例
uint32_t last_ts = 0xFFFFFF00;
uint32_t current_ts = 0x00000100;  // 回绕后
if (is_timestamp_newer(current_ts, last_ts)) {
    // 正确处理:current_ts 是更新的
    printf("时间戳正常递增,差值 = %u\n", current_ts - last_ts);
}

警告:不要用 current_ts > last_ts 直接比较!回绕后 0x00000100 小于 0xFFFFFF00,你会误判为“时间戳倒退”,然后丢弃所有后续包。我见过一个开源播放器因为这个 bug,每 13 小时就断流一次。

接收端处理时间戳时,通常要做三件事:

  • 去抖动:用 jitter buffer 吸收网络延迟波动,根据时间戳排序后按序播放。
  • 同步:根据 RTCP SR 中的 NTP-RTP 映射,将不同流的时间戳对齐到同一时间轴。
  • 播放调度:根据时间戳计算每个包的播放时刻,驱动音频/视频渲染。
RTP 时间戳与同步核心流程 编码器生成媒体帧 计算时间戳增量 封装 RTP 包(timestamp) RTCP SR(NTP + RTP) 接收 RTP 包 Jitter Buffer 排序 解析 RTCP SR 建立 NTP↔RTP 映射 音画同步播放 关键点 • 时钟频率 = 采样率 • 增量 = 每帧采样数 • 初始值随机化 同步核心 • SR 包携带 NTP+RTP • 多流映射到同一 NTP • 处理 32 位回绕

这张图把整个流程串起来了。你从发送端看起:编码器生成帧 → 计算增量 → 封装 RTP → 定期发 RTCP SR。接收端拿到后:排序 → 解析 SR → 建立映射 → 同步播放。每一步都离不开时间戳。

最后说一句:时间戳的精度直接影响用户体验。我见过一些实现为了省 CPU,把时间戳精度降到毫秒级,结果音频播放时出现周期性抖动。我的建议是:能用 90kHz 就别用 8kHz,能用 48kHz 就别用 16kHz。精度越高,播放器做插值和同步时越从容。