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 码流结构层次图 H.264 码流(字节流格式) 起始码(0x00000001)+ NAL 单元 NAL 头(1字节)+ RBSP(载荷数据) 类型 7:SPS 类型 8:PPS 类型 5:IDR Slice 类型 1:非 IDR Slice SPS 关键字段 profile_idc, level_idc 分辨率、帧率、参考帧数 PPS 关键字段 熵编码模式、片组数量 引用 SPS ID Slice 头关键字段 first_mb_in_slice, slice_type pic_parameter_set_id, frame_num 解码器流程: 接收 SPS/PPS → 初始化解码参数 → 接收 IDR 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 码流的二进制数据了。