🎬 第三章 RTP与RTCP流媒体协议
📄 一句话概括

RTP 的固定头只有 12 字节,里面塞了九个字段。 真正决定「这是哪一帧、属于哪一路」的只有两个:时间戳与 SSRC。

📝 本节和前面几节的关系

第二章讲了 RTSP 只发命令,画面走 RTP。这一节就开始看 RTP 长什么样。 3.1.2 讲怎么把 H.264 塞进去,3.1.4 讲抓包时怎么看。

一、12 字节的固定头

RTP 头分三段:固定头 12 字节、可选的 CSRC 列表、可选的扩展头。

固定头九个字段,按顺序排: ① V:2 位,版本,固定 2。看到 1 的那种是早期的老协议,早没人用了。 ② P:1 位,填充位。音频加密时常在末尾补齐块大小。 ③ X:1 位,扩展位。为 1 说明头后面还有扩展头(3.5 节)。 ④ CC:4 位,CSRC 个数。混音才用,点播直播恒为 0。 ⑤ M:1 位,标记位。视频里表示「本帧最后一个包」。 ⑥ PT:7 位,载荷类型。96 起是动态分配,具体是什么看 SDP。 ⑦ 序号:16 位。每个包加一,用来查丢包与乱序。 ⑧ 时间戳:32 位。同一帧的多个包用同一个值。 ⑨ SSRC:32 位。同步源标识,一路流一个。

二、「12 字节」只是最小值

很多人第一次写解析代码,按 12 字节硬切,结果遇到音频就错位。 原因是头是变长的,12 只是最常见的情况。

两处会变长: ① 有 CSRC:CC 个 4 字节,接在固定头后面。CC=3 就是多 12 字节。 ② X=1 有扩展:先 4 字节扩展头,再按长度字段接内容。长度单位是 4 字节,所以至少 4 字节。

所以正确的解析顺序是: 读 12 字节 → 看 CC 跳过 CC×4 → 看 X 跳过 4+4N → 剩下的才是载荷。 漏掉任何一步,裁出来的「载荷」就会带上扩展的尾巴,解码器会说「NAL 头非法」。

三、时间戳不是「发送时刻」

这是最容易误解的一点:RTP 的时间戳不是墙上时间,是「这一帧该在什么时候显示」。

证据有三个: ① 同一帧切成 5 个包,这 5 个包的时间戳完全一样。 如果它标发送时刻,五个包必然不同。 ② 步长是固定的。 90000 ÷ 25fps = 3600,相邻两帧恒差 3600,不管网络多快多慢。 ③ 起点是随机的。 每路流的起始时间戳都是随机的,防止明文泄露「服务器开了多久」。

既然起点随机,接收端怎么知道「该等多久」? 靠 RTCP 的 SR 把 RTP 时间戳和 NTP 绝对时间对起来(3.2.2 讲)。 没有 SR,就只能按「收到就播」来猜,音画必然不同步。

四、序号与 SSRC 各管什么

序号 16 位,到 65535 绕回 0。 它的唯一用途是让接收端知道「丢了几个、哪几个乱序了」。

注意序号是「每个包」加一,时间戳是「每帧」一个。所以序号跳 5 而时间戳没变,说明「这一帧的五个包丢了」而不是「丢了五帧」。这两个计数器的粒度不一样,混了就会误判。

SSRC 32 位,随机生成,一路流一个。 它的作用是「在同一对端口上区分多路流」——比如音视频复用一对端口时,靠 SSRC 分开。

SSRC 撞了会怎样? 两路流的数据会被接收端当成一路,画面来回跳。 标准要求检测到冲突就换一个值重来,但很多简陋实现根本不做这个检测。

五、动手 Demo:打一个 12 字节头再解回来

用 struct.pack 按 RFC 3550 拼一个头,再逐字段解回来对。 关键是看清位宽:V/P/X/CC 挤在第一个字节里。

import struct

PT, SEQ, TS, SSRC = 96, 1000, 3600 * 7, 0x1A2B3C4D

