简介:面向视频编码初学者与希望深入理解H.264实现细节的开发者,这份资料包围绕H.264/AVC标准、参考源代码与配套解析展开,可用于课程学习、算法研读和编解码器开发入门。压缩包约13.65MB,内含H.264源代码、标准协议中文译本与英文原版文档,以及覆盖宏块划分、熵编码、运动估计、变换与量化等环节的讲解材料,兼顾规范查阅与代码对照。已有205人学习,适合作为从理论走向实践的过渡资料。读者可借助中文版标准梳理编码器与解码器接口、语法及解码流程,再结合源代码观察编码实现,并通过文档理解预测技术、率失真优化等要点;英文原版便于核对规范细节,整体形成标准、源码、解析相互印证的阅读路径,帮助建立对H.264框架的系统认识。
1. 打开 H.264 源代码和文档,先别急着找算法
第一次翻开一份 H.264 源代码和配套文档的人,大概率会失望:目录里最长的函数不是运动估计,而是位流读写、边界判断和一堆查表。真正反直觉的地方在这里——H.264 相比上一代标准省下来的码率,只有一小部分来自「把搜索窗口开得更大」这种堆算力的改动,大头来自一堆看起来像工程琐事的规矩:参考帧怎么编号、环路滤波放在哪一步、亚像素插值用几抽头、熵编码的上下文怎么初始化。
这些规矩彼此咬合,任何一条顺序错了,解码器不会崩、也不会报错,只会给你一片慢慢糊掉的画面——这就是码流这个黑匣子最容易出错的地方。把标准文档和逐行注释的源码对着看,价值不在「能编译跑通」,而在于你能顺着解码路径,把每个语法元素和它对应的重建像素一一对齐。搞懂这条路径,你才会明白为什么同样叫 H.264,有的实现低码率下干干净净,有的实现一到复杂运动场景就出现明显块效应。
2. 分层架构:为什么标准要把语法和工具切成两层
几乎所有 H.264 源代码的目录结构,第一层都是两个名字:VCL 和 NAL。这不是命名洁癖,而是一次很硬的设计切割。VCL 管「这一帧的宏块怎么编码」——预测模式、运动矢量、残差系数、量化参数;NAL 管「这段数据怎么放进字节流」——起始码、单元类型、长度边界。中间隔着一层,编码工具就和传输方式彻底解耦了。
这么切的动机很现实:同一份编码数据,可能要走实时传输协议、要走文件容器、要走广播流。如果编码器直接输出「带起始码的字节流」,那换一种封装就得改编码器;反过来把封装抽出来,编码器只吐 slice 数据,剩下的交给外层拼装,两边就能各自演进。代价也很直接——所有解码器的入口都必须先做一层 NAL 解析,并且必须老老实实处理那些「不是图像数据」的单元。
2.1 NAL 与 VCL 解耦:网络无关性是怎么换来的
一个 NAL 单元 = 1 字节头 + 若干字节负载。头部三个字段:forbidden_zero_bit(1 位,规范要求为 0,用来检测比特错误)、nal_ref_idc(2 位,值越大表示这个单元越重要,0 表示不参与参考、丢了也不影响后续帧)、nal_unit_type(5 位)。这 5 位类型码是整个解析流程的分叉点,常见取值如下。
| 类型值 | 名称 | 作用 | 谁产生 |
|---|---|---|---|
| 1 | non-IDR slice | 普通图像片,可参考前后帧 | 编码器每帧多片 |
| 5 | IDR slice | 立刻刷新片,清空参考列表和 POC | 编码器在关键帧处 |
| 6 | SEI | 补充增强信息,如时间戳、画面结构 | 编码器可选 |
| 7 | SPS | 序列参数集,分辨率、profile、帧号位宽 | 每个序列一份 |
| 8 | PPS | 图像参数集,熵编码模式、滤波偏移 | 每个序列可多份 |
| 9 | AUD | 访问单元分隔符,纯粹给解析器省事 | 编码器可选 |
解析的第一步永远是扫起始码。Annex B 字节流里,每个单元前面有00 00 01或00 00 00 01,扫描时要注意四字节起始码的前导零会被误认成上一个单元的内容,很多实现干脆把尾部零归给前一个单元。
/* 从 Annex B 字节流切出一个 NAL 单元:返回 0 成功,-1 表示流结束 */ static int next_nal(const uint8_t *buf, size_t size, size_t *pos, const uint8_t **payload, size_t *payload_len, int *nal_type, int *ref_idc) { size_t i = *pos; /* 1. 找三字节起始码;四字节起始码多出的那个 0 归到上一个单元的尾部 */ while (i + 3 <= size && !(buf[i] == 0 && buf[i+1] == 0 && buf[i+2] == 1)) i++; if (i + 3 > size) return -1; i += 3; /* 2. NAL 头只有一字节:forbidden(1) | ref_idc(2) | type(5) */ uint8_t hdr = buf[i++]; *ref_idc = (hdr >> 5) & 0x03; /* 0 表示非参考单元,可安全丢弃 */ *nal_type = hdr & 0x1F; /* 1/5 是 slice,7/8 是参数集 */ /* 3. 负载延伸到下一个起始码之前,此处不剥防竞争字节,交给下一层 */ size_t start = i; while (i + 3 <= size && !(buf[i] == 0 && buf[i+1] == 0 && buf[i+2] <= 1)) i++; *payload = &buf[start]; *payload_len = i - start; *pos = start; /* 下次从负载起点重扫,兼容三/四字节起始码 */ return 0; }这段代码里唯一值得琢磨的是最后那句*pos = start。如果直接把*pos推到下一个起始码之后,遇到四字节起始码时会跳过一字节,导致后面的单元全部错位;把游标回退到负载起点重新扫描,代价是多扫几个字节,换来的是对两种起始码长度都成立。实际源码里通常还会顺手过滤nal_type == 12(填充数据)和nal_type == 9(分隔符),它们的负载对重建毫无贡献。
真正容易被忽略的是参数集的时机。SPS 和 PPS 不是每帧都出现,但它们决定了解码缓冲区怎么分配、熵编码用哪种模式、滤波偏移是多少。解码器必须在收到第一个 slice 之前缓存好参数集,并且支持带外传输——有的封装把参数集放在容器头部而不是码流里,源码里对应一个「外部注入 SPS」的接口。
2.2 防竞争字节:为什么码流里凭空多出 0x03
RBSP 是裸的语法数据,里面的字节序列完全可能凑出00 00 01。一旦出现,解析器就把它当成起始码,整个 slice 从那里断成两截。标准的解法是编码端在打包时插入一个0x03:只要 RBSP 里出现连续两个0x00后面跟着00、01、02、03之一,就在第二个0x00后面塞一个0x03,把危险的序列打断。解码端收到后把这层剥掉,还原成 RBSP。
/* EBSP -> RBSP:剥掉防竞争字节。返回剥离后的真实长度 */ static size_t ebsp_to_rbsp(const uint8_t *src, size_t n, uint8_t *dst) { size_t i, j = 0; int zeros = 0; /* 已经连续看到的 0x00 个数 */ for (i = 0; i < n; i++) { /* 00 00 03 xx 且 xx <= 03:这个 03 是编码端塞进去的,丢弃 */ if (zeros >= 2 && src[i] == 0x03 && i + 1 < n && src[i+1] <= 0x03) { zeros = 0; continue; } zeros = (src[i] == 0x00) ? zeros + 1 : 0; dst[j++] = src[i]; } return j; }这段逻辑短到可以背下来,但它有三个容易出错的地方。第一,剥离必须对 SPS、PPS、SEI 一起做,只处理 slice 会让参数集在特定字节组合下解析出天文数字。第二,zeros在丢弃0x03后必须归零,否则连续的00 00 03 00 00 03 00这种序列会被多剥一个字节。第三,0x03后面必须跟小于等于0x03的字节才算防竞争,否则它是合法的 RBSP 数据——这个判断做反了,画面会从某个宏块开始整体偏移,而且只在特定画面内容下复现,属于典型的玄学 bug。
3. 预测:残差是怎么被压到接近零的
编码器真正传的不是像素,是预测值和原始值的差。预测越准,残差越小,变换后的非零系数越少,熵编码越省。这条链条上任何一环变差,都会以「码率涨了但画质没涨」的形式体现出来。H.264 给了两条预测路线:帧内用同一帧里已经重建好的邻居,帧间用之前帧里位移过的块。两条路线共享一个前提——预测只能基于「解码器也已经有的数据」,这是整个标准不产生漂移的根本保证。
3.1 帧内预测:九种模式与「边界不可用」分支
4×4 亮度块有九种预测模式:垂直、水平、DC、对角下左、对角下右、垂直偏右、水平偏下、垂直偏左、水平偏上。除了 DC,其余模式都是拿当前块左边一列(4 个像素)、上边一行(4 个像素)、右上角 4 个、左上角 1 个,共 13 个参考像素做带权外推。16×16 亮度块只有四种模式(垂直、水平、DC、平面),因为大块内部纹理变化小,方向性预测收益有限。高档次档次里还有 8×8 帧内预测,模式与 4×4 相同,但对参考像素先做一次低通,抑制量化噪声沿预测方向扩散。
真正让新手卡住的不是模式公式,而是「参考像素不可用」的分支。当前宏块位于图像最左列时没有左邻居;相邻宏块不属于同一 slice 时按规范视为不可用;邻居是帧间预测且未重建完成时也不可用。
/* 4x4 帧内 DC 预测:p 指向当前块左上角,p[-stride] 是上方行首,p[-1] 是左侧列首 */ static void intra4x4_dc(uint8_t pred[4][4], const uint8_t *p, int stride, int has_up, int has_left) { int sum = 0, i, j; if (has_up && has_left) { for (i = 0; i < 4; i++) sum += p[i - stride]; /* 上方 4 个 */ for (i = 0; i < 4; i++) sum += p[-1 + i * stride]; /* 左侧 4 个 */ sum = (sum + 4) >> 3; /* 8 个样本求平均 */ } else if (has_up) { for (i = 0; i < 4; i++) sum += p[i - stride]; sum = (sum + 2) >> 2; } else if (has_left) { for (i = 0; i < 4; i++) sum += p[-1 + i * stride]; sum = (sum + 2) >> 2; } else { sum = 128; /* 都不可用,用中值 */ } for (j = 0; j < 4; j++) for (i = 0; i < 4; i++) pred[j][i] = (uint8_t)sum; }舍入偏置+4和移位 3 对应「8 个样本求和后四舍五入」,写成sum / 8在负数上会出错——虽然这里的 sum 恒为正,但换成带符号的残差场景就会踩到。has_up/has_left这两个布尔量的来源,是参考软件里一张按宏块坐标和 slice 边界预先算好的可用性表,每次预测前查表,而不是现场判断坐标。
还有一个非常关键、但文档里往往一笔带过的细节:帧内预测取的邻居样本是解块滤波之前的重建值。滤波在整帧重建完成后统一做,如果提前滤波,编解码两端用的参考不一致,误差会沿着预测链一路累积,几帧之后画面就开始发灰、边缘发散。这一点和不少人的直觉相反,也是自己手写解码器时最容易翻车的地方。
3.2 运动估计与亚像素插值:1/4 精度的代价
帧间预测的精度是 1/4 像素,意味着一个 16×16 块的位移可以精确到 0.25 像素。整像素位置直接取参考帧样本;半像素位置用 6 抽头滤波器(1, -5, 20, 20, -5, 1) / 32先水平后垂直插值,两步之间中间结果要饱和到 16 位区间再进第二步;1/4 像素位置用相邻的半像素或整像素做双线性平均(a + b + 1) >> 1。
这个设计代价不小:一个 16×16 块如果落在半像素位置,需要读 21×21 的参考样本区域,算力和内存带宽都是整像素的数倍。换来的收益也很实在——自然视频里的运动几乎不可能正好落在整像素格点上,1/4 精度能把预测残差显著压低,尤其在缓慢平移和缩放场景下。这也是为什么参考软件默认开 1/4 精度,而某些追求极致速度的实现会退到 1/2 像素并接受码率上涨。
搜索策略是另一回事。标准只规定「怎么插值、怎么编码运动矢量」,完全不规定「怎么找到这个矢量」。参考实现里用的是非对称十字加多层六边形搜索,产品代码里常见菱形搜索、PMVFAST 之类,甚至直接拿上一帧同位置的运动矢量做候选。这属于可以自由替换的部分,源码里对应的模块往往被一整个文件独立出来,换算法不影响码流兼容性。
3.3 运动矢量预测:为什么
本文还有配套的精品资源,点击获取