ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

用Verilog在FPGA上实现HEVC解码器:核心模块与仿真指南

用Verilog在FPGA上实现HEVC解码器:核心模块与仿真指南 简介这是一份基于 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项目时大概率会翻车——跑了几万个周期波形窗口里一片 XYUV 输出全是花屏。这套基于 Verilog 的 HEVC 解码器源码本质是把 H.265 从「NAL 层解析」到「SAO 重建」的完整链路搬进了硬件描述语言面向的是想在自己的 FPGA 板卡上实现视频硬解、或者想搞懂 CABAC 和 CTU 划分到底怎么走时序的开发者。它适合两类人一类是拿它当学习蓝本对照协议看熵解码和帧间预测的硬件实现另一类是直接拿它做二次开发裁剪模块、换分辨率、挂到自己的图像处理流水线上。这篇文章会从模块骨架、核心代码、仿真运行到踩坑点全部拆开讲。2. 整体设计与技术骨架NAL 解析、CTU 流水与参考帧管理的分层这个解码器的顶层设计不算复杂但分层很清晰。拿到一段 H.265 裸流硬件上要干的事和软件解码器是同一套逻辑先做 NAL 单元解析把 SPS、PPS、SEI、Slice 数据分开再进熵解码层用 CABAC 解出语法元素然后按 CTUCoding 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 2d0; localparam S_DECODE 2d1; localparam S_RENORMAL 2d2; 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 时 bin1 always (posedge clk or negedge rst_n) begin if (!rst_n) begin bin_out 1b0; bin_ready 1b0; end else begin bin_ready (state S_RENORMAL); case (state) S_DECODE: begin // 比较 offset 与 range 的量化乘积决定 bin 值 if (offset range_q) bin_out 1b1; else bin_out 1b0; end endcase end end endmodule这段代码只保留了状态机框架真正的决策逻辑在S_DECODE状态里。range_q是当前编码区间的高 9 位量化值HEVC 标准规定它始终不小于 256offset是码流中已经读入的偏移量。判决原理是如果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 4d0; ctu_done 1b0; end else if (start) begin block_stack[0] {ctu_x, ctu_y}; depth_stack[0] 2d0; stack_ptr 4d1; ctu_done 1b0; end else if (stack_ptr ! 4d0 split_flag_in) begin // 有 split_flag 且深度未到 3则把四个子块压栈 if (cur_depth 3) begin block_stack[stack_ptr] {block_x, block_y}; block_stack[stack_ptr1] {block_x, block_y 32d16}; // ... 实际按当前深度计算子块偏移 stack_ptr stack_ptr 4d3; end end else if (stack_ptr ! 4d0) begin // 无 split_flag处理完当前块后出栈 stack_ptr stack_ptr - 4d1; {block_x, block_y} block_stack[stack_ptr - 4d1]; cur_depth depth_stack[stack_ptr - 4d1]; if (stack_ptr 4d1) ctu_done 1b1; end end endmodule这段代码的要点在于栈顶的split_flag决定当前块是否继续划分而cur_depth限制了最大递归深度为 364x64 到 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 StrengthBSBS 值决定了滤波器的开关和强度。判定规则在软件里是串行 if-else在硬件里通常做成查表// 边界强度计算简化 always (*) begin if (is_intra_p is_intra_q) bs 4d4; // 两侧都是帧内编码块最强滤波 else if (non_zero_coeff_p || non_zero_coeff_q) bs 4d2; // 有非零系数中等滤波 else if (ref_frame_p ! ref_frame_q) bs 4d2; // 参考帧不同需要滤波 else if (abs_dx_p abs_dx_q 1) bs 4d1; // 运动矢量差很小弱滤波 else bs 4d0; // 不滤波 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, dtypenp.uint8) frame_size w * h * 3 // 2 # YUV420 格式每像素 1.5 字节 return data[:frame_size * frames].reshape( frames, frame_size ) def psnr(actual, expected, max_val255): 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(fframe {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里用绝对路径或通过defineSTIM_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 管理。从那以后我每次改熵解码或滤波的代码都强制先跑完这三条流再合入不再等到上板了才对着花屏画面挠头。希望这套思路能帮你少踩几个这个项目里的坑跑通之后再做自己的裁剪就顺了。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表