# 第一个字节:V=2 P=0 X=0 CC=0 -> 0b10_0_0_0000 = 0x80
# 第二个字节:M=0 PT=96 -> 0b0_1100000 = 0x60
hdr = struct.pack("!BBHII", 0x80, PT, SEQ, TS, SSRC)
print("打包 %d 字节:%s" % (len(hdr), hdr.hex()))

b0, b1, seq, ts, ssrc = struct.unpack("!BBHII", hdr)
print("V=%d P=%d X=%d CC=%d" % (b0 >> 6, (b0 >> 5) & 1,
                                (b0 >> 4) & 1, b0 & 15))
print("M=%d PT=%d" % (b1 >> 7, b1 & 127))
print("序号=%d 时间戳=%d SSRC=0x%08X" % (seq, ts, ssrc))

# 时间戳步长
print("25fps 每帧步长 = %d" % (90000 // 25))
print("32 位时间戳约 %.2f 小时绕回" % (2 ** 32 / 90000 / 3600))

实跑输出:

--------------------------------------------------------------------
RTP 头——12 字节里塞了九个字段
--------------------------------------------------------------------
**① 先按 RFC 3550 打一个 12 字节的头**

  打包结果 12 字节:806003e8000062701a2b3c4d

**② 再解回来,逐个字段对**


  字段       位宽      取值           含义
  ----------------------------------------------------------------------------
  V          2 位      2              版本,固定 2
  P          1 位      0              填充位,末字节含填充长度
  X          1 位      0              扩展位,为 1 则头后面还有扩展
  CC         4 位      0              CSRC 个数,每个占 4 字节
  M          1 位      0              标记位,视频里表示「本帧最后一个包」
  PT         7 位      96             载荷类型,96 起是动态分配
  序号       16 位     1000           每个包加一,用来查丢包与乱序
  时间戳     32 位     25200          同一帧的多个包用同一个值
  SSRC       32 位     0x1A2B3C4D     同步源标识,一路流一个

**③ 变长的地方只有两处**

  **① 有 CSRC 就在基本头后面接 CC×4 字节**,**混音场景才用,普通的点播直播 CC 恒为 0。**
  **② X=1 时再接 4 字节扩展头**(3.5 节讲),**长度以 4 字节为单位,可以嵌套多个扩展。**

  **所以「RTP 头 12 字节」是最小值,不是定值。**
  **抓包看到 RTP 头比 12 大,先看 X 位和 CC 位。**

**④ 时间戳的那个 3600 是怎么来的**

  **90000 ÷ 25 = 3600**——视频时钟频率除以帧率,得到每帧的步长。
  **所以相邻两帧的时间戳差恒为 3600。**
  **而同一帧切成 5 个包,这 5 个包的时间戳完全相同——它们是一帧。**

第一个字节 0x80 = 1000 0000,高两位是版本 2,后面六位全是 0。这就是「V=2 且没有 CSRC、没有扩展」的样子。 第二个字节 0x60 = 0110 0000,最高位是 M,低七位是 PT=96。 同一帧的五个包,序号连加但时间戳完全相同——这是 RTP 最核心的一条约定。

30 秒实验

  1. 把第一个字节改成 0x90(X=1),看解出来的 X 是不是变成 1——这就是 3.5 节扩展头的开关。
  2. 把 CC 改成 3(0x83),解析时要多跳 12 字节,否则载荷起点就错了。
  3. 把 TS 改成 3600 * 7 + 1800,算一下它对应「半帧」是什么意思——半帧只在对齐音频时出现。

常见误区

  1. 「RTP 头就是 12 字节」——那是最小值。有 CSRC 或扩展时会变长。
  2. 「时间戳是发送时间」——它是显示时间,同一帧的多个包时间戳相同,且起点随机。
  3. 「序号和时间戳是一回事」——序号按包加一,时间戳按帧变。粒度不同,混了会误判丢包。
自测三问(点开对答案) 1. RTP 固定头的最小长度是多少? —— **12 字节。**加上 CSRC(CC×4)和扩展(4+4N)会变长。 2. 同一帧的 5 个包,序号和时间戳分别怎么变? —— **序号连加 5 个,时间戳完全相同。**一个是包计数,一个是帧时钟。 3. 90000 是什么? —— **视频的时钟频率。**除以帧率得到每帧的时间戳步长。

相关笔记