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

X 位置 1,RTP 头就从 12 字节变成 16 字节以上。 扩展头只有 4 字节:2 字节 profile 加 2 字节长度,长度单位是 4 字节。

📝 本节和前面几节的关系

3.1.1 提到过 X 位,当时只说「为 1 说明后面有扩展头」。 这一节把扩展头拆开看,3.5.2 再看它的升级版。

一、X 位一置 1,多了 4+N 字节

结构是「固定头 12 + 扩展头 4 + 扩展内容 4N + 载荷」。 那个 N 就是扩展头里的长度字段。

扩展头 4 字节分两半: ① profile:2 字节,说明「这个扩展按哪套规矩读」。 0xBEDE 是最常见的一个。 ② 长度:2 字节,单位是「4 字节」不是「字节」。 所以长度写 2 表示后面还有 8 字节。

为什么长度单位用 4 字节? 因为扩展内容本身要 4 字节对齐,用 4 做单位能让「长度」这个字段只占 16 位就够表达 262140 字节。 如果按字节算,65535 字节就封顶了。

解析代码里最容易错的一行就是这里: ext_len = 4 + ln * 4,把 4 字节的扩展头本身也算进去。 漏了这个 4,后面所有字节都会偏 4 位。

二、0xBEDE 与半字节长度

profile 为 0xBEDE 时,扩展内容是一串「元素」,每项两段: 第一个字节里,高 4 位是 id、低 4 位是长度减一;后面跟数据。

「长度减一」这三个字必须记住: 低 4 位为 0 表示 1 字节,为 3 表示 4 字节,为 15 表示 16 字节。 这样 4 位就能表达 1 到 16,正好覆盖常用场景。 超出部分用 0 补齐到 4 字节边界——所以一个 4 字节数据的元素占 8 字节:1 字节头 + 4 字节数据 + 3 字节填充。

实测构造一个 id=1、长度 4 的元素: 元素头是 0x13(高 4 位 1,低 4 位 3 表示长度 4),后面跟四个数据字节,再补三个 0。 整个包 12+4+8+41 = 65 字节。

三、标准预定义的元素

id 不是随便编的,有几个已经分配了固定含义: ① id=1 音频电平:告诉接收方这一路此刻的音量,用来做「谁在说话」的高亮。 多人会议里的绿框就是靠它。 ② id=2 裁剪区域:画中画要裁掉一部分时用。 ③ id=3 视频方向:手机横竖屏切换时告诉对端怎么转。 ④ id=5 传输层序号:用于重传,配合 RTX 机制。

这些 id 是「有共识的」,但协议并不强制——两端约定好用别的号也能跑。 所以抓包看到不认识的 id,先别怀疑解错了,可能只是私有扩展。

四、扩展的代价:头变大

加上扩展,RTP 头从 12 字节变成 16 字节起,实测这个例子是 20 字节。 看起来只多几个字节,算到带宽上就不小了。

算一笔账: 1080p 每秒大约 3000 个包,每包多 4 字节就是每秒 12 KB,约合 96 kbps。 在 4 Mbps 的码流里占 2.4%,不算大;但在 500 kbps 的窄带上就是 20%,足够决定卡与不卡。

所以判断标准是「扩展带来的信息值不值这几个字节」: 音频电平值(会议里要知道谁在说)→ 值。 一个几乎用不上的私有标记 → 不值。

还有一条实践建议: 做带宽估算时一定要按「实际包长」算,不能按「载荷长度」算。 很多线上事故就是因为估算时漏了头,实际码率比设计值高出百分之十几。

五、动手 Demo:构造一个带单扩展头的包

手工拼一个 X=1 的 RTP 包:固定头 12、扩展头 4、扩展内容 8、载荷 41。 再逐字段解回来,确认每个数字都对得上。

import struct

