H.264 NALU 完全指南:从比特到码流容器
一份面向音视频与流媒体工程师的 NALU(网络抽象层单元)工程参考手册:从 H.264 的两层架构出发,系统讲解 NALU 的结构、类型、载荷格式、两种码流格式(Annex B / AVCC)、关键参数集解析、解析流程,以及在 RTMP / RTSP / WebRTC / HLS 等主流协议中的封装方式。
1. NALU 基础概念(VCL + NAL 两层架构)
1.1 H.264 的两层架构
H.264(又称 AVC,Advanced Video Coding)的核心设计思想之一,是把"图像怎么编"和"编码结果怎么打包运输"两件事解耦,形成了两层架构:
+-------------------------+
原始 YUV 帧 | VCL(Video Coding | 压缩后的图像数据
-----------> | Layer,视频编码层) | ----------------->
| - 帧 / Slice / 宏块 |
| - 运动估计 / 变换量化 |
| - 熵编码 (CAVLC/CABAC) |
+-----------+-------------+
|
v
+-------------------------+
| NAL(Network | 面向传输的数据包
| Abstraction Layer, | ----------------->
| 网络抽象层) | (即 NALU)
| - 打包格式 |
| - 类型标识 |
| - 优先级与参考性标识 |
+-------------------------+
-
VCL(Video Coding Layer,视频编码层):负责压缩算法的核心工作,处理帧、Slice、宏块、运动估计、变换量化、熵编码等环节。VCL 的输出是一段纯粹的压缩比特流。
-
NAL(Network Abstraction Layer,网络抽象层):负责把 VCL 的输出封装成适合在网络或存储介质中传输的基本数据单元。NAL 层使得同一段压缩视频可以方便地在 RTP、TS、MP4、FLV 等不同容器/协议中流转。
1.2 NALU = NAL 层的基本数据单元
NALU(NAL Unit,网络抽象层单元) 是 NAL 层的基本数据单位,也是编码器输出、解码器输入的最小逻辑包。
一个 NALU 的结构非常简洁:
+---------------+----------------------------------+
| NALU Header | NALU Payload |
| (1 字节) | (可变长度,实际内容为 EBSP) |
+---------------+----------------------------------+
-
Header(1 字节):标识本 NALU 的类型与参考优先级。详见第 2 章。
-
Payload(可变长度):装载实际的压缩数据或参数集数据。在存储/传输时呈现为 EBSP(见第 4 章),解析后还原为 RBSP 再交给熵解码器。
⚠️ 注意:上面所示的结构是 NALU 的本体。在实际码流中,NALU 的前方还会附加"分隔符"——起始码(Annex B)或长度前缀(AVCC),这部分属于码流格式层,不是 NALU 本身。第 5 章会详细讨论。
1.3 VCL NALU vs Non-VCL NALU
按照载荷性质,NALU 分为两类:
| 类别 | NALU type 范围 | 作用 | 典型代表 |
|---|---|---|---|
| VCL NALU | type = 1 ~ 5 | 携带真正的图像压缩数据(即 Slice 数据) | 普通 P/B 帧 Slice (type 1)、IDR 帧 Slice (type 5) |
| Non-VCL NALU | type = 6、7、8、9、10、11、12 … | 携带解码所需的参数或辅助信息,不含图像像素 | SPS (type 7)、PPS (type 8)、SEI (type 6)、AUD (type 9) |
工程提醒:在代码里判断一个 NALU 是否为 VCL,只需取 header & 0x1F,若结果在 1~5 范围内即为 VCL NALU。更严谨的判据是参考 ITU-T H.264 规范,type 0 保留未使用,type ≥ 6 均为 non-VCL。
2. NALU Header 结构与字段含义
2.1 位域图
NALU Header 只有 1 字节(8 bit),内部划分为三段:
bit: 7 6 5 4 3 2 1 0
+---+-------+-------------------+
| F | NRI | TYPE |
+---+-------+-------------------+
| | |
| | +-- nal_unit_type (5 bit, 值 0~31)
| +--------------- nal_ref_idc (2 bit, 值 0~3)
+--------------------- forbidden_zero_bit (1 bit, 必须为 0)
三段字段的完整名称:
| 字段 | 缩写 | 位数 | 含义 |
|---|---|---|---|
| forbidden_zero_bit | F | 1 | 规范要求必须为 0。若为 1,通常表示传输比特错误,该 NALU 应丢弃 |
| nal_ref_idc | NRI | 2 | 指示本 NALU 是否为参考图像的一部分,值越大重要性越高 |
| nal_unit_type | TYPE | 5 | 标识 NALU 的内容类型,取值 0 ~ 31 |
2.2 forbidden_zero_bit(F)
-
取值:固定为 0。
-
若接收到 F=1 的 NALU,通常意味着传输链路发生比特错误或数据被加密/篡改。
-
工程实践:解析时可先检查该位,若为 1 直接丢弃该 NALU 并记录错误。
2.3 nal_ref_idc(NRI,2 bits,值 0 ~ 3)
表示 NALU 的参考重要性:
| nal_ref_idc | 含义 | 典型 NALU |
|---|---|---|
00 (0) | 非参考,丢弃不影响参考帧完整性 | 非参考 B 帧 Slice、SEI、AUD、Filler |
01 (1) | 参考(较低优先级) | 普通 P 帧 Slice 的低优先级变体 |
10 (2) | 参考 | 普通 P 帧 Slice |
11 (3) | 重要参考,丢失会导致后续解码严重错误 | SPS、PPS、IDR 帧 Slice |
工程提醒:弱网环境下做主动丢包时,只能丢弃 nal_ref_idc == 0 的 NALU;SPS/PPS/IDR 的 nal_ref_idc=3,严禁丢弃。详见第 9 章。
2.4 nal_unit_type(TYPE,5 bits,值 0 ~ 31)
标识 NALU 的内容类型。完整类型表见第 3 章。这里先给出几个最常见的:
| type | 名称 | 用途 |
|---|---|---|
| 1 | Coded slice of a non-IDR picture | 普通 P/B 帧 Slice(最频繁出现) |
| 5 | Coded slice of an IDR picture | IDR 帧 Slice,GOP 的起点 |
| 6 | SEI(Supplemental Enhancement Information) | 补充增强信息(时间码、HDR 元数据等) |
| 7 | SPS(Sequence Parameter Set) | 序列参数集,整段视频的全局解码参数 |
| 8 | PPS(Picture Parameter Set) | 图像参数集,单张图像的解码参数 |
| 9 | AUD(Access Unit Delimiter) | 访问单元分隔符,标记一帧的起点 |
2.5 常用字节取值速查表
把 F/NRI/TYPE 组合成 1 字节后,你会在码流中反复看到以下这些"魔法值",直接记住它们可以大幅提高调试效率:
| Header 字节 | 二进制 | F | NRI | type | 含义 |
|---|---|---|---|---|---|
0x67 | 0110 0111 | 0 | 11 (3) | 00111 (7) | SPS(序列参数集) |
0x68 | 0110 1000 | 0 | 11 (3) | 01000 (8) | PPS(图像参数集) |
0x65 | 0110 0101 | 0 | 11 (3) | 00101 (5) | IDR 帧 Slice(关键帧) |
0x61 | 0110 0001 | 0 | 10 (2) | 00001 (1) | 普通 P 帧 Slice(参考,NRI=2) |
0x41 | 0100 0001 | 0 | 01 (1) | 00001 (1) | 普通 P 帧 Slice(参考等级较低,NRI=1) |
0x01 | 0000 0001 | 0 | 00 (0) | 00001 (1) | 非参考 B 帧 Slice(NRI=0,可丢) |
0x09 | 0000 1001 | 0 | 00 (0) | 01001 (9) | AUD(访问单元分隔符) |
0x06 | 0000 0110 | 0 | 00 (0) | 00110 (6) | SEI(补充增强信息) |
2.6 位运算解析示例
在代码中从一个字节拆出 F/NRI/TYPE 非常直接:
/**
* 从 1 字节的 NALU Header 中拆出 forbidden_zero_bit / nal_ref_idc / nal_unit_type。
* 返回值依次为 [forbiddenZeroBit, nalRefIdc, nalUnitType]。
* 若 forbiddenZeroBit != 0,说明传输错误,该 NALU 应丢弃。
*/
public static int[] parseNaluHeader(byte header) {
int h = header & 0xFF; // Java byte 为有符号,先转成无符号
int forbiddenZeroBit = (h >> 7) & 0x01; // 最高 1 位
int nalRefIdc = (h >> 5) & 0x03; // 中间 2 位
int nalUnitType = h & 0x1F; // 低 5 位
return new int[]{ forbiddenZeroBit, nalRefIdc, nalUnitType };
}
// 使用示例:
// int[] parts = parseNaluHeader(headerByte);
// if (parts[0] != 0) { /* 传输错误,丢弃 */ }
🔑 调试小技巧:在 Wireshark 或 hex dump 里看到
67 68 65 61 ...这样的字节序列,基本可以立刻判断是 SPS → PPS → IDR → P 帧的典型输出顺序(起始码00 00 00 01通常在它们之前)。
3. NALU Type 完整类型表(0~31)
3.1 完整类型表
nal_unit_type 字段占 5 位,因此理论取值范围为 0 ~ 31。H.264 规范定义了以下常用类型:
| type | 名称 | VCL? | 说明 |
|---|---|---|---|
| 0 | — | — | 保留 / 禁止使用 |
| 1 | Coded slice of a non-IDR picture | 是 | 最常见类型:普通 P 帧 / B 帧 Slice |
| 2 | Coded slice data partition A | 是 | 数据分区 A(运动信息/头部,较少使用) |
| 3 | Coded slice data partition B | 是 | 数据分区 B(帧内/残差重要部分,较少使用) |
| 4 | Coded slice data partition C | 是 | 数据分区 C(残差低频部分,较少使用) |
| 5 | Coded slice of an IDR picture | 是 | IDR Slice:即时解码刷新帧,GOP 的起点 |
| 6 | SEI(Supplemental Enhancement Information) | 否 | 补充增强信息:时间码、用户数据、HDR 元数据等,解码可选 |
| 7 | SPS(Sequence Parameter Set) | 否 | 序列参数集:整段视频的全局解码参数(最关键) |
| 8 | PPS(Picture Parameter Set) | 否 | 图像参数集:单张图像的解码参数(次关键) |
| 9 | AUD(Access Unit Delimiter) | 否 | 访问单元分隔符:标记一帧图像的起点,可选 |
| 10 | End of sequence | 否 | 序列结束符 |
| 11 | End of stream | 否 | 整个码流结束符 |
| 12 | Filler data | 否 | 填充字节,用于码率平滑或字节对齐 |
| 13 ~ 23 | 扩展 / 保留 | — | H.264 扩展或 SVC(可伸缩视频编码)等使用 |
| 24 | STAP-A(Single-time aggregation packet A) | 否(RTP) | RTP 聚合包:同一时间戳的多个 NALU 打包 |
| 25 | STAP-B | 否(RTP) | RTP 聚合包变体 |
| 26 | MTAP16(Multi-time aggregation packet 16) | 否(RTP) | RTP 多时间聚合包(16 位时间戳偏移) |
| 27 | MTAP24 | 否(RTP) | RTP 多时间聚合包(24 位时间戳偏移) |
| 28 | FU-A(Fragmentation unit A) | 否(RTP) | RTP 分片单元 A:大 NALU 跨越多个 RTP 包 |
| 29 | FU-B | 否(RTP) | RTP 分片单元 B |
| 30 ~ 31 | — | — | 保留未使用 |
🔑 关键点:
type 24 ~ 31 属于 RTP Payload Format(RFC 3984 / RFC 6184),只出现在 > RTP 网络传输中,不会出现在裸 H.264 码流或 MP4/FLV 容器里。解析器遇到这些 type 时,应按照 RTP 规则先还原为普通 NALU。
type 2 ~ 4 的数据分区模式在实际编码器中很少启用,大部分编码器使用 type 1 来承载普通 Slice。
3.2 VCL / Non-VCL 判定规则
-
VCL NALU:type ∈ { 1, 2, 3, 4, 5 } → 即
1 ≤ type ≤ 5 -
Non-VCL NALU:type ≥ 6,以及 type = 0
代码判断:
/**
* 判断一个 NALU 是否为 VCL(携带图像数据)。
* VCL 的 nal_unit_type ∈ { 1, 2, 3, 4, 5 }。
*/
public static boolean isVclNalu(byte header) {
int type = (header & 0xFF) & 0x1F;
return (type >= 1 && type <= 5);
}
3.3 一个典型 GOP 的 NALU 输出顺序
编码器输出一个 GOP(图像组,Group of Pictures)时,NALU 的典型顺序为:
[AUD] → SPS (0x67) → PPS (0x68) → [SEI (0x06)] → IDR Slice (0x65)
→ P Slice (0x61) → P Slice (0x61) → [B Slice (0x01)] → P Slice ...
... → 下一个 GOP 的 SPS → PPS → IDR
对应的十六进制字节流片段(Annex B 格式):
00 00 00 01 67 64 00 1f ac 3b ... // SPS
00 00 00 01 68 ee 3c ... // PPS
00 00 00 01 65 b4 1f ff ff d9 ... // IDR Slice
00 00 00 01 61 e0 43 5f 90 ... // P Slice
00 00 00 01 61 e2 13 7d ... // P Slice
说明:
-
SPS / PPS 出现在 IDR 之前,是解码器初始化必需的参数。
-
AUD 和 SEI 可选,部分容器/播放器可能要求它们存在以正确识别帧边界。
-
IDR Slice 是 GOP 的第一帧,解码器可以从这里开始独立解码。
-
随后是若干 P 帧 / B 帧 Slice,直到下一个 IDR 开始。
4. SODB / RBSP / EBSP 三层关系与字节填充
这是 NALU 中最容易踩坑、也是工程师最需要理解透彻的概念。简单说:VCL 层产生的原始比特数据,经过两次"包装"才能变成最终写入文件或网络的字节序列。
4.1 三层概念总览
| 名称 | 全称 | 含义 | 出现位置 |
|---|---|---|---|
| SODB | String Of Data Bits | VCL 层产生的原始比特串,可能不是 8 的整数倍,长度以 bit 为单位 | 概念层,不会直接出现在文件里 |
| RBSP | Raw Byte Sequence Payload | SODB 末尾追加 1 + 若干 0 补齐到字节边界,是 NALU 载荷的标准形式 | SPS / PPS / Slice 的解析输入 |
| EBSP | Encapsulated Byte Sequence Payload | 在 RBSP 中插入"防竞争字节" 0x03,避免载荷中出现与起始码相同的字节序列 | Annex B / AVCC 码流里实际存储的字节序列 |
三层的转换关系可以用下图概括:
熵编码器输出
|
v
SODB [原始比特串] → 追加 rbsp_trailing_bits() 对齐到字节
|
v
RBSP [已对齐字节] → 扫描并插入防竞争字节 0x03
|
v
EBSP [最终存储字节] → 写入 Annex B / AVCC 码流
4.2 SODB → RBSP 转换:rbsp_trailing_bits
SODB 是比特级的原始数据,长度可能不是 8 的整数倍。为了让它变成整齐的字节序列,H.264 规定在 SODB 末尾追加:
-
一个
1比特(作为"结束标志") -
若干个
0比特,直到总长度为 8 的整数倍
举例:
SODB = 1011 01 (长度 6 bits)
追加1 = 1011 011 (长度 7 bits)
追加0 = 1011 0110 (长度 8 bits = 1 字节 ✅)
RBSP = 0xB6
如果 SODB 恰好是 8 的整数倍,则整个追加一个 1000 0000(即 0x80)作为结束标志。
这一步通常由编码器自动完成,写解析器时一般不需要手动处理,但需要知道它的存在——因为你解析出来的 RBSP 末尾会有这个 pattern。
4.3 RBSP → EBSP 转换:Emulation Prevention(防竞争字节)
为什么需要防竞争字节?
Annex B 格式用 0x00 0x00 0x01 或 0x00 0x00 0x00 0x01 作为 NALU 的起始码。如果 RBSP(即载荷数据)中恰好也出现 0x00 0x00 0x01 这样的字节序列,解析器就会误把它当作新 NALU 的起点,导致解析失败。
为了避免这种情况,H.264 规定:在写入文件/网络之前,对 RBSP 进行如下扫描处理:
遇到
0x00 0x00后,如果接下来的第三个字节≤ 0x03,则在0x00 0x00之后插入一个 ****0x03。
换句话说,以下四种子序列需要被"转义":
| 原始 RBSP 中出现 | 写入 EBSP 时变为 |
|---|---|
00 00 00 | 00 00 03 00 |
00 00 01 | 00 00 03 01 |
00 00 02 | 00 00 03 02 |
00 00 03 | 00 00 03 03 |
其余所有其他字节组合都不做处理。
一个完整的转换示例
原始 RBSP: aa bb 00 00 01 cc dd 00 00 03 ee ff
^ ^
插入 0x03 插入 0x03
转换后 EBSP: aa bb 00 00 03 01 cc dd 00 00 03 03 ee ff
4.4 EBSP → RBSP 反转(写解析器时用到)
解析方向相反:凡是遇到 0x00 0x00 0x03,且其后紧跟 ≤ 0x03 的字节,就丢弃中间的 ****0x03。
代码示例(Java — for 循环版本):
/**
* 去除 EBSP 中的防竞争字节 0x03,还原为 RBSP。
*
* @param ebsp 从码流中提取的 NALU 载荷字节(不含 Header)
* @return 去除填充后的 RBSP 字节
*/
public static byte[] ebspToRbsp(byte[] ebsp) {
if (ebsp == null || ebsp.length == 0) return new byte[0];
java.io.ByteArrayOutputStream rbsp =
new java.io.ByteArrayOutputStream(ebsp.length);
for (int i = 0; i < ebsp.length; i++) {
if (i >= 2
&& (ebsp[i] & 0xFF) == 0x03
&& (ebsp[i - 1] & 0xFF) == 0x00
&& (ebsp[i - 2] & 0xFF) == 0x00) {
continue; // 丢弃防竞争字节 0x03
}
rbsp.write(ebsp[i] & 0xFF);
}
return rbsp.toByteArray();
}
/**
* 反向:把 RBSP 中可能出现的 00 00 01/00 00 00 等模式,
* 通过插入 0x03 改造为 EBSP(供 Annex B 传输使用)。
*/
public static byte[] rbspToEbsp(byte[] rbsp) {
if (rbsp == null || rbsp.length == 0) return new byte[0];
java.io.ByteArrayOutputStream ebsp =
new java.io.ByteArrayOutputStream(rbsp.length + 8);
for (int i = 0; i < rbsp.length; i++) {
if (i >= 2
&& (rbsp[i] & 0xFF) <= 0x03
&& (rbsp[i - 1] & 0xFF) == 0x00
&& (rbsp[i - 2] & 0xFF) == 0x00) {
ebsp.write(0x03); // 插入防竞争字节
}
ebsp.write(rbsp[i] & 0xFF);
}
return ebsp.toByteArray();
}
while 循环版本:
/**
* EBSP → RBSP 的 while 循环风格实现(便于与上面的 for 循环版本对照理解)。
*/
public static byte[] ebspToRbspWhile(byte[] ebsp) {
if (ebsp == null || ebsp.length == 0) return new byte[0];
java.io.ByteArrayOutputStream rbsp =
new java.io.ByteArrayOutputStream(ebsp.length);
int i = 0;
int n = ebsp.length;
while (i < n) {
if (i >= 2
&& (ebsp[i] & 0xFF) == 0x03
&& (ebsp[i - 1] & 0xFF) == 0x00
&& (ebsp[i - 2] & 0xFF) == 0x00) {
i += 1; continue; // 遇到 00 00 03 → 跳过 0x03
}
rbsp.write(ebsp[i] & 0xFF);
i += 1;
}
return rbsp.toByteArray();
}
4.5 工程提醒:最常见的错误
❗️ 这是 NALU 解析中最容易出错的一步。很多新手在写解析器时会忘记去填充,直接把 EBSP 当作 RBSP 解析,导致:
-
SPS / PPS 中的参数解析到"错位"的值(分辨率、Profile 等都不对);
-
Slice 熵编码的码字错位,整帧无法解码;
-
偶尔能解析成功、偶尔失败(取决于载荷中是否恰好出现
00 00 03)。
调试技巧:在解析前先 dump 一段 16 进制,肉眼搜索
00 00 03,看看去掉03后的长度是否变化。如果两个 NALU 解析到的数据长度不一致,通常就是去填充没做好。
另外还要注意:去填充必须按 NALU 为单位做,不能对整段码流"一刀切"。因为起始码本身就是 00 00 00 01 或 00 00 01,如果对整段去填充反而会破坏起始码结构。
5. Annex B vs AVCC 两种码流格式
NALU 本身是一个"1 字节 Header + EBSP 载荷"的结构,但它如何被"打包"到文件或网络中,有两种主流标准格式。理解这两种格式的区别,是处理 H.264 码流最基础的技能之一。
5.1 对比总表
| 维度 | Annex B(起始码格式) | AVCC / AVC1(长度前缀格式) |
|---|---|---|
| 分隔符 | 0x00 00 01(3 字节)或 0x00 00 00 01(4 字节) | N 字节大端整数长度字段(N 通常为 4) |
| SPS / PPS 位置 | 嵌在码流中,每个 IDR 前都可能重复出现 | 放在文件头 avcC box / FLV Sequence Header,不重复出现在数据帧中 |
| 典型容器 / 协议 | .h264 裸文件、MPEG-TS、HLS-TS、RTP(封装逻辑不同) | MP4 / MOV / M4V、FLV、fMP4(HLS CMAF / DASH) |
| 随机访问特性 | 任意位置查找起始码即可切分 NALU,适合实时流 | 必须先解析头信息才能知道长度,适合随机访问文件 |
| 载荷是否含防竞争字节 | ✅ 是(EBSP) | ✅ 是(EBSP) |
补充说明:RTP 协议里 NALU “既不用 Annex B 起始码,也不用 AVCC 长度前缀”。它依赖 RTP Header 的长度信息和特殊类型(FU-A / STAP)来处理边界和分片。详见第 8 章。
5.2 Annex B 字节流示例
Annex B 的结构非常直观:每个 NALU 前面放一个起始码。
┌──────────────────────┬──────────────────────┬──────────────────────┐
│ 00 00 00 01 67 ... │ 00 00 00 01 68 ... │ 00 00 00 01 65 ... │
│ 起始码 + SPS │ 起始码 + PPS │ 起始码 + IDR Slice │
└──────────────────────┴──────────────────────┴──────────────────────┘
实际的十六进制片段:
00 00 00 01 67 64 00 1f ac 3b 01 60 7e 40 00 00 03 00 40 00 00 07 a3 c5 8b 90 // SPS
00 00 00 01 68 eb ec b2 28 // PPS
00 00 00 01 65 b4 1f ff ff d9 00 01 02 ...(IDR Slice 数据,往往几十 KB)
00 00 00 01 61 e0 43 5f 90 0c 01 23 ... // P Slice
说明:
-
**4 字节起始码 **
00 00 00 01常用于 SPS / PPS / IDR 或文件开头,以增强同步鲁棒性; -
**3 字节起始码 **
00 00 01常见于普通 P / B 帧 Slice; -
解析器逻辑:从左到右扫描,每当遇到
00 00 01就算一个新 NALU 的起点。
5.3 AVCC 字节流示例
AVCC 的每个 NALU 前面是一个大端整数长度字段(通常 4 字节)。SPS / PPS 放在文件的"配置区域",而不是每个数据帧都重复。
┌────────────────────┬────────────────────┬──────────────────────┐
│ [00 00 00 1B] 67.. │ [00 00 00 05] 68.. │ [00 05 12 34] 65.. │
│ 长度=27 + SPS │ 长度=5 + PPS │ 长度=332340 + IDR │
└────────────────────┴────────────────────┴──────────────────────┘
实际的十六进制片段(FLV Video Tag Body 或 MP4 mdat 中可能看到):
00 00 00 1b 67 64 00 1f ac 3b 01 60 7e 40 00 00 03 00 40 00 00 07 a3 c5 8b 90
00 00 00 05 68 eb ec b2 28
00 05 12 34 65 b4 1f ff ff d9 00 01 02 ...(IDR Slice 数据)
🔑 小工具:4 字节长度字段 =
N,表示从下一个字节开始连续N字节是一个完整的 NALU(包含 1 字节 Header + EBSP 载荷)。只要循环读取长度字段 -> N 字节数据 -> 长度字段 -> N 字节数据 ...就能正确切分。
5.4 avcC box 结构详解(AVCC 的"元数据")
在 MP4 / FLV 中,SPS / PPS 并不会直接写在数据帧里,而是放在一个叫 avcC 的 box 中。它的结构由 ISO/IEC 14496-15 规定:
字段 大小 (字节) 说明
──────────────────────────────────────────────────────────
configurationVersion 1 固定为 0x01
AVCProfileIndication 1 profile_idc(与 SPS[1] 相同)
profile_compatibility 1 profile 兼容 flags(与 SPS[2] 相同)
AVCLevelIndication 1 level_idc(与 SPS[3] 相同)
reserved + lengthSizeMinusOne 1 高 6 位 = 111111;低 2 位 = NALU 长度前缀字节数 - 1
(常见为 0xFF - 0xFC + 3 = 0xFF,即 4 字节长度)
reserved + numOfSequenceParameterSets 1 高 3 位 = 111;低 5 位 = SPS 个数(一般为 1)
for (每个 SPS):
sequenceParameterSetLength 2 SPS NALU 字节数(大端)
sequenceParameterSetNALUnit 可变 SPS 内容(1 字节 Header + EBSP,通常以 67 开头)
numOfPictureParameterSets 1 PPS 个数(一般为 1)
for (每个 PPS):
pictureParameterSetLength 2 PPS NALU 字节数(大端)
pictureParameterSetNALUnit 可变 PPS 内容(通常以 68 开头)
一个真实的 avcC 十六进制示例(High Profile, Level 3.1,4 字节长度前缀,1 个 SPS + 1 个 PPS):
01 configurationVersion = 1
64 AVCProfileIndication = 100 (High Profile)
00 profile_compatibility = 0x00
1f AVCLevelIndication = 31 (Level 3.1)
ff reserved(111111) + lengthSizeMinusOne(11) = 4 字节长度前缀
e1 reserved(111) + numOfSequenceParameterSets(00001) = 1 个 SPS
00 1b SPS 长度 = 27 字节
67 64 00 1f ac 3b 01 60 7e 40 00 00 03 00 40 00 00 07 a3 c5 8b 90 ... (SPS 共 27 字节)
01 numOfPictureParameterSets = 1 个 PPS
00 05 PPS 长度 = 5 字节
68 eb ec b2 28 ... (PPS 共 5 字节)
常见坑点:解析 avcC 时,lengthSizeMinusOne 字段的值 = (byte & 0x03),实际长度前缀字节数 = lengthSizeMinusOne + 1。常见值为 3(表示 4 字节长度前缀)。
6. 关键 NALU 内容解析(SPS / PPS / IDR)
SPS / PPS 是解码的"入口钥匙",没有它们解码器无法工作。本节给出 SPS / PPS 中最关键参数的含义,以及如何从中计算出视频分辨率等信息。
本节内容涉及 H.264 熵编码的 Exp-Golomb(指数哥伦布码)。简单理解:许多参数在码流中不是按固定字节存储的,而是以比特为单位存储。写解析器时需要一个能逐 bit 读取并解析
ue(v)/se(v)/f(n)/u(n)的辅助类。
6.1 Exp-Golomb 熵编码快速说明
| 符号 | 含义 | 解析方法 |
|---|---|---|
f(n) | 固定 n bit 读取 | 直接读 n bit |
u(n) | 无符号整数,n bit | 直接读 n bit 解释为无符号整数 |
ue(v) | 无符号 Exp-Golomb(v = variable length) | 先数前导 0 的个数 leadingZeroBits,再读 leadingZeroBits + 1 bit 得到 codeNum,值 = codeNum - 1 |
se(v) | 有符号 Exp-Golomb | 先用 ue(v) 得到 codeNum,值 = (-1)^(codeNum+1) * ceil(codeNum/2) |
一个简化的 ue(v) Java 解析示例:
/**
* 按比特读取 RBSP 的辅助类:持有字节缓冲与比特位置,
* 提供读取无符号 Exp-Golomb 码字(ue(v))、有符号(se(v))等能力。
*/
public static class BitReader {
private final byte[] bytes;
private int bitPos; // 当前读取位置(以比特为单位)
public BitReader(byte[] rbsp) { this.bytes = rbsp; this.bitPos = 0; }
/** 读取 1 bit(0 或 1),数据结束时返回 -1。 */
public int readBit() {
int byteIdx = bitPos >>> 3;
if (byteIdx >= bytes.length) return -1;
int bitInByte = 7 - (bitPos & 0x7);
int bit = ((bytes[byteIdx] & 0xFF) >>> bitInByte) & 0x01;
bitPos++;
return bit;
}
/** 读取一个无符号 Exp-Golomb 码字 ue(v)。 */
public long readUv() {
int leadingZeroBits = 0;
while (bitPos < bytes.length * 8) {
int b = readBit();
if (b == -1) break;
if (b == 0) { leadingZeroBits++; } else { break; }
}
long codeNum = 0;
for (int i = 0; i < leadingZeroBits; i++) {
int b = readBit();
if (b == -1) b = 0;
codeNum = (codeNum << 1) | b;
}
codeNum += (1L << leadingZeroBits) - 1;
return codeNum;
}
/** 读取一个有符号 Exp-Golomb 码字 se(v)。 */
public long readSe() {
long v = readUv();
return (v % 2 == 0) ? -(v >> 1) : (v + 1) >> 1;
}
}
// 使用示例:
// BitReader br = new BitReader(rbsp);
// long seqParameterSetId = br.readUv(); // 对应 SPS 中第一个 ue(v) 字段
本节重点是"看懂 SPS/PPS 中有什么",而不是手写完整解析器。实际工程中推荐直接使用 FFmpeg / h264bitstream 等成熟库。
6.2 SPS(Sequence Parameter Set)关键字段
SPS 的 NALU Header 是 0x67(type=7, NRI=3)。其 RBSP 的语法结构大致如下:
| 字段 | 类型 | 含义 |
|---|---|---|
profile_idc | u(8) | 编码档次:66=Baseline,77=Main,88=Extended,100=High,110=High 10,122=High 4:2:2,244=High 4:4:4 |
constraint_set0_flag ~ constraint_set5_flag | f(1) × 6 | 档次兼容约束标志 |
reserved_zero_2bits | f(2) | 保留位,应为 0 |
level_idc | u(8) | 编码级别,如 30=Level 3.0,31=Level 3.1,40=Level 4.0,41=Level 4.1。限制码率 / 分辨率 / 宏块处理速率 |
seq_parameter_set_id | ue(v) | 该 SPS 的 ID,供 PPS 引用 |
chroma_format_idc | ue(v) | 仅当 profile_idc > 100 时存在。1=4:2:0,2=4:2:2,3=4:4:4 |
bit_depth_luma_minus8 | ue(v) | 亮度位深度 - 8(常见为 0,表示 8 bit) |
bit_depth_chroma_minus8 | ue(v) | 色度位深度 - 8(常见为 0,表示 8 bit) |
qpprime_y_zero_transform_bypass_flag | f(1) | 变换旁路标志 |
seq_scaling_matrix_present_flag | f(1) | 是否存在缩放矩阵 |
log2_max_frame_num_minus4 | ue(v) | frame_num 的位数 - 4 |
pic_order_cnt_type | ue(v) | POC(显示顺序计数)类型:0 / 1 / 2 |
max_num_ref_frames | ue(v) | 参考帧缓冲区最大帧数,影响内存与 DPB 大小 |
gaps_in_frame_num_value_allowed_flag | f(1) | frame_num 是否允许不连续 |
pic_width_in_mbs_minus1 | ue(v) | 宽度(以 16×16 宏块为单位) - 1 |
pic_height_in_map_units_minus1 | ue(v) | 高度(以宏块行/场为单位) - 1 |
frame_mbs_only_flag | f(1) | 是否只有帧宏块(=1 表示纯帧模式,无场编码) |
mb_adaptive_frame_field_flag | f(1) | 宏块级帧/场自适应 |
direct_8x8_inference_flag | f(1) | 直接 8×8 运动向量推断标志 |
frame_cropping_flag | f(1) | 是否存在画面裁剪参数(最终分辨率由它调整) |
frame_crop_left_offset | ue(v) | 左裁剪像素数 |
frame_crop_right_offset | ue(v) | 右裁剪像素数 |
frame_crop_top_offset | ue(v) | 上裁剪像素数 |
frame_crop_bottom_offset | ue(v) | 下裁剪像素数 |
vui_parameters_present_flag | f(1) | 是否存在 VUI(视频可用性信息) |
| (vui_parameters) | 可变 | 含 aspect_ratio、timing_info(帧率线索)、video_signal_type(色彩空间 / HDR)等 |
6.3 从 SPS 计算视频分辨率
/**
* 根据 SPS 中解析出的关键字段计算视频最终分辨率。
* 三段逻辑:宽度 → 高度(帧/场) → 裁剪。
*
* @param chromaFormatIdc 1=4:2:0, 2=4:2:2, 3=4:4:4(profile<=100 默认 1)
* @return 二元数组 [finalWidth, finalHeight]
*/
public static int[] computeResolution(
int picWidthInMbsMinus1,
int picHeightInMapUnitsMinus1,
int frameMbsOnlyFlag,
int frameCroppingFlag,
int chromaFormatIdc,
int cropLeft, int cropRight, int cropTop, int cropBottom) {
// (1) 宽度:以宏块为单位,每个宏块 16×16
int widthInMbs = picWidthInMbsMinus1 + 1;
int width = widthInMbs * 16;
// (2) 高度:帧模式直接乘 16;场模式再乘 2
int heightInMapUnits = picHeightInMapUnitsMinus1 + 1;
int height = (frameMbsOnlyFlag == 1)
? heightInMapUnits * 16
: heightInMapUnits * 16 * 2;
// (3) 裁剪:仅当 frame_cropping_flag == 1 时生效
if (frameCroppingFlag == 1) {
int cropUnitX = (chromaFormatIdc == 3) ? 1 : 2;
int cropUnitY = (chromaFormatIdc == 3) ? 1
: 2 * (frameMbsOnlyFlag == 1 ? 1 : 2);
width -= cropUnitX * (cropLeft + cropRight);
height -= cropUnitY * (cropTop + cropBottom);
}
return new int[]{ width, height };
}
工程提醒:许多视频的实际可视分辨率会比宏块对齐的分辨率小几像素,例如 1920×1088 的编码分辨率裁剪为 1920×1080。如果不读取裁剪参数,播放器会显示黑边。
6.4 PPS(Picture Parameter Set)关键字段
PPS 的 NALU Header 是 0x68(type=8, NRI=3)。
| 字段 | 类型 | 含义 |
|---|---|---|
pic_parameter_set_id | ue(v) | 该 PPS 的 ID |
seq_parameter_set_id | ue(v) | 关联哪个 SPS(通过 SPS 的 id 来引用) |
entropy_coding_mode_flag | f(1) | 熵编码模式:0=CAVLC,1=CABAC |
pic_order_present_flag | f(1) | Slice Header 中是否存在 pic_order_cnt_lsb |
num_slice_groups_minus1 | ue(v) | Slice 组个数 - 1(=0 表示全部 Slice 在同一组) |
num_ref_idx_l0_default_active_minus1 | ue(v) | 前向参考帧列表默认长度 - 1 |
num_ref_idx_l1_default_active_minus1 | ue(v) | 后向参考帧列表默认长度 - 1 |
weighted_pred_flag | f(1) | 加权预测开关 |
weighted_bipred_idc | u(2) | B 帧加权预测模式 |
pic_init_qp_minus26 | se(v) | 初始 QP = 值 + 26(常见 0,即 QP=26) |
pic_init_qs_minus26 | se(v) | 初始 QS(SP/SI 帧用) |
chroma_qp_index_offset | se(v) | 色度 QP 相对亮度 QP 的偏移 |
deblocking_filter_control_present_flag | f(1) | 去块滤波器控制是否在 Slice Header 中显式携带 |
constrained_intra_pred_flag | f(1) | 帧内预测约束标志 |
redundant_pic_cnt_present_flag | f(1) | 冗余图像计数标志 |
6.5 IDR Slice 的特性
IDR(Instantaneous Decoding Refresh,即时解码刷新)的 NALU type = 5。它的特殊之处在于:
-
清空 DPB(解码图像缓冲区):解码器遇到 IDR 帧时,所有此前解码的参考帧都被标记为"不可用于后续参考"。这意味着你可以从 IDR 帧开始独立解码,不需要依赖任何之前的帧。
-
GOP 的起点:编码器通常在每个 GOP 开头插入一个 IDR。直播场景中每隔几秒(常见 1~5 秒)插入一个 IDR。
-
随机接入点 / 码流切换点:HLS 切片边界、RTMP 推流断开重连、ABR 码率切换都必须以 IDR 为边界。
与普通 I 帧(type=1 且 slice_type=I)的区别:
-
普通 I 帧:只是"帧内编码"(不参考其他帧),但后续的 P/B 帧仍然可能参考它之前已解码的其他帧。它不是一个严格的随机接入点。
-
IDR 帧:不仅自己是帧内编码,还强制"之后的所有帧都不能参考 IDR 之前的任何帧"。
简单理解:IDR 是 I 帧的一种,但不是所有 I 帧都是 IDR。
7. NALU 解析流程(Annex B 五步 + AVCC 流程)
本节把前面章节的知识串起来,给出完整的解析流程和示例代码,帮助你把"零散的知识点"变成"可运行的逻辑"。
7.1 Annex B 裸流解析:五步走
原始字节流 (Annex B)
│
│ Step 1: 扫描并定位起始码(00 00 01 或 00 00 00 01)
▼
起始码之后的第一个字节 = NALU Header
│
│ Step 2: 解析 Header → forbidden / nal_ref_idc / type
▼
继续扫描,直到下一个起始码出现
│
│ Step 3: 两段起始码之间的区域 = 本 NALU 的 EBSP 载荷
▼
EBSP(可能含防竞争字节 0x00 00 03)
│
│ Step 4: 去除防竞争字节 → 得到 RBSP
▼
RBSP(纯净的比特级数据)
│
│ Step 5: 按 type 分发处理
│ - type=7 → 解析 SPS(记录全局参数)
│ - type=8 → 解析 PPS
│ - type=5 → 标记随机接入点(清空 DPB)
│ - type=1/2/3/4 → 送入熵解码器
▼
解码结果
Java 代码(Annex B 解析器):
import java.util.ArrayList;
import java.util.List;
/** 解析结果的简单数据结构:1 字节 Header + RBSP 载荷 + type。 */
public static class Nalu {
public final byte header;
public final byte[] rbsp;
public final int type;
public Nalu(byte header, byte[] rbsp) {
this.header = header;
this.rbsp = rbsp;
this.type = (header & 0xFF) & 0x1F;
}
}
/**
* 从 Annex B 字节流(以 00 00 01 / 00 00 00 01 为分隔符)中切分出所有 NALU。
*
* @return 每个 NALU 的 (header, rbsp, type) 列表
*/
public static List<Nalu> parseAnnexB(byte[] data) {
List<Nalu> nalus = new ArrayList<>();
int n = data.length;
int i = 0;
while (i < n) {
// Step 1: 搜索下一个 00 00 01 起始码核心模式
int pos = findStartCode(data, i);
if (pos == -1) break;
// 若前面还有一个 0x00,说明是 4 字节起始码(00 00 00 01)
int startCodeLen = (pos > 0 && (data[pos - 1] & 0xFF) == 0x00) ? 4 : 3;
if (startCodeLen == 4) pos -= 1; // 回退到真正的起点
// Step 2: 起始码之后的第一个字节 = NALU Header
int headerPos = pos + startCodeLen;
if (headerPos >= n) break;
byte header = data[headerPos];
// Step 3: 搜索下一个起始码 → 得到本 NALU EBSP 结束位置
int nextStart = findStartCode(data, headerPos + 1);
int ebspEnd = (nextStart == -1) ? n : nextStart;
// Step 4: EBSP → RBSP
byte[] ebsp = subarray(data, headerPos + 1, ebspEnd);
byte[] rbsp = ebspToRbsp(ebsp);
nalus.add(new Nalu(header, rbsp));
i = (nextStart == -1) ? n : nextStart;
}
return nalus;
}
/** 在 data 中从 fromIdx 开始搜索 00 00 01 三字节序列,找不到返回 -1。 */
private static int findStartCode(byte[] data, int fromIdx) {
for (int k = fromIdx; k + 2 < data.length; k++) {
if ((data[k] & 0xFF) == 0x00
&& (data[k + 1] & 0xFF) == 0x00
&& (data[k + 2] & 0xFF) == 0x01) {
return k;
}
}
return -1;
}
/** 取 data 的 [from, to) 子数组。 */
private static byte[] subarray(byte[] data, int from, int to) {
if (from >= to) return new byte[0];
byte[] out = new byte[to - from];
System.arraycopy(data, from, out, 0, out.length);
return out;
}
🔑 实际生产环境提示:上面的
find循环在大文件中效率不高。真实实现(如 FFmpeg 的h264_parser.c)会用单遍扫描,同时维护一个"最近是否出现 00 00"的状态机,避免反复查找。
7.2 AVCC 解析流程
AVCC 的解析分两个阶段:先解析头信息(avcC box)得到全局配置,再按长度切分数据帧。
阶段 1:解析 avcC box
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 解析 ISO/IEC 14496-15 定义的 AVCDecoderConfigurationRecord(即 avcC box)。
*
* 返回 map 包含:
* - "profile" (Integer) AVCProfileIndication
* - "level" (Integer) AVCLevelIndication
* - "lengthSize" (Integer) NALU 长度前缀字节数(常见为 4)
* - "sps" (List<byte[]>) 每个 SPS 的完整 NALU 字节
* - "pps" (List<byte[]>) 每个 PPS 的完整 NALU 字节
*/
public static Map<String, Object> parseAvcc(byte[] avccBox) {
ByteBuffer bb = ByteBuffer.wrap(avccBox).order(ByteOrder.BIG_ENDIAN);
int configurationVersion = bb.get() & 0xFF; // 固定为 1
int profile = bb.get() & 0xFF;
int profileCompat = bb.get() & 0xFF;
int level = bb.get() & 0xFF;
int lengthSizeMinusOne = (bb.get() & 0xFF) & 0x03; // 只看低 2 位
int lengthSize = lengthSizeMinusOne + 1; // 通常 = 4
int numSps = (bb.get() & 0xFF) & 0x1F; // 高 3 位为 111,低 5 位才是数量
List<byte[]> spsList = new ArrayList<>();
for (int i = 0; i < numSps; i++) {
int spsLen = bb.getShort() & 0xFFFF; // 2 字节大端长度
byte[] sps = new byte[spsLen];
bb.get(sps);
spsList.add(sps);
}
int numPps = bb.get() & 0xFF;
List<byte[]> ppsList = new ArrayList<>();
for (int i = 0; i < numPps; i++) {
int ppsLen = bb.getShort() & 0xFFFF;
byte[] pps = new byte[ppsLen];
bb.get(pps);
ppsList.add(pps);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("profile", profile);
result.put("level", level);
result.put("lengthSize", lengthSize);
result.put("sps", spsList);
result.put("pps", ppsList);
return result;
}
阶段 2:解析数据帧中的 NALU
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.ArrayList;
import java.util.List;
/**
* 从 AVCC 数据帧(格式:[N 字节长度][NALU][N 字节长度][NALU]...)中切分 NALU。
*
* @param lengthSize NALU 长度前缀字节数(一般 = 4,从 avcC box 读取)
*/
public static List<Nalu> parseAvccNalus(byte[] data, int lengthSize) {
List<Nalu> nalus = new ArrayList<>();
if (data == null || lengthSize < 1 || lengthSize > 4) return nalus;
ByteBuffer bb = ByteBuffer.wrap(data).order(ByteOrder.BIG_ENDIAN);
while (bb.remaining() >= lengthSize) {
// Step 1: 读 lengthSize 字节大端整数 = 当前 NALU 总长度
int nalLen = readUnsignedNBytes(bb, lengthSize);
if (bb.remaining() < nalLen) break;
// Step 2: 读 1 字节 Header + (nalLen-1) 字节 EBSP
byte header = bb.get();
byte[] ebsp = new byte[nalLen - 1];
bb.get(ebsp);
// Step 3: EBSP → RBSP
byte[] rbsp = ebspToRbsp(ebsp);
nalus.add(new Nalu(header, rbsp));
}
return nalus;
}
/** 以大端序读取 n 字节(1 <= n <= 4)的无符号整数。 */
private static int readUnsignedNBytes(ByteBuffer bb, int n) {
int value = 0;
for (int i = 0; i < n; i++) {
value = (value << 8) | (bb.get() & 0xFF);
}
return value;
}
7.3 Annex B ⇄ AVCC 互转
这是音视频工程中极高频的操作(比如把 ffmpeg 的输出从 Annex B 装进 FLV/RTMP,或者把 MP4 解复用后喂给只认起始码的解码器)。
Annex B → AVCC
import java.io.ByteArrayOutputStream;
import java.util.ArrayList;
import java.util.List;
/**
* 把 Annex B(起始码格式)转换为 AVCC(长度前缀格式)。
* 注意:写入 AVCC 的必须是原始 EBSP(保留防竞争字节),
* 因此这里不调用 parseAnnexB(它会把 EBSP → RBSP 丢失防竞争字节),
* 而是做一次"只切边界"的扫描。
*/
public static byte[] annexBToAvcc(byte[] annexB, int lengthSize) {
List<Integer> nalStarts = new ArrayList<>(); // 每个 NALU Header 的绝对下标
List<Integer> nalEnds = new ArrayList<>(); // 每个 NALU 结束下标(不含)
int n = annexB.length;
int i = 0;
while (i < n) {
int pos = findStartCode(annexB, i);
if (pos == -1) break;
int startCodeLen = (pos > 0 && (annexB[pos - 1] & 0xFF) == 0x00) ? 4 : 3;
if (startCodeLen == 4) pos -= 1;
int headerPos = pos + startCodeLen;
if (headerPos >= n) break;
nalStarts.add(headerPos);
int next = findStartCode(annexB, headerPos + 1);
int end = (next == -1) ? n : next;
nalEnds.add(end);
i = (next == -1) ? n : next;
}
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (int k = 0; k < nalStarts.size(); k++) {
int from = nalStarts.get(k);
int to = nalEnds.get(k);
int nalLen = to - from;
writeBigEndianLength(out, nalLen, lengthSize);
out.write(annexB, from, nalLen);
}
return out.toByteArray();
}
/** 以大端序写入 lengthSize 字节长度值。 */
private static void writeBigEndianLength(
ByteArrayOutputStream out, int length, int lengthSize) {
for (int shift = (lengthSize - 1) * 8; shift >= 0; shift -= 8) {
out.write((length >>> shift) & 0xFF);
}
}
AVCC → Annex B
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
/**
* 把 AVCC(长度前缀格式)还原为 Annex B(起始码格式)。
* 默认使用 4 字节起始码 00 00 00 01;传 false 则使用 3 字节 00 00 01。
*
* 注意:AVCC 中保存的载荷本身就是 EBSP(已含防竞争字节),不需要再调用 rbspToEbsp。
*/
public static byte[] avccToAnnexB(byte[] avcc, int lengthSize, boolean fourByteStartCode) {
ByteArrayOutputStream annexB = new ByteArrayOutputStream();
ByteBuffer bb = ByteBuffer.wrap(avcc).order(ByteOrder.BIG_ENDIAN);
while (bb.remaining() >= lengthSize) {
int nalLen = readUnsignedNBytes(bb, lengthSize);
if (bb.remaining() < nalLen) break;
byte[] nalUnit = new byte[nalLen];
bb.get(nalUnit);
// 写入 "起始码 + NALU"
byte[] startCode = fourByteStartCode
? new byte[]{ 0x00, 0x00, 0x00, 0x01 }
: new byte[]{ 0x00, 0x00, 0x01 };
try {
annexB.write(startCode);
annexB.write(nalUnit);
} catch (java.io.IOException impossible) {
// ByteArrayOutputStream.write 不会抛出 IO 异常
throw new RuntimeException(impossible);
}
}
return annexB.toByteArray();
}
🔑 工程提醒:
FFmpeg 已经内置了这一功能:
-bsf h264_mp4toannexb和-bsf h264_annexbtomp4。实际生产代码要注意处理
length_size = 1/2/3等非 4 字节的罕见情况。AVCC → Annex B 转换后,需要在每个 IDR 前重复注入 SPS/PPS(原始 AVCC 中 SPS/PPS 只出现在 avcC box 头部)。
8. 应用场景:各协议中的 NALU 封装
理解了 NALU 的两层结构和两种码流格式后,本节把它们放到真实的传输协议/容器中,看看 NALU 在每个协议里具体"长什么样"。
8.1 RTMP / FLV
采用格式:AVCC(长度前缀)。
数据结构:
一个 FLV Video Tag 的 Body 结构:
[1 字节] FrameType(4 bit) + CodecID(4 bit)
FrameType: 1=keyframe, 2=inter frame
CodecID: 7=H.264
[1 字节] AVCPacketType
0 = AVC Sequence Header(即 avcC box 配置)
1 = AVC NALU(数据帧)
2 = AVC end of sequence
[3 字节] CompositionTime(仅当 CodecID=7 时存在,PTS - DTS)
[可变] Data
- 若 AVCPacketType = 0:avcC box 的完整内容
- 若 AVCPacketType = 1:一个或多个 NALU(每一个 = length_size 字节长度 + NALU 数据)
关键注意点:
-
Sequence Header(avcC box) 只在第一个视频帧(keyframe)发送一次,后续的 keyframe 和 inter frame 都是 NALU 数据。
-
推流中途观众加入(Late Join)时看不到 Sequence Header,需要周期地重新发送 Sequence Header。实际工程中通常每 1~2 秒或每个关键帧前都附带一次。
-
RTMP 推流时,如果你的编码器输出的是 Annex B,必须先做
Annex B → AVCC转换,再打包进 FLV Tag。
8.2 RTSP / RTP(RFC 3984 / RFC 6184)
采用格式:既非 Annex B 也非 AVCC。RTP 自身就有"长度 / 边界 / 分片"能力,所以 NALU 直接放在 RTP Payload 里,不需要额外的分隔符或长度前缀。
三种封装模式:
| 模式 | 适用场景 | NALU Header 的 type 范围 |
|---|---|---|
| 单 NALU 包 | NALU 大小 < MTU(一般 1200~1400 字节) | 1~23(即原始 NALU type) |
| 聚合包 STAP-A / STAP-B | 多个小 NALU(如 SPS + PPS)打成一个 RTP 包 | 24(STAP-A)/ 25(STAP-B) |
| 分片包 FU-A / FU-B | 大 NALU(如 1080p IDR 几十 KB)跨越多个 RTP 包 | 28(FU-A)/ 29(FU-B) |
单 NALU 包(最简单情况)
RTP Payload 的第一个字节就是 NALU Header:
RTP Header (12+ 字节) | NALU Header (1 字节) | EBSP 载荷 (可变)
F | NRI | TYPE (1~23)
FU-A 分片(最复杂但最常见的大帧场景)
一个大 NALU 被切成多个 RTP 包:
每个分片的 Payload 结构:
┌─────────────────┬─────────────────┬──────────────────────┐
│ FU indicator │ FU header │ NALU 载荷片段 │
│ (1 字节) │ (1 字节) │ (来自原 NALU EBSP) │
│ F|NRI|TYPE=28 │ S|E|R|TYPE │ │
└─────────────────┴─────────────────┴──────────────────────┘
-
FU indicator:高 3 位 = 原 NALU 的 F/NRI;低 5 位 = 28(表示 FU-A)。
-
FU header:
-
S(Start)= 1 表示这是该 NALU 的第一个分片 -
E(End)= 1 表示这是最后一个分片 -
R= 0(保留位) -
TYPE= 原 NALU 的真正 type(如 5 表示 IDR,1 表示 P 帧)
-
接收端还原逻辑:
for 每个 RTP 包:
if type == 28 (FU-A):
if S=1: 创建一个新 NALU,记录 FU indicator 的 NRI + FU header 的 TYPE 得到原 Header
if E=1: 结束当前 NALU 的组装,交给解码器
把载荷片段追加到当前正在组装的 NALU 中
SPS / PPS 通过 RTSP 的 SDP 协商
在 RTSP 交互中,SDP 里会包含:
a=fmtp:97 packetization-mode=1; profile-level-id=64001f;
sprop-parameter-sets=Z2QAH6zZQPa8AQBB, aO48gA==
其中 sprop-parameter-sets 的值是 Base64 编码的 SPS 和 PPS(逗号分隔)。客户端解码前必须先把它们 Base64 解码并喂给解码器作为初始配置。
8.3 WebRTC
WebRTC 的底层走 RTP,所以 NALU 的封装逻辑与 8.2 节相同(RFC 6184)。但有几个工程上的差异:
| 维度 | 普通 RTSP/RTP | WebRTC |
|---|---|---|
| 传输层 | UDP / TCP 可选 | 强制 UDP(或 QUIC)+ SRTP 加密 |
| 重传机制 | 可选 RTCP 重传 | 强制 RTX(RFC 4588)重传 |
| 冗余纠错 | 可选 | 常启用 RED/FEC(RFC 2198 / RFC 5109) |
| SPS/PPS 传输 | SDP sprop-parameter-sets 或周期插入 | SDP sprop-parameter-sets + 关键帧请求时重新协商 |
| 关键帧请求 | RTCP PLI / FIR | 同样使用 RTCP PLI / FIR |
处理 RED 封装的额外步骤:WebRTC 常把 NALU 再包一层 RED(冗余编码)。接收端需要先解 RED 包,再按 FU-A / STAP 规则还原 H.264 NALU。
推荐实践:使用 packetization-mode=1(默认),即启用 STAP 和 FU 分片。
8.4 HLS
HLS 有两种主流切片方式,对应两种 NALU 封装格式:
传统 HLS-TS
-
容器:MPEG-TS 流(
filename.ts) -
NALU 格式:Annex B,NALU 前面带
00 00 01或00 00 00 01起始码 -
SPS/PPS 位置:每个 TS 切片开头的 PAT/PMT 之后的视频流中重复出现
-
关键帧对齐:每个切片必须以 IDR 帧开始,且
#EXT-X-DISCONTINUITY(码率 / 分辨率切换)前后都必须是 IDR 边界
现代 HLS-fMP4 / CMAF(LL-HLS)
-
容器:fMP4(fragmented MP4,
filename.m4s) -
NALU 格式:AVCC,NALU 带长度前缀
-
SPS/PPS 位置:
-
init.mp4(Initialization Segment)的moov/trak/mdia/minf/stbl/stsd/avc1/avcCbox 中 -
每个数据切片
m4s的moof/traf/trun里 sample 数据长度前缀 + NALU
-
-
优点:与 MP4 一致,兼容 DASH,支持更低延迟(LL-HLS)
m3u8 播放列表示例:
#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MAP:URI="init.mp4" # fMP4 初始化片段(含 avcC / SPS/PPS)
#EXTINF:4.000,
seg1.m4s
#EXTINF:4.000,
seg2.m4s
...
🔑 关键提醒:做 HLS 切片/打包时,必须确保切片边界与 IDR 对齐。如果你的编码器输出的 IDR 间隔不均匀(或过小/过大),切片时长就会抖动,甚至超出
#EXT-X-TARGETDURATION,导致播放器卡顿或拒绝播放。
8.5 小结:一图看懂各协议的 NALU 长什么样
Annex B(裸流 / TS): [00 00 00 01][NALU Header + EBSP][00 00 00 01][NALU Header + EBSP]...
AVCC(MP4 / FLV) : [avcC box (SPS/PPS 配置)] + [4字节长度][NALU][4字节长度][NALU]...
RTP(RTSP / WebRTC): [RTP Header][NALU Header + EBSP] (小 NALU, 单包)
[RTP Header][F|NRI|28][S|E|R|Type][EBSP片段] (大 NALU, FU-A 分片)
[RTP Header][F|NRI|24][2字节长度][NALU][2字节长度][NALU] (STAP 聚合)
9. 常见问题与坑点总结
本章以 “现象 → 原因 → 解决办法” 的三段式结构,汇总处理 H.264 NALU 时最常踩到的坑。
9.1 坑点一:Annex B 与 AVCC 混用
现象:
-
MP4 文件直接按二进制切割喂给只认 Annex B 的硬件解码器 → 解码失败 / 黑屏。
-
把编码器输出的 Annex B 原封不动塞进 RTMP/FLV → 播放器花屏、首帧卡顿。
-
把
.h264裸流文件扩展名改成.mp4→ 大多数播放器直接拒绝播放。
原因:播放器/解码器在读取 H.264 时必须知道"用什么规则切分 NALU"。起始码(00 00 01)和长度前缀(4 字节大端整数)是完全不同的分隔机制,混用时会发生"伪命中"或"长度错位",导致切出的 NALU 长度为错值。
解决办法:
-
做显式转换:
-
Annex B → AVCC:
ffmpeg -i input.h264 -c:v copy -bsf:v h264_annexbtomp4 output.mp4 -
AVCC → Annex B:
ffmpeg -i input.mp4 -c:v copy -bsf:v h264_mp4toannexb output.h264
-
-
在自己的代码里实现一套转换函数(参考第 7 章的
annex_b_to_avcc/avcc_to_annex_b)。 -
读取容器时先识别文件格式(读取
avcCbox 还是裸流),再决定使用哪一套切分逻辑。
9.2 坑点二:SPS / PPS 丢失或缺失
现象:
-
RTMP 拉流 / 直播:观众中途加入房间 → 前几秒黑屏/花屏,直到下一个 IDR。
-
转码任务:从中间某个偏移量开始喂数据给 H.264 解码器 → 直接报错"missing sequence/picture parameter set"。
-
HLS 切片:某个切片缺失 SPS/PPS → 播放器在该切片切换时卡顿/马赛克。
原因:SPS/PPS 只在 AVCC 的头部(avcC box)或 Annex B 每个 GOP 的开头出现一次。如果播放器/解码器没有正确捕获,就无法初始化解码参数(profile、level、分辨率、QP 等)。
解决办法:
-
RTMP/FLV 推流:在第一个 Video Tag 发送 Sequence Header(avcC box),之后周期性地(每 1~2 秒或每个 keyframe 前)重复发送 Sequence Header,保证 Late Join 观众能获取。
-
RTSP/RTP:在 SDP 里通过
sprop-parameter-sets带 SPS/PPS,同时在码流里周期插入。 -
HLS:fMP4 方式通过
init.mp4携带;TS 方式每个切片开头都应有 SPS/PPS + IDR。 -
手动处理:如果你的业务是"从码流中间切开喂给解码器",请在切开点前后插入一份缓存的 SPS/PPS。
9.3 坑点三:Emulation Prevention(防竞争字节)处理错误
现象:
-
SPS 解析出的分辨率偶尔正常,偶尔出现完全离谱的超大值。
-
某些 Slice 能正常解码,某些 Slice 直接报错 “slice header 解析失败”。
-
调试时把十六进制打印出来,肉眼能在载荷里看到
00 00 03的字节,但代码完全忽略了它。
原因:
NALU 的载荷中存在一种特殊的"转义字节":0x00 0x00 0x03 后面跟着 <=0x03 的值,其中 0x03 是"为了避免与起始码冲突而人为插入的占位字节",在解析时必须丢弃。如果不丢,它会被当作正常数据读入,导致后续所有 bit-level 的熵编码偏移 8 位,所有字段都错位。
解决办法:
-
把 EBSP → RBSP 的去填充做成独立的辅助函数,在每个 NALU 的载荷被熵解码器读取前调用它(见第 4 章的
ebsp_to_rbsp)。 -
写单元测试:构造一段含
00 00 03 01的测试数据,断言去填充后变为00 00 01。 -
在解析前先检查 NALU 中是否存在
00 00 03模式,若存在但长度不变,说明去填充没生效。
9.4 坑点四:起始码 3 字节 vs 4 字节
现象:
-
解析出的 NALU 第一个字节有时是
0x00而非正常的 Header。 -
偶尔出现 NALU 之间边界漂移 1~2 字节,表现为偶发解码错误。
原因:
H.264 规范同时允许 00 00 01(3 字节)和 00 00 00 01(4 字节)两种起始码。额外的一个 0x00 常出现在 SPS/PPS/IDR 之前,作为字节对齐或增强同步。如果你只硬编码"起始码 = 固定 3 字节",那么 4 字节起始码的前缀 0x00 会被当作上一个 NALU 的末尾数据,导致边界漂移。
解决办法:
-
统一按
00 00 01搜索,把它前面任何连续的0x00字节都视为起始码的一部分丢弃。FFmpeg 的ff_avc_find_startcode就是这样实现的。 -
记录每个起始码的实际长度(3 或 4),并在调试输出中打印。
-
如果你的代码里硬编码了
+=3来跳过起始码,立即改掉它,改为"搜索到起始码位置后,按实际长度跳过"。
9.5 坑点五:nal_ref_idc 丢弃策略不当
现象:
-
在弱网环境下做主动丢包(拥塞控制),丢了几个 “看起来不大的” NALU 之后,解码器陷入长时间花屏,直到下一个 IDR。
-
或者反过来,什么都不敢丢,导致延迟越攒越大。
原因:
-
nal_ref_idc > 0的 NALU(SPS/PPS/IDR/普通参考 P 帧)是参考链上的一环,丢了一个,后面所有依赖它的帧都无法正确解码。 -
只有
nal_ref_idc == 0的 NALU(非参考 B 帧、SEI、AUD、Filler)可以安全丢弃。
解决办法:
- 解析时把每个 NALU 的
nal_ref_idc记录下来:
int nri = (header >> 5) & 0x03;
if (nri == 0) { /* 可丢 */ }
else { /* 不可丢 */ }
-
弱网策略:
-
优先丢弃:AUD(type=9)、Filler(type=12)、SEI(type=6)、非参考 B 帧(type=1, nri=0)。
-
绝对不能丢弃:SPS(type=7)、PPS(type=8)、IDR(type=5)。
-
尽量不丢弃:普通 P 帧(type=1, nri=2/3)。
-
-
一旦不得不丢弃 IDR 或重要 P 帧,应该连同直到下一个 IDR 的全部 NALU 一起丢弃,避免解码器处于不可恢复的部分解码状态;然后在下一个 IDR 恢复。
9.6 坑点六:IDR vs 普通 I 帧混淆
现象:
-
以 “I 帧” 为边界做 HLS 切片,切片出来的 TS 文件独立播放失败,或跳播到该切片时花屏。
-
做码率切换(ABR)时,从低分辨率流切换到高分辨率流出现"闪屏"。
-
错误地把
nal_ref_idc > 0 && type == 1的 Slice 当作随机接入点处理。
原因:
-
普通 I 帧(type=1, slice_type=I):自己是帧内编码,但后续帧仍可以参考 I 帧之前已解码的其他帧。它不是一个严格的随机接入点。
-
IDR 帧(type=5):不仅自己是帧内编码,还强制清空 DPB,保证之后的所有帧都只能参考本帧及之后的帧。只有 IDR 才是真正的随机接入点。
解决办法:
-
判断随机接入点的唯一标准是
(header & 0x1F) == 5(type=IDR)。 -
做 HLS 切片、ABR 码率切换、追帧、广告插入等操作时,必须以 IDR 为边界切分。
-
如果编码器输出的 IDR 间隔与切片目标时长不匹配,调整编码器的
keyint(x264 的--keyint)或scenecut参数,不要在编码器外部强行切分。
9.7 坑点七:RTP FU-A 分片重组错误
现象:
-
接收端解码 IDR 帧正常,但解码较大的 P 帧 / B 帧偶尔失败。
-
大帧(IDR / 高分辨率 P 帧)比小帧更容易失败。
-
Wireshark 里看 RTP 流有大量 type=28 的包,但解析器只处理到 type<=23 的情况。
原因:
-
当 NALU 大小超过 MTU(通常 ~1400 字节)时,RTP 会用 FU-A(type=28)把它切成多个包。
-
接收端如果不处理 FU-A 重组,就会把每个分片当成独立 NALU,丢给解码器 → 解码器拿到"残缺的 NALU",必定失败。
解决办法:
-
在 RTP 解析层增加 FU-A 重组逻辑(见第 8.2 节):
-
遇到
type=28,读取 FU indicator(1 字节)+ FU header(1 字节)。 -
S=1→ 新建一个 NALU,Header =(FU indicator & 0xE0) | (FU header & 0x1F)(保留原 NRI 和原 type)。 -
非 S 也非 E → 把载荷追加到当前 NALU。
-
E=1→ 结束,把完整 NALU 交给解码。
-
-
处理 STAP-A(type=24) 聚合包:先读 2 字节长度 → 读取该长度的 NALU → 循环直到包尾。
-
如果用 FFmpeg / GStreamer,它们内部已处理 FU-A,无需自己实现;如果手写 RTP 接收端,一定要实现它。
9.8 坑点八:avcC box lengthSizeMinusOne 误判
现象:
-
自写的 MP4 解复用器在某些设备/文件上 NALU 边界完全错乱。
-
首帧解析正常,第二帧开始错位。
原因:
avcC box 里的 lengthSizeMinusOne 字段表示"NALU 前面的长度前缀占用几字节 - 1"。常见值为 3(表示 4 字节长度前缀),但规范允许 0/1/2/3(对应 1/2/3/4 字节前缀)。如果代码里硬编码 length_size=4,遇到罕见的 1/2/3 字节前缀文件就会出错。
解决办法:
-
始终从 avcC box 中动态读取
lengthSizeMinusOne = (byte & 0x03),然后length_size = lengthSizeMinusOne + 1。 -
给这个字段加上 DCHECK / assertion,遇到非法值直接报错并附调试信息。
附录 A:快速参考表(Cheatsheet)
A.1 常用 NALU type 速查
| type | 名称 | VCL? | 何时出现 | Header 示例 |
|---|---|---|---|---|
| 1 | Coded slice / 非 IDR 图像 | 是 | 普通 P 帧 / B 帧 | 0x61 (P, NRI=2) / 0x01 (B, NRI=0) |
| 5 | Coded slice / IDR 图像 | 是 | GOP 起点、关键帧 | 0x65 |
| 6 | SEI | 否 | 时间码 / HDR 元数据 | 0x06 |
| 7 | SPS | 否 | 解码器初始化配置 | 0x67 |
| 8 | PPS | 否 | 解码器初始化配置 | 0x68 |
| 9 | AUD | 否 | 帧边界标记 | 0x09 |
| 10 | End of Sequence | 否 | 序列结束 | — |
| 12 | Filler | 否 | 填充 / 码率平滑 | — |
| 24 | STAP-A | 否 (RTP) | 小 NALU 聚合 | 仅 RTP |
| 28 | FU-A | 否 (RTP) | 大 NALU 分片 | 仅 RTP |
A.2 常用 Profile 值速查
| profile_idc | 名称 | 典型场景 |
|---|---|---|
| 66 | Baseline Profile | 低延迟、监控、视频会议 |
| 77 | Main Profile | 传统流媒体、DVD |
| 88 | Extended Profile | 较少使用 |
| 100 | High Profile | 最常见,高清/4K 视频 |
| 110 | High 10 Profile | 10 bit 深度视频 |
| 122 | High 4:2:2 Profile | 专业视频 |
| 244 | High 4:4:4 Predictive | 无损 / 近无损 |
A.3 两种码流格式速查
| 维度 | Annex B | AVCC |
|---|---|---|
| 分隔符 | 00 00 01 / 00 00 00 01 | N 字节大端长度 (N=1~4,通常 4) |
| SPS/PPS 位置 | 码流中每个 GOP 开头 | 文件头 avcC box |
| 典型容器 | .h264 / TS / HLS-TS | MP4 / FLV / fMP4 / M4V |
A.4 Emulation Prevention 模式表
| EBSP 中看到的 4 字节 | 去掉填充后变为 |
|---|---|
00 00 03 00 | 00 00 00 |
00 00 03 01 | 00 00 01 |
00 00 03 02 | 00 00 02 |
00 00 03 03 | 00 00 03 |
00 00 03 XX (XX > 03) | 不处理,03 是正常数据 |
记忆口诀:看到 00 00 03,如果后面跟着 00/01/02/03,就把 03 丢掉;否则 03 是正常数据。
A.5 分辨率计算公式速查
width = (pic_width_in_mbs_minus1 + 1) * 16
height = (pic_height_in_map_units_minus1 + 1) * 16 * (frame_mbs_only_flag ? 1 : 2)
// 如果 frame_cropping_flag == 1,再减去裁剪偏移
crop_unit_x = (chroma_format_idc == 3) ? 1 : 2
crop_unit_y = (chroma_format_idc == 3) ? 1 : 2 * (frame_mbs_only_flag ? 1 : 2)
final_width = width - crop_unit_x * (crop_left + crop_right)
final_height = height - crop_unit_y * (crop_top + crop_bottom)
附录 B:参考资料与延伸阅读
B.1 官方规范
| 文档 | 标题 | 内容 |
|---|---|---|
| ITU-T Rec. H.264 / ISO/IEC 14496-10 | Advanced Video Coding for Generic Audiovisual Services | H.264/AVC 完整编码规范 |
| ISO/IEC 14496-15 | Advanced Video Coding (AVC) file format | NALU 在 ISO Base Media File 中的存储规范(avcC box 定义) |
| RFC 6184 | RTP Payload Format for H.264 Video | RTP 中 NALU 的封装规范(替代旧 RFC 3984) |
| RFC 8216 | RTP Payload Format for High Efficiency Video Coding (HEVC) | H.265 的 RTP 封装(作为延伸参考) |
B.2 开源实现参考
| 项目 | 文件 | 作用 |
|---|---|---|
| FFmpeg | libavcodec/h264_parser.c | H.264 Annex B 码流解析器实现 |
| FFmpeg | libavcodec/h2645_parse.c | 通用起始码 / 防竞争字节处理 |
| FFmpeg | libavformat/avc.c | AVCC / avcC box 读写 |
| FFmpeg | libavcodec/h264_mp4toannexb_bsf.c | AVCC ↔ Annex B 转换 |
| ZLMediaKit | src/Rtp/H264Rtp.cpp | RTP H.264 FU-A / STAP 封装与解封装 |
| srs-librtmp | src/rtmp/srs_h264_raw_parser.cpp | H.264 裸流解析 + RTMP 打包 |
B.3 常用调试工具
| 工具 | 用途 |
|---|---|
| ffprobe | 查看 SPS/PPS 参数、profile/level、分辨率、GOP 结构 |
| ffmpeg -bsf:v trace_headers | 打印每个 NALU 的语法元素(调试解析器) |
h264bitstream (h264_analyze) | 逐条解析 SPS/PPS/Slice Header,适合学习 H.264 语法 |
Wireshark + h264_sdp_rtp 插件 | 抓包分析 RTP 中的 H.264 NALU |
| Elecard StreamEye | 商业工具,可视化码流结构 |
更多推荐

所有评论(0)