跳到正文

音视频数据流基础 · 第 1 篇

H264流

约 13 分钟音视频流基础#H264#裸流
目录1. 音视频数据流转全景2. H.264 裸流的基本结构:NALU 与起始码2.1 什么是 NALU?2.2 两种封装格式:Annex B vs AVCC2.3 起始码的两个边界问题2.4 实战:在十六进制中寻找 Start Code 与防竞争机制3. 深入 NALU Header 拆解4. H.264 宏观架构与关键参数集4.1 SPS 与 PPS 详解4.2 SPS 解析流程:白皮书 7.3 小节的伪代码启示4.3 编码黑科技:Exp-Golomb(指数哥伦布编码)5. 帧的类型与预测机制 (I / P / B 帧)5.1 B帧引发的时间戳乱序危机 (PTS vs DTS)5.2 I帧 与 IDR帧 的本质区别6.GOP7. SEI (补充增强信息)8. 常用提取与分析命令速查

1. 音视频数据流转全景

在深入 H.264 底层细节之前,我们需要先建立音视频数据在整个生命周期中的宏观流转认知。一个典型的音视频开发链路,会经历从采集、编码、封装,到网络传输、解封装、解码,最终渲染的过程。

正如上图所示,未经压缩的原始视频数据(YUV)体积极其庞大。为了便于存储与传输,必须将其送入视频编码器(如 x264/x265)进行极高强度的压缩。对于 H.264 标准而言,压缩后输出的数据流也就是我们常说的 H.264 裸流(Raw Stream)。

2. H.264 裸流的基本结构:NALU 与起始码

2.1 什么是 NALU?

H.264 裸流在物理形态上是由一个个 NALU(Network Abstraction Layer Unit,网络抽象层单元) 拼接而成的。每一个 NALU 就像是一个独立的积木块,包含了极其精简的头信息(Header)和实际的压缩数据正文(Payload)。

... | 起始码 | NALU Header | NALU Payload | 起始码 | NALU Header | NALU Payload | ...

2.2 两种封装格式:Annex B vs AVCC

这是初学者最容易混淆的概念。H.264 裸流在存储和网络传输时,主要存在两种封装边界边界格式:

1. Annex B 格式(字节流格式) 这是最“原始”的裸流格式,广泛用于实时传输协议(如 RTMP推流、MPEG-TS切片)以及 .264 纯视频文件中。 由于网络传输(如 TCP/UDP)是无边界的连续字节流,解码器必须依靠特殊的特征码来切分出一帧帧的数据,这个特征码就是 起始码(Start Code)。

  • 4字节起始码 0x00 00 00 01:用于 SPS、PPS、IDR 帧等极其关键的 NALU。
  • 3字节起始码 0x00 00 01:用于普通的非关键 NALU(如 P 帧、B 帧的 Slice),用以极致节省传输带宽。

2. AVCC 格式(长度前缀格式) 这是 MP4、MOV、FLV 等点播容器内部使用的格式。它抛弃了起区分作用的起始码,改为在每个 NALU 前面放置一个 4字节的长度字段(大端序),直接声明该 NALU 的字节大小。

  • 特点:精确高效,非常适合文件的随机访问(Seek),但解码器在解析它之前,必须从容器的全局头部(如 MP4 的 moov box)获取解码配置(SPS/PPS)。

2.3 起始码的两个边界问题

追问一:到底什么时候用 4 字节(00 00 00 01),什么时候用 3 字节(00 00 01)? 标准规定的区分逻辑是基于“当前帧是否刚刚开始”:

  • 4 字节起始码:用于全局参数(SPS/PPS)或一帧的绝对起点。无论一帧的第一个 NALU 是 IDR 帧、普通 P 帧,还是 AUD 分隔符、SEI,只要它是新画面的开头,就必须用 4 字节。解码器一看到它,就知道新的一帧开始了。
  • 3 字节起始码:用于同一帧画面内部的后续切片(Slice)。如果一张 1080p 的 I 帧过大,被切分成了 5 个 NALU,那么除了第 1 个 Slice 用 4 字节外,第 2~5 个 Slice 统统只用 3 字节,以极致节省带宽。(注:部分编码器如 x264 默认会图省事全部强制使用 4 字节,这也是合法的。)

