3、时间戳生成策略:系统时钟 vs 媒体时钟、单调递增时间戳、跨平台时间戳实现

时间戳,说白了就是音画同步的「锚」。锚不稳,船就晃。我在做第一个 RTSP 推流项目时,就踩过这个坑——画面和声音各走各的,观众看着像在看译制片,嘴型永远对不上。

这一节,我们聊聊时间戳怎么生成才靠谱。不是简单调个 API 就完事,里面门道不少。

3.1 系统时钟 vs 媒体时钟

先搞清楚两个概念:系统时钟和媒体时钟。很多人混着用,结果出问题了。

时钟类型 来源 特点 适用场景
系统时钟 操作系统 wall-clock 可被 NTP 调整、可能回跳 日志记录、绝对时间标记
媒体时钟 采集设备驱动或编码器 单调递增、与帧率绑定 音画同步、RTP 时间戳

系统时钟,就是你的电脑时间。它有个致命问题——会被 NTP 同步调整。你想想看,推流推到一半,系统时间突然往回跳了 200 毫秒,RTP 时间戳也跟着回退,接收端直接懵了。

媒体时钟则不同。它通常来自硬件设备驱动,比如摄像头每 33ms 产生一帧,这个间隔是物理世界决定的,不受 NTP 影响。我建议:RTP 时间戳必须用媒体时钟,系统时钟只用来做日志和调试。

核心原则:RTP 时间戳 = 媒体时钟采样时刻,不是系统时间。

3.2 单调递增时间戳

什么叫单调递增?就是时间戳只能变大,不能变小。听起来简单,但实现起来容易翻车。

我曾经在一个 Android 项目里直接用 System.currentTimeMillis() 做时间戳。结果用户把手机时间调慢了 1 分钟,时间戳直接倒流,接收端解码器崩溃了三次。后来我改成 System.nanoTime(),问题才解决。

各平台单调时钟 API 如下:

平台 推荐 API 单位 说明
Linux clock_gettime(CLOCK_MONOTONIC) 纳秒 系统启动后单调递增
macOS/iOS mach_absolute_time() 纳秒 需转换到纳秒
Windows QueryPerformanceCounter() 计数 需配合频率转换
Android System.nanoTime() 纳秒 基于 CLOCK_MONOTONIC

注意:单调时钟在设备休眠时可能暂停。如果你需要跨休眠周期的时间戳,得自己处理补偿逻辑。

3.3 跨平台时间戳实现

跨平台推流,最头疼的就是时间戳统一。我在做一个跨 iOS/Android/Windows 的推流 SDK 时,踩了不少坑。下面给出一套我验证过的方案。

3.3.1 统一时间基

RTP 时间戳的基准是 90kHz。为什么是 90kHz?因为 90000 能被常见的视频帧率(24、25、30、50、60)整除。音频则用采样率,比如 44100 或 48000。

代码实现思路:

// 伪代码:统一时间戳生成
class MediaTimestampGenerator {
    int64_t startTime;  // 单调时钟起始点
    int64_t lastTimestamp;  // 上一个时间戳

    // 视频时间戳(90kHz 基)
    uint32_t generateVideoTimestamp(int64_t monotonicNs) {
        int64_t elapsed = monotonicNs - startTime;
        uint32_t ts = (uint32_t)(elapsed * 90 / 1000000);  // ns → 90kHz
        if (ts <= lastTimestamp) {
            ts = lastTimestamp + 1;  // 强制单调递增
        }
        lastTimestamp = ts;
        return ts;
    }

    // 音频时间戳(采样率基)
    uint32_t generateAudioTimestamp(int sampleCount, int sampleRate) {
        static uint32_t audioTs = 0;
        audioTs += (uint32_t)((double)sampleCount / sampleRate * 90000);
        return audioTs;
    }
}

我的习惯:视频和音频的时间戳基统一用 90kHz,这样在 RTCP SR 包中计算 NTP 对应关系时更方便。

3.3.2 音画时间戳对齐

光有单调递增还不够,音画时间戳必须对齐到同一个时间轴。我常用的方法是:

  1. 推流开始时,记录单调时钟的起始点 startTime
  2. 视频帧和音频包都基于 当前单调时间 - startTime 计算时间戳
  3. 视频用 90kHz 基,音频用采样率基,但都映射到同一个物理时间轴

举个例子:

// 对齐示例
int64_t now = getMonotonicNs();
int64_t elapsedUs = (now - startTime) / 1000;  // 微秒

// 视频帧时间戳(90kHz)
uint32_t videoTs = (uint32_t)(elapsedUs * 90 / 1000);

// 音频帧时间戳(48kHz 采样率)
// 假设音频帧有 1024 个采样
uint32_t audioTs = (uint32_t)(elapsedUs * 48 / 1000);

这样,视频的 90kHz 时间戳和音频的 48kHz 时间戳都指向同一个物理时刻。接收端解码时,就能准确对齐。

3.4 时间戳生成流程图

下面这张图,是我做项目时画的,帮你理清整体流程:

时间戳生成与音画同步流程 视频采集(摄像头) 音频采集(麦克风) 单调时钟(CLOCK_MONOTONIC) 视频时间戳(90kHz 基) 音频时间戳(采样率基) RTP 打包(视频/音频分别写入时间戳字段) RTCP SR 包(NTP + RTP 时间戳映射) 接收端解码 & 音画同步渲染 所有时间戳基于同一单调时钟

3.5 避坑指南

最后,分享几个我踩过的坑:

  • 别用 gettimeofday():它可能被 NTP 调整,导致时间戳回跳。我吃过这个亏。
  • 注意 32 位溢出:RTP 时间戳是 32 位无符号整数,90kHz 基下大约 13 小时溢出一次。长时间推流需要处理回绕。
  • 音频时间戳别用帧计数:直接用采样数累加,不要依赖系统时间。否则采样率漂移会让你崩溃。
  • 跨平台测试:Windows 的 QueryPerformanceCounter 在多核 CPU 上可能乱跳,记得绑定核心或做校准。

一个小技巧:调试时,把时间戳和系统时间一起打日志。如果发现时间戳差值超过 1ms,说明有问题,赶紧查。