prof = 0xBEDE
# 元素头:id=1 占高 4 位,长度 4 占低 4 位但要减一,所以写 3
elem = struct.pack("!BB", (1 << 4) | 3, 0xAA) + b"\x01\x02\x03"
body = elem + b"\x00" * ((4 - len(elem) % 4) % 4)
ext = struct.pack("!HH", prof, len(body) // 4) + body

hdr = struct.pack("!BBHII", 0x90, 96, 1000, 3600 * 7, 0x1A2B3C4D)
pkt = hdr + ext + b"\x41" + b"z" * 40
print("整包 %d 字节" % len(pkt))

b0 = pkt[0]
print("X 位 = %d" % ((b0 >> 4) & 1))
p2, ln = struct.unpack("!HH", pkt[12:16])
print("profile = 0x%04X  长度 = %d(即 %d 字节)" % (p2, ln, ln * 4))

e = pkt[16]
eid, elen = e >> 4, (e & 15) + 1
print("元素 id=%d 长度=%d 数据=%s" % (eid, elen, pkt[17:17 + elen].hex()))
print("载荷起点 = %d,载荷 %d 字节" % (16 + ln * 4, len(pkt) - 16 - ln * 4))

实跑输出:

--------------------------------------------------------------------
RTP 单扩展头——X 位置 1 之后多出什么
--------------------------------------------------------------------
**① 构造一个带单扩展头的 RTP 包**

  固定头 12 + 扩展头 4 + 扩展内容 8 + 载荷 41 = 65 字节

**② 逐字段解回来**

  X 位 = 1(为 1 说明后面接了扩展头)
  profile = 0xBEDE(0xBEDE 是「单字节扩展元素」的约定值)
  length = 2,表示后面还有 8 字节扩展内容(单位是 4 字节)

  部分         字节     内容
  --------------------------------------------------------
  固定头       12       V/P/X/CC/M/PT/序号/时间戳/SSRC
  扩展头       4        profile 0xBEDE + 长度 2
  扩展内容     8        每项一个字节 id+len,再跟数据
  载荷         41       RTP 真正的媒体数据

**③ 扩展元素怎么读**

  第一个元素:id=1 长度=4 数据=aa010203

  **id 占高 4 位,长度占低 4 位但表示「长度减一」——所以低 4 位为 3 表示 4 个字节。**
  **这样设计是为了省字节:0~15 的长度刚好塞进半个字节。**

**④ 扩展头是给谁用的**

  **标准定义了几个固定用途:**
  **① 音频电平**(id=1):告诉接收方这一路的音量,用来做「谁在说话」的高亮。
  **② 视频方向**(id=3):手机横竖屏切换时用。
  **③ 传输层序号**(id=5):用于重传。

  **还有一个坑:扩展会让 RTP 头从 12 字节变成 16 字节以上,于是「每包多少载荷」的预算就要重算。**
  **做带宽估算时如果漏算这 4 字节,1080p 每秒三千个包就少算 12 KB。**
  **看着不多,但在窄带上就是卡与不卡的分界。**

整包 65 字节 = 12 + 4 + 8 + 41,四段分别对应固定头、扩展头、扩展内容、载荷。 长度字段是 2,乘 4 得 8 字节——单位是 4 字节,这一点必须记牢。 元素长度解出来是 4(低 4 位是 3,加一),数据是 aa010203,正好 4 字节。

30 秒实验

  1. 把长度字段从 2 改成 3,看载荷起点变到哪——前端会多读 4 字节填充,载荷就是错的。
  2. 把元素头 0x13 改成 0x10,看解出的长度变成几——低 4 位 0 表示长度 1,这就是「长度减一」的直接验证。
  3. 把 profile 改成 0x1000,再看解析结果——这就是 3.5.2 的多扩展,读法完全不同。

常见误区

  1. 「长度字段单位是字节」——是 4 字节。写 2 表示 8 字节。
  2. 「低 4 位就是长度」——是长度减一。0 表示 1 字节。
  3. 「扩展只多 4 字节,可以忽略」——每秒三千包时,4 字节就是 96 kbps。
自测三问(点开对答案) 1. 扩展头的 4 字节里有什么? —— **2 字节 profile 加 2 字节长度,长度单位是 4 字节。** 2. `0xBEDE` 是什么意思? —— **单字节扩展元素的约定 profile。**每项一个字节的 id+len。 3. 元素长度为什么写「减一」? —— **为了用 4 位表达 1 到 16。**0 表示 1 字节,15 表示 16 字节。

相关笔记