追问二:如何精准判断 NALU 结束?比如遇到 [NALU_1] 00 00 00 01 [NALU_2],第一个 00 到底是上一个的结尾,还是下一个的开头? 答案是:解码器根本不需要区分它属于谁,只要扫到 00,全部当垃圾丢掉就行了! 这背后隐藏着 H.264 的两个精妙设计:

  1. RBSP 停止位(rbsp_stop_one_bit):标准强制规定,任何一个有用的 NALU 载荷,在编码结束时必须追加一个比特的 1,然后再用比特 0 补齐到字节边界。这意味着:一个有效 NALU 数据的最后一个字节,绝对不可能等于 0x00!
  2. 向后剥洋葱算法:解析器在向前扫描找到 00 00 01 时,会回头看上一个 NALU 的尾巴。因为有效数据绝对不会是 0x00,所以在这之前的无论多少个 0x00,统统被视为无意义的“填充字节(Trailing Zeros)”。解析器只需一直往回删 00,直到遇到第一个非零字节,那里就是上一个 NALU 真正的结束位置。

2.4 实战:在十六进制中寻找 Start Code 与防竞争机制

我们可以通过 FFmpeg 提取出一个纯粹的 H.264 裸流文件:

ffmpeg -i input.mp4 -vcodec copy -an raw.h264

使用十六进制查看器(如 Mac 上的 HexFiend,Windows 的 010 Editor)打开 raw.h264,你会在满屏乱码中清晰地看到起分割作用的起始码 00 00 00 01。

灵魂拷问:如果 NALU 的画面 Payload 数据中,正好天然包含了一段 00 00 00 01,会不会和起始码冲突导致解码器切错帧造成死机?

答案是:H.264 引入了巧妙的防竞争字节(Emulation Prevention Byte)机制。 在编码器生成 Payload 时,如果检测到连续的 0x00 00 00 或 0x00 00 01 等危险组合,会自动在中间强行插入一个 0x03。 例如,遇到 00 00 01 会被改写为 00 00 03 01。解码器在遇到 00 00 03 时,会自动将其丢弃,从而完美还原原始画面数据,彻底避免了与起始码的冲突。

3. 深入 NALU Header 拆解

每个 NALU 的第一字节是 NALU Header,它的结构异常紧凑,仅占用 8 个 bit:

+----------------+---------------+------------------+
| forbidden_zero | nal_ref_idc   | nal_unit_type    |
|    (1 bit)     |   (2 bits)    |    (5 bits)      |
+----------------+---------------+------------------+

逐字段拆解:

  1. forbidden_zero_bit(1 bit):严格固定为 0。这是一个天然的错误检测位。如果在网络传输中发生比特翻转变成 1,解码器会立即判定该 NALU 已损坏并将其丢弃。
  2. nal_ref_idc(2 bits):参考优先级指示,取值 0~3。值越大代表该 NALU 越重要。11 通常用于 SPS、PPS 或 IDR 这类影响全局的关键 NALU。若为 00,表示当前帧不被其他帧参考(如普通的 B 帧或 SEI),网络拥堵时丢弃它也不会引起大面积花屏。
  3. nal_unit_type(5 bits):标识 NALU 的具体类型,取值 0~31。最核心的类型包括:
nal_unit_type含义说明
1非 IDR SliceP帧或非 IDR 的I帧的编码切片
5IDR Slice瞬时解码刷新帧(关键帧中的关键帧)
6SEI补充增强信息(常携带时间戳或自定义业务元数据)
7SPS序列参数集(涵盖分辨率、帧率等全局配置)
8PPS图像参数集(涵盖熵编码方式等图像级配置)

4. H.264 宏观架构与关键参数集

从宏观到微观,H.264 裸流的层级关系可以描绘为一棵树:

视频序列(Video Sequence)
├── SPS(序列参数集:分辨率、Profile、Level...)
├── PPS(图像参数集:熵编码方式、参考帧数...)
└── GOP(Group of Pictures,图像组)
    ├── IDR帧(NALU type=5,清空 DPB,真正的随机接入点)
    ├── P帧(NALU type=1,前向预测)
    ├── B帧(NALU type=1,双向预测)
    └── ...
        └── 每一帧(Picture / Access Unit)
            ├── SEI(可选,如 HDR 元数据,连麦布局信令)
            └── Slice(切片,防止单帧过大,利于网络传输分包)
                ├── Slice Header(包含 slice_type、frame_num 等)
                └── Slice Data
                    └── Macroblock(宏块,通常为 16×16 像素)
                        ├── mb_type(编码模式)
                        ├── 预测信息(运动矢量 / 帧内预测模式)
                        └── 残差数据(DCT 变换系数)

