3、RTP时间戳的生成:发送端时间戳生成逻辑
时间戳怎么来的?这个问题我问过不少刚入行的同事。很多人觉得,不就是取个系统时间嘛,有什么好讲的?
嗯,还真没那么简单。我见过因为时间戳生成逻辑写错,导致整个音画同步系统崩溃的案例。说白了,时间戳是RTP流的"心跳",它乱了,后面所有同步逻辑都是白搭。
3.1 发送端时间戳生成逻辑
先理清一个基本概念:RTP时间戳不是绝对时间,它是个相对值。它表示的是媒体采样在时间轴上的位置,而不是"现在是几点几分"。
举个例子。你录制一段音频,采样率是48000Hz。第一个采样点的时间戳是0,第48000个采样点的时间戳就是48000。这中间经过了1秒,但时间戳的增量是48000,不是1。
为什么会这样?因为RTP时间戳的单位是采样周期,不是毫秒。这个设计是为了让接收端能精确还原采样时序,不受系统时钟漂移影响。
核心原则:RTP时间戳的增量必须与媒体采样率严格对应。每采集一个采样点,时间戳加1(对于音频);每采集一帧,时间戳加一个帧周期的采样数(对于视频)。
我习惯把时间戳生成逻辑分成三步:
- 确定基准时间戳:通常是随机初始值,避免被攻击者预测
- 计算增量:根据采样率和帧率计算每次应增加的值
- 累加输出:每发送一个RTP包,时间戳累加一次
这里有个坑。视频的RTP时间戳增量不是固定的。为什么?因为视频编码是变码率的,I帧、P帧、B帧的大小不一样,但时间戳增量只跟帧率有关。比如30fps的视频,每帧的时间戳增量是90000/30=3000(90000是视频常用的时钟频率)。不管这帧是I帧还是B帧,增量都是3000。
注意:音频和视频的时钟频率不同。音频常用8000、16000、44100、48000;视频固定用90000。这个差异是音画同步的根源之一,后面章节会详细讲。
3.2 基于系统时钟的实现
最直接的做法:每次发送RTP包时,取当前系统时间,换算成RTP时间戳。
伪代码大概是这样的:
// 基于系统时钟的RTP时间戳生成
uint32_t get_rtp_timestamp() {
struct timeval tv;
gettimeofday(&tv, NULL);
// 将系统时间转换为RTP时间戳
// 假设时钟频率为 clock_rate
uint64_t now_us = tv.tv_sec * 1000000 + tv.tv_usec;
uint32_t timestamp = (uint32_t)(now_us * clock_rate / 1000000);
return timestamp;
}
这段代码看起来没问题,对吧?我当年也是这么写的。直到有一次做直播推流,发现音画不同步越来越严重。排查了一整天,最后发现问题就出在这里。
问题在哪?系统时钟不是单调递增的。NTP校时、用户手动改时间、系统休眠唤醒,都会导致系统时间跳变。一旦时间往回跳,时间戳就乱了。接收端看到时间戳倒退,会认为发生了乱序,直接丢弃后续包。
我的建议:如果非要用系统时钟,至少用 clock_gettime(CLOCK_MONOTONIC, ...)。单调时钟不会回跳,但要注意它在系统休眠时可能暂停。
基于系统时钟的实现还有一个问题:精度。gettimeofday的精度是微秒级,但RTP时间戳的精度取决于时钟频率。对于48kHz音频,每个时间戳单位对应约20.8微秒。微秒级的抖动会被放大,导致时间戳抖动。
我曾经在一个项目中,用系统时钟生成音频时间戳。结果发现,每次发送包的时间戳增量不是固定的48000,而是在47980到48020之间波动。虽然波动很小,但接收端的抖动缓冲区会因此频繁调整,导致音频卡顿。
3.3 基于媒体时钟的实现
更好的做法:用媒体时钟来驱动时间戳生成。媒体时钟是一个独立的计时器,只跟媒体采样相关,不受系统时钟影响。
实现思路是这样的:
- 创建一个高精度计时器,频率等于媒体时钟频率
- 每次媒体时钟"滴答"一次,时间戳加1
- 发送RTP包时,直接读取当前媒体时钟值作为时间戳
伪代码:
// 基于媒体时钟的RTP时间戳生成
class MediaClock {
private:
uint64_t base_time_; // 起始时间(单调时钟)
uint64_t base_ts_; // 起始时间戳
uint32_t clock_rate_; // 时钟频率
public:
uint32_t now() {
// 获取当前单调时间
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
uint64_t now_ns = ts.tv_sec * 1000000000 + ts.tv_nsec;
// 计算时间差,换算为时间戳增量
uint64_t elapsed_ns = now_ns - base_time_;
uint64_t ts_delta = elapsed_ns * clock_rate_ / 1000000000;
return (uint32_t)(base_ts_ + ts_delta);
}
};
这个实现比直接取系统时间好在哪里?
- 单调性有保障:CLOCK_MONOTONIC不会回跳
- 精度可控:纳秒级精度,换算到RTP时间戳的误差可以忽略
- 与采样同步:媒体时钟的"滴答"频率与采样率一致,时间戳自然对齐
但要注意,媒体时钟也有坑。CLOCK_MONOTONIC在系统休眠时不会前进。如果你在做长时间录制,中间系统休眠了,恢复后时间戳会"卡住"一段时间,然后突然跳变。
避坑指南:我曾经在嵌入式设备上遇到这个问题。设备待机后唤醒,媒体时钟比实际时间慢了5秒。结果音频时间戳落后视频5秒,音画不同步持续了十几秒才慢慢追上。解决方案是:在唤醒后重新校准媒体时钟的基准时间。
3.4 两种方案的对比
| 对比维度 | 基于系统时钟 | 基于媒体时钟 |
|---|---|---|
| 实现复杂度 | 低,几行代码搞定 | 中等,需要管理时钟状态 |
| 单调性 | 差,受NTP校时影响 | 好,使用单调时钟 |
| 精度 | 微秒级,可能有抖动 | 纳秒级,抖动小 |
| 休眠恢复 | 时间跳变 | 时间暂停,需校准 |
| 多流同步 | 难,各流独立取时间 | 易,共享同一个媒体时钟 |
| 适用场景 | 简单推流、测试环境 | 生产环境、多流同步场景 |
我个人习惯,只要涉及音画同步,一律用媒体时钟。虽然多写几行代码,但省去了后面排查同步问题的痛苦。你想想看,一个直播系统跑起来,音画不同步的投诉是最多的。与其花时间修bug,不如一开始就把时间戳生成逻辑做扎实。
3.5 知识体系总览
下面这张图总结了RTP时间戳生成的完整逻辑:
从这张图可以看得很清楚:两种方案的最终目标都是生成单调递增的RTP时间戳,但实现路径不同。系统时钟方案简单但脆弱,媒体时钟方案复杂但可靠。
嗯,这里要注意一点:不管你用哪种方案,时间戳的初始值都应该随机化。RFC 3550建议初始时间戳随机选择,防止攻击者预测。我一般用 rand() 生成一个随机偏移量,加到基准时间戳上。
实战技巧:如果你在做多路流同步(比如同时推音频和视频),一定要让它们共享同一个媒体时钟。这样音频时间戳和视频时间戳的基准一致,后续同步会简单很多。我在做WebRTC相关项目时,就是这么干的。
最后总结一下:RTP时间戳生成,说白了就是回答"这个包在媒体流中的位置在哪"。系统时钟方案适合快速原型,媒体时钟方案适合正式产品。如果你问我选哪个——我选媒体时钟,省心。