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 秒实验
- 把长度字段从 2 改成 3,看载荷起点变到哪——前端会多读 4 字节填充,载荷就是错的。
- 把元素头
0x13改成0x10,看解出的长度变成几——低 4 位 0 表示长度 1,这就是「长度减一」的直接验证。 - 把 profile 改成
0x1000,再看解析结果——这就是 3.5.2 的多扩展,读法完全不同。
常见误区
- 「长度字段单位是字节」——是 4 字节。写 2 表示 8 字节。
- 「低 4 位就是长度」——是长度减一。0 表示 1 字节。
- 「扩展只多 4 字节,可以忽略」——每秒三千包时,4 字节就是 96 kbps。
自测三问(点开对答案)
1. 扩展头的 4 字节里有什么? —— **2 字节 profile 加 2 字节长度,长度单位是 4 字节。** 2. `0xBEDE` 是什么意思? —— **单字节扩展元素的约定 profile。**每项一个字节的 id+len。 3. 元素长度为什么写「减一」? —— **为了用 4 位表达 1 到 16。**0 表示 1 字节,15 表示 16 字节。- ☐ (本节完,下一节 3.5.2 RTP多扩展头)