跳转到主内容
趣航编程网 - 趣学编程,启航技术之路!

AVC 解码器学习第一弹:从比特到 NAL——码流结构全解析

H.264/AVC 码流学习笔记 FFmpeg 源码参考: h264dec.c , h2645_parse.c , h264_slice.c 前言 本人入行音视频行业近两年,主要从事音视频框架与解码相关工作,熟悉 MediaCodec、OpenMAX 、mediaplayer等通路。

周末抽空学习 FFmpeg 以拓展思路,并将心得整理成文档分享给大家。

如有不足,欢迎留言指正。

第一章:AVC 码流的整体结构 1.1 码流是什么 一个

.h264

文件或 MP4 里的视频轨道,本质上是 一串连续的二进制数据 。

解码器从头到尾读取这些数据,还原出一帧一帧的画面。

但这一大串二进制数据不能是杂乱无章的——必须有固定的结构,解码器才知道"这段是什么、多长、怎么解"。

1.2 码流的层级结构 H.264 码流从大到小分为 5 个层级:

序列 (Sequence)

└── 图像 / 帧 (Picture / Frame) └── Slice (条带) └── Macroblock / 宏块 (16×16 像素) └── Block / 块 (4×4 或 8×8 像素)

用一句话理解每一层: 层级包含什么类比 序列一段视频的所有帧,从第一个 IDR 到下一个 IDR一整集电视剧帧一幅完整的画面(I/P/B 帧)一张截图Slice帧的一部分,可独立解码把一张图切成几块宏块16×16 像素区域,编解码的基本单位拼图的一小块块4×4 或 8×8 像素,变换/预测的最小单位拼图小块里的像素组 为什么要有 Slice?

一帧图像可能很大(如 4K = 3840×2160)。

如果整帧作为一个整体编码: 传输中丢一个比特,整帧就坏了 无法并行解码 于是 H.264 把一帧切成若干 Slice ,每个 Slice 独立编码、独立解码。

一个 Slice 损坏不影响其他 Slice。

Slice 之间还可以并行处理。

一帧 1920×1080 的图像:

┌──────────┬──────────┐ │ Slice 0 │ Slice 1 │ ← 每个 Slice 含若干行宏块 ├──────────┼──────────┤ │ Slice 2 │ Slice 3 │ ├──────────┼──────────┤ │ Slice 4 │ Slice 5 │ └──────────┴──────────┘

1.3 NAL 单元——码流的基本容器 NAL = Network Abstraction Layer(网络抽象层)。

H.264 把码流中每一段独立的数据包装成一个 NAL 单元 。

就像寄快递——每个包裹外有标签(NAL header),里面有货物(NAL payload)。

每一个 NAL 单元:

┌──────────────┬─────────────────────────────┐ │ NAL Header │ NAL Payload │ │ (1 字节) │ (实际数据: SPS/PPS/Slice) │ └──────────────┴─────────────────────────────┘

NAL Header 只有 1 个字节,但这 1 个字节说了三件事:

NAL Header 的 8 位:

┌───┬─────┬───────────┐ │ 0 │ 优先级 │ NAL 类型 │ │ │ (2bits)│ (5bits) │ └───┴─────┴───────────┘ ↑ ↑ 禁止位(总是0) 核心: 告诉解码器"这个NAL里面装的是什么"

1.4 NAL 单元的类型 NAL 类型用 5 个 bit 表示,可以有 0~31 共 32 种。

分为两大类: VCL NAL(视频编码层)——装的是像素数据 类型号名称含义 1非 IDR 图像的 Slice (P/B 帧)普通的帧间预测 slice5IDR 图像的 Slice (I 帧)关键帧,解码器可从此处重新开始2,3,4数据分区 A/B/C把 Slice 数据按重要性分成几段(极少用) 非 VCL NAL(非视频编码层)——装的是配置/辅助信息 类型号名称含义 7SPS(序列参数集)整个序列的全局参数:分辨率、Profile、Level8PPS(图像参数集)每帧可变的参数:熵编码类型(CABAC/CAVLC)、QP6SEI(辅助增强信息)可选附加信息:时间码、HDR 元数据、胶片颗粒9AUD (访问单元分隔符)标记一帧的开始10,11序列结束/码流结束标记序列或码流的终点 重点理解 SPS 和 PPS: SPS 是"全局配置"——分辨率 1920×1080、Profile=High、Level=4.1,整段视频都一样 PPS 是"帧级配置"——用 CABAC 还是 CAVLC?

初始 QP 是多少?

可以每帧不同 解码器必须先收到 SPS+PPS,才能解码后面的 Slice 数据 1.5 Access Unit——"一帧"的完整数据 一个 Access Unit (AU) 就是解码一帧画面所需的所有 NAL 的集合:

一个 Access Unit (一帧):

