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

结构体的 sizeof 不等于字段相加——位域和对齐会让它变大。 实测 RTPFixed 字段只有 12 字节,sizeof 却是 16,多出的 4 字节全是填充。

📝 本节和前面几节的关系

3.1.1 讲过 RTP 头有哪九个字段,这一节看它们在内存里到底怎么摆。 3.4.5 发码流时要按这套布局拼包,偏移错一个字节就全废。

一、结构体不是字段相加

先看三个实测数字: 五字段的 RTPHeader 是 12 字节,位域版的 RTPFixed 是 16 字节,NALUHeader 是 1 字节。

RTPFixed 那个 16 就是陷阱。 它里面所有字段加起来只有 12 字节,多出的 4 字节是编译器补的填充。 补的原因有两层: ① 位域被打包进一个 4 字节单元,v/p/x/cc/m/pt 六项共用它。 ② 后面 seq 是 2 字节、ts 是 4 字节,按 4 字节对齐时中间要垫。

所以有一条铁律:跨语言或跨进程传 RTP 头,绝对不能用 struct 直接 memcpy。 要么像前面几个 Demo 那样用 struct.pack 手工按字节拼,要么显式加 _pack_ = 1 强制紧凑。

为什么强调「跨进程」? 因为同一个编译器、同一份头文件,两边算出的布局是一致的,凑巧能跑。 但一旦一端换成 64 位、或者换个编译器,填充规则就可能变,这种 bug 表现为「偶尔字段串位」,极难复现。

二、偏移量才是真正要记的

加了 _pack_ = 1 之后,实测偏移是这样的: seq 在 4、ts 在 8、ssrc 在 12。

这个布局和 RFC 3550 完全一致,也正是手工拼包时的依据: 前 4 字节装标志位与序号,接着 4 字节时间戳,再 4 字节 SSRC。 对照一下前面用 struct.pack("!BBHII", ...) 拼包的那几个 Demo——格式串里的 BBHII 就是「1+1+2+4+4」= 12 字节,一一对应。

还有一个细节:位域字段的 size 属性在 ctypes 里很怪,它会给出一个很大的数,而不是 1 或 2 位。 所以量位域不要看 size,要看 offset—— 六个位域字段的偏移全是 0,说明它们确实共用第一个 4 字节单元。

三、NAL 头一字节塞了四个字段

H.264 的 NAL 头只有 1 字节,却分了四段: 1 位禁止位 F、2 位重要性 NRI、5 位类型。

取一个真实的字节 0x65 来拆: 0x65 = 0110 0101,最高位 0 是 F,接着 11 是 NRI=3,低五位 00101 是类型 5。 类型 5 就是 IDR 切片,也就是关键帧。

三个字段各自的意义: ① F:传输中检测到错误就置 1,接收端看到 1 应当丢掉。 正常码流里它永远是 0。 ② NRI:重要性,取值 0 到 3。关键帧必须给 3。 ③ 类型:1 是非关键帧切片、5 是 IDR、7 是 SPS、8 是 PPS。 抓包时先看这一个字节,就知道手上是画面还是参数。

NRI 的实用价值就在「不均衡保护」上: 网络拥塞时优先丢 NRI 低的包—— NRI=3 丢了会让整段画面花掉,NRI=0 丢了顶多糊一下。 WebRTC 的丢包策略就是这么排优先级的。

四、为什么课程里全在手工拼字节

你可能会问:既然有结构体,为什么前面每个 Demo 都用 struct.pack?

三个原因: ① 布局可控:手工拼永远是大端(网络字节序),而结构体受本机字节序影响。 ② 长度精确:拼出来一定是 12 字节,结构体可能是 16。 网络上多 4 个字节,对端就会把载荷解错。 ③ 便于讲解:每拼一个字节都能指出它对应哪个字段,而结构体赋值看不出字节位置。

真实项目里的分工通常是: 自己的代码用结构体(可读性好),跨网络的那一层用手工拼或 pack(布局确定)。 C 里的通行做法是 #pragma pack(1) 加一个固定宽度的整型,两边都这么写。

五、动手 Demo:用 ctypes 量出真实布局

用 ctypes.Structure 定义三个结构体,量 sizeof 与每个字段的偏移。 这是唯一能「看见」编译器怎么摆字段的办法。

import ctypes

class RTPHeader(ctypes.Structure):
    _fields_ = [("b0", ctypes.c_uint8),
                ("pt", ctypes.c_uint8),
                ("seq", ctypes.c_uint16),
                ("ts", ctypes.c_uint32),
                ("ssrc", ctypes.c_uint32)]

