2. H264码流结构:NAL单元概念、NAL头解析、RBSP与EBSP

好,我们直接进入正题。H264的码流,说白了就是一串二进制数据。但这一大串0和1,总得有个组织方式吧?不然解码器拿到手,根本不知道从哪里开始读。

这个组织方式,就是 NAL单元(Network Abstraction Layer,网络抽象层)。我个人习惯把NAL单元想象成「数据包」——每个包都有自己的身份标识,包里装着压缩后的视频数据。解码器拿到码流后,第一步就是把这些包一个个拆出来,然后根据包头的指示去处理。

核心概念:H264码流 = NAL单元序列。每个NAL单元 = NAL头 + RBSP数据。

2.1 NAL单元结构

每个NAL单元由两部分组成:NAL头(1字节)和 RBSP(原始字节序列载荷)。

先看一个典型的NAL单元在码流中的样子:

+--------+-----------------------------+
| NAL头  |          RBSP               |
| (1字节)|   (可变长度,包含视频数据)    |
+--------+-----------------------------+

嗯,这里要注意:NAL单元之间用 起始码(Start Code)分隔。起始码有两种格式:

  • 0x00000001(4字节):用于关键位置,比如SPS、PPS、IDR帧开头
  • 0x000001(3字节):用于普通数据包之间

我曾经在解析一个直播流时,发现解码器总是丢帧。排查了半天,最后发现是推流端在某些关键帧前用了3字节起始码,而解码器只认4字节的。从那以后,我写解析器时都会同时兼容两种格式。

2.2 NAL头解析

NAL头只有1个字节,8个比特位。结构如下:

比特位 字段名 长度(bit) 说明
bit[7] forbidden_zero_bit 1 必须为0。如果为1,表示语法错误
bit[6:5] nal_ref_idc 2 重要性指示。00=不重要,非00=重要(如SPS/PPS/IDR)
bit[4:0] nal_unit_type 5 NAL单元类型,取值范围0~31

你想想看,解码器拿到一个字节,先看最高位是不是0——如果是1,直接丢包。然后看 nal_ref_idc,决定这个包要不要优先处理。最后看 nal_unit_type,才知道包里装的是什么。

常见的 nal_unit_type 值:

类型值 缩写 含义 重要性
1 SLICE 非IDR图像片 视频数据
5 IDR IDR图像片(关键帧) 极高
6 SEI 补充增强信息 可选
7 SPS 序列参数集 极高
8 PPS 图像参数集 极高
9 AU 访问单元分隔符 辅助

个人经验:解析NAL头时,我习惯用位运算直接取。比如 nal_unit_type = byte & 0x1F,nal_ref_idc = (byte >> 5) & 0x03。别用结构体位域,不同编译器的内存对齐会让你怀疑人生。

2.3 RBSP与EBSP

好,现在我们知道NAL头怎么读了。但NAL头后面的数据,并不是直接就能用的。这里有两个概念:RBSP 和 EBSP。

先看它们的关系:

原始编码数据(SODB)
    ↓ 添加RBSP尾部填充位
RBSP(原始字节序列载荷)
    ↓ 防竞争字节插入(0x03插入)
EBSP(扩展字节序列载荷)—— 这才是码流中实际存储的数据

为什么会这样?

H264的起始码是 0x000001 或 0x00000001。如果视频数据里恰好出现了 0x000001 或 0x000000 这样的字节序列,解码器就会误判为起始码。所以编码器在生成码流时,会在数据中插入一个 0x03 字节来「转义」这些敏感序列。

具体规则:

  • 如果数据中出现连续的 0x000000 → 在第三个0后面插入 0x03,变成 0x00000300
  • 如果数据中出现 0x000001 → 在 0x01 前面插入 0x03,变成 0x00000301
  • 如果数据中出现 0x000002 → 同样插入 0x03,变成 0x00000302

解码时,我们需要把 0x03 去掉,还原成RBSP。这个过程叫 EBSP到RBSP的转换。

避坑指南:我曾经在写解码器时,直接拿EBSP去解析SPS,结果解析出来的分辨率是错的。查了半天才发现,SPS里的某些字段值恰好包含 0x000003 序列,而我没有先做0x03去除。记住:所有语法解析必须在RBSP上进行,EBSP只是传输格式。

2.4 从EBSP到RBSP的转换算法

这个算法其实很简单,但细节容易出错。我直接给代码:

// 输入:EBSP数据(包含0x03插入)
// 输出:RBSP数据(去除0x03)
// 返回:RBSP长度

int ebsp_to_rbsp(uint8_t *ebsp, int ebsp_len, uint8_t *rbsp) {
    int rbsp_len = 0;
    int zero_count = 0;
    
    for (int i = 0; i < ebsp_len; i++) {
        if (zero_count == 2 && ebsp[i] == 0x03) {
            // 遇到连续的 0x00 0x00 0x03,跳过这个0x03
            zero_count = 0;
            continue;
        }
        
        if (ebsp[i] == 0x00) {
            zero_count++;
        } else {
            zero_count = 0;
        }
        
        rbsp[rbsp_len++] = ebsp[i];
    }
    
    return rbsp_len;
}

注意:这个算法只处理 0x000003 的情况。如果遇到 0x000002,那是保留的语法标记,不应该出现在普通数据中。如果遇到 0x000000 后面跟的不是 0x03,那说明码流有问题。

2.5 整体流程:从码流到NAL单元

把上面的知识点串起来,解析一个H264码流的完整流程是这样的:

  1. 查找起始码:在码流中搜索 0x000001 或 0x00000001
  2. 提取NAL单元:从起始码之后到下一个起始码之前,就是完整的NAL单元(包含NAL头+EBSP)
  3. 解析NAL头:读取第一个字节,提取 forbidden_zero_bit、nal_ref_idc、nal_unit_type
  4. EBSP转RBSP:对NAL头后面的EBSP数据做0x03去除,得到RBSP
  5. 解析RBSP:根据 nal_unit_type 调用对应的解析函数(SPS解析、PPS解析、Slice解析等)

下面这张图展示了整个流程:

H264码流解析流程 H264原始码流(字节流) 步骤1:搜索起始码 0x000001 或 0x00000001 步骤2:提取NAL单元(NAL头 + EBSP) 步骤3:解析NAL头(1字节) nal_unit_type nal_ref_idc 步骤4:EBSP → RBSP(去除0x03) 步骤5:根据类型解析RBSP(SPS/PPS/Slice等)

实用技巧:实际开发中,我建议把NAL单元解析和RBSP解析分开成两个模块。NAL单元解析负责「切包」,RBSP解析负责「解包」。这样如果后续要支持H265,只需要换NAL单元解析部分,RBSP解析逻辑可以复用。

2.6 小结

这一章我们讲了三个核心概念:

  • NAL单元:H264码流的基本组成单位,由NAL头+RBSP构成
  • NAL头:1字节,包含类型和重要性信息,是解码器判断数据类型的依据
  • RBSP与EBSP:EBSP是码流中实际存储的格式,通过0x03插入防止起始码误判;解码时需要还原成RBSP才能解析

嗯,这些概念看起来简单,但实际编码时坑不少。尤其是EBSP转RBSP这一步,很多人会忽略,导致解析出来的参数全是错的。记住我前面说的:所有语法解析都在RBSP上进行。