┌───────┬───────┬───────┬───────────┬───────────┬───────────┬───── │ AUD │ SPS │ PPS │ SEI │ Slice 0 │ Slice 1 │ ... │ (可选) │ (可能) │ (可能) │ (可选) │ (VCL) │ (VCL) │ └───────┴───────┴───────┴───────────┴───────────┴───────────┴───── ↑ ↑ 非 VCL NAL VCL NAL (辅助信息) (实际的像素数据)

I 帧(IDR)的 Access Unit 通常会带 SPS 和 PPS,因为解码器要从这里"重新开始"。

P 帧和 B 帧一般只包含 Slice NAL。

1.6 一个具体例子 下面是一个典型的 I 帧(关键帧)的完整码流(AnnexB 格式):

00 00 00 01 67 64 00 1F AC D9 80 50 ... ← SPS NAL (type=7)

│ 00 00 00 01 68 EE 3C 80 ← PPS NAL (type=8) │ 00 00 00 01 06 00 0A ... ← SEI NAL (type=6, 可选) │ 00 00 00 01 65 88 84 00 50 ... ← IDR Slice NAL (type=5) ↑ 实际的压缩像素数据

解码器按顺序读:

67

→ 哦,这是个 SPS,记下分辨率、Profile 等信息

68

→ 哦,这是个 PPS,记下用 CABAC 还是 CAVLC、QP 表

06

→ 哦,这是个 SEI,读一下看有没有有用的信息

65

→ 这是个 IDR 帧的 Slice!

可以用前面记下的 SPS/PPS 配置来解码它 1.7 第一章小结

码流 = 一串 NAL 单元的序列

每个 NAL = 1字节Header(含类型号) + Payload

NAL 类型: ├─ VCL (1,5): 压缩像素数据 └─ 非VCL (7,8,6): SPS配置、PPS配置、SEI辅助信息

一"帧" = 一个 Access Unit = 一组 NAL 的集合

层级: 序列 → 帧 → Slice → 宏块(16×16) → 块(4×4/8×8)

第二章:NAL 单元的分隔方式 — AnnexB 与 AVCC 上一章讲了 NAL 单元是码流的基本容器。

现在的问题是: 一条连续的字节流中,解码器怎么知道第一个 NAL 在哪结束、第二个 NAL 从哪开始?

答案有两种方案,分别对应两种封装格式。

2.1 两种方案 特性AnnexBAVCC(也叫 MP4 格式 / NALFF) 思路用特殊标记分隔,像书签在每个 NAL 前写明"我有多长"分隔符号00 00 00 0100 00 014 字节大端整数(长度值)典型场景裸流 .h264、TS 流、实时直播MP4、MKV、FLV 等容器封装如何找 NAL扫描到起始码 = 新 NAL 开始读长度前缀 → 跳过 N 字节 → 下一个长度前缀 2.2 AnnexB 格式 起始码 AnnexB 在每个 NAL 前插入一个"不可能出现在数据中"的特殊字节序列作为分隔标记: 4 字节起始码 :

00 00 00 01

— 用于 SPS、PPS、IDR、每个 Access Unit 的第一个 NAL 3 字节起始码 :

00 00 01

— 编码器可对普通 Slice 使用,节省 1 字节

AnnexB 码流的内存布局:

┌───────────┬─────────────────┬───────────┬─────────────────┬───────────┬───── │ 00 00 00 │ NAL 数据 │ 00 00 00 │ NAL 数据 │ 00 00 00 │ ... │ 01 │ (第一个NAL) │ 01 │ (第二个NAL) │ 01 │ └───────────┴─────────────────┴───────────┴─────────────────┴───────────┴───── ↑ 起始码标记新NAL开始 ↑ ↑

防竞争字节 (Emulation Prevention Byte) 这是一个关键问题:起始码是

00 00 01

,但如果 NAL 数据内部恰好也出现了

00 00 01

怎么办?

解码器会误以为这里开始了新的 NAL。

H.264 的解决方案: 编码时插入0x03,解码时去掉 。

规则:如果 NAL 数据中出现以下模式,就在两个 0 后面插入

0x03

: 原始数据编码后说明

00 00 0000 00 03 00防止被误认为 3 个起始 000 00 0100 00 03 01防止被误认为起始码00 00 0200 00 03 02防止被误认为保留码00 00 0300 00 03 03连 0x03 自己也要防!

也就是说,原始的 NAL 数据叫 RBSP(Raw Byte Sequence Payload),插入0x03后的实际传输数据叫 EBSP(Encapsulated…)。

解码器收到 EBSP 后,通过ff_h2645_extract_rbsp()把所有0x03去掉,还原为 RBSP。

编码过程: RBSP ──(插入0x03)──→ EBSP ──(写入文件/发送)

解码过程: EBSP ──(去除0x03)──→ RBSP ──(解码)

FFmpeg 中对应的代码在 h2645_parse.c ,

ff_h2645_extract_rbsp()

函数。

2.3 AVCC 格式 AVCC 的思路更直接: 每个 NAL 前放一个 4 字节长度,告诉解码器后面的 NAL 有多长,不需要特殊标记 。