class RTPFixed(ctypes.Structure):
    _fields_ = [("v", ctypes.c_uint32, 2),
                ("p", ctypes.c_uint32, 1),
                ("x", ctypes.c_uint32, 1),
                ("cc", ctypes.c_uint32, 4),
                ("m", ctypes.c_uint32, 1),
                ("pt", ctypes.c_uint32, 7),
                ("seq", ctypes.c_uint16),
                ("ts", ctypes.c_uint32),
                ("ssrc", ctypes.c_uint32)]

print("RTPHeader = %d 字节" % ctypes.sizeof(RTPHeader))
print("RTPFixed  = %d 字节(字段只有 12)" % ctypes.sizeof(RTPFixed))

RTPFixed._pack_ = 1
for f in RTPFixed._fields_:
    print("%-6s 偏移=%d" % (f[0], getattr(RTPFixed, f[0]).offset))

b = 0x65
print("0x65 -> F=%d NRI=%d 类型=%d" % (b >> 7, (b >> 5) & 3, b & 31))

实跑输出:

--------------------------------------------------------------------
结构体的真实布局——sizeof 与字段偏移
--------------------------------------------------------------------
**① 三种写法的 sizeof 差异**

  结构体                   sizeof     说明
  ------------------------------------------------------------------------
  RTPHeader(五字段)      12         字段自然对齐,正好 12 字节
  RTPFixed(位域)         16         位域被塞进 4 字节,但整体扩到 16
  NALUHeader(单字节)     1          就是一个字节

  **这个 16 是个陷阱:RTPFixed 里所有字段加起来只有 12 字节。**
  **但 ctypes 和 C 编译器都会按最大对齐单位补齐到 4 的倍数。**
  **位域占满 4 字节后 `seq` 又要对齐到 2 字节边界,于是多出 4 字节填充。**

  **结论:跨语言/跨进程传 RTP 头时,绝对不能用 struct 直接 memcpy。**
  **要么手工按字节拼(像前面几个 Demo 那样用 `struct.pack`),要么加 `_pack_ = 1` 强制紧凑。**

**② 偏移量实测**

  RTPFixed._pack_ = 1
  字段     偏移     大小
  ----------------------------
  v        0        131072
  p        0        65538
  x        0        65539
  cc       0        262148
  m        0        65544
  pt       0        458761
  seq      4        2
  ts       8        4
  ssrc     12       4

  **`ts` 在偏移 4、`ssrc` 在偏移 8**——这就是 RFC 3550 规定的固定头布局。
  **手工拼包时按这个偏移写,就不会错。**

**③ H.264 的 NAL 头只有一字节,但塞了四个字段**

  取一个真实的 NAL 头 0x65:F=0 NRI=3 类型=5

  **F 是禁止位(传输中出错置 1),NRI 是重要性(越高越不能丢)。**
  **类型里 1 是非关键帧切片、5 是 IDR、7 是 SPS、8 是 PPS。**
  **0x65 解出来是 F=0、NRI=3、类型=5,正是一个 IDR 切片。**

  **所以「丢包严重时先丢 NRI 低的包」是有依据的:**
  **NRI=3 的是关键帧,丢了整段就花;NRI=0 的丢了顶多糊一下。**
  **这条路子叫「不均衡保护」,WebRTC 里用得很多。**

RTPHeader 正好 12 字节——五个字段自然对齐,一个字节都没浪费。 RTPFixed 是 16 字节,多出 4 字节填充——所以网络上绝不能直接发这个结构体的内存。 加 _pack_ = 1 后 seq 落在 4、ts 落在 8、ssrc 落在 12,正好与 RFC 3550 的布局对齐,手工拼包按这个偏移写就不会错。

30 秒实验

  1. 把 _pack_ = 1 去掉,再打印一次偏移——ts 会从 4 跳到 4 但 ssrc 变成 12,整体又多出填充。
  2. 把 0x65 换成 0x67,算一下类型是多少——7 就是 SPS,抓包时最先看到的常常是它。
  3. 把 b 的高位置 1(0xE5),看 F 变成几——F=1 表示这一包在传输中出过错,接收端应当丢弃。

常见误区

  1. 「结构体 sizeof 就是字段相加」——位域与对齐会让它变大,RTPFixed 是 16 不是 12。
  2. 「结构体可以直接发到网络上」——长度和字节序都不对,必须手工拼或 pack。
  3. 「NAL 头要两三个字节」——只有 1 字节,后面跟的是载荷不是头。
自测三问(点开对答案) 1. `RTPFixed` 为什么是 16 字节? —— **位域占满 4 字节后,`seq` 又要 2 字节对齐,中间和尾部被补了 4 字节填充。** 2. 手工拼 RTP 头时,时间戳在哪个偏移? —— **偏移 8。**前 4 字节是标志位加序号,接着 4 字节时间戳。 3. NAL 头 0x65 是哪一帧? —— **F=0、NRI=3、类型=5,是 IDR 切片,也就是关键帧。**

相关笔记