H264解码:读取和解析Nalu
想学习下视频解码相关的知识,本系列记录下学习过程。现在有了 AI 知识获取很快,但还是感觉还是写一写记一记,不然回头搞忘记又得重新开始学。思绪有点跳跃,想到哪就写到哪了。所有代码可以在找到在 H.264/AVC 视频编码标准中,NALU 的全称是 Network Abstraction Layer Unit(网络抽象层单元)。简单来说,它是 H.264 数据的基本打包单位。
文章目录
前言
想学习下视频解码相关的知识,本系列记录下学习过程。现在有了 AI 知识获取很快,但还是感觉还是写一写记一记,不然回头搞忘记又得重新开始学。思绪有点跳跃,想到哪就写到哪了。
所有代码可以在 minimal_h264_lab 找到
一、准备工作
准备一个 h264 的裸流文件,用 ffmpeg 直接转就行
ffmpeg -i video_1280x720_30fps_5sec.mp4 \
-vcodec libx264 \
-profile:v baseline \
-level 3.0 \
-coder 0 \
-bf 0 \
-g 1 \
-an \
-f h264 \
output_simple.h264
minimal_h264_lab 仓库下 data 目录已经准备好了,可以直接使用
二、Nalu
2.1 什么是 Nalu
在 H.264/AVC 视频编码标准中,NALU 的全称是 Network Abstraction Layer Unit(网络抽象层单元)。
简单来说,它是 H.264 数据的基本打包单位。为了让视频数据既能在本地存储(如 MP4 文件),又能在网络上传输(如直播流 RTSP/RTMP),H.264 将结构分成了两层:
VCL (Video Coding Layer - 视频编码层):负责高效的视频压缩,包含核心的像素残差、运动矢量等数据。
NAL (Network Abstraction Layer - 网络抽象层):负责将 VCL 生成的数据进行封装,并添加头部信息,使其能够适应不同的传输环境。NALU 就是这一层产出的一个个数据包。
2.2 Nalu 结构
一个完整的 NALU 由两部分组成:
- NALU Header (头部):通常占 1 个字节。
- NALU Payload (负载):实际的数据内容(RBSP - Raw Byte Sequence Payload)。
NALU Header (1 Byte) 的内部构造:
这个字节位(Bit)的含义如下:
- F (forbidden_zero_bit, 1 bit):禁止位。正常情况下为 0。如果传输中发生错误,可能会被设为 1。
- NRI (nal_ref_idc, 2 bits):重要性指示。值越大,表示该 NALU 越重要。例如,SPS/PPS 或关键帧(I帧)的 NRI 通常为 11 (3),而 B 帧可能为 00 (0)。
- Type (nal_unit_type, 5 bits):最关键的部分,决定了该 NALU 是什么类型。
2.3 Nalu 类型
通过分析 Header 的后 5 位,可以知道这个包里装的是什么:
| Type 值 | NALU 类型 | 含义与说明 |
|---|---|---|
| 1 | 非 IDR 图像的片 (Slice) | 包含 P 帧、B 帧 或 非 IDR 的 I 帧。需解析 Slice Header 才能确定具体类型。 |
| 5 | IDR 图像的片 (IDR Slice) | 立即刷新关键帧。特殊的 I 帧,要求解码器立即清空参考帧队列 (DPB),常用于 GOP 起点。 |
| 6 | SEI (补充增强信息) | 携带视频之外的辅助信息,如用户自定义数据、定时信息、HDR 元数据等。 |
| 7 | SPS (序列参数集) | 核心配置。包含分辨率、档次 (Profile)、级别 (Level)、帧率计算基础等全局参数。 |
| 8 | PPS (图像参数集) | 图像配置。引用 SPS,包含熵编码模式 (CAVLC/CABAC)、片组划分、初始量化参数等。 |
| 9 | AUD (分界符) | 访问单元分隔符,用于标识一个完整帧的开始(在某些流格式中常见)。 |
2.3.1 IDR 帧与普通 I 帧的区别
- 普通 I 帧:类型也是 1(非 IDR 图像),但它是关键帧。
- IDR 帧 (Instantaneous Decoding Refresh):一种特殊的 I 帧。
- 核心区别:
- 当解码器遇到 IDR 帧时,会立即清空参考帧队列(DPB)。这意味着 IDR 帧之后的帧绝对不会参考 IDR 帧之前的帧。
- 如果是普通的 I 帧,后面的帧可能会跨过 I 帧去参考之前的帧。
- 结论:IDR 帧一定是 I 帧,但 I 帧不一定是 IDR 帧。所有的播放起点的第一个帧必须是 IDR 帧。
2.4 如何找到 Nalu
在处理 H.264 裸流(Annex B 格式)时,NALU 之间是用**开始码(Start Code)**分隔的,这样解码器才能从一长串二进制数据中找到每个包的起始位置:
- 00 00 01 (3 字节)
- 00 00 00 01 (4 字节,通常用于一帧的开头或关键数据)
例子: 00 00 00 01 67 … -> 67 换成二进制是 0110 0111,后五位是 00111 (十进制 7),说明这是一个 SPS。
2.4.1 防止竞争字节 (Emulation Prevention Bytes)
问题描述:H.264 用 00 00 01 或 00 00 00 01 作为开始码。如果视频压缩后的数据(Payload)中恰好也出现了这些字节序列怎么办?
解决方案:H.264 引入了 脱壳操作。在编码时,如果探测到 Payload 中有连续两个 00 00,就会在后面强行插入一个 03。
00 00 00->00 00 03 0000 00 01->00 00 03 0100 00 02->00 00 03 0200 00 03->00 00 03 03
因此我们在解析 nalu 数据前,必须扫描所有的 00 00 03 还原 00 00 否则解析会出错。
2.5 Annex B 和 avvC
在 H.264 视频流的实际应用中,NALU 需要被打包后才能传输或存储。目前主流的打包格式有两种:Annex B 和 avcC。
它们的主要区别在于:如何定位 NALU 的边界 以及 SPS/PPS 等元数据存放的位置。
Annex B
这是 H.264 标准协议(附录B)中定义的格式,通常用于实时流传输(如 RTSP、TS 流、广播电视)或裸流文件(.h264/ .264)。
- 分隔方式:使用特殊的**开始码(Start Code)**来区分 NALU。
- 开始码为 00 00 01 或 00 00 00 01。
- SPS/PPS 的位置:SPS 和 PPS 作为普通的 NALU 直接插入在视频流中。通常在每个 IDR 帧(关键帧)之前都会重复发送一次 SPS/PPS。
- 特点:
- 容错性强:解码器如果在中途丢失了数据,只需要在流中寻找下一个 00 00 01 就能重新同步。
- 适合流媒体:观众随时点开直播,只要收到下一组 SPS/PPS 和 I 帧就能开始解码。
avcC 格式 (也叫 MP4 格式)
这种格式通常用于容器格式(如 MP4、MKV、MOV)。它不使用开始码,而是使用长度前缀。
- 分隔方式:在每个 NALU 前面加上固定字节(通常是 4 字节)的长度字段,指明后续 NALU 的字节数。
- 例如:[00 00 04 65] [Data…] 表示后面有 1125 字节的数据。
- SPS/PPS 的位置:SPS 和 PPS 不在数据流中,而是存放在文件头的 extradata(或称 AVCDecoderConfigurationRecord)中。
- 特点:
- 解析效率高:解码器可以直接读取长度,然后跳转到下一个 NALU,不需要像 Annex B 那样逐个字节搜索开始码。
- 不适合丢包环境:如果长度字段损坏,整个流就无法解析,因为找不到下一个包的起点。
- 节省空间:不需要在每个关键帧前重复存储 SPS/PPS。
对比
| 特性 | Annex B | avcC (MP4 格式) |
|---|---|---|
| NALU 分隔符 | 开始码 00 00 01 或 00 00 00 01 |
NALU 长度(通常 4 字节) |
| SPS/PPS 存放 | 周期性插入在视频流中 (In-band) | 存放在文件头 extradata 中 (Out-of-band) |
| 常见场景 | RTSP, RTMP, TS 流, HLS, 裸流文件 | MP4, MKV, MOV 文件 |
| 优点 | 适合网络传输,容错性好,支持随机进入 | 存储效率高,定位/seek 速度快 |
| 缺点 | 解码器需逐字节扫描寻找开始码 | 数据损坏可能导致后续整个流无法解码 |
2.6 Nalu 类型:SEI
什么是 SEI
SEI (Supplemental Enhancement Information),即补充增强信息。它是 H.264 (AVC) 标准中定义的一种 NALU(Network Abstraction Layer Unit)类型。
- 非必需性:SEI 包含的是辅助信息,对于解码过程来说不是必不可少的。即使解码器忽略所有的 SEI 信息,视频的主体内容依然可以正常解码。
- 功能性:它提供了诸如视频显示参数、定时信息、用户自定义数据、恢复点信息等,能够帮助解码器更好地呈现视频或实现特定业务功能。
SEI 的内部封装格式
一个 SEI NALU 内部可以包含一个或多个 SEI Message。每个 SEI Message 的结构如下:
| 字段 | 描述 |
|---|---|
| payloadType | 负载类型,指示该消息的功能(如:缓冲周期、用户数据等)。 |
| payloadSize | 负载大小,指示后面 payload 数据的字节长度。 |
| payload | 实际的增强信息数据。 |
常见的 SEI 负载类型 (Payload Type)
| Type | 名称 | 描述 |
|---|---|---|
| 0 | Buffering period | 缓冲周期,用于 HRD(假想参考解码器)初始化。 |
| 1 | Pic timing | 图像计时信息,包含 CPB 移除延迟和 DPB 输出延迟。 |
| 5 | User data unregistered | 用户自定义数据。这是最常用的类型,允许开发者插入任意私有数据(需包含一个 16 字节的 UUID)。 |
| 6 | Recovery point | 恢复点信息,用于支持随机访问和 GOP 切换。 |
| 137 | Mastering display colour volume | 用于 HDR 视频,描述显示器的色域和亮度范围。 |
| 144 | Content light level information | 用于 HDR 视频,包含 MaxCLL 和 MaxFALL。 |
2.7 Nalu 类型:SPS
什么是 SPS?
SPS (Sequence Parameter Set),即序列参数集。它是 H.264 码流中的关键配置信息,包含了一个视频序列(一组 GOP)的所有全局参数。
- 重要性:它是解码器初始化所必需的。如果缺失 SPS,解码器将无法知道视频的分辨率、Profile、Level 等基本信息,导致无法解码。
- 作用范围:从一个 SPS 开始,直到下一个新的 SPS 出现为止,其间的视频帧都遵循该 SPS 定义的参数。
SPS 核心参数解析
SPS 的内部字段非常多,采用 Exp-Golomb (指数哥伦布编码) 进行压缩。以下是其中最核心的字段:
1.档次与级别 (Profile & Level)
- profile_idc: 标识编码档次。
- 66: Baseline (基础档次,无B帧)
- 77: Main (主档次)
- 100: High (高级档次,最常见)
- level_idc: 标识解码级别(如 3.1, 4.0, 5.1)。它限制了最大分辨率、帧率和码率。
2. 序列 ID (seq_parameter_set_id)
- 用于唯一标识该 SPS。PPS (图像参数集) 会引用这个 ID 来关联对应的 SPS。
3.分辨率相关 (Resolution)
SPS 并不直接存储像素宽度,而是存储“宏块数量”:
- pic_width_in_mbs_minus1: 图像宽度(以宏块为单位)减 1。
- 宽度 = ( p i c _ w i d t h _ i n _ m b s _ m i n u s 1 + 1 ) × 16 宽度 = (pic\_width\_in\_mbs\_minus1 + 1) \times 16 宽度=(pic_width_in_mbs_minus1+1)×16
- pic_height_in_map_units_minus1: 图像高度(以片组为单位)减 1。
- 通常情况下: 高度 = ( p i c _ h e i g h t _ i n _ m a p _ u n i t s _ m i n u s 1 + 1 ) × 16 高度 = (pic\_height\_in\_map\_units\_minus1 + 1) \times 16 高度=(pic_height_in_map_units_minus1+1)×16
- frame_cropping_flag: 如果分辨率不是 16 的倍数,该标志为 1,并提供上下左右的裁剪偏移量。
4.帧与场 (Frame & Field)
- frame_mbs_only_flag:
- 1:表示整个序列全为帧编码(Progressive,逐行)。
- 0:表示可能包含场编码(Interlaced,隔行)。
5.参考帧信息
- max_num_ref_frames: 解码时需要的最大参考帧数量。用于确定解码器 DPB(Decoded Picture Buffer)的大小。
6.帧率计算相关
- log2_max_frame_num_minus4: 用于计算帧号 (frame_num) 的最大值。
- pic_order_cnt_type: 图像顺序计数 (POC) 的类型,决定了如何计算 B 帧和 P 帧的显示顺序。
VUI (Video Usability Information)
SPS 的末尾通常包含 VUI 选项,它是可选的,但对渲染至关重要:
- aspect_ratio_info: 宽高比(SAR/DAR)。
- video_full_range_flag: 标志图像是 Limited Range (16-235) 还是 Full Range (0-255)。
- colour_primaries: 色域信息(如 BT.709, BT.2020)。
- timing_info: 包含
num_units_in_tick和time_scale,解码器根据这两个值计算固定帧率。
2.8 Nalu 类型:PPS
PPS (Picture Parameter Set),即图像参数集。它是 H.264 码流中紧随 SPS 之后的重要参数集。SPS 描述的是整个序列(多个帧)的参数,而 PPS 描述的是图像级别(一帧或多帧)的参数。
- 依赖关系:PPS 必须引用一个 SPS 的 ID。
- 作用范围:一个 PPS 的参数应用于引用该 PPS ID 的所有 Slice(片)。在同一个序列中,可以有多个不同的 PPS。
PPS 核心参数解析
PPS 内部字段同样采用 Exp-Golomb (指数哥伦布编码)。以下是其中最关键的字段:
引用与标识
- pic_parameter_set_id: 该 PPS 的唯一标识 ID。Slice Header 会通过这个 ID 来找到对应的 PPS。
- seq_parameter_set_id: 指明该 PPS 关联的是哪一个 SPS。
熵编码模式 (Entropy Coding)
- entropy_coding_mode_flag: 极为重要的参数。
- 0:表示采用 CAVLC 编码。
- 1:表示采用 CABAC 编码(效率更高,计算更复杂)。
SPS、PPS 与 Slice 的层级关系
- SPS (Type 7): 包含分辨率、Profile 等全局信息。
- PPS (Type 8): 引用 SPS_ID,包含熵编码方式、初始 QP 等图像信息。
- Slice Header: 引用 PPS_ID,包含当前帧的帧号、类型(I/P/B)等具体信息。
这种层级结构允许在视频中途改变图像参数(如切换熵编码或调整 QP 基准),只需发送一个新的 PPS 并在后续 Slice 中引用它,而无需重发整个 SPS。
Slice 和 帧
“帧(Frame)”是视频在时间轴上的一个完整画面(即你看到的一张图);而“切片(Slice)”是 H.264 为了方便传输和并行处理,将这一张图横向切分出的数据块。一帧图像可以只包含一个切片(此时两者等同),也可以划分为多个切片;每个切片都是独立编码的,并被封装成一个 NALU 进行传输,这样即使某个切片数据丢失,也不会导致整张图片完全无法显示。
数据的抽象层级 (SODB -> RBSP -> EBSP)
- SODB (String of Data Bits):最原始的编码比特流,长度不一定是 8 的倍数。
- RBSP (Raw Byte Sequence Payload):在 SODB 末尾添加停止位(1 bit)并补 0,使其凑齐整字节。
- EBSP (Encapsulated Byte Sequence Payload):在 RBSP 基础上插入了“防止竞争字节(03)”后的数据。
- 关系图:NALU Header + EBSP = NALU。
三、Show Me The Code
3.1 使用 FFmpeg 解析 nalu 信息
完整代码在 c1.cpp
FFmpeg 提供了 av_parser_parse2 来读取 h264 裸流数据,它可以从杂乱的、不连续的二进制流中,切割出完整的 NALU(帧)。
int av_parser_parse2(AVCodecParserContext *s,
AVCodecContext *avctx,
uint8_t **poutbuf, int *poutbuf_size,
const uint8_t *buf, int buf_size,
int64_t pts, int64_t dts,
int64_t pos);
- s: 解析器上下文(AVCodecParserContext)。它内部维护了一个缓冲区,会自动处理跨 Buffer 的 NALU(比如一个 NALU 在上一次 fread 读了一半,下一次又读了一半)。
- poutbuf (输出): 指向解析出的完整 NALU 数据的指针。
- poutbuf_size (输出): 解析出的完整 NALU 的长度。如果还没凑够一帧,这个值会是 0。
- buf (输入): 你从文件里读出来的原始数据块。
- buf_size (输入): 原始数据块的大小。
- 返回值: 告诉你消耗了多少输入数据。通常等于 buf_size,但也可能小于它。
简单使用流程大致是:
// 初始化
codec = avcodec_find_decoder(AV_CODEC_ID_H264);
parser = av_parser_init(codec->id);
codec_ctx = avcodec_alloc_context3(codec);
pkt = av_packet_alloc();
// 不停地读取
void NaluParser::parse(const std::string &filename) {
std::ifstream ifs(filename, std::ios::binary);
if (!ifs.is_open()) {
std::cerr << "无法打开文件: " << filename << std::endl;
return;
}
// 设置读取缓冲区
const size_t buffer_size = 4096;
std::vector<uint8_t> buffer(buffer_size + AV_INPUT_BUFFER_PADDING_SIZE, 0);
while (!ifs.eof()) {
ifs.read(reinterpret_cast<char *>(buffer.data()), buffer_size);
auto bytes_read = ifs.gcount();
if (bytes_read <= 0) {
std::cerr << "读取文件失败或已到达文件末尾" << std::endl;
break;
}
auto *data = buffer.data();
while (bytes_read > 0) {
auto ret = av_parser_parse2(parser, codec_ctx, &pkt->data, &pkt->size, data, (int) bytes_read,
AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
data += ret;
bytes_read -= ret;
// got a nalu
if (pkt->size > 0) {
process_packet(pkt->data, pkt->size);
}
}
}
}
注意 av_parser_parse2 并不是只读取一个 nalu,而是读取一个 AU。这里涉及到另外一个概念:完整访问单元(Access Unit,AU)。
概念区分:NALU vs. AU (帧)
- NALU (Network Abstraction Layer Unit):这是 H.264 的最小打包单位(如一个 SPS、一个 PPS、或者一个 Slice)。
- AU (Access Unit):这是一个完整帧的所有数据集合。一个 AU 内部可能包含:
- AUD (分隔符,可选)
- SPS (序列参数集,如果是 IDR 帧通常会有)
- PPS (图像参数集)
- SEI (增强信息,可选)
- 多个 Slices (如果一帧被切成了多个片)
例如第一帧,这是视频的起点,也是解码器最先需要拿到的“全家桶”。为了让播放器能从这里直接开始播放,这个 AU 必须包含所有的配置信息。
一个典型的 IDR AU 结构: [SPS] + [PPS] + [SEI] + [IDR Slice]
- SPS (Type 7): 告诉解码器:“视频宽 1920,高 1080,请准备好内存”。
- PPS (Type 8): 告诉解码器:“我们要用 CABAC 方式解码”。
- SEI (Type 6): 告诉解码器:“这是在 2023 年拍摄的,带有 HDR 信息”。
- IDR Slice (Type 5): 真正的图像像素数据,它是完整的,不参考任何人。
解析器的行为: av_parser_parse2 会一直收集,直到看到 IDR Slice 结束,并且下一个 NALU 是新类型或新帧,它才会把上面这一堆东西打包成一个 poutbuf 输出。
一旦视频跑起来了,SPS 和 PPS 通常不再变化(除非分辨率变了),所以后续的 AU 会非常精简。
一个典型的 P 帧 AU 结构: [Slice P]
- Slice (Type 1): 包含的是“残差数据”和“运动矢量”。解码器拿到它后,会去参考之前的 I 帧来“拼”出这个画面。
解析器的行为: 看到 AUD 开始,收集 Slice,直到看到下一个开始码(比如下一个 AUD),就把这两个 NALU 打包输出。
3.2 使用 h264bitstream 解析 nalu 信息
完整代码在 完整代码在 c2.cpp
h264bitstream 是一个开源的、轻量级的 C 语言库,专门用于解析和构造 H.264 码流。
它的使用很简单,与 FFmpeg 不同它读取的每个单独的 Nalu,使用 read_debug_nal_unit 打印出每个 Nalu 的结构
#include <fstream>
#include <iostream>
#include <vector>
#include <cstring> // 必须包含,为了使用 memmove
#include "h264_stream.h"
int main(int argc, char* argv[]) {
if (argc < 2) {
std::cout << "Usage: " << argv[0] << " <input_h264_file>" << std::endl;
return -1;
}
auto filename = argv[1];
std::ifstream ifs(filename, std::ios::binary);
if (!ifs.is_open()) {
std::cerr << "无法打开文件: " << filename << std::endl;
return -1;
}
// 1. 缓冲区大小
const size_t buffer_size = 32 * 1024 * 1024;
std::vector<uint8_t> buffer(buffer_size);
h264_stream_t* h = h264_new();
size_t sz = 0; // 当前 buffer 中已有的数据长度
int64_t total_off = 0; // 用于记录全局偏移量(可选,用于调试)
while (true) {
// 2. 读取数据到 buffer 的空余位置 (从 buffer.data() + sz 开始)
ifs.read(reinterpret_cast<char *>(buffer.data() + sz), buffer_size - sz);
size_t bytes_read = ifs.gcount();
if (bytes_read == 0 && sz == 0) {
break; // 文件读完且 buffer 已空
}
sz += bytes_read; // 当前 buffer 总有效数据量
uint8_t* p = buffer.data();
int nal_start, nal_end;
// 3. 循环查找 NALU
// 注意:传入的长度是 sz (当前 buffer 的有效字节数)
while (find_nal_unit(p, sz, &nal_start, &nal_end) > 0) {
// nal_start 是相对于当前 p 的偏移
uint8_t* nal_ptr = p + nal_start;
int nal_size = nal_end - nal_start;
std::cout << "Found NALU: Type=" << (int)(nal_ptr[0] & 0x1F)
<< ", Size=" << nal_size << std::endl;
// 4. 解析 NALU
read_debug_nal_unit(h, nal_ptr, nal_size);
// 5. 更新指针和剩余长度
// nal_end 是 find_nal_unit 找到的一个完整 NALU 的结束位置(包含起始码)
p += nal_end;
sz -= nal_end;
total_off += nal_end;
}
// 6. 处理残留数据 (非常重要)
// 如果循环结束,说明剩下的数据不足以构成一个完整的 NALU
// 将这部分数据移到 buffer 的最前面,下次 read 时接着往后写
if (sz > 0) {
memmove(buffer.data(), p, sz);
}
if (ifs.eof() && sz > 0) {
// 如果文件读完了,但 buffer 里还有最后一点点数据(可能不是完整的 NALU)
// 可以在这里做最后的清理,或者直接跳出
break;
}
if (ifs.eof() && bytes_read == 0) break;
}
h264_free(h);
return 0;
}
总结
- 流水账式的介绍了 Nalu 相关的知识
- 介绍 FFmpeg 和 h264bitstream 读取 Nalu 的方法
后续想继续尝试 h264 解码,但感觉很硬核,加油💪🏻
参考
更多推荐



所有评论(0)