结构体的 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 秒实验
- 把
_pack_ = 1去掉,再打印一次偏移——ts会从 4 跳到 4 但ssrc变成 12,整体又多出填充。 - 把
0x65换成0x67,算一下类型是多少——7 就是 SPS,抓包时最先看到的常常是它。 - 把
b的高位置 1(0xE5),看 F 变成几——F=1 表示这一包在传输中出过错,接收端应当丢弃。
常见误区
- 「结构体 sizeof 就是字段相加」——位域与对齐会让它变大,
RTPFixed是 16 不是 12。 - 「结构体可以直接发到网络上」——长度和字节序都不对,必须手工拼或
pack。 - 「NAL 头要两三个字节」——只有 1 字节,后面跟的是载荷不是头。
自测三问(点开对答案)
1. `RTPFixed` 为什么是 16 字节? —— **位域占满 4 字节后,`seq` 又要 2 字节对齐,中间和尾部被补了 4 字节填充。** 2. 手工拼 RTP 头时,时间戳在哪个偏移? —— **偏移 8。**前 4 字节是标志位加序号,接着 4 字节时间戳。 3. NAL 头 0x65 是哪一帧? —— **F=0、NRI=3、类型=5,是 IDR 切片,也就是关键帧。**- ☐ (本节完,下一节 3.4.5 使用JRTPLIB发送H.264码流)