名称 一句话
SPS(Sequence Parameter Set) 整个序列的”全局说明书”:分辨率、profile/level、参考帧数量等
PPS(Picture Parameter Set) 单张图像的”编码参数”:熵编码方式、slice 组、量化参数初值等
IDR(Instantaneous Decoder Refresh) 一种特殊的关键帧:解码器遇到它就清空参考帧缓存,后面的帧不会再引用它之前的帧,所以可以从这里随机接入

三者关系:没有 SPS/PPS,解码器不知道怎么解码;没有 IDR,解码器无法从中间位置开始解码。 所以直播里起播必须先拿到 SPS + PPS,再等到 IDR。


NALU 基础

1
2
3
4
5
6
7
码流/视频序列
└── 帧 Access Unit(一张画面)
├── NALU: AUD / SPS / PPS / SEI ← 载荷不是 slice
└── NALU: type 1/5 ← 载荷是 slice
└── slice (header + data)
└── 宏块 macroblock(16×16)
└── 子块/像素

Access Unit(访问单元,AU)

一个 AU = 解码器还原一张画面时,应一起处理的那一组 NALU。

1
2
3
4
5
一张画面
↓ 编码
一个 Access Unit
↓
里面包含 1 个或多个 NAL Unit

码流就是 AU 一个接一个排下去:

1
2
3
4
5
Access Unit 1  → IDR 图像
Access Unit 2 → P 图像
Access Unit 3 → P 图像
Access Unit 4 → P 图像
...

AU 里有什么(两类 NALU 混在一起):

类别 NALU 说明
VCL(Video Coding Layer) type 1(非 IDR slice)、type 5(IDR slice) 真正装像素数据
non-VCL AUD(9)、SPS(7)、PPS(8)、SEI(6) 等 参数、分隔符、附加信息
1
2
3
4
5
6
7
8
9
10
11
12
13
14
IDR 对应的 Access Unit
├── NAL:AUD(可选)
├── NAL:SPS
├── NAL:PPS
├── NAL:SEI(可选)
├── NAL:IDR slice
├── NAL:IDR slice ← 多 slice 时才有
└── ...

P 帧对应的 Access Unit
├── NAL:AUD(可选)
├── NAL:SEI(可选)
├── NAL:P slice
└── 可能还有 SEI 等

关键点:

  • AU 里必有至少一个 VCL NALU(主编码图像的 slice),其余都是可选的 non-VCL。
  • 一个 AU 只含一张主编码图像的所有 slice,这就是”一张画面 = 一个 AU”。
  • FLV/RTMP 的一个视频 tag,装的就是一个 AU。FFmpeg 的一个 AVPacket 通常也对应一个 AU。

NALU(NAL Unit)

NALU 是码流的”传输/封装单位”:一段带 1 字节头的数据。 NAL 层的设计目的,就是让 H.264 数据能方便地装进各种传输和存储格式(TS、FLV、RTP、MP4)。

结构

1
| NAL header (1 字节) | NAL payload (RBSP) |

头部 1 字节:

1
| forbidden_zero_bit (1) | nal_ref_idc (2) | nal_unit_type (5) |
  • forbidden_zero_bit:必须为 0,为 1 表示出错。
  • nal_ref_idc:重要程度,0 表示不被其他帧参考(可丢),非 0 表示会被参考。SPS/PPS/IDR 通常是 3。
  • nal_unit_type: 类型 ,决定后面 payload 是什么。
    type 含义 类别
    1 非 IDR 图像的 slice(P/B 帧等) VCL
    5 IDR 图像的 slice VCL
    6 SEI(补充增强信息) non-VCL
    7 SPS non-VCL
    8 PPS non-VCL
    9 AUD(访问单元分隔符) non-VCL
    常见首字节:0x67=SPS,0x68=PPS,0x65=IDR slice,0x41/0x61=P slice。

Slice(条带)

Slice 是一张图像内部可独立解码的一块区域,由若干宏块组成,是 VCL NALU 的载荷内容。

1
2
3
4
一张图像
├── slice 0:宏块 0 ~ 99
├── slice 1:宏块 100 ~ 199
└── slice 2:宏块 200 ~ 299

Slice 的 NALU 内部:

1
| NAL header | slice header | slice data(一串宏块) |

slice 的类型

slice header 里有 slice_type:

类型 含义
I slice 只用帧内预测
P slice 可参考前面的帧
B slice 可参考前后的帧

