音视频技术栈基础
| 名称 | 一句话 |
|---|---|
| SPS(Sequence Parameter Set) | 整个序列的”全局说明书”:分辨率、profile/level、参考帧数量等 |
| PPS(Picture Parameter Set) | 单张图像的”编码参数”:熵编码方式、slice 组、量化参数初值等 |
| IDR(Instantaneous Decoder Refresh) | 一种特殊的关键帧:解码器遇到它就清空参考帧缓存,后面的帧不会再引用它之前的帧,所以可以从这里随机接入 |
三者关系:没有 SPS/PPS,解码器不知道怎么解码;没有 IDR,解码器无法从中间位置开始解码。 所以直播里起播必须先拿到 SPS + PPS,再等到 IDR。
NALU 基础
1 | 码流/视频序列 |
Access Unit(访问单元,AU)
一个 AU = 解码器还原一张画面时,应一起处理的那一组 NALU。
1 | 一张画面 |
码流就是 AU 一个接一个排下去:
1 | Access Unit 1 → IDR 图像 |
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 | IDR 对应的 Access Unit |
关键点:
- 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 | 一张图像 |
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_typepic_parameter_set_id:指向 PPS(这就是前面讲的 slice → PPS → SPS 引用链的起点)frame_num、POC 相关字段- 量化参数偏移等
一帧一个 slice,还是多个
- 最常见:一帧一个 slice,一帧对应一个 NALU,x264 默认如此。
- 多 slice:把一帧横向切成几条,每条一个 NALU。
为什么要分多个 slice
- 抗丢包(最主要):各 slice 独立解码,丢了一个只损坏图像的一部分,其他区域仍可正常显示。RTP 传输、UDP 场景常用。
- 适配 MTU:让一个 NALU 不超过网络包大小,避免再做 FU-A 分片。
- 并行编解码:多个 slice 可以交给多线程同时处理。
- 低延迟:不用等整帧编码完,一个 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 的作用
- 随机接入:播放器 seek、中途加入直播,必须从 IDR 开始解码。
- 错误恢复:传输出错后,解码器等下一个 IDR 就能彻底复位,错误不再扩散。
- 重置参数:IDR 前后可以更换 SPS/PPS(比如换分辨率),新的序列从 IDR 开始。
- 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 | Annex-B: 00 00 00 01 [SPS] 00 00 00 01 [PPS] 00 00 00 01 [IDR slice] ... |
- Annex-B 靠起始码定界,适合流式、随机接入(可以在任意位置重新同步)。
- AVCC 靠长度前缀定界,解析快,适合容器格式。
- 两者互转是常见工作:RTMP/FLV 收到的 AVCC 要送给只吃 Annex-B 的解码器(如部分硬解)时,要把长度前缀换成起始码,并在每个 IDR 前插入 SPS/PPS。
AVCDecoderConfigurationRecord 结构(RTMP Sequence Header 的内容)
1 | configurationVersion (1) = 1 |
播放器起播流程(整合)
以 RTMP / HTTP-FLV 拉流为例:
- 收到 Sequence Header → 解析出 SPS/PPS → 初始化解码器(知道分辨率、profile)。
- 后续视频包的 NALU 是 AVCC 格式,按长度前缀拆出 NALU。
- 丢弃 IDR 之前的非 IDR 帧(P/B 帧没有参考,解了也是花屏),直到遇到第一个 IDR 才开始送显。
- 之后正常解码、渲染。
所以前面提到”服务端缓存 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 | // 1. 打开输入,探测流信息(此时 extradata 里已有 SPS/PPS) |
SPS/PPS 相关的实战细节
- extradata 为空会怎样:AVCC 流(FLV/MP4)里解码器靠 extradata 初始化。TS 或裸流是 Annex-B,SPS/PPS 在码流里,
find_stream_info要读到若干包才能解析出来,所以启动会慢一点。 - 推流中途分辨率/参数变化:RTMP 会再发一个 Sequence Header。FFmpeg 的 FLV demuxer 会把它作为
AV_PKT_DATA_NEW_EXTRADATA的 side data 挂在后续包上,H.264 解码器会据此更新 SPS/PPS。但渲染端要处理frame->width/height变化(重建纹理或缩放上下文),否则会花屏或崩溃。 - 起播慢的常见原因:
avformat_find_stream_info默认会读较多数据来探测。直播里常调小probesize、analyzeduration,并加fflags=nobuffer、flags=low_delay。代价是探测不充分时可能拿不到完整的流信息,需要实测。
IDR 相关的实战细节
- FFmpeg 自带保护:H.264 解码器内部有”恢复”状态,默认情况下,还没遇到 IDR(或 SEI recovery point)时,不会输出参考不完整的帧,所以从 P 帧起播通常不会直接吐出花屏画面。具体行为受版本和标志位影响(例如
AV_CODEC_FLAG_OUTPUT_CORRUPT会允许输出损坏帧),我记忆里的细节不一定精确,建议用你的 FFmpeg 版本实测。 - 自己判断关键帧:
pkt->flags & AV_PKT_FLAG_KEY:FLV demuxer 根据 FrameType 设置,它表示”关键帧”,不严格等于 IDR。- 解码后看
frame->pict_type == AV_PICTURE_TYPE_I,同样只说明是 I 帧。 - 要精确判断 IDR,只能解析 NALU 类型是否为 5。
- 什么时候值得自己丢帧:弱网丢包、断线重连、切流后,你想控制”从下一个 IDR 才开始显示”,可以自己维护一个标志位:
1 | bool waitIDR = true; // 起播/重连/出错后置 true |
关键是 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。
高频追问
- 花屏/绿屏常见原因? 没有 SPS/PPS 就开始解码;从非 IDR 帧起播;丢包导致参考帧缺失。解决:起播等 IDR,检测到丢包就丢帧并等下一个 IDR。
- 推流中途 SPS/PPS 变了怎么办? 说明分辨率或编码参数变了。RTMP 下会再发一个新的 Sequence Header,播放器要重新初始化解码器;MPEG-TS 下则是新的 IDR 前带新的 SPS/PPS。
- 怎么判断一个包是不是关键帧? RTMP/FLV 里看 FrameType = 1;但更准确的是解析 NALU 的 type 是否为 5。FrameType = 1 只表示”关键帧”,部分编码器的非 IDR I 帧也可能标成关键帧,需要警惕。
- 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 开始送显,避免花屏。

