简介:这是一份基于 Verilog 的 H.265/HEVC 解码器 FPGA 工程源码,面向 Zynq7035 平台构建,适用于 FPGA 开发者、视频编解码研究人员及有硬件基础的嵌入式学习者。项目在 Zynq7035 上完成验证,实现熵解码、语法解析、运动补偿、去块效应滤波、逆变换和图像重构等核心流程,可作为 HEVC 硬件解码器设计参照。压缩包共 146 个文件,约 4.62MB,其中以 33 个 .v 和 19 个 .sv 源码文件为主,另含 9 个 sample 测试示例、4 个 .265 码流文件,以及工程配置、Makefile 和辅助脚本,方便进行仿真、综合与上板调试。目前已有 298 人浏览学习。通过这套资源可以获得完整模块级 Verilog 源码、测试序列和 Zynq 工程结构,结合配套文章能快速掌握 H.265 解码的硬件实现方法、模块划分与软硬件协同设计技巧。
1. FPGA 上读 HEVC 码流:这个 Verilog 解码器到底能做什么
如果你以为在 FPGA 上解 H.265 就是把.h265文件丢给仿真器、等波形出来就完事,那第一次跑这个h265_decoder项目时大概率会翻车——跑了几万个周期,波形窗口里一片 X,YUV 输出全是花屏。这套基于 Verilog 的 HEVC 解码器源码,本质是把 H.265 从「NAL 层解析」到「SAO 重建」的完整链路搬进了硬件描述语言,面向的是想在自己的 FPGA 板卡上实现视频硬解、或者想搞懂 CABAC 和 CTU 划分到底怎么走时序的开发者。它适合两类人:一类是拿它当学习蓝本,对照协议看熵解码和帧间预测的硬件实现;另一类是直接拿它做二次开发,裁剪模块、换分辨率、挂到自己的图像处理流水线上。这篇文章会从模块骨架、核心代码、仿真运行到踩坑点全部拆开讲。
2. 整体设计与技术骨架:NAL 解析、CTU 流水与参考帧管理的分层
这个解码器的顶层设计不算复杂,但分层很清晰。拿到一段 H.265 裸流,硬件上要干的事和软件解码器是同一套逻辑:先做 NAL 单元解析,把 SPS、PPS、SEI、Slice 数据分开;再进熵解码层,用 CABAC 解出语法元素;然后按 CTU(Coding Tree Unit)为单位做四叉树划分,逐个块走帧内预测或帧间预测、反变换、重建;最后过一遍环路滤波(去块滤波 DBF 和样点自适应补偿 SAO),把重建帧写进参考帧缓冲区,供后续帧做运动补偿时读取。
2.1 五层流水架构:从码流到像素的硬件划分
整个工程按数据流方向分成五层,每一层对应 RTL 目录下的一个子模块集合。第一层是nal_parser,负责起始码检测、NAL header 解析和字节流到语法元素的位宽对齐;第二层是cabac,包含上下文模型初始化、bin 解码状态机和去量化后的语法元素分发;第三层是ctu_tree,负责 CTU 的四叉树递归划分和预测模式解析;第四层是intra_pred、inter_pred、transform三个并行块,分别处理帧内预测、运动补偿和反 DCT/DST 变换;第五层是loop_filter和dpb,完成滤波并管理参考帧读写。
这种分层的好处是每一级的接口都收敛在语法元素和像素块上,调试时可以单独给某一级喂激励。比如你只想验证 CABAC 解码是否正确,就不需要把整个解码器跑起来,直接构造一个 slice 段的上下文初始化值,观察bin_out和参考软件解码器的输出对比即可。实际工程里nal_parser和cabac之间还插了一个小的 FIFO,用来吸收 NAL 层输出的字节流速率波动,避免 CABAC 模块因为码流暂时不足而进入不必要的 stall 状态。
2.2 目录结构与模块职责:拿到源码先看哪里
打开源码包,主要的目录结构大致是这样:
h265_decoder/ ├── rtl/ │ ├── nal_parser/ │ │ ├── start_code_detect.v │ │ ├── nal_header.v │ │ └── emulation_prevention.v │ ├── cabac/ │ │ ├── cabac_engine.v │ │ ├── ctx_model_init.v │ │ └── cabac_bin_decode.v │ ├── ctu_tree/ │ │ ├── ctu_quadtree.v │ │ ├── split_flag_parser.v │ │ └── pred_mode_parser.v │ ├── intra_pred/ │ ├── inter_pred/ │ │ ├── motion_comp.v │ │ └── ref_fetch.v │ ├── transform/ │ │ ├── inverse_transform.v │ │ └── dequant.v │ ├── loop_filter/ │ │ ├── deblock.v │ │ └── sao.v │ └── dpb/ │ ├── ref_frame_mgr.v │ └── frame_buffer.v ├── sim/ │ ├── tb_top.v │ ├── stimulus/ │ ├── scripts/ │ │ ├── sim.do │ │ └── filelist.f │ └── ref_model/ ├── fpga/ │ ├── vivado/ │ └── constraints/ └── tools/ └── yuv_check/建议拿到代码先看两个文件:sim/scripts/filelist.f和rtl/cabac/cabac_engine.v。前者告诉你整个工程的编译顺序,后者是整个解码器最容易卡住的地方。filelist.f里每一行的路径顺序不是随意的——ctx_model_init.v必须在cabac_bin_decode.v之前编译,因为后者例化了前者的输出作为上下文初始值;ref_frame_mgr.v必须在motion_comp.v之前,因为运动补偿模块调用参考帧管理器输出的读端口。如果你用 Vivado 的 add sources 自动导入而忽略了编译顺序,跑到仿真阶段大概率会报一堆Unknown module或者端口不匹配的错误。
3. 核心模块逐个拆解:从 CABAC 到 SAO 的时序逻辑实现
这一章是重点。我不会把全部代码贴出来,只挑四个最值得读的核心模块,讲清它们的端口含义和时序关系:CABAC 熵解码、CTU 四叉树划分、参考帧管理、环路滤波。这四个模块分别对应了 HEVC 解码器里最容易出问题、也最影响性能的四个环节。
3.1 CABAC 熵解码:让 bin 流变成语法元素的有限状态机
CABAC 在 FPGA 上实现时,软件里那种「while 循环读位」的写法完全不可综合,硬件必须把它拆成状态机。模块对外暴露的接口很简洁:上游解析出ctx_idx和bin_valid,模块内部根据当前range和offset判断下一个 bin 是 0 还是 1,然后更新上下文状态。下面是一段简化的状态机骨架:
// cabac_bin_decode.v 简化版状态机 module cabac_bin_decode ( input wire clk, input wire rst_n, input wire bin_valid, // 上层请求解码一个 bin input wire [5:0] ctx_idx, // 上下文模型索引,6bit 可覆盖 64 状态 output reg bin_out, // 解码出的 bin 值 output reg bin_ready // bin 解码完成,可接收下一个请求 ); localparam S_IDLE = 2'd0; localparam S_DECODE = 2'd1; localparam S_RENORMAL = 2'd2; reg [1:0] state, next_state; reg [8:0] range_q; // 当前区间的量化值,HEVC 中始终保持在 [256, 510] reg [8:0] offset; // 当前码流偏移值 reg [6:0] ctx_state; // 上下文状态值,共 64 个状态 // 状态转移:每个 bin 至少占用 3 个时钟周期 always @(posedge clk or negedge rst_n) begin if (!rst_n) state <= S_IDLE; else state <= next_state; end always @(*) begin next_state = state; case (state) S_IDLE: if (bin_valid) next_state = S_DECODE; S_DECODE: next_state = S_RENORMAL; S_RENORMAL: next_state = S_IDLE; default: next_state = S_IDLE; endcase end // range 更新与判决:核心公式 LUT 化 // 实际代码中,range_q 会根据 ctx_state 查 64x2 的表, // 然后与 offset 比较:offset >= range_q * prob 时 bin=1 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin bin_out <= 1'b0; bin_ready <= 1'b0; end else begin bin_ready <= (state == S_RENORMAL); case (state) S_DECODE: begin // 比较 offset 与 range 的量化乘积,决定 bin 值 if (offset >= range_q) bin_out <= 1'b1; else bin_out <= 1'b0; end endcase end end endmodule这段代码只保留了状态机框架,真正的决策逻辑在S_DECODE状态里。range_q是当前编码区间的高 9 位量化值,HEVC 标准规定它始终不小于 256;offset是码流中已经读入的偏移量。判决原理是:如果offset大于等于range_q * 上下文概率,则 bin 为 1,否则为 0。工程里常用查表代替乘法,把ctx_state和range_q拼成地址,输出是该状态下的两个子区间边界。
ctx_idx这个参数很关键,它决定了上下文模型的选择。HEVC 里有几百个上下文模型,覆盖不同类型语法元素,ctx_model_init.v就是根据 SPS/PPS 和 slice 类型生成这些初始值的。你在修改这个模块时,最常做的事就是调整上下文查表的位宽或初始值——如果你发现解码出的split_flag总是错,先查ctx_idx是否映射到了正确的上下文模型区间。
3.2 CTU 四叉树划分:用状态机代替递归的块遍历
HEVC 的 CTU 最大是 64x64,可递归划分到 8x8,每一层都有一个split_flag决定是否继续分。软件里用递归函数很容易写,但 Verilog 不能递归调用模块,所以工程里用了一个「深度计数器 + 待处理块队列」的思路:
// ctu_quadtree.v 四叉树遍历状态机(简化) module ctu_quadtree ( input wire clk, input wire rst_n, input wire [5:0] ctu_x, ctu_y, // CTU 坐标,0~63 input wire start, input wire split_flag_in, // 当前层的 split_flag output reg [5:0] block_x, block_y, // 当前正在处理的子块坐标 output reg [1:0] cur_depth, // 当前划分深度 output reg ctu_done ); reg [5:0] block_stack [0:15]; // 待处理的子块栈 reg [3:0] stack_ptr; reg [1:0] depth_stack [0:15]; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin stack_ptr <= 4'd0; ctu_done <= 1'b0; end else if (start) begin block_stack[0] <= {ctu_x, ctu_y}; depth_stack[0] <= 2'd0; stack_ptr <= 4'd1; ctu_done <= 1'b0; end else if (stack_ptr != 4'd0 && split_flag_in) begin // 有 split_flag 且深度未到 3,则把四个子块压栈 if (cur_depth < 3) begin block_stack[stack_ptr] <= {block_x, block_y}; block_stack[stack_ptr+1] <= {block_x, block_y + 32'd16}; // ... 实际按当前深度计算子块偏移 stack_ptr <= stack_ptr + 4'd3; end end else if (stack_ptr != 4'd0) begin // 无 split_flag,处理完当前块后出栈 stack_ptr <= stack_ptr - 4'd1; {block_x, block_y} <= block_stack[stack_ptr - 4'd1]; cur_depth <= depth_stack[stack_ptr - 4'd1]; if (stack_ptr == 4'd1) ctu_done <= 1'b1; end end endmodule这段代码的要点在于:栈顶的split_flag决定当前块是否继续划分,而cur_depth限制了最大递归深度为 3(64x64 到 8x8 正好三次二分)。实际工程里不仅需要split_flag,还要解析pred_mode(帧内/帧间)和part_mode(对称/非对称划分),这些都挂在同一个状态机上,只是把待解析的信息一起压栈。
为什么不用递归语法?因为综合工具无法把递归展开成电路,而显式栈可以。你在二次开发时如果想支持更大的 CTU(比如 128x128,下一代标准的方向),只需要把MAX_DEPTH加 1、把坐标位宽从 6bit 扩到 7bit,栈深度从 16 增到 32,其他逻辑几乎不用动。这也是这个模块设计得比较干净的地方。
3.3 参考帧管理:乒乓缓冲与 DPB 读写的地址映射
参考帧管理是 HEVC 解码器里最绕的部分,不是算法难,而是地址映射搞错会导致花屏且极难排查。工程里采用经典的乒乓缓冲策略:两块帧缓存,当前帧写一块,参考帧从另一块读:
// 双缓冲切换核心逻辑 wire [3:0] ref_read_idx = ref_buf_sel ? BUF_B_ID : BUF_A_ID; wire [3:0] cur_write_idx = ref_buf_sel ? BUF_A_ID : BUF_B_ID; // 运动补偿读取参考像素:按块坐标计算帧内偏移 // 以 16x16 块为例 wire [15:0] ref_base_addr = ref_read_idx * FRAME_SIZE + (mv_y + ref_origin_y) * STRIDE + (mv_x + ref_origin_x);ref_buf_sel是乒乓切换标志,每一帧解码完成后翻转一次。mv_x和mv_y是运动矢量,ref_origin是当前块在帧内的起点坐标。实际工程里还得多加一层映射:HEVC 的参考帧列表不只有一帧,ref_idx可能指向 DPB 里的任意一帧,所以更完整的实现是维护一个ref_frame_table,把ref_idx映射到具体的物理 buffer ID,再检查该帧是否已经重建完成——这一步不合法的话,运动补偿模块会提前读到未写完的数据,画面会出现类似「拖影」的伪影。
3.4 环路滤波:去块滤波的边界强度和 SAO 补偿
去块滤波器的核心是计算边界强度(Boundary Strength,BS),BS 值决定了滤波器的开关和强度。判定规则在软件里是串行 if-else,在硬件里通常做成查表:
// 边界强度计算(简化) always @(*) begin if (is_intra_p && is_intra_q) bs = 4'd4; // 两侧都是帧内编码块,最强滤波 else if (non_zero_coeff_p || non_zero_coeff_q) bs = 4'd2; // 有非零系数,中等滤波 else if (ref_frame_p != ref_frame_q) bs = 4'd2; // 参考帧不同,需要滤波 else if (abs_dx_p + abs_dx_q < 1) bs = 4'd1; // 运动矢量差很小,弱滤波 else bs = 4'd0; // 不滤波 end这段逻辑每周期输出一个边界强度值,决定后续滤波运算是否执行。SAO 的部分相对独立,它属于像素域滤波,先统计当前 CTU 的像素分类(边界补偿或带补偿),再把补偿值累加到重建像素上。工程里 SAO 通常可以和去块滤波共用一个像素读口,分时复用,避免多读一次 DDR 带宽。
4. 仿真与运行实战:Modelsim 拉起 tb 并验证 YUV 输出的完整流程
源码包不带现成的比特流文件,但工程里有测试激励生成脚本和标准的sim.do文件。跑仿真的第一步不是直接双击打开 Modelsim,而是先把文件清单理清楚。
4.1 第一步:核对 filelist 编译顺序并完成仿真
打开sim/scripts/filelist.f后,你会看到全部 RTL 文件按依赖顺序排列。如果文件清单没有预先整理好,仿真编译时最常遇到的错误就是instance 'u_cabac' of design unit 'cabac_engine' not found,这是因为被例化的模块还没编译。正确做法是让底层模块先编译,顶层模块放最后。在 Modelsim 里的执行流程如下:
# 进入仿真目录 cd sim/scripts # 创建 work 库 vlib work vlib work_lib # 按依赖顺序编译全部源码 vlog -f filelist.f -work work_lib -incr # 编译测试平台 vlog ../tb_top.v -work work # 启动仿真,单位用 ps 避免时间精度丢失 vsim -t ps work.tb_top -do "log -r /*; run -all"-t ps这个参数值得注意。HEVC 解码器的时钟周期通常在 10ns 级别,但逻辑内部有亚周期时序,比如 FIFO 的读写窗口只有几纳秒。如果时间精度设置到ns,波形窗口里可能看不到竞争窗口的细节。如果仿真跑起来后进度条长时间不动,先看看是不是陷入了死循环——CABAC 模块对码流不足的响应是否正确。
跑完后仿真目录下会生成decoded.yuv输出文件。这个文件是最直接的验证依据:如果解码正确,它应该和参考软件解码器输出的 YUV 逐字节一致;如果解码失败,通常表现为全黑、雪花或者只解出第一帧。
4.2 第二步:用 YUV 对比脚本量化验证解码正确性
光靠肉眼判断花不花屏还不够,我一般会写一个 Python 脚本做 PSNR 对比,把输出 YUV 和参考 YUV 逐帧比较:
# yuv_compare.py import sys import numpy as np def read_yuv(path, w, h, frames): data = np.fromfile(path, dtype=np.uint8) frame_size = w * h * 3 // 2 # YUV420 格式,每像素 1.5 字节 return data[:frame_size * frames].reshape( frames, frame_size ) def psnr(actual, expected, max_val=255): mse = np.mean((actual.astype(np.float32) - expected.astype(np.float32)) ** 2) if mse == 0: return float('inf') return 10 * np.log10(max_val * max_val / mse) if __name__ == "__main__": w, h, frames = int(sys.argv[1]), int(sys.argv[2]), int(sys.argv[3]) ours = read_yuv("decoded.yuv", w, h, frames) ref = read_yuv("reference.yuv", w, h, frames) for i in range(frames): p = psnr(ours[i], ref[i]) print(f"frame {i}: PSNR = {p:.2f} dB")如果某帧的 PSNR 低于 30dB,那基本可以断定该帧前面的某个环节出了问题,再结合波形窗口里ctu_done信号在哪一帧拉高异常来定位。帧数参数frames需要和仿真时的tb_top里配置一致,否则np.fromfile会读到错误边界。
4.3 第三步:查看关键波形信号定位第一处异常
仿真最有效的调试手段不是看完整波形,而是只看几个关键信号:nal_start、slice_state、cabac_ready、ctu_done、frame_done。如果frame_done始终拉不高,往前查ctu_done是否有周期性拉高;如果ctu_done有但frame_done没有,问题大概率在参考帧管理模块——当前帧被标记为「可参考」之前,DPB 的写入还没有完成,产生了握手阻塞。
add wave的时候建议按分组添加,把同一子模块的信号放一个 group 里,折叠后按帧推进节奏看整体数据流,而不是逐个信号盯。血泪经验:HEVC 解码的波形动辄几百万周期,逐一翻信号绝对会看花眼,分组之后按帧索引定位异常要快得多。
5. 避坑记录:仿真卡死、波形全 X 与综合时序翻车
下面几条都是这个项目上容易被绊倒的典型坑,按「现象 → 原因 → 解决」的顺序记录,部分来自我自己的调试经历,部分来自同行在这个系统上的踩坑共性。
现象一:仿真开始后总在一个固定周期数处停住,Modelsim 提示# ** Error: (vsim-8045)且 progress 不再前进。
原因:CABAC 模块在码流输入不足时进入了不可恢复的 stall 状态。range在重归一化时需要从码流读取新的比特,但如果read_side握手信号没有和重归一化状态机对齐,相当于状态机停在S_RENORMAL等待一个永远不会来的data_valid。解决:在S_RENORMAL进入S_IDLE的条件上加一个超时计数器,若超过 64 个周期仍未收到新数据则主动拉高stall_error,便于在波形窗口直接看到错误标志。更根本的方式是检查 NAL parser 到 CABAC 之间的 FIFO 深度,把 FIFO 深度从 8 提高到 32,通常能掩盖输入速率的抖动。
现象二:仿真结束但decoded.yuv是全 0,波形窗口里几乎所有信号都是 X 态。
原因:多数情况下不是逻辑错误,而是测试激励里$readmemh加载的码流文件路径不对。Modelsim 启动时的工作目录是sim/scripts,如果码流文件的相对路径写的是../stimulus/stream.h265,但脚本是从别的目录启动的,文件加载失败后mem里全是 X,后续所有逻辑都崩了。解决:在tb_top.v里用绝对路径或通过+define+STIM_PATH宏传入路径,加载后立刻检查$readmemh的返回值并打印加载字节数。
现象三:行为仿真完全正确,但综合到 FPGA 后时序收敛不了,主时钟频率只能跑到标称值的 60%。
原因:CABAC 的上下文查表逻辑被综合成了一个巨大的组合逻辑路径,尤其是ctx_idx到range_q的选择树层级过深,导致关键路径延迟过长。解决:把查表的输出打一拍寄存器,从「组合逻辑直接决定 bin」改成「流水线决定 bin」——代价是每个 bin 多一拍延迟,但换来了频率的大幅提升。这个改动对架构影响极小,具体做法是在S_DECODE和S_RENORMAL之间插入一个中间寄存器,把range_q和offset的比较结果锁存一拍。
现象四:解码输出 PSNR 始终在 20dB 上下,画面看起来像是「模糊但有轮廓」。
原因:和熵解码无关,通常是反变换模块的量化参数QP与当前 slice 的slice_qp_delta没有正确累加。HEVC 里每个 CTU 可以有自己的 QP 偏移,如果qp更新状态机在 CTU 边界处漏更新了一个周期,后面的所有变换系数都会放大或缩小一个比例。解决:在dequant.v里增加一个断言,比较当前 CTU 的qp与参考软件输出是否有超过 2 的偏差。这类问题最好在仿真阶段解决,进了硬件再抓会非常痛苦。
6. 二次开发建议:把 CABAC 改造成可配置上下文模型的思路
如果你想把这个解码器用在自己的项目里,改造的切入点和优先级建议这样排:第一优先是替换上下文模型初始化方式,第二优先是增加流水线级数,第三才是动预测和变换模块。CABAC 的上下文模型是整个解码器的「黑匣子」,改起来收益最大也最容易验证。
最常见的改造是把ctx_model_init.v里的初始化常数做成可配置的 ROM,而不是硬编码的case分支。具体做法是把 SPS/PPS 里解析出的initial_qp和 slice 类型作为 ROM 地址高位,把ctx_idx作为低位,用一块小的block memory存储不同配置下的上下文初始值。这样换一条不同编码参数的码流时,不需要重新综合工程,只要把 ROM 的初始化文件替换一下即可。注意 HEVC 的上下文模型初始化和 QP 强相关,不同的初始 QP 会生成完全不同的初始概率状态,默认的硬编码方案换码流后出现花屏,八成就是这里的问题。
第二个值得做的改造是给运动补偿路径加 AXI 接口。原始代码的ref_fetch.v用的是简单的同步读端口,挂在自己的 SRAM 上没问题;但如果你的系统里参考帧存在 DDR 里,这个模块就要改成 AXI master 发送读请求,并处理乱序返回。改造时不要把motion_comp的像素拼接逻辑动掉,只需要在ref_fetch内部换掉读端口协议,外部接口保持不变。实测 DDR 带宽足够时,1080p 分辨率的解码帧率能稳住。
验证方法建议固化成一个回归脚本:每次改完 RTL,跑三条标准测试流——一条低分辨率快速验证、一条高分辨率压测时序、一条特意用乱序参考帧的码流检验 DPB 管理。从那以后我每次改熵解码或滤波的代码,都强制先跑完这三条流再合入,不再等到上板了才对着花屏画面挠头。希望这套思路能帮你少踩几个这个项目里的坑,跑通之后再做自己的裁剪就顺了。
本文还有配套的精品资源,点击获取