一帧里的不同 slice 可以类型不同,所以”I 帧”严格说是”所有 slice 都是 I slice 的帧”。IDR 帧则是所有 slice 的 nal_unit_type 都为 5。

slice header 里有什么

每个 slice 带自己的头,内容包括:

  • first_mb_in_slice:这个 slice 从第几个宏块开始(多 slice 时靠它拼回整帧)
  • slice_type
  • pic_parameter_set_id:指向 PPS(这就是前面讲的 slice → PPS → SPS 引用链的起点)
  • frame_num、POC 相关字段
  • 量化参数偏移等

一帧一个 slice,还是多个

  • 最常见:一帧一个 slice,一帧对应一个 NALU,x264 默认如此。
  • 多 slice:把一帧横向切成几条,每条一个 NALU。
为什么要分多个 slice
  1. 抗丢包(最主要):各 slice 独立解码,丢了一个只损坏图像的一部分,其他区域仍可正常显示。RTP 传输、UDP 场景常用。
  2. 适配 MTU:让一个 NALU 不超过网络包大小,避免再做 FU-A 分片。
  3. 并行编解码:多个 slice 可以交给多线程同时处理。
  4. 低延迟:不用等整帧编码完,一个 slice 好了就能先发。
    代价:slice 之间不能做帧内预测和去块滤波的跨边界参考(默认),压缩效率会略降,头部开销也增加。

NALU 和 slice 的关系与区别(重点)

一句话:slice 是”内容”,NALU 是”包装”。slice 是 NALU 的一种(VCL 类),但 NALU 不都是 slice。

NALU Slice
层次 NAL 层(封装/传输层) VCL 层(编码/视频内容层)
回答的问题 数据怎么装、怎么分界、是什么类型 图像的哪一块区域、怎么编码
包含范围 参数集、SEI、AUD、slice 等所有类型 只是图像像素数据的一部分
与图像的关系 不一定和图像有关(SPS/PPS 就和具体图像无关) 一张图像由 1 个或多个 slice 组成

对应规则:

  • 一个 slice 恰好放进一个 NALU(type 1 或 5)。
  • 一个 NALU 不一定是 slice:SPS、PPS、SEI、AUD 都是 NALU,但不是 slice。
  • 所以:slice ⊂ NALU,”slice NALU”就是 type 1/5 的那些 NALU。

对照记忆表:

你看到的词 属于哪层 例子
nal_unit_type NAL header 1、5、7、8
slice_type slice header I、P、B
pic_parameter_set_id slice header 指向 PPS
first_mb_in_slice slice header 本 slice 起始宏块

判断”一个 AU 结束了没有”

多 slice 时,要把属于同一帧的 slice 凑齐,才算一个完整 AU:

  • 码流有 AUD(type 9) 就用它作分隔;
  • 否则比较相邻 slice 的 frame_num、pic_parameter_set_id、nal_unit_type(是否 IDR)、POC 等字段,出现变化就说明进入了新 AU;
  • RTP 里看 M 位 和时间戳。

和 FFmpeg 的对应

概念 FFmpeg 里的体现
AU 一个 AVPacket(FLV demux 出来通常是一个 AU,AVCC 格式)
NALU 包内按长度前缀排列的多段数据,解码器内部拆分,业务代码通常不碰
SPS/PPS codecpar->extradata(AVCC 配置记录),avcodec_open2 时解析
slice 解码器内部处理,可多线程并行解 slice(thread_type = FF_THREAD_SLICE)
IDR 判断 pkt->flags & AV_PKT_FLAG_KEY(近似),精确要解析 NALU type=5

总结

NALU 是 H.264 在 NAL 层的封装单位,1 字节头(forbidden_zero_bit、nal_ref_idc、nal_unit_type)加载荷,类型包括 SPS、PPS、SEI、AUD 和 slice。Slice 是一张图像内可独立解码的一块区域,属于 VCL 层,每个 slice 装进一个 NALU(type 1 或 5),所以 slice 是 NALU 的一种,但 NALU 不都是 slice。Access Unit 是一张画面对应的一组 NALU,包含一张图像的所有 slice,加上可选的 AUD、SEI 等;FLV 里一个视频 tag 就是一个 AU,FFmpeg 里对应一个 AVPacket。多数场景一帧一个 slice,多 slice 用于抗丢包、适配 MTU 和并行编码。


SPS:序列参数集