4.1 SPS 与 PPS 详解

SPS(Sequence Parameter Set) 是整个视频序列的“总纲”。解码器只有拿到 SPS,才能初始化内存、分配解码缓冲区。其内部的 RBSP 结构包含以下核心字段:

SPS RBSP 结构:
├── profile_idc(8 bits):编码档次
│   ├── 66 = Baseline(最简,无B帧,适合实时通信)
│   ├── 77 = Main(支持B帧、CABAC,适合一般场景)
│   ├── 100 = High(8x8变换、量化矩阵,主流选择)
│   └── 110 = High 10(10bit色深)
├── constraint_set0_flag ~ constraint_set5_flag(各1 bit):约束标志
├── reserved_zero_2bits(2 bits)
├── level_idc(8 bits):编码等级
│   ├── 30 = Level 3.0(720p@30fps)
│   ├── 31 = Level 3.1(720p@60fps / 1080p@30fps)
│   ├── 40 = Level 4.0(1080p@60fps)
│   └── 51 = Level 5.1(4K@30fps)
├── seq_parameter_set_id(ue(v)):SPS编号(0-31)
├── log2_max_frame_num_minus4(ue(v)):frame_num最大值 = 2^(val+4)
├── pic_order_cnt_type(ue(v)):POC计数方式(0/1/2)
│   └── 若type=0:log2_max_pic_order_cnt_lsb_minus4
├── max_num_ref_frames(ue(v)):最大参考帧数量(典型值2-4)
├── gaps_in_frame_num_value_allowed_flag(1 bit)
├── pic_width_in_mbs_minus1(ue(v)):宽度 = (val+1) × 16
├── pic_height_in_map_units_minus1(ue(v)):高度 = (val+1) × 16 × (2-frame_mbs_only)
├── frame_mbs_only_flag(1 bit):1=逐行扫描,0=场编码
├── direct_8x8_inference_flag(1 bit)
├── frame_cropping_flag → crop偏移量(如有)
└── vui_parameters_present_flag → VUI参数
    └── timing_info(如有)
        ├── num_units_in_tick
        └── time_scale → 帧率 = time_scale / (2 × num_units_in_tick)

PPS(Picture Parameter Set) 依附于某个 SPS,负责图像级别的编码策略配置,如熵编码方式、量化参数等。其结构详图如下:

PPS RBSP 结构:
├── pic_parameter_set_id(ue(v)):PPS编号
├── seq_parameter_set_id(ue(v)):引用的SPS编号
├── entropy_coding_mode_flag(1 bit):0=CAVLC,1=CABAC
│   (CABAC压缩率高5-10%,但CPU开销大)
├── bottom_field_pic_order_in_frame_present_flag(1 bit)
├── num_slice_groups_minus1(ue(v)):Slice Group数(通常为0)
├── num_ref_idx_l0_default_active_minus1(ue(v)):L0默认参考帧数
├── num_ref_idx_l1_default_active_minus1(ue(v)):L1默认参考帧数
├── weighted_pred_flag(1 bit):加权预测开关
├── weighted_bipred_idc(2 bits):B帧加权预测模式
├── pic_init_qp_minus26(se(v)):初始QP偏移(基准QP=26)
├── pic_init_qs_minus26(se(v))
├── chroma_qp_index_offset(se(v)):色度QP偏移
├── deblocking_filter_control_present_flag(1 bit)
│   (1=每个Slice可独立控制去块滤波强度)
├── constrained_intra_pred_flag(1 bit)
└── redundant_pic_cnt_present_flag(1 bit)

4.2 SPS 解析流程:白皮书 7.3 小节的伪代码启示

每个字段的出现顺序由 ITU-T H.264 标准文档(ISO/IEC 14496-10)白皮书的 7.3 小节(具体如 7.3.2.1.1 seq_parameter_set_rbsp())白纸黑字地规定,一丝一毫不能变。标准以伪代码的形式给出了极其精确的解析流程,例如 SPS 的解析(简化版):