AVCC 码流的内存布局:

┌────────────┬─────────────────┬────────────┬─────────────────┬────────────┬───── │ 00 00 00 1A│ NAL 数据 │ 00 00 00 08│ NAL 数据 │ 00 00 00 FA│ ... │ (=26字节) │ (第一个NAL) │ (=8字节) │ (第二个NAL) │ (=250字节) │ └────────────┴─────────────────┴────────────┴─────────────────┴────────────┴───── ↑ 长度字段 ↑ ↑ 跳过26字节后就是下一个长度 8字节后又是下一个长度

extradata (avcC) MP4 容器在 extradata 中存储

avcC

box,告诉解码器: 用几字节做长度前缀(

lengthSizeMinusOne + 1

,几乎总是 4) 把 SPS 和 PPS 也预先放在 extradata 中(所以 MP4 的码流数据里可以不含 SPS/PPS)

avcC 结构:

┌──────────────────────────┬───────┬────────────────────────────────┐ │ configurationVersion = 1 │ 1字节 │ 固定值 │ │ AVCProfileIndication │ 1字节 │ 从 SPS 中提取的 profile_idc │ │ profile_compatibility │ 1字节 │ 兼容性标记 │ │ AVCLevelIndication │ 1字节 │ 从 SPS 中提取的 level_idc │ │ lengthSizeMinusOne │ 6 bits│ 长度字段的字节数-1 (通常=3, 即4字节)│ │ SPS 个数 + SPS列表 │ 变长 │ SPS_length(2B) + SPS_data │ │ PPS 个数 + PPS列表 │ 变长 │ PPS_length(2B) + PPS_data │ └──────────────────────────┴───────┴────────────────────────────────┘

2.4 两种格式的对比总结

AnnexB (裸流 / 直播):

优点: 简单,不需要预先知道长度,可以一边编码一边发送 缺点: 定位某个 NAL 需要从头扫描所有起始码 标志: 以 00 00 00 01 开头

AVCC (MP4 / 文件): 优点: 读长度就能跳转,支持随机访问 缺点: 必须预先知道每个 NAL 的长度才能写长度前缀 标志: 前 4 字节是长度值 (通常 > 1 且 ≤ 文件大小)

2.5 FFmpeg 如何自动检测格式 在 h264dec.c:602 ,解码器可以通过前几个字节自动判断:

if (h->nal_length_size == 4) {

if (AV_RB32(buf) == 1 && AV_RB32(buf+5) > buf_size) h->is_avc = 0; // 前4字节=0x00000001(起始码), 且第5字节的值>总大小 → AnnexB else if (AV_RB32(buf) > 1 && AV_RB32(buf) <= buf_size) h->is_avc = 1; // 前4字节>1且≤总大小 → 这是NAL长度 → AVCC }

简单理解:读前 4 字节,如果是0x00000001就是 AnnexB,否则看是不是合理的长度值来判断。

2.6 ff_h2645_packet_split —— 两种格式统一解析 一个函数同时处理两种格式,核心是一个精巧的

next_avc

变量 ( h2645_parse.c:527 ):

int next_avc = (flags & H2645_FLAG_IS_NALFF) ? 0 : length;

// AVCC: 从 0 开始 AnnexB: 设为末尾(永不触发AVCC分支)

while (还有可读数据) { if (当前位置 == next_avc) { // ===== AVCC 路径 ===== 读4字节长度 → 跳过长度前缀 → next_avc = 下个NAL位置 } else { // ===== AnnexB 路径 ===== 扫描 00 00 01 起始码 → 跳过起始码 → 读到下一个起始码为止 } 去防竞争字节 (0x03); // 公共处理 }

模式next_avc初值分支走向 AnnexBlength(buffer 末尾)当前位置永远 <next_avc,走 else 分支AVCC0(开头)当前位置 == 0 == next_avc,走 if 分支 2.7 格式转换

# MP4(AVCC) → 裸流(AnnexB)

ffmpeg -i input.mp4 -c copy -bsf:v h264_mp4toannexb output.h264

# 裸流(AnnexB) → MP4(AVCC) —— 自动处理 ffmpeg -i input.h264 -c copy output.mp4

附录:解码器核心文件索引 文件作用 h264dec.c解码器顶层入口:h264_decode_frame()decode_nal_units()h2645_parse.cNAL 切分:ff_h2645_packet_split()支持 AnnexB 和 AVCCh264_ps.cSPS/PPS 解析h264_slice.cSlice header 解析 + 逐宏块解码循环h264_cabac.cCABAC 熵解码(算术编码)h264_cavlc.cCAVLC 熵解码(变长编码)h264_mb.c宏块重建:预测 + IDCT + 残差叠加h264_loopfilter.c去块效应滤波h264_refs.c参考帧管理 / DPB 操作h264_sei.cSEI 辅助信息解析

相关文章