RTP固定头部:流媒体的身份证

做流媒体开发这么多年,我见过太多音画不同步的惨案。有一次线上直播,用户投诉声音比画面快了整整两秒——查到最后,就是RTP头部里的时间戳字段被人为篡改了。嗯,从那天起我就明白,搞懂RTP固定头部,是做好音画同步的第一道门槛。

RTP头部其实不复杂,一共12字节固定部分。你想想看,这12个字节里塞了版本、填充、扩展、CSRC计数、标记、负载类型、序列号、时间戳、SSRC、CSRC——整整10个字段。每个字段都有它的脾气,我们一个一个来拆。

核心观点:RTP固定头部是音画同步的基石。序列号保证包序,时间戳保证播放节奏,SSRC保证流身份。这三个字段,缺一个都会出大问题。

2.1 头部结构总览

先看一张整体结构图,我习惯把RTP头部想象成一个快递面单——版本号是快递公司,序列号是运单号,时间戳是发货时间,SSRC是寄件人ID。这样记起来就轻松多了。

RTP固定头部结构(12字节) V 2位 P 1位 X 1位 CC 4位 M 1位 PT 7位 序列号 16位 时间戳 32位 SSRC 32位 CSRC(0-15个,每个32位) 由CC字段决定数量 固定头部 = 12字节(不含CSRC) CSRC数量 = CC值,每个CSRC占4字节 0 4 8 16 32 64 96

这张图我建议你保存下来。每次调试RTP流的时候,对照着看,哪个字段在哪个位置,心里就有数了。

2.2 逐字段详解

版本(V, 2位)

目前RTP只有两个版本:版本2是主流,版本1已经没人用了。我建议你直接写死为2。曾经有个外包团队把版本号写成了0,结果抓包工具直接忽略了这个流——排查了整整一天才发现。

填充(P, 1位)

这个位很有意思。置1表示包尾有填充字节,填充长度由最后一个字节的值决定。什么时候用?加密场景。有些加密算法要求明文长度是块大小的整数倍,这时候就需要填充。不过说实话,我在实际项目中很少见到有人用这个位。

扩展(X, 1位)

置1表示头部后面紧跟着一个扩展头部。扩展头部是干什么的?给RTP加自定义信息用的。比如有些厂商会在扩展里塞时间同步信息。我个人不太建议滥用扩展,因为不是所有接收端都能正确解析。

CSRC计数(CC, 4位)

这个字段告诉你有多少个CSRC(贡献源)跟在固定头部后面。取值范围0-15。在混音场景下特别有用——比如视频会议里5个人同时说话,CC就是5,后面跟着5个CSRC标识符。

标记(M, 1位)

标记位的含义由负载类型决定。对于视频流,通常用M位标记帧边界——置1表示这是一个视频帧的最后一个包。对于音频流,M位可以标记静音期的开始或结束。我记得有一次调试音频卡顿,发现是发送端忘了在静音结束后置M位,导致接收端一直处于静音抑制状态。

负载类型(PT, 7位)

这个字段告诉接收端:包里装的是什么编码格式的数据。比如96表示H.264视频,0表示PCMU音频,8表示PCMA音频。取值范围0-127,其中96-127是动态类型,可以自定义。

PT值 编码格式 典型应用
0 PCMU G.711 μ-law 音频
8 PCMA G.711 A-law 音频
96 H.264 视频(动态类型)
97 OPUS 音频(动态类型)

小技巧:调试时如果发现接收端无法解码,先检查PT值是否匹配。我曾经遇到过发送端用PT=96发H.264,接收端却用PT=96解析成VP8——结果当然是花屏。

序列号(16位)

这是RTP头部里最容易被忽视但又最重要的字段之一。发送端每发一个包,序列号加1。接收端靠它来检测丢包、乱序、重复包。

序列号是循环的——从0到65535,然后回到0。为什么是16位?因为RTP设计时认为65535个包足够覆盖一个会话周期。但在高码率场景下,比如4K视频每秒60帧,每帧可能拆成10个包,序列号几秒钟就循环一次了。

