4. H.264 码流结构:NAL 单元、SPS/PPS/IDR/Slice 的概念与解析
H.264 的码流结构,说白了就是视频数据在传输和存储时的“打包方式”。很多初学者一上来就看标准文档,结果被那一堆 NAL、RBSP、EBSP 给绕晕了。我个人习惯是,先搞懂它为什么要这么设计,再看具体怎么解析。
你想想看,一个视频流里既有画面数据,又有参数集,还有帧类型标记。如果不做分层封装,解码器拿到数据根本不知道从哪下手。H.264 的码流结构就是为了解决这个问题——它把数据分成一个个逻辑单元,每个单元都有自己的“身份证”。
4.1 NAL 单元:码流的基本容器
NAL(Network Abstraction Layer,网络抽象层)是 H.264 码流的最小逻辑单元。每个 NAL 单元包含一个字节的头部和若干字节的载荷数据。
我记得第一次手动解析 H.264 码流时,就是在二进制文件里找 0x00 0x00 0x01 这个起始码。找到之后,紧跟着的第一个字节就是 NAL 头。
NAL 单元结构:
- 起始码:
0x00 0x00 0x01(3字节)或0x00 0x00 0x00 0x01(4字节,用于字节流格式) - NAL 头:1字节,包含 forbidden_bit、nal_ref_idc、nal_unit_type
- RBSP:原始字节序列载荷,即实际数据
NAL 头的结构很简单,就一个字节,分成三部分:
| 位域 | 长度(bit) | 说明 |
|---|---|---|
| forbidden_bit | 1 | 必须为 0,如果为 1 表示语法错误 |
| nal_ref_idc | 2 | 重要性指示:0 表示不用于参考,非 0 表示用于参考 |
| nal_unit_type | 5 | NAL 单元类型,取值范围 0~31 |
这里有个坑,我踩过。nal_ref_idc 为 0 的 NAL 单元,解码器可以丢弃而不影响后续解码。但有些编码器会把 IDR 帧的 nal_ref_idc 设成 3,而普通 P 帧设成 2。如果你在做丢包策略,千万别把 nal_ref_idc 非 0 的包全丢了,否则解码器会崩。
4.2 NAL 单元类型:从 1 到 31 的含义
nal_unit_type 是 NAL 头的核心字段。常见的类型有:
| 类型值 | 名称 | 说明 |
|---|---|---|
| 1 | 非 IDR 图像片(Slice) | 普通 P/B 帧的片数据 |
| 5 | IDR 图像片 | 关键帧的片数据,解码器重置参考帧列表 |
| 6 | SEI | 补充增强信息,如时间戳、字幕等 |
| 7 | SPS | 序列参数集,描述整个视频序列的属性 |
| 8 | PPS | 图像参数集,描述每帧图像的编码参数 |
| 9 | 访问单元分隔符 | 标记一个访问单元的开始 |
实际项目中,你只需要重点关注类型 1、5、7、8。其他类型要么很少用,要么是可选扩展。
我的经验:在做 RTP 封装时,SPS 和 PPS 通常放在 SDP 中带外传输,或者随 IDR 帧一起发送。如果只传类型 1 的包而不传 SPS/PPS,解码器会直接罢工。
4.3 SPS 和 PPS:解码器的“说明书”
SPS(Sequence Parameter Set)和 PPS(Picture Parameter Set)是 H.264 码流里最重要的两个参数集。没有它们,解码器连画面尺寸都不知道。
SPS 包含的内容:
- profile_idc:编码档次,如 Baseline、Main、High
- level_idc:编码级别,如 3.0、4.0、5.0
- pic_width_in_mbs_minus1:图像宽度(以宏块为单位)
- pic_height_in_map_units_minus1:图像高度
- log2_max_frame_num_minus4:frame_num 的位数
- max_num_ref_frames:参考帧最大数量
PPS 包含的内容:
- pic_parameter_set_id:PPS 的 ID,被 Slice 引用
- seq_parameter_set_id:引用的 SPS ID
- entropy_coding_mode_flag:熵编码模式(0=CABAC,1=CAVLC)
- num_slice_groups_minus1:片组数量
我曾经在调试一个播放器时,发现画面花屏。查了半天,原来是 SPS 里的 pic_width_in_mbs_minus1 算错了。宽度是 1920,宏块大小是 16,所以应该是 1920/16 - 1 = 119。编码器写成了 120,结果解码器以为宽度是 1936 像素,画面就歪了。
注意:SPS 和 PPS 的 ID 是从 0 开始的。Slice 头里会引用 pps_id,PPS 里会引用 sps_id。如果引用链断了,解码器会报错。我建议在接收端缓存最近收到的 SPS 和 PPS,不要只依赖第一个。
4.4 IDR 帧与 Slice:画面的基本单位
IDR(Instantaneous Decoding Refresh)帧是 H.264 中的关键帧。解码器遇到 IDR 帧时,会清空所有参考帧列表,重新开始解码。说白了,IDR 就是一个“重置点”。
Slice(片)是帧的组成部分。一帧图像可以分成多个 Slice,每个 Slice 独立编码。Slice 的类型有:
| Slice 类型 | 说明 |
|---|---|
| I Slice | 帧内编码,不依赖其他帧 |
| P Slice | 前向预测,依赖前面的 I/P 帧 |
| B Slice | 双向预测,依赖前后帧 |
| SI Slice | 切换帧内编码,用于流切换 |
| SP Slice | 切换帧间编码,用于流切换 |
IDR 帧的 Slice 一定是 I Slice,但 I Slice 不一定是 IDR 帧。这个区别很重要:IDR 帧会重置参考帧列表,而普通 I 帧不会。举个例子,一个视频序列里可能有多个 I 帧,但只有第一个 I 帧是 IDR。
我遇到过一个问题:编码器每 2 秒生成一个 IDR 帧,但中间还有几个非 IDR 的 I 帧。播放器在 seek 时,如果跳到非 IDR 的 I 帧,画面会花掉,因为参考帧列表里没有正确的数据。后来我强制播放器只 seek 到 IDR 帧,问题就解决了。
4.5 码流解析实战:从二进制到可读信息
下面是一个简单的 NAL 单元解析流程。假设你已经从码流中找到了起始码,接下来要解析 NAL 头:
// 伪代码:解析 NAL 头
uint8_t nal_header = read_byte();
uint8_t forbidden_bit = (nal_header >> 7) & 0x01;
uint8_t nal_ref_idc = (nal_header >> 5) & 0x03;
uint8_t nal_unit_type = nal_header & 0x1F;
if (nal_unit_type == 7) {
// 解析 SPS
parse_sps();
} else if (nal_unit_type == 8) {
// 解析 PPS
parse_pps();
} else if (nal_unit_type == 5) {
// IDR Slice
parse_slice_header();
} else if (nal_unit_type == 1) {
// 非 IDR Slice
parse_slice_header();
}
解析 SPS 时,需要用到指数哥伦布编码(Exp-Golomb)。H.264 的语法元素大量使用这种变长编码。比如 pic_width_in_mbs_minus1 就是用 ue(v) 编码的。
实用技巧:如果你只是想快速查看 H.264 码流的信息,可以用 ffprobe 或 h264_analyze 工具。但做底层开发时,还是得自己写解析器。我建议先拿一个小的 H.264 文件,用十六进制编辑器打开,对照标准文档逐字节分析。
4.6 知识体系总览
下面这张图展示了 H.264 码流结构的核心层次关系。从起始码到 NAL 单元,再到具体的参数集和 Slice 数据,每一步都有明确的规则。
这张图把 H.264 码流的层次关系梳理清楚了。从上到下,从码流到具体的参数集和 Slice 数据,每一步都有明确的解析规则。
4.7 常见问题与避坑指南
我在实际项目中遇到过不少 H.264 码流相关的问题,挑几个典型的说说:
- 起始码冲突:RBSP 里如果出现了
0x00 0x00 0x01或0x00 0x00 0x00 0x01,解码器会误认为是 NAL 起始码。H.264 用字节填充(byte stuffing)来解决:在0x00 0x00后面插入一个0x03。解析时要把这些0x03去掉。 - SPS 丢失:我曾经在弱网环境下测试,SPS 包丢了,结果解码器一直报错。后来我在应用层做了 SPS 缓存和重传机制,问题才解决。
- IDR 帧间隔:有些编码器把 IDR 帧间隔设得很大(比如 10 秒),导致 seek 响应很慢。我建议直播场景下 IDR 间隔不超过 2 秒,点播场景不超过 5 秒。
- Slice 数量:一帧分成多个 Slice 可以提高并行编码效率,但 Slice 太多会降低压缩率。我一般控制在每帧 4~8 个 Slice。
重要提醒:解析 H.264 码流时,一定要做边界检查。有些编码器会生成非标准的码流,比如 nal_ref_idc 为 0 的 IDR 帧。如果你严格按照标准来解析,可能会漏掉这些特殊情况。我建议在解析时加一个“宽松模式”,遇到非标准数据时尽量容错,而不是直接崩溃。
好了,H.264 码流结构的内容就讲到这里。从 NAL 单元到 SPS/PPS,再到 IDR 和 Slice,每个概念都有它的设计目的。理解了这些,你就能看懂 H.264 码流的二进制数据了。