H.264 的 NAL 单元有大有小,小的直接塞进一个 RTP 包,大的必须切。 切法叫 FU-A,每片开头加两字节,代价很小、收益是能过 MTU。
3.1.1 讲了 RTP 头,这一节讲「头后面的载荷怎么装」。 3.4.5 会用同一套分片把码流真发出去。
一、先分清 NAL 与帧
一帧不等于一个 NAL。 一帧画面常常是「SPS + PPS + SEI + 若干个切片」的组合。
实测 720p 编 50 帧,得到 405 个 NAL,其中: ① 类型 1(非关键帧切片)384 个——P 帧的主体。 ② 类型 5(IDR 切片)16 个——关键帧,一个关键帧常切成十几个切片。 ③ 类型 7/8(SPS/PPS)各 2 个——只在开头出现,丢了就整段解不出来。
所以「一帧 → 一个 RTP 包」这个想法完全行不通。 一帧要发几十个包,而关键帧的一个切片就可能超过 MTU。
二、三种载荷格式
RFC 6184 定义了三种装法,按 NAL 大小选:
① 单 NAL 单元包:一个 NAL 塞进一个 RTP 包,最简单。条件是 NAL ≤ MTU − 12。 ② STAP-A 聚合包:把多个小 NAL 拼进一个包,省掉重复的 RTP 头。常用于把 SPS 和 PPS 打包一起发,因为它们必须成对出现。 ③ FU-A 分片包:一个 NAL 切成多片,每片一个 RTP 包。这是大关键帧唯一的选择。
为什么不干脆把 MTU 调大? 因为 MTU 是链路属性,不是你能定的。 1500 是以太网的上限,中间任何一跳都可能更小;一旦超过就会被分片,而 IP 分片丢一片等于整包报废。 所以 RTP 层先切好,比让 IP 层去分片稳得多。
三、FU-A 的两字节头
FU-A 的结构是「RTP 头 + 2 字节 FU 头 + 分片数据」。
第一字节叫 FU indicator:高 3 位照抄原 NAL 的 F 和 NRI,低 5 位固定填 28(就是 FU-A 的类型号)。 第二字节叫 FU header:最高位 S 表示第一片,次高位 E 表示最后一片,低 5 位是原 NAL 的类型。
重组时靠 S 和 E 判断边界:
收到 S=1 就开始攒,收到 E=1 就结束,中间的全往缓冲区里追加。
拼出来的第一个字节要自己造:(indicator & 0xE0) | 原类型。
代价算一下: 720p 的关键帧切片最大 2009 字节,按 1386 切是 2 片,多花 4 字节。 1080p 的关键帧常有 20 KB,切 15 片多花 30 字节——占比不到 0.2%。
四、MTU 与载荷长度怎么定
推荐值不是 1500,而是 1200 左右。 原因有三条:
① 隧道要留余量:VPN、PPPoE 会吃掉几十字节,按 1500 算就会在特定网络下大量丢包。 ② IPv6 头更大:40 字节,比 IPv4 多 20。 ③ WebRTC 的共识值是 1200,实践验证过足够安全。
算一个包能装多少载荷: 1200 − 20(IP)− 8(UDP)− 12(RTP)− 2(FU-A)= 1158 字节。 所以每片最多 1158 字节数据,比按 1500 算小了一大截。
片越小,包越多,头开销越大。 按 1386 切,1080p 关键帧 15 片正好;按 1158 切就变 18 片。 这个取舍要按实际网络定,没有唯一答案。
五、动手 Demo:真编 720p,看哪些 NAL 必须切
编 50 帧 1280×720,切出全部 NAL,统计类型与大小分布。 再按 MTU 算一遍:多少能单包发,多少必须 FU-A 切。
import io, os, tempfile
import numpy as np, av
tmp = os.path.join(tempfile.mkdtemp(), "a.h264")
c = av.open(tmp, "w", format="h264")
st = c.add_stream("libx264", rate=25)
st.width, st.height, st.pix_fmt = 1280, 720, "yuv420p"
st.options = {"crf": "26", "preset": "veryfast", "g": "25"}
raw = []
for i in range(50):
f = av.VideoFrame(1280, 720, "yuv420p")
y = np.zeros((720, 1280), np.uint8)
y[:, :] = np.linspace(16, 210, 1280, dtype=np.uint8)[None, :]
gx = (np.arange(1280)[None, :] // 16 + np.arange(720)[:, None] // 16) % 2
y += (gx * 38).astype(np.uint8)
f.planes[0].update(y.tobytes())
for p in (1, 2):
f.planes[p].update(bytes([128]) * f.planes[p].buffer_size)
f.pts = i
for pk in st.encode(f):
raw.append(bytes(pk))
for pk in st.encode(None):
raw.append(bytes(pk))
c.close()
# 按 00 00 01 切 NAL
ns, buf = [], b""
for d in raw:
buf += d
while True:
i = buf.find(b"\x00\x00\x01")
j = buf.find(b"\x00\x00\x01", i + 3) if i >= 0 else -1
if i < 0 or j < 0:
break
ns.append(buf[i + 3:j])
buf = buf[j:]
print("NAL 单元 %d 个,最大 %d 字节" % (len(ns), max(len(x) for x in ns)))
MTU = 1400
single = sum(1 for x in ns if len(x) + 12 <= MTU)
fua = 0
for x in ns:
if len(x) + 12 > MTU:
fua += (len(x) + MTU - 12 - 2 - 1) // (MTU - 12 - 2)
print("单 NAL %d 包,FU-A %d 包" % (single, fua))
实跑输出:
--------------------------------------------------------------------
RTP 封装 H.264——大 NAL 怎么切成 FU-A
--------------------------------------------------------------------
**① 真编 50 帧 1280×720,切出 NAL 单元**
编码包 50 个(其中关键帧 2 个),NAL 单元 405 个
**② 每个 NAL 的类型(首字节低 5 位)**
类型 名字 个数
---------------------------------------
1 非关键帧切片 384
5 关键帧切片(IDR) 16
6 SEI 1
7 SPS 2
8 PPS 2
**③ 按 RFC 6184 分片:超过 MTU 就切 FU-A**
方式 包数 条件
-----------------------------------------------------------------
单 NAL 389 NAL 小于 1388 字节,直接塞进一个包
FU-A 32 太大,按 1386 字节一片切,每片加 2 字节头
合计 421 载荷总字节 44076
**④ 为什么 FU-A 要加 2 字节**
**FU-A 的头是两字节:第一字节是 FU indicator(F|NRI|28),第二字节是 FU header(S|E|R|类型)。**
**S 表示第一片、E 表示最后一片,靠这两个标志把分片重新拼起来。**
**代价是每个分片多 2 字节,平均每个 NAL 分 3 片就是 6 字节。**
**1080p 的关键帧常有 20 KB,按 1386 切就是 15 片,多花 30 字节——占比很小。**
**所以 FU-A 是「宁可多两字节也不多一个包」的取舍。**
405 个 NAL 里,只有 32 个需要切——绝大多数 P 帧切片都很小,塞得进一个包。 真正大的只有关键帧:16 个 IDR 切片撑起了绝大部分字节数。 所以「帧越大包越多」不准确,应该说「关键帧越大包越多」。P 帧哪怕内容再复杂,切片也小。
30 秒实验
- 把 MTU 从 1400 改成 1200,看 FU-A 包数变多少——片小了,片数就多。
- 把 CRF 从 26 改成 18,看最大 NAL 涨到多少——质量高、关键帧大,切片数跟着涨。
- 把
g从 25 改成 250,看关键帧数量从 2 掉到 1,但每个关键帧更大——这就是「GOP 越长,首帧越重」。
常见误区
- 「一帧一个包」——一帧常是几十个 NAL,要发几十个包。
- 「MTU 调大点就不用切了」——MTU 由链路决定,而且 IP 分片丢一片等于整包报废。
- 「FU-A 头是可以省的」——省了就没法判断边界,接收端会把两片拼错。
自测三问(点开对答案)
1. H.264 over RTP 有哪三种装法? —— **单 NAL、STAP-A 聚合、FU-A 分片。**按 NAL 大小选。 2. FU-A 的 S 和 E 分别表示什么? —— **S 是第一片,E 是最后一片。**重组靠这两个标志判断边界。 3. 为什么推荐 MTU 用 1200 而不是 1500? —— **隧道与 IPv6 会吃掉几十字节。**1200 是验证过的安全值。- ☐ (本节完,下一节 3.1.3 RTP的会话过程)