作用于整个编码视频序列(从一个 IDR 到下一个 IDR 之前 GOP)。主要内容:

  • profile_idc / level_idc:编码档次和级别(Baseline、Main、High;Level 3.1、4.0 等),决定解码器能力要求。
  • 分辨率:pic_width_in_mbs_minus1、pic_height_in_map_units_minus1(以宏块为单位),还有 cropping(裁剪,比如 1080p 编码时实际是 1088 行,靠 crop 裁成 1080)。
  • 帧率等 VUI 信息(可选):时间基、宽高比、颜色空间。
  • max_num_ref_frames:最多参考几帧。
  • pic_order_cnt_type 等:控制显示顺序(和 B 帧、PTS 有关)。
  • frame_mbs_only_flag:是否逐行(影响是否支持隔行扫描)。

要点:分辨率不是直接存 1920×1080,而是存宏块数(16×16)再加裁剪,所以从 SPS 解析分辨率要自己算。

PPS:图像参数集

作用于一张或多张图像,每个 slice header 里通过 pic_parameter_set_id 指向某个 PPS,PPS 再通过 seq_parameter_set_id 指向 SPS。引用链是:

1
slice → PPS → SPS

主要内容:

  • entropy_coding_mode_flag:熵编码方式,0 = CAVLC,1 = CABAC(Main/High profile 才有 CABAC)。
  • slice group 相关(FMO,Baseline 用,现在很少)。
  • num_ref_idx_default_active:默认参考帧数。
  • 加权预测、初始量化参数(pic_init_qp)。
  • deblocking_filter_control_present_flag:是否在 slice header 里控制去块滤波。
  • transform_8x8_mode_flag:High profile 的 8×8 变换。

为什么拆成 SPS 和 PPS 两层? 为了减少冗余传输:序列级不变的信息(分辨率等)放 SPS 只发一次,图像级可能变化的参数放 PPS,可以有多个 PPS 供不同图像引用,每帧不需要重复携带全部参数。


IDR:关键帧中的关键

I 帧 vs IDR 帧

普通 I 帧 IDR 帧
帧内编码 是 是
后续帧能否引用它之前的帧 可以(B/P 帧可能越过 I 帧去引用更早的帧) 不能,IDR 之后的帧只能引用 IDR 及之后的帧
能否作为随机接入点 不保证 保证
对参考帧缓存 (DPB) 无影响 立即清空 DPB
nal_unit_type 1(slice) 5

所以:IDR 一定是 I 帧,但 I 帧不一定是 IDR。 从普通 I 帧开始解码,可能因为后续帧引用了它之前的帧而花屏。

IDR 的作用

  1. 随机接入:播放器 seek、中途加入直播,必须从 IDR 开始解码。
  2. 错误恢复:传输出错后,解码器等下一个 IDR 就能彻底复位,错误不再扩散。
  3. 重置参数:IDR 前后可以更换 SPS/PPS(比如换分辨率),新的序列从 IDR 开始。
  4. POC 和 frame_num 复位:IDR 之后显示顺序计数从 0 重新开始。

GOP

GOP(Group of Pictures):两个相邻 IDR 之间的帧序列。直播推流常设 GOP = 1~2 秒。

  • GOP 太长:起播慢(要等 IDR)、错误恢复慢,但压缩率高。
  • GOP 太短:码率高,I 帧占比大。
  • HLS 要求分片起始必须是 IDR,所以 GOP 要和分片时长对齐。

在直播协议里它们放哪

对应几个协议,也是高频考点。

场景 SPS/PPS 放哪 视频帧格式
RTMP / FLV 开头一个 Sequence Header 包(AVCPacketType=0),里面是 AVCDecoderConfigurationRecord,包含 SPS、PPS AVCC:4 字节长度前缀 + NALU
MP4 / fMP4 avcC box(同样是 AVCDecoderConfigurationRecord) AVCC
MPEG-TS / HLS(TS) 随码流内嵌,通常每个 IDR 前面重复带 SPS+PPS Annex-B:起始码 00 00 00 01 分隔
RTSP / RTP SDP 的 sprop-parameter-sets(base64),也可能随码流周期性发送 RTP 封装(Single NAL / STAP-A / FU-A)
AVCC vs Annex-B(内部实现了解即可)
1
2
Annex-B:  00 00 00 01 [SPS]  00 00 00 01 [PPS]  00 00 00 01 [IDR slice] ...
AVCC: [4字节长度][NALU] [4字节长度][NALU] ... (SPS/PPS 单独放在配置记录里)
  • Annex-B 靠起始码定界,适合流式、随机接入(可以在任意位置重新同步)。
  • AVCC 靠长度前缀定界,解析快,适合容器格式。
  • 两者互转是常见工作:RTMP/FLV 收到的 AVCC 要送给只吃 Annex-B 的解码器(如部分硬解)时,要把长度前缀换成起始码,并在每个 IDR 前插入 SPS/PPS。

