简介:围绕 H.264 这一主流视频编码标准整理的源码与文档合集,面向已具备一定 C 语言与图像处理基础、希望从规范条文落到具体实现的开发者与学习者。压缩包约 13.65MB,收录 H.264 标准协议中文译版与英文原版文档、编解码库源码及配套的代码讲解材料,具体文件构成以实际解压为准。中文版协议梳理了编码器与解码器之间的接口约定、语法结构与解码流程,英文原版便于核对细节;源码部分可直接对照宏块划分、帧内帧间预测、整数变换与量化、熵编码、运动估计与率失真优化等环节,看清每一步的数据走向。已有 205 人学习。适合用来搭建从标准文本到工程实现的完整认知路径:边读标准边跑代码,理解编码器内部机制,也可作为二次开发或课程设计的参考底本。
1. 从一份 rar 说到 H.264 参考代码:它到底解决什么问题
去年接手一个播放端的兼容问题,某段直播流在硬解路径上出来是整屏的马赛克,软解正常。日志只打了一句 slice 头解析失败,链路里四五个模块都可能是凶手。那几天我最想要的东西不是播放器,而是能逐字节对着标准核对的一份 h.264 源代码和一份 h.264 文档:码流第几个 bit 该是什么、CABAC 的上下文怎么初始化的、去块滤波的边界强度取了几——全得有个能编译、能改、能打断点的东西跟你对。这个压缩包里就是这套东西:一套可编译的参考编解码器源码、配套的标准文档、以及逐函数讲解的源码详解。它适合做解码器移植、码流分析、编码参数调优、要给团队做内部分享的人;如果你只是想调个 API 出画面,那它对你来说太重了,直接上现成库更快。
2. 目录结构与技术栈:三个工程模块是怎么分工的
拿到一个压缩包,第一步别急着 make,先把目录摊开看一遍。参考软件的组织方式几十年没大变:编码、解码、公共三块拆开,各自一个 Makefile,公共部分编成静态库被两边链接。这个拆法是它作为“参考实现”的核心价值——编码器写 NAL 的那段代码和解码器读 NAL 的那段代码大概率能一一对上,你改一侧的时候另一侧有现成的对照物。
2.1 解开之后目录是什么样子
常见的目录大概是这个长相,不同版本会有增删,但骨架基本一致:
h264_ref/ ├── lcommon/ # 编解码共用:NAL、内存分配、公共结构体 │ ├── inc/ # 头文件:nalu.h、mbuffer.h、defines.h、ifunctions.h │ └── src/ # nalu.c、memalloc.c、nalucommon.c ├── lencod/ # 编码器,独立可执行 │ ├── inc/ # 编码侧私有头文件 │ ├── src/ # lencod.c、readconfig.c、image.c、mv_search.c ... │ ├── Makefile │ └── *.cfg # 各种编码配置模板 ├── ldecod/ # 解码器,独立可执行 │ ├── inc/ │ ├── src/ # ldecod.c、decoder.c、header.c、dpb.c ... │ └── Makefile ├── bin/ # 编译产物和测试序列 └── doc/ # 标准文档、源码详解、参数说明lcommon是理解全局的入口。它里面就三个东西最值得先看:defines.h里一大坨宏定义(像素位深、最大参考帧数、最大 SPS/PPS 数量),nalu.h里的NALU_t结构体,以及memalloc.c里那套malloc封装。很多人上来就扎进mv_search.c看运动估计,看完一头雾水,就是因为没先搞明白 NAL 是怎么被描述和传的。
关键文件的作用我列个表,方便你按图索骥:
| 路径 | 文件 | 作用 |
|---|---|---|
| lcommon | nalu.c / nalu.h | NAL 单元的读写、Annex-B 起始码处理 |
| lcommon | memalloc.c | 带对齐的内存分配,供 SIMD 路径使用 |
| lencod | lencod.c | 编码器 main,参数解析与帧循环 |
| lencod | readconfig.c | 解析 .cfg 文本配置 |
| lencod | mv_search.c | 运动搜索,整像素/亚像素 |
| lencod | rdopt.c | 率失真优化决策 |
| lencod | biariencode.c / cabac.c | CABAC 编码与上下文模型 |
| lencod | loopFilter.c | 编码端环路滤波(重建帧用) |
| ldecod | decoder.c | 解码主循环 |
| ldecod | header.c | SPS/PPS/Slice Header 解析 |
| ldecod | dpb.c | 解码图像缓存、POC 重排序 |
| ldecod | biaridecod.c / cabac.c | CABAC 解码与上下文初始化 |
| ldecod | erc_do_i.c / erc_do_p.c | 错误隐藏(丢包场景) |
解码侧的erc_*两个文件是很多新手会忽略的宝藏。真实网络环境下丢包是常态,错误隐藏怎么用相邻宏块的运动矢量去填补丢失区域,这套逻辑在别的地方很难看到完整实现。
2.2 技术栈:纯 C89、Makefile、裸 YUV
技术栈朴素得有点反直觉:纯 C,没有 C++、没有 STL、没有第三方库,连构建都只给 Makefile。这么选不是为了怀旧,是因为它必须能被移植到尽可能多的平台上——从 DSP 到 FPGA 的软核,验证实现的时候不会有人在那种环境里给你装 CMake 和 Boost。
由此带来几个你必须提前接受的现实:
输入输出全是裸 YUV。编码器吃的是.yuv文件,解码器吐的也是.yuv,没有封装、没有容器、没有时间戳。想看画面得自己拿 YUV 播放器打开,或者用 FFmpeg 转一道。好处是链路干净,坏处是你得记住 YUV420 一帧的大小是宽 × 高 × 1.5字节,这个 1.5 后面会坑到很多人。
构建靠 Makefile 加一堆编译宏。Makefile里CFLAGS决定了启用哪些特性,比如是不是要开-O2、是不是要打开 trace 输出、是不是要给大文件支持加宏。改配置的方式不是改代码,是改 Makefile 或者加命令行参数。
配置靠文本 cfg。所有编码参数写在一个.cfg文件里,格式是Key = Value。这意味着你可以把不同的实验配置存成不同文件,做参数对比的时候直接diff两个 cfg,比在代码里翻#define舒服得多。
文档部分通常是标准正文(语法表、语义描述、附录里的解码流程伪码)加上一份源码详解。详解的价值在于它把标准里那些“第 7.3.2.1 节”的条款和header.c里的具体行号对上了号,省掉你自己做映射的时间。
2.3 文档和源码怎么对着读
直接按顺序从头读源码,基本读不过第三章。我的做法是先建立一条“最小可追踪链路”,再往两头扩。
第一步盯住 SPS 解析。在header.c里找到解析序列参数集的那个函数,把它逐行对着标准里的语法表看。profile_idc、level_idc、log2_max_frame_num_minus4、pic_width_in_mbs_minus1这几项看完,你就知道了码流里的宽度是怎么从“宏块数减一”还原成像素数的——(pic_width_in_mbs_minus1 + 1) * 16,这个公式后面调试分辨率问题天天用。
第二步看 Slice Header。first_mb_in_slice、slice_type、pic_parameter_set_id、frame_num、pic_order_cnt_lsb这几个字段决定了这一帧在时间轴上的位置和参考关系。DPB 的重排序逻辑完全依赖 POC,搞不清 POC 就看不懂为什么解码顺序和显示顺序不一样。
第三步才是宏块层。这时候你已经知道当前帧有哪些参考帧可用、QP 是多少、熵编码是 CAVLC 还是 CABAC,再进去看宏块语法元素就不会迷路。
提示:读的时候开着 trace 输出。参考软件一般都有宏块级 trace 开关,编出来的日志里每个宏块的类型、运动矢量、残差都能打出来,拿日志和你手算的比特位对照,比干看代码快十倍。
3. 编码主链路拆解:一帧 YUV 到 NAL 的完整路径
编码器是这份代码里最值得先啃的部分,因为它把所有工具都串起来了:帧内预测、运动估计、变换量化、熵编码、环路滤波。走通一遍,解码器那边基本就是反过来读。这一章按“配置进 → 帧循环 → 宏块处理 → NAL 出”的顺序拆。
3.1 配置项与 cfg 文件:参数从哪进来
编码器启动后第一件事是读配置。参数结构体先被填上默认值,然后 cfg 文件的内容覆盖上去,最后做一遍合法性修补(比如宽高必须对齐到 16 的倍数,参考帧数不能超过 DPB 上限)。
一个典型的 cfg 长这样:
# encoder.cfg 节选 InputFile = "test.yuv" # 裸 YUV 输入,无文件头 SourceWidth = 352 # 必须是 16 的倍数,否则内部会补齐 SourceHeight = 288 FramesToBeEncoded = 100 # 要编多少帧,0 表示读到文件尾 FrameRate = 30.0 QPISlice = 28 # I 帧量化参数,0-51,越小越清晰 NumberReferenceFrames = 2 # 参考帧数量,直接决定 DPB 占用 SymbolMode = 1 # 0=CAVLC 1=CABAC几个参数需要单独说。QPISlice是量化参数不是质量等级,QP 每加 6 大约码率减半,调试的时候按 6 的步进调比按 1 调更有感觉。NumberReferenceFrames改大会提升压缩率但线性增加内存和搜索耗时,桌面端跑 5 帧参考帧能把机器拖到个位数帧率,别以为是代码写崩了。SymbolMode切 CABAC 后码率通常能降 10% 上下,但解码复杂度上升,做嵌入式移植时这一项经常要退回去。
配置解析完之后有一个动作很容易被忽略:参数修补。比如你写了宽 350,它内部会按宏块对齐处理成 352,实际编码的帧尺寸和你以为的不一样,后面算 PSNR 的时候对不上就会被坑。我一般会在读完配置后先把实际生效的宽高、帧数、QP 打一行日志出来,把预期钉死。
3.2 main 与帧循环:encode_one_frame 前后的调用顺序
主流程的骨架其实很短,长的是每一帧里面的处理:
/* lencod 主流程节选,函数名以手上版本为准 */ int main(int argc, char **argv) { InputParameters params; ImageParameters *img = NULL; init_params(¶ms, &img); /* 全部参数落到默认值 */ if (ParseCommandLine(argc, argv, ¶ms) != 0) return -1; /* 读 cfg,失败直接退 */ PatchInputParameters(¶ms, img); /* 宽高对齐、参考帧数裁剪 */ GenerateParameterSets(¶ms, img); /* 生成 SPS/PPS,先写进码流 */ OpenFiles(¶ms, img); /* 打开 yuv、码流、日志 */ AllocBuffers(¶ms, img); /* 帧缓存、参考帧、宏块信息 */ while (img->frame_no < params.no_frames) { if (ReadOneFrame(¶ms, img) != 0) /* 读一帧 YUV420 */ break; encode_one_frame(¶ms, img); /* 编码一帧,内部按 slice 切分 */ img->frame_no++; } CloseFiles(¶ms, img); /* 冲刷码流、写统计信息 */ return 0; }init_params和PatchInputParameters这两个函数是排查“为什么参数没生效”的第一现场。现象通常是改了 cfg 但输出码率纹丝不动,原因可能是你改的那个 key 名字拼错了,解析器遇到不认识的 key 只是静默跳过,不报错也不提示;也可能是你在Patch阶段被覆盖了——比如设了超大的参考帧数,被裁剪到硬上限。解决办法很简单,在Patch之后打个断点,把整个 params 结构体 dump 出来比什么都快。
encode_one_frame内部先做帧级初始化:更新帧号、算 POC、决定这一帧是 I/P/B、给每个 slice 分配 QP。然后按 slice 循环调用编码函数,编码完成后再把重建帧写进参考帧队列、更新 DPB 状态。注意重建帧这个动作在编码器里是必须的——编码器必须用和差分解码器完全一致的参考帧,否则后续帧的预测就全错了,这也是为什么编码器里也会有完整的反变换、反量化和环路滤波代码。
3.3 宏块级流程:模式决策、变换量化、熵编码
进到宏块层,核心就是一件事:为每个 16×16 宏块选一个代价最小的编码方式。参考软件提供了完整的率失真优化(RDO)路径,虽然慢,但它是你理解“为什么这么选”的唯一可靠参照。
/* 宏块编码的典型顺序,简化自参考实现 */ void encode_one_macroblock(Macroblock *currMB, ImageParameters *img) { int best_mode, best_cost = INT_MAX; /* 1. 帧内预测:遍历 4x4 / 16x16 的所有模式 */ for (mode = 0; mode < n_intra_modes; mode++) { intra_predict(currMB, mode); cost = distortion(currMB) + lambda * rate(mode); if (cost < best_cost) { best_cost = cost; best_mode = mode; } } /* 2. 帧间预测:在参考帧里做搜索,得到 MVD */ if (img->type != I_SLICE) { for (blk = 0; blk < 4; blk++) { mv_search(currMB, blk, &mv[blk]); /* 整像素 -> 1/4 像素 */ mv_prediction(currMB, blk, &mvp[blk]); } cost = inter_cost(currMB, lambda); if (cost < best_cost) { best_cost = cost; best_mode = INTER; } } /* 3. 残差变换:4x4 整数 DCT,再量化 */ if (best_mode != SKIP) { transform(currMB); /* 残差 -> 变换系数 */ quant(currMB, QP); /* 量化,QP 越大丢得越多 */ } /* 4. 反量化反变换,重建当前宏块,供后续帧参考 */ dequant(currMB, QP); inverse_transform(currMB); reconstruct(currMB); /* 5. 熵编码:CAVLC 用 VLC 表,CABAC 走区间编码器 */ if (img->symbol_mode == CABAC) write_mb_cabac(currMB, img); /* 上下文模型按邻块自适应 */ else write_mb_cavlc(currMB, img); }这段里最值得抠的是第 2 步的亚像素搜索。整像素搜完之后要做 1/2 像素和 1/4 像素插值,用的是 6 抽头 FIR 滤波,这组滤波器系数是标准里规定死的,不能自己改,改了解码器插出来的像素就对不上。很多人自己魔改滤波器图一点画质提升,结果是码流不符合标准,真实解码器直接花屏。
第 5 步的 CABAC 是整份代码里最难啃的部分。它的核心是上下文建模:每个语法元素根据左边和上边邻块的取值选一张概率表,编码后概率表自适应更新。context_ini.c里那几张初始化的表看着像天书,但你只要记住它是按 slice 类型(I/P/B)分别初始化,并且初始化依据是SliceQp和cabac_init_idc,就够你排查大部分“CABAC 解出来和 CAVLC 不一致”的问题了。
3.4 NAL 封装与输出:Annex-B 起始码
宏块编完,最后一步是把 slice 数据包成 NAL 单元写进文件。这一步看着简单,出的问题却不少。
/* NAL 封装与输出,简化示例 */ int WriteNALU(NALU_t *nalu, FILE *out) { int len = nalu->len + 1; /* 1 字节 NAL header */ unsigned char *buf = malloc(len); buf[0] = (nalu->nal_ref_idc << 5) | nalu->nal_unit_type; /* 拼头 */ memcpy(buf + 1, nalu->buf, nalu->len); /* Annex-B 格式:每个 NAL 前加 4 字节起始码 0x00000001 */ fwrite(start_code, 1, 4, out); fwrite(buf, 1, len, out); free(buf); return len + 4; }NAL header 只有 1 个字节,拆开是 1 bit 的forbidden_zero_bit、2 bit 的nal_ref_idc、5 bit 的nal_unit_type。nal_ref_idc为 0 表示这个 NAL 不会被参考(典型是 SEI 和部分非参考 slice),大于 0 就表示要进参考队列。nal_unit_type里最常用的几个:7 是 SPS,8 是 PPS,5 是 IDR slice,1 是非 IDR slice,6 是 SEI。
起始码这件事上翻车的人最多。参考软件默认输出 Annex-B,每个 NAL 前面是00 00 00 01或00 00 01。而很多播放器和容器(MP4 里的 avcC)用的是 AVCC 格式,每个 NAL 前面是 4 字节的长度前缀。这两种格式肉眼看着都是二进制,拿去看开头几个字节就能分辨:开头是00 00 00 01就是 Annex-B,开头是个大端整数后面跟着67(SPS 的 NAL type 是 7,头字节 0x67)就是 AVCC。
注意:起始码是防竞争处理的,码流内部出现
00 00 00、00 00 01、00 00 02、00 00 03这几种序列时要插入0x03转义。你手写解析器只按00 00 01去找起始码,遇到转义字节就会错位,症状是 slice 数据解到一半突然乱掉。标准里叫 emulation prevention byte,别跳过。
4. 解码主链路与码流解析:NAL、SPS/PPS 和 Slice
解码器是把上面那条链路反着走一遍,但多了几件编码器没有的事:码流可能不干净、参考帧可能缺失、显示顺序和到达顺序不一样。这一章按解析顺序拆,边看边把你自己的解析器对上去。
4.1 NAL 提取:起始码扫描与打包模式
解码的第一步是从裸码流里切出一个个 NAL 单元。参考实现里的做法是扫起始码,找到下一个起始码的位置,中间的字节就是当前 NAL 的载荷。
/* 从 Annex-B 码流里取下一个 NAL,简化示例 */ int GetNextNALU(NALU_t *nalu, FILE *in) { int leading_zero_bits = 0, pos = 0; int c; /* 1. 跳过前导零,直到找到一个 0x01 */ while ((c = fgetc(in)) != EOF) { if (c == 0) { leading_zero_bits++; continue; } if (c == 1 && leading_zero_bits >= 2) break; /* 至少 00 00 01 */ leading_zero_bits = 0; } /* 2. 第一个字节是 NAL header,拆出类型 */ nalu->len = 0; nalu->buf[nalu->len++] = c = fgetc(in); nalu->nal_unit_type = c & 0x1F; /* 低 5 bit */ nalu->nal_ref_idc = (c >> 5) & 0x03; /* 3. 一直读到下一个起始码或文件尾,途中处理 0x03 转义 */ while ((c = fgetc(in)) != EOF) { if (c == 0) { /* 攒着零,看后面是不是 00 01 */ } else if (c == 3 && prev_two_are_zero) { continue; /* 丢弃防竞争字节 0x03 */ } else { nalu->buf[nalu->len++] = c; } } return nalu->len; }这里最容易写错的两处:一是起始码长度,标准允许 3 字节00 00 01也允许 4 字节00 00 00 01,按 3 字节扫、判断时要求“至少两个前导零”最稳;二是转义字节,遇到00 00 03要把03丢掉,恢复成00 00。有些人拿参考代码的这段直接移植到产品里,忘了处理03,本地测试全过,一上真实码流就偶发解码失败,排查起来极其玄学。
读出来的 NAL 按nal_unit_type分发:7 送 SPS 解析,8 送 PPS 解析,5 和 1 送 slice 解码,6 送去读 SEI(里面可能有帧打包信息、恢复点等,很多播放器不看,但调试有用)。
4.2 SPS/PPS 解析:宽高、profile、熵编码方式的来源
SPS 决定了整个序列的“物理规格”。解析的时候要特别注意 Exp-Golomb 编码,标准里的绝大部分语法元素都是无符号或带符号的指数哥伦布码,不是定长整数。
/* SPS 解析节选,u(n) 表示读 n 位定长,ue(v) 表示读指数哥伦布 */ void ProcessSPS(SeqParamSet *sps, NALU_t *nalu) { sps->profile_idc = u(8); /* 66=Baseline 77=Main 100=High */ u(8); /* constraint_set_flags,常忽略 */ sps->level_idc = u(8); /* 等级,3.1 / 4.0 等,决定上限 */ sps->seq_parameter_set_id = ue(); /* SPS 编号,PPS 会引用它 */ sps->log2_max_frame_num = ue() + 4; /* frame_num 的位宽 */ sps->pic_order_cnt_type = ue(); /* POC 计算方式,0/1/2 */ if (sps->pic_order_cnt_type == 0) sps->log2_max_poc_lsb = ue() + 4; else if (sps->pic_order_cnt_type == 1) { /* 一坨 delta_pic_order 相关 */ } sps->max_num_ref_frames = ue(); /* 参考帧上限,DPB 要按它分配 */ u(1); /* gaps_in_frame_num_allowed */ sps->pic_width_in_mbs = ue() + 1; /* 单位是宏块! */ sps->pic_height_in_map = ue() + 1; /* 单位是宏块! */ sps->frame_mbs_only_flag = u(1); /* 0 表示隔行 */ if (!sps->frame_mbs_only_flag) u(1); /* mb_adaptive_frame_field */ /* 到这里才算得出真实分辨率 */ sps->width = sps->pic_width_in_mbs * 16; sps->height = (2 - sps->frame_mbs_only_flag) * sps->pic_height_in_map * 16; }pic_width_in_mbs单位是宏块,这一点坑过太多人。你从 SPS 读出 22,实际宽度是 352,不是 22。同理高度还要乘(2 - frame_mbs_only_flag),因为隔行编码时每个宏块对包含两场。算错这一步的现象是画面正常但每一行错位,看着像 stride 问题,其实是分辨率解析错了。
PPS 更短但更关键,它决定了熵编码方式:
void ProcessPPS(PicParamSet *pps, NALU_t *nalu) { pps->pps_id = ue(); pps->sps_id = ue(); /* 指向哪个 SPS */ pps->entropy_coding_mode = u(1); /* 0=CAVLC 1=CABAC */ pps->bottom_field_pic_order_in_frame_present = u(1); pps->num_slice_groups = ue() + 1; /* FMO,多数流里是 1 */ /* ... 后面是去块滤波相关、加权预测相关、QP 初始值等 */ int qp = ue() + 26; /* 这里给的是 pic_init_qp_minus26 */ int qs = ue() + 26; /* pic_init_qs_minus26 */ }entropy_coding_mode这一位如果判断错了,后面的 slice data 整个解不出来,而且不会立刻报错,会解出一堆看似合理的垃圾系数。所以我的习惯是在解第一个 slice 之前,把 SPS 的 profile/level/宽高和 PPS 的熵编码方式全部打一行日志,确认无误再往下走。这一步五秒钟,能省掉半小时的“为什么画面是雪花”。
4.3 Slice 解码与 DPB:重排序、参考帧管理
Slice 头解析完之后进入数据解码。这部分代码很长,但结构清楚:按宏块地址递增遍历,每个宏块先读mb_type(决定是 I 还是 P、是 16×16 还是 8×8 分割),再读预测信息(帧内模式号或运动矢量差),然后读残差系数、反量化、反变换、重建、最后累积去块滤波的边界强度。
真正容易出问题的地方不在宏块里,在 DPB。解码顺序和显示顺序不一致,B 帧尤其明显。参考实现的做法是每解完一帧就按 POC 插进 DPB,然后检查有没有“可以输出”的帧:
/* DPB 插入与输出判断,简化示例 */ void insert_picture_into_dpb(DecodedPictureBuffer *dpb, Frame *frm) { dpb->fx[dpb->used_size++] = frm; /* 按解码顺序放进来 */ /* 参考帧标记:nal_ref_idc 为 0 的不是参考帧,不会留在 DPB */ if (frm->nal_ref_idc == 0) frm->is_reference = 0; /* 输出判断:所有 POC 小于当前帧的都已到齐,就可以吐了 */ while (dpb->used_size > 0) { Frame *out = get_smallest_poc(dpb); if (out->poc > frm->poc) break; /* 还有更早的没来,等 */ output_one_frame(out); /* 写 yuv,释放槽位 */ } }get_smallest_poc是重排序的核心。POC 的算法有三种,由 SPS 里的pic_order_cnt_type决定:type 0 用pic_order_cnt_lsb加溢出判断,type 1 用帧号偏移量算,type 2 最简单,只用于显示顺序等于解码顺序的场合。如果你只跑 I/P 帧,用 type 2 完全没问题;一旦上 B 帧又用了 type 2,输出顺序会乱,症状是画面播放起来一顿一顿的,像丢帧,其实是顺序错了。
参考帧管理还有一条容易忽略的规则:max_num_ref_frames不只是参考帧数,它还包含当前正在解码的那一帧,并且不能超过 Level 规定的上限。你设了 16 个参考帧、分辨率又是 1080p,可能直接超了 Level 4.1 的 DPB 容量限制,参考软件会给你一句断言失败,产品级解码器则会直接拒绝解码这个码流。
4.4 后处理:去块滤波与输出 YUV
重建完的帧还不能直接输出,要先过环路滤波。H.264 的块效应来自 4×4 变换的块边界,去块滤波在块边界上按边界强度bS决定滤多少,bS的取值跟边界两侧的宏块类型、残差系数是否为 0、运动矢量差是否够大都有关系。
/* 边界强度计算的核心分支,简化 */ int get_boundary_strength(Macroblock *p, Macroblock *q, int edge) { if (p->is_intra || q->is_intra) return 4; /* 帧内边界,最强 */ if (p->coded_block_pattern || q->coded_block_pattern) return 2; if (abs(p->mv[edge] - q->mv[edge]) >= 4) return 1; /* 运动差异大 */ if (abs(p->ref_idx[edge] - q->ref_idx[edge]) != 0) return 1; return 0; /* 不滤 */ }滤波强度定了之后,还要用alpha和beta两个阈值判断这条边该不该滤、滤几个像素。这两个阈值由SliceQp和filterOffsetA/B查表得到,表在标准里写死了。你在 PPS 里能看到deblocking_filter_control_present_flag,如果它是 0,说明整个序列的滤波参数由 PPS 统一给,slice header 里不会再有调整字段——解析器要是没判这一位就去读 slice header 的滤波参数,后面所有字段全部错位。
输出环节就简单了:把重建帧的 Y、U、V 三个平面按宽×高、宽/2×高/2、宽/2×高/2依次写到文件。注意是先写完整帧 Y 再写 U 再写 V,不是按行交织。写错顺序的结果是画面看起来像蒙了一层绿纱,这也是为什么那么多人说自己“解出来是绿屏”——十有八九不是解码错了,是输出布局错了。
5. 编译与跑通排查:从 make 报错到第一帧 YUV
代码看懂了不等于能跑起来。这份代码年头久,用新编译器编经常会撞上一堆警告和报错,加上配置文件的坑,第一次跑通平均要花掉一两个小时。这一章按我实际踩过的顺序整理。
5.1 编译链路:Makefile 与编译宏
三个模块分别编译,lcommon先编成静态库,lencod和ldecod再链接它。Makefile 里真正要动的就几个地方:
# lencod/Makefile 节选 CC = gcc CFLAGS = -O2 -Wall -std=gnu89 \ -I../lcommon/inc -Iinc \ # 头文件路径,缺一个就报 implicit declaration -D__USE_LARGEFILE64 \ # 大文件支持,读大 YUV 必须开 -DFILE_OPEN_FAILURE=1 LDFLAGS = -L../lcommon -llcommon OBJS = lencod.o readconfig.o image.o mv_search.o rdopt.o \ transform.o quant.o macroblock.o slice.o header.o \ nal.o loopFilter.o biariencode.o cabac.o context_ini.o编译顺序不能错,lcommon没编出来就去编lencod,链接阶段一定报找不到符号。稳妥的做法是在根目录写个三行脚本按顺序编:
#!/bin/bash set -e # 任何一步失败立刻停,别带着旧产物往下编 make -C lcommon clean && make -C lcommon make -C lencod clean && make -C lencod make -C ldecod clean && make -C ldecod echo "build ok"set -e这行别省。我见过不止一次有人make -C lcommon失败了但没注意,接着编编码器,链接的时候用的是上一次的旧.a,跑出来的行为和源码对不上,白白怀疑了半天自己的改动。
5.2 跑通第一个例子:编码 + 解码闭环
编译成功之后先别碰自己手上的真实码流,用最简单的配置跑一个闭环,确认工具链本身没问题。
# 1. 造一个测试序列:176x144 的 QCIF,30 帧,YUV420 裸流 # 每帧字节数 = 176*144*1.5 = 38016,30 帧约 1.1MB ffmpeg -f lavfi -i testsrc=size=176x144:rate=30 -frames:v 30 \ -pix_fmt yuv420p -f rawvideo test.yuv # 2. 编码:输出 Annex-B 裸码流 ./lencod/lencod -p encoder.cfg # cfg 里 InputFile 指向 test.yuv # 3. 解码:还原成 YUV ./ldecod/ldecod -i test.264 -o out.yuv # 4. 比对:编码是有损的,不做逐字节比较,比 PSNR ffmpeg -f rawvideo -pix_fmt yuv420p -s 176x144 -i test.yuv \ -f rawvideo -pix_fmt yuv420p -s 176x144 -i out.yuv \ -lavfi psnr -f null -第四步的 PSNR 是我判断“这条链路到底通没通”的唯一标准。QP 28 下 Y 分量 PSNR 在 35dB 以上算正常,低于 30dB 说明哪里错了——可能只是前几帧对上了后面错位,也可能根本不是同一段内容。别用眼睛看画面“感觉还行”,有损编码差异肉眼很难分辨,但 PSNR 掉 3dB 就已经是肉眼可见的糊了。
编码器跑完通常会输出一份统计,里面有每帧的比特数、Y/U/V 三个分量的 PSNR、总码率。这份统计是你后面调参的基线,建议存下来,每次改配置都对比着看。
5.3 五类高频翻车现场:现象、原因、解决
下面这几条是我和周围人实际撞过的,按出现频率排。
第一,编译报implicit declaration of function,一堆函数“未定义”。现象是make刷屏警告,链接时报undefined reference。原因基本是CFLAGS里的-I路径少了一个,头文件没被找到,编译器只好按老 C 的隐式声明处理,参数类型全靠猜。解决是把-I../lcommon/inc和-Iinc都补上,然后make clean重编——注意必须 clean,因为已经编出来的.o里存的是错误的调用约定,不清就接着链接还是会崩。
第二,编码器跑起来了,但输出全是灰帧或者绿屏。现象是 YUV 打开一片灰,或者整屏偏绿。原因九成是输入 YUV 和配置对不上:要么宽高填错,要么帧不是 YUV420 而是 YUV422,要么文件里混了帧头。校验方法很土但有效——用文件大小除以宽×高×1.5,看能不能整除出整数帧数,除不尽就说明格式或尺寸有问题。另外注意有些工具输出的 YUV 是yuv420p10le,每像素 2 字节,按 8bit 读必然全错。
第三,解码速度慢到离谱,一帧要好几秒。现象是能出画面,但慢得没法用。原因通常是编译没开优化,-O2被注释掉了,或者打开了宏块级 trace 输出,每解一个宏块就往文件里写一行。去 Makefile 里确认CFLAGS有-O2,再去 cfg 里确认 trace 相关开关是关的。参考软件本身就不追求速度,但开优化前后差 5 到 10 倍是常事,这个差距足够让人误判成“算法有问题”。
第四,解出来的码流 FFmpeg 播不了。现象是参考解码器自己能解,扔给 FFmpeg 就报Invalid NAL unit size或者直接黑屏。原因几乎都是格式搞混:参考软件吐的是 Annex-B,你把它塞进 MP4 容器时没转成 AVCC,或者反过来,拿了个 AVCC 的裸流喂给 Annex-B 解析器。用xxd | head看文件头四个字节,00 00 00 01是 Annex-B,其他多半是长度前缀。转换用 FFmpeg 一行就够:ffmpeg -i in.264 -c copy -bsf:v h264_mp4toannexb out.264。
第五,新编译器报一堆 error,全是“函数没写返回类型”或者char符号性相关的警告。现象是gcc版本比较新,编这份老代码直接失败。原因是代码按 C89 写的,大量 K&R 风格函数定义,新编译器默认-std=gnu17不认。解决是在CFLAGS里显式加-std=gnu89,再补上-Wno-implicit-function-declaration、-Wno-pointer-sign之类的降噪开关。别想着一次性把代码改写成现代 C,几千个函数,改完你自己都不认识了,能编过、行为不变就够了。
提示:每次改完 Makefile,先
make clean再编。这份代码的文件依赖关系写得比较糙,头文件改了不会触发重编,很容易出现“改了头文件但行为没变”的灵异事件。
6. 二次开发改造:把参考代码改成能干活的模块
参考软件最大的问题是它只有main,没有库。想把它嵌进自己的系统,第一件事就是把编码器和解码器的main拆掉,改成可重入的接口。我的做法是保留原来所有的内部函数,只在外面包一层薄壳:把InputParameters和ImageParameters收进一个句柄结构体,init/create一次做完全部分配,encode/decode一次处理一帧,destroy释放干净。
/* 封装后的对外接口,内部还是调参考实现那套函数 */ typedef struct h264_enc_handle h264_enc_handle; h264_enc_handle *h264_enc_create(const char *cfg_path); int h264_enc_push_frame(h264_enc_handle *h, const uint8_t *yuv, int w, int hgt); /* 送一帧 */ int h264_enc_get_packet(h264_enc_handle *h, uint8_t *buf, int cap); /* 取一个 NAL */ void h264_enc_destroy(h264_enc_handle *h);改的过程中有三个地方必须有耐心。第一是全局变量,参考实现里不少状态是文件级静态变量,多实例并发跑会互相污染,得一个个挪进句柄。第二是文件 IO,OpenFiles里把 cfg、YUV、码流全绑在FILE*上,要改成内存缓冲或者回调。第三是错误处理,参考代码大量使用exit()和assert(),嵌到服务里遇到一个不合规码流就整个进程没了,得逐个换成错误码返回。
改完怎么验证?别靠眼睛。我的做法是拿改造前后的输出做逐字节比对:同一份 YUV 输入、同一份 cfg,改造前的lencod输出和改造后库的输出必须完全一致,一个 bit 都不能差。不一致就说明你在封装过程中动了某个状态,最典型的是 QP 的初始化时机或者参考帧队列的清理逻辑。这条比对的脚本我从第一次改造一直用到现在,血泪经验换来的安心。
再往上一层是性能。参考实现里memalloc是按需分配的,一帧编解码下来几百次 malloc/free,做实时编码的时候这块能占掉相当比例的时间。改成按最大分辨率预分配一大块内存池,每次复位指针,实测能省下可观的耗时。但要小心 SIMD 路径的对齐要求,参考实现里有 16 字节对齐的假设,池子起始地址没对齐会直接崩,抓这种问题没有后悔药,只能提前用aligned_alloc或者手工 pad。
| 改造项 | 优先级 | 风险 |
|---|---|---|
| main 拆库、句柄化 | 高 | 全局变量污染,多实例必炸 |
| 文件 IO 改内存回调 | 高 | 错误分支漏释放会泄漏 |
| exit/assert 改错误码 | 高 | 漏改一处就是线上崩溃 |
| 内存池替换 malloc | 中 | 对齐问题导致 SIGSEGV |
| 关键路径 SIMD | 低 | 输出必须和 C 版本逐比特一致 |
最后说个看着不起眼但很好用的技巧:参考实现的 trace 输出是可以按宏块过滤的。你只关心第 12 帧第 5 个宏块为什么选了帧间 8×8 而不是 16×16 的时候,把 trace 范围卡到那一个宏块,日志从几十万行降到几十行,一眼就能看到候选模式的代价对比。我调率失真相关的问题时全靠这个,直接从“猜”变成“看”。
从那以后我每次接手一份新码流,都强制走一遍“参考解码器先解一遍、PSNR 对一遍、trace 落到宏块级”这三步,再动手改自己那条链路。这套东西笨,但它会把问题钉死在某一层,省掉大量来回猜的时间。这份包里源码、文档、详解都齐全,拿去按第二章的目录结构摊开,从 SPS 解析那一节开始读,希望帮到你。
本文还有配套的精品资源,点击获取