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 01或00 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 辅助信息解析