AVCDecoderConfigurationRecord 结构(RTMP Sequence Header 的内容)

1
2
3
4
5
6
7
8
9
configurationVersion (1)   = 1
AVCProfileIndication (1)
profile_compatibility (1)
AVCLevelIndication (1)
lengthSizeMinusOne (1) 低 2 位,NALU 长度前缀字节数 - 1(通常是 3,即 4 字节)
numOfSPS (1) 低 5 位
SPS length (2) + SPS data
numOfPPS (1)
PPS length (2) + PPS data

播放器起播流程(整合)

以 RTMP / HTTP-FLV 拉流为例:

  1. 收到 Sequence Header → 解析出 SPS/PPS → 初始化解码器(知道分辨率、profile)。
  2. 后续视频包的 NALU 是 AVCC 格式,按长度前缀拆出 NALU。
  3. 丢弃 IDR 之前的非 IDR 帧(P/B 帧没有参考,解了也是花屏),直到遇到第一个 IDR 才开始送显。
  4. 之后正常解码、渲染。

所以前面提到”服务端缓存 Sequence Header + 最近一个 GOP”做秒开,就是保证新观众一连上就拿到 SPS/PPS 和一个 IDR。

用 FFmpeg 时,每一步对应哪段代码

原理步骤 FFmpeg 里对应的调用 谁在做事
收到 Sequence Header,取出 SPS/PPS avformat_open_input + avformat_find_stream_info FLV demuxer 把 Sequence Header 原样放进 stream->codecpar->extradata(即 AVCC 格式的配置记录)
初始化解码器 avcodec_parameters_to_context → avcodec_open2 H.264 解码器在 open 时解析 extradata:取 SPS/PPS,记下 NALU 长度前缀字节数
按长度前缀拆 NALU avcodec_send_packet 解码器内部完成,你的代码里看不到循环
丢弃 IDR 之前的帧 解码器内部有保护,你也可以自己加 见下文
解码、渲染 avcodec_receive_frame → 渲染 你的代码

加上前面的 demux 和后面的 receive。

骨架如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. 打开输入,探测流信息(此时 extradata 里已有 SPS/PPS)
avformat_open_input(&fmt, url, nullptr, &opts);
avformat_find_stream_info(fmt, nullptr);
int vi = av_find_best_stream(fmt, AVMEDIA_TYPE_VIDEO, -1, -1, nullptr, 0);
AVCodecParameters* par = fmt->streams[vi]->codecpar; // par->extradata 就是 avcC

// 2. 初始化解码器
const AVCodec* codec = avcodec_find_decoder(par->codec_id);
AVCodecContext* ctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(ctx, par);
avcodec_open2(ctx, codec, nullptr); // 这里解析 SPS/PPS

// 3. 循环:读包、送解码、取帧
while (av_read_frame(fmt, pkt) >= 0) {
if (pkt->stream_index == vi) {
avcodec_send_packet(ctx, pkt);
while (avcodec_receive_frame(ctx, frame) == 0) {
render(frame);
}
}
av_packet_unref(pkt);
}

SPS/PPS 相关的实战细节

  1. extradata 为空会怎样:AVCC 流(FLV/MP4)里解码器靠 extradata 初始化。TS 或裸流是 Annex-B,SPS/PPS 在码流里,find_stream_info 要读到若干包才能解析出来,所以启动会慢一点。
  2. 推流中途分辨率/参数变化:RTMP 会再发一个 Sequence Header。FFmpeg 的 FLV demuxer 会把它作为 AV_PKT_DATA_NEW_EXTRADATA 的 side data 挂在后续包上,H.264 解码器会据此更新 SPS/PPS。但渲染端要处理 frame->width/height 变化(重建纹理或缩放上下文),否则会花屏或崩溃。
  3. 起播慢的常见原因:avformat_find_stream_info 默认会读较多数据来探测。直播里常调小 probesize、analyzeduration,并加 fflags=nobuffer、flags=low_delay。代价是探测不充分时可能拿不到完整的流信息,需要实测。