// 标准 7.3.2.1.1 seq_parameter_set_rbsp()
profile_idc                              = read_bits(8)    // 固定8位
constraint_set0_flag                     = read_bits(1)    // 固定1位
constraint_set1_flag                     = read_bits(1)
constraint_set2_flag                     = read_bits(1)
constraint_set3_flag                     = read_bits(1)
constraint_set4_flag                     = read_bits(1)
constraint_set5_flag                     = read_bits(1)
reserved_zero_2bits                      = read_bits(2)    // 固定2位
level_idc                                = read_bits(8)    // 固定8位
seq_parameter_set_id                     = read_ue()       // ← Exp-Golomb 变长编码!
if (profile_idc == 100 || profile_idc == 110 || ...) {     // ← 条件分支!
    chroma_format_idc                    = read_ue()
    if (chroma_format_idc == 3)
        separate_colour_plane_flag       = read_bits(1)
    bit_depth_luma_minus8                = read_ue()
    bit_depth_chroma_minus8              = read_ue()
    ...
}
log2_max_frame_num_minus4                = read_ue()
pic_order_cnt_type                       = read_ue()
if (pic_order_cnt_type == 0) {           // ← 又是条件分支
    log2_max_pic_order_cnt_lsb_minus4    = read_ue()
} else if (pic_order_cnt_type == 1) {
    delta_pic_order_always_zero_flag     = read_bits(1)
    offset_for_non_ref_pic               = read_se()       // ← 有符号Exp-Golomb
    ...
}
max_num_ref_frames                       = read_ue()
...

这段经典的伪代码揭示了 H.264 底层解析的两个核心硬核规则:

观察1:顺序固定,但“是否存在”由条件动态决定。 某些字段只在特定条件下才出现。比如 chroma_format_idc 只在 High Profile(100)及以上才存在;log2_max_pic_order_cnt_lsb_minus4 只在 pic_order_cnt_type == 0 时出现。如果你没正确读出前面的 profile_idc 或 pic_order_cnt_type,就无法判断后面的字段该不该读,整个解码链路就会瞬间崩溃。

观察2:这是一个“顺序消费”的过程,没有回头路。 解析器内部必须维护一个 比特游标(Bit Cursor),每读一个字段就往前推进相应的位数。特别是 read_ue() 变长编码字段消费的位数是不确定的,只有彻底读完当前字段,才知道游标推进了多远,才能开始读下一个字段。

那么,代码里的 read_ue() 到底是如何实现长度不固定的读取的呢?这就引出了 H.264 最精妙的底层压缩设计。

4.3 编码黑科技:Exp-Golomb(指数哥伦布编码)

在解析 SPS/PPS 时,H.264 并没有使用传统的固定字节位宽来存储参数,而是大量采用了一种前缀码技术——Exp-Golomb 编码(ue(v)/se(v))。

它的核心思想是:通过前导零的个数,动态决定整个数字的二进制位数。

  • 0 编码为 1(占 1 位)
  • 1 编码为 010(占 3 位)
  • 3 编码为 00100(占 5 位)

解码算法极其精简:

  1. 连续读取比特,直到遇到第一个 1,统计前面有多少个 0,记为 leadingZeroBits。
  2. 消费掉这个 1。
  3. 再往后读取 leadingZeroBits 个比特,记为 suffix。
  4. 最终值计算式:value = 2^leadingZeroBits - 1 + suffix。

为什么要用这种极其晦涩的编码? 因为在 SPS/PPS 中,绝大多数参数的常见取值都极小(0、1、2...)。Exp-Golomb 赋予小数值极短的编码长度(如 0 仅占 1 个 bit),实现了数据在最底层比特流级别的天然压缩。

5. 帧的类型与预测机制 (I / P / B 帧)

如果将视频的每一帧都当作独立的 JPEG 图片完整存储,一部高清电影将轻易突破百 GB 大关。H.264 实现上百倍压缩率的核心秘籍在于:拒绝记录重复信息,极致压榨空间冗余与时间冗余。

  • I帧(关键帧):完好无损的原始风景画。它不依赖任何其他帧,仅利用画面内部相邻像素的相似性(空间冗余)进行压缩。I帧体积最大,但它是视频流的绝对锚点。当你拖动播放器进度条,或者网络丢包导致花屏时,必须等到下一个 I 帧出现才能重新点亮正常画面。
  • P帧(前向预测帧):利用时间冗余,它会参考前面已经解码的 I 帧或 P 帧。通过运动估计寻找相似的区块,仅仅记录“相对上一帧的位移方向与距离”(运动矢量)以及微小的色彩差异残差。
  • B帧(双向预测帧):极致压缩的代表。它同时参考过去和未来的画面,记录处于两张画面中间的状态。由于极其依赖参考帧,它对解码器的算力消耗极大。