注意:序列号循环时,接收端必须正确处理回绕。不能用简单的「新序列号 > 旧序列号」来判断顺序。正确的做法是用有符号16位整数的差值来判断——差值在-32768到32767之间,正数表示新包,负数表示旧包。

时间戳(32位)

时间戳是音画同步的灵魂。它表示RTP数据包中第一个字节的采样时刻。注意,不是系统时钟,而是采样时钟——对于音频,采样时钟就是采样率(如8000Hz、44100Hz);对于视频,采样时钟通常是90000Hz。

为什么视频用90000Hz?因为90kHz是视频常用帧率(24、25、30、60)的最小公倍数,能保证每个帧的时间戳都是整数。这个设计很巧妙,我每次想到都觉得RTP的设计者确实考虑得很周全。

时间戳也是32位循环的。在90000Hz下,大约13小时循环一次。对于大多数会话来说够用了。

SSRC(32位)

SSRC是同步源标识符,用来唯一标识一个RTP流。同一个会话中,每个SSRC必须唯一。怎么保证唯一?随机生成。32位的随机数,碰撞概率很低,但不是零。

我在项目中遇到过SSRC冲突——两个编码器实例用了相同的随机种子,导致接收端把两个流当成一个流来处理。从那以后,我习惯在SSRC里嵌入进程ID和启动时间戳,确保唯一性。

CSRC(每个32位,0-15个)

CSRC是贡献源列表,由混音器插入。比如在视频会议中,混音器把5个人的音频混合成一个流,CSRC列表就记录这5个人的SSRC。接收端可以根据CSRC列表知道:当前这个混合包里有谁的声音。

2.3 实战:解析RTP头部

光说不练假把式。下面这段代码演示如何解析RTP固定头部。我习惯用C语言写底层解析,因为效率高、控制力强。

// RTP固定头部解析示例
#include <stdint.h>

typedef struct {
    uint8_t version:2;
    uint8_t padding:1;
    uint8_t extension:1;
    uint8_t csrc_count:4;
    uint8_t marker:1;
    uint8_t payload_type:7;
    uint16_t sequence_number;
    uint32_t timestamp;
    uint32_t ssrc;
} rtp_header_t;

void parse_rtp_header(uint8_t *buffer) {
    rtp_header_t *hdr = (rtp_header_t *)buffer;
    
    printf("版本: %d\n", hdr->version);
    printf("负载类型: %d\n", hdr->payload_type);
    printf("序列号: %u\n", ntohs(hdr->sequence_number));
    printf("时间戳: %u\n", ntohl(hdr->timestamp));
    printf("SSRC: 0x%08x\n", ntohl(hdr->ssrc));
    
    // 注意:CSRC在固定头部之后
    if (hdr->csrc_count > 0) {
        uint32_t *csrc_list = (uint32_t *)(buffer + 12);
        for (int i = 0; i < hdr->csrc_count; i++) {
            printf("CSRC[%d]: 0x%08x\n", i, ntohl(csrc_list[i]));
        }
    }
}

调试建议:用Wireshark抓包时,直接看RTP头部解析结果。Wireshark会把每个字段都标出来,对照着看,很快就能上手。我刚开始学RTP时,就是靠抓包+对照RFC 3550,一个包一个包地看,慢慢就熟了。

2.4 常见坑点

  • 字节序问题:序列号、时间戳、SSRC都是网络字节序(大端)。在x86机器上解析必须用ntohs/ntohl转换。我见过有人忘了转换,结果序列号65535变成了65535,但65536却变成了0——排查起来非常痛苦。
  • 时间戳单位混淆:音频用采样率,视频用90000Hz。有人把音频时间戳也按90000Hz算,结果播放速度变成了11倍(44100/90000≈0.49)。
  • SSRC冲突:随机生成不是万能的。建议用哈希(IP+端口+进程ID)来生成SSRC,碰撞概率几乎为零。
  • CSRC数量溢出:CC字段只有4位,最多15个CSRC。混音器如果超过15个参与者,必须丢弃一些CSRC或者分多个流发送。