IDR 相关的实战细节

  1. FFmpeg 自带保护:H.264 解码器内部有”恢复”状态,默认情况下,还没遇到 IDR(或 SEI recovery point)时,不会输出参考不完整的帧,所以从 P 帧起播通常不会直接吐出花屏画面。具体行为受版本和标志位影响(例如 AV_CODEC_FLAG_OUTPUT_CORRUPT 会允许输出损坏帧),我记忆里的细节不一定精确,建议用你的 FFmpeg 版本实测。
  2. 自己判断关键帧:
    • pkt->flags & AV_PKT_FLAG_KEY:FLV demuxer 根据 FrameType 设置,它表示”关键帧”,不严格等于 IDR。
    • 解码后看 frame->pict_type == AV_PICTURE_TYPE_I,同样只说明是 I 帧。
    • 要精确判断 IDR,只能解析 NALU 类型是否为 5。
  3. 什么时候值得自己丢帧:弱网丢包、断线重连、切流后,你想控制”从下一个 IDR 才开始显示”,可以自己维护一个标志位:
1
2
3
4
5
6
7
8
9
bool waitIDR = true;          // 起播/重连/出错后置 true

// 收到包后:
if (waitIDR) {
if (!(pkt->flags & AV_PKT_FLAG_KEY)) { av_packet_unref(pkt); continue; }
waitIDR = false;
avcodec_flush_buffers(ctx); // 清掉解码器里的旧参考帧
}
avcodec_send_packet(ctx, pkt);

关键是 avcodec_flush_buffers:重连、seek、切换流时必须调用,等于手动实现”IDR 清空 DPB”的效果,否则旧参考帧会污染新画面。

硬解时的差别

用 hw_device_ctx 做硬解时,SPS/PPS 和 IDR 的处理流程不变,仍然是 extradata 初始化、送包。区别只是 avcodec_receive_frame 拿到的是硬件表面(如 D3D11、VAAPI 的 surface),需要 av_hwframe_transfer_data 拷回内存,或直接用于零拷贝渲染。只有绕开 FFmpeg、直接调平台原生解码接口时,才要考虑 AVCC 转 Annex-B。

面试回答模板

我们的播放器用 FFmpeg 拉 FLV 流。demuxer 把 Sequence Header 放进 codecpar->extradata,里面是 AVCC 格式的 SPS/PPS;通过 avcodec_parameters_to_context 传给解码上下文,avcodec_open2 时解码器解析 SPS/PPS 完成初始化,之后送入的包是长度前缀格式,由库内部拆 NALU。
起播时我只显示从关键帧开始的画面,重连、seek 时调用 avcodec_flush_buffers 清空参考帧。推流中途如果分辨率变化,会收到新的 Sequence Header,解码器会更新参数,渲染端要同步处理帧尺寸变化。为了加快起播,我调小了 probesize 和 analyzeduration,服务端也缓存了 Sequence Header 和最近一个 GOP。


高频追问

  1. 花屏/绿屏常见原因? 没有 SPS/PPS 就开始解码;从非 IDR 帧起播;丢包导致参考帧缺失。解决:起播等 IDR,检测到丢包就丢帧并等下一个 IDR。
  2. 推流中途 SPS/PPS 变了怎么办? 说明分辨率或编码参数变了。RTMP 下会再发一个新的 Sequence Header,播放器要重新初始化解码器;MPEG-TS 下则是新的 IDR 前带新的 SPS/PPS。
  3. 怎么判断一个包是不是关键帧? RTMP/FLV 里看 FrameType = 1;但更准确的是解析 NALU 的 type 是否为 5。FrameType = 1 只表示”关键帧”,部分编码器的非 IDR I 帧也可能标成关键帧,需要警惕。
  4. HEVC 呢? 多了一层 VPS(Video Parameter Set),所以是 VPS + SPS + PPS;NALU 头是 2 字节;关键帧类型有 IDR_W_RADL / IDR_N_LP(19、20)、CRA(21)等,CRA 不一定能作为严格的随机接入点(可能带前导帧),这点和 H.264 的 IDR 略有不同。

面试回答模板

H.264 码流由 NALU 组成。SPS 是序列级参数集,包含 profile/level、分辨率(以宏块为单位加裁剪)、参考帧数等;PPS 是图像级参数集,包含熵编码方式(CAVLC/CABAC)、量化初值等,slice 通过 PPS id 引用 PPS,PPS 再引用 SPS。解码器必须先拿到 SPS/PPS 才能初始化。
IDR 是一种特殊的 I 帧,解码到它时会清空参考帧缓存,之后的帧不会引用它之前的帧,所以它是随机接入点,也用于错误恢复。普通 I 帧不保证这一点。
所以起播时需要先等 SPS/PPS,再从第一个 IDR 开始送显,避免花屏。