5.1 B帧引发的时间戳乱序危机 (PTS vs DTS)

由于 B 帧需要参考“未来”的帧才能被计算出来,这就导致了画面的播放顺序与解码顺序彻底脱节。 假设现实的显示顺序是:I1 -> B2 -> B3 -> P4。 但解码器必须先解出 P4,才能回过头去计算夹在中间的 B2 和 B3。因此实际喂给解码器的顺序变成了:I1 -> P4 -> B2 -> B3。

为了解决这个时空错乱,H.264 与封装层联合引入了双重时间戳机制:

  • DTS(Decoding Time Stamp,解码时间戳):告诉解码器,这个 Packet 该在什么时间被送入解码引擎。
  • PTS(Presentation Time Stamp,显示时间戳):告诉渲染层,这帧解好的画面该在几分几秒展示给用户的肉眼。

5.2 I帧 与 IDR帧 的本质区别

(音视频面试高频题)所有的 IDR 帧都是 I 帧,但并非所有 I 帧都是 IDR 帧:

  • IDR 帧(NALU type=5):Instantaneous Decoding Refresh。当解码器遇到 IDR 帧时,必须暴力清空 DPB(已解码图像缓冲区)中所有的历史参考帧。它是一刀切的绝对物理隔离墙,是真正的随机接入点。
  • 普通 I 帧(NALU type=1,slice_type=I):它自身只使用帧内预测,但不清空 DPB。这意味着在它之后的 P/B 帧,依然有可能越过它,去参考它之前的历史帧(常用于 Open GOP 场景)。

6.GOP

GOP(Group of Pictures,图像组)通常表示一组相关编码图像。工程上常用两个关键参数:

GOP 帧数 = 两个关键随机访问点之间的帧数
GOP 时长 = GOP 帧数 / 帧率

例如:

30 fps,-g 60 → GOP 约 2 秒
25 fps,-g 250 → GOP 约 10 秒

但“两个 I 帧之间就是一个 GOP”只是入门近似。

  • 普通 I 帧:自身采用帧内编码,但前后预测链不一定完全断开。
  • IDR(Instantaneous Decoding Refresh,即时解码刷新图像):要求解码器清理之前的参考图像,后续图像不能再引用 IDR 之前的图像。
  • Key Frame:容器或工具标记的随机访问帧,H.264 中通常对应 IDR,但不能永远机械画等号。

因此,直播缓存和随机接入真正关注的通常是 IDR 或其他合法随机访问点,而不只是任意 I 帧。

GOP优点代价
较短首屏快、切台快、故障恢复快、切片容易对齐I 帧更多,码率和带宽增加
较长压缩效率更高,相同画质下码率更低首屏等待、Seek、丢包恢复可能更慢

直播中常见 GOP 为 1~4 秒,低延迟互动直播通常取 1~2 秒,并经常关闭 B 帧。

7. SEI (补充增强信息)

SEI(Supplemental Enhancement Information,NALU type=6)并不参与核心的像素解码流程,即使丢弃也不会花屏,但它在现代工业中承载着极为关键的业务元数据:

  • 携带精准的自定义时间戳,用于多路视频的对齐。
  • 携带直播连麦过程中的 SEI 自定义信令(如主播连麦的坐标布局信息)。
  • 携带 HDR 视频的 Mastering Display 色彩元数据(告知屏幕如何映射亮度)。

8. 常用提取与分析命令速查

从普通 MP4 文件中,无损提取出纯粹的 H.264 裸流,供十六进制编辑器分析:

ffmpeg -i input.mp4 -c:v copy -an -sn output.h264

查看pts,dts解码时间

ffprobe -v error -select_streams v:0 -show_entries packet=codec_type,dts,pts,size,flags -of csv=p=0 gemini.mp4

音视频数据流基础

共 5 篇

  1. H264流
  2. 音频流基础:PCM、WAV 与 AAC 编码深度解析
  3. 视频底层密码:YUV 颜色空间、内存对齐与格式转换揭秘
  4. 音视频容器格式剖析:MP4、FLV 与 TS
  5. FFMpeg 命令记录