☰
H.265/HEVC FPGA解码器Verilog工程:CTU状态机与CABAC流水设计解析
2026/10/18 3:07:13 网站建设 项目流程

简介:这套新一代高效视频编码解码器源码,是一份基于硬件描述语言完成的现场可编程门阵列工程,适合视频编码研究人员、数字逻辑开发者和芯片验证工程师。工程将新一代标准的解码流程拆分为熵解码、语法元素解析、运动补偿、去块效应滤波、逆变换与图像重建等模块,模块化程度较高,并面向集成处理器与可编程逻辑的芯片平台进行过系统验证,体现了软件控制与硬件加速配合的解码方案,便于理解从比特流解析到像素重建的完整数据通路。压缩包约四点六二兆字节,总共包含一百四十六个文件,其中三十三个设计源文件、十九个系统验证文件、九个测试样本和四个码流文件,另有构建脚本、配置参数与工程说明等配套文件;压缩包内的码流样本覆盖不同分辨率场景,为验证解码正确性提供了便利。整体结构完整,可用于工程复现、仿真调试以及上板测试。已有二百九十八人下载学习,若想对照真实工程学习视频硬件解码,或希望借鉴完整参考设计,这套源码能提供很好的起点。

1. h.265/HEVC 的 Verilog 解码工程:拿到的不只是一套解码器,更是一组 CTU 状态机

h.265(HEVC)在 FPGA 上做纯硬件解码,向来是视频方向里出了名的硬骨头,它的四叉树划分、CABAC 上下文建模和参考帧管理,每一项都是串行依赖极重的环节,和 FPGA 天生擅长的并行流水正好拧着来。这份源代码是一个用 Verilog 写成的 H.265/HEVC 解码器工程,面向 FPGA 平台,目标不是做一个全功能播放器,而是把从 NAL 到 YUV 这条完整解码链路用可综合 RTL 组织起来。它能帮你绕开最痛苦的 CABAC 写码阶段,适合手里已经有码流、想直接做解码落地的开发者,也适合作为视频硬件相关课程设计的骨架工程。

2. 解码链路先理清:从 NAL 到 YUV 的四级流水与参考帧取舍

2.1 码流入口与 SPS 参数:第一个卡点在参数集,不在 CABAC

拿到这个包之后,先别急着跑仿真,先看码流入口这段逻辑。HEVC 码流进来,第一件事是剥 NAL header,判断单元类型:VPS、SPS、PPS、IDR 切片、非 IDR 切片,以及 EOS/EBB 这类边界单元。VPS/SPS/PPS 参数集的更新频率很低,但内容却是后续所有解码的全局字典,SPS 里的 CTU 大小(默认 64)、bit_depth(8 或 10)、色度格式(4:2:0),PPS 里的初始 QP、去块滤波开关,全都要解析完缓存下来,后面每个 CTU 的处理都要反复查这些表。

我拆过的同类工程里,有的为了省 RAM,把 SPS 参数直接写成宏定义,比如CTU_SIZE=64、BIT_DEPTH=8,码流一换就解错,而且错得很隐蔽。稳妥的做法是参数集解析完成后,把关键字段打印出来,与硬件宏定义做一次断言比对,不一致就拉 error 标志。这一步很像上板前查 pin 分配,是省后面排错时间的关键一步。

2.2 四叉树与 CABAC:硬件天然犯怵的串行依赖怎么拆

真正的第一个性能卡点在 CTU 的四叉树递归。一个 64x64 的 CTU 理论上可以递归划分到 8x8,每一层都要等 CABAC 的解码结果才能决定是否继续划分。这个依赖链在硬件上很致命,因为大多数模块都是打满拍流水的,一个叉子进来,下一拍就要知道是继续划还是进入叶子节点处理。

常见做法是把四叉树深度预测做进 CABAC 流水:边解码边判断 split_flag,一旦命中叶子节点,就并行启动 PU/TU 的处理。我一般会给划分子模块维护一个深度状态机,跟踪当前层数,正常情况下 64 到 8 最多四层深度。如果已经到 8x8 还拿到 split 标记,判定码流异常,直接停下来报 slice 错误,而不是死循环空转。

再看 CABAC 本身。HEVC 里的 CUF、TUF 语法元素都是 CABAC 编码的,每个 bin 要完成三件事:查上下文模型、算术解码、更新概率状态,而上下文的选择依赖相邻块的状态。比如 CBF(coded block flag)要参考左边块和上边块有没有非零系数。这个依赖在 FPGA 上做不了多 bin 并行,最实际的做法是把 CABAC 做成每周期一个 bin 的流水线,再用提前旁路把可跳过的 bin 直接放行。

看综合报告的时候,CABAC 路径通常是 fmax 的天花板,如果它的关键路径超过预期目标,我优先查上下文模型那块 RAM 是不是还在用异步读,换成同步 RAM 打一拍,时序通常能立刻好不少。

2.3 参考帧与 DPB:DDR 带宽是隐藏的设计约束

帧间预测要从参考帧里取像素块做插值。HEVC 的 luma 插值最大到 8 抽头,色度最大到 4 抽头,参考帧一定放在 DDR 里,片内只保留行缓冲。DPB 的管理由 SPS 的max_dec_pic_buffering决定,解码器至少要留两个参考帧槽位,加上当前重建帧,相当于三帧同时在 DDR 里周转。

这里有个做仿真的新手不太容易注意到的点:带宽要提前算,别等上板了再测。1080p60 4:2:0 一帧约 3MB,帧间预测每 CTU 要读参考像素,最坏情况下一个 CTU 会把参考窗口的像素读两到三遍。粗算一下,解码一帧要读 6 到 9MB、写重建 3MB,折算到 60fps,再加上 DDR 刷新开销,很容易到 1.2GB/s 的量级。这还没算显示通路在同一条 DDR 上抢带宽的情况。如果工程里带了带宽计算脚本,先把最坏场景填进去看余量,别等掉帧了才回来查。

3. 工程跑通:目录结构、顶层例化与综合前准备

3.1 文件边界先分清:RTL、仿真与脚本各管什么

这类源码包建议先看目录结构,不要急着综合。成熟工程一般分三层:RTL、仿真、脚本,文件的典型组织方式如下。

归属典型文件作用需要重点关注的点
RTLdec_core.v / h265_decoder_top.v顶层解码状态机例化入口,先看端口定义
RTLcabac 目录CABAC 解码单元上下文模型用了哪种 RAM
RTLintra_pred 目录帧内预测参考像素 line buffer 深度
RTLinter_pred 目录帧间预测与插值行缓冲深度与块大小
RTLinloop 目录去块滤波 + SAO全工程时序最紧的区域
仿真tb_top.v / tb_top.sv测试平台码流加载方式与完成判定
仿真streams 目录测试码流与 RTL 支持的档次是否匹配
脚本run_sim.tcl / Makefile仿真与回归默认仿真器与编译选项

看文件边界有个很实际的目的:确认哪些文件是给仿真用的、哪些是能综合的。有的包会把 testbench 和 RTL 放在同一目录,综合时忘记 exclude,白跑一遍。我一般会先按归属把文件列表筛一遍,确保综合文件集里没有 sim 目录的内容。

提示:综合前先检查文件列表,把仿真目录从综合文件集里排除,这是最容易被忽略、浪费时间最多的一步。

另外注意 testbench 的后缀,是.sv还是.v。如果用 SystemVerilog 写的.sv,而当前综合环境只认 Verilog-2001,不需要立刻迁移全套工具链,直接用文件列表把 RTL 的.v文件单独摘出来就够了。仿真和综合用两套文件列表,本身也是该有的习惯。

3.2 顶层模块例化:接口信号、参数与时钟复位接法

顶层接口通常是这个风格:一侧输入打包好的 AXI-Stream 码流,一侧输出重建的 YUV 像素,中间带一组控制状态寄存器。

module h265_decoder_top #( parameter CTU_SIZE = 64, parameter BIT_DEPTH = 8, parameter MAX_DPB_FRAMES = 3, parameter AXI_DATA_WIDTH = 128 )( input wire clk, input wire rst_n, // 码流输入:AXI4-Stream 风格 input wire s_axis_tvalid, input wire s_axis_tlast, input wire [31:0] s_axis_tdata, output wire s_axis_tready, // 解码帧输出:重建 YUV output wire m_axis_tvalid, output wire m_axis_tlast, output wire [255:0] m_axis_tdata, input wire m_axis_tready, // 状态与错误 output wire [7:0] dec_status, output wire [3:0] err_code, input wire [31:0] dpb_base_addr // DDR 中 DPB 的基地址 );

接口上一般就三类:码流输入、重建帧输出、控制与状态。有的工程把码流入口做成 AXI-Lite 寄存器写 RAM 的模式,用写指针和读指针组成环形缓冲;有的直接走 Stream 通路,两种方式都很常见。选哪种取决于周边的 DMA 通路,如果上游已经是打包码流的 DMA 输出,Stream 方式更顺。

参数说明:CTU_SIZE决定行缓冲的宽度和整条数据通路的位宽,MAX_DPB_FRAMES影响 DDR 里 DPB 的地址分配,AXI_DATA_WIDTH要跟 DDR 控制器的端口位宽对齐。改参数之前先对齐 SPS,如果码流里 SPS 写的是 32,而这里保持 64,第一帧花屏的概率非常高。

时钟复位时序方面,复位至少维持 30 个像素时钟以上,让 CABAC 内部状态和 DPB 管理模块完成初始化。我一般会等复位释放后,再看到一个明确的帧边界事件拉高,才开始送码流。注意码流入口要按完整切片发送,不要在 TLAST 中间插入数据或者拆包,否则 NAL 解析会出现半个 NAL 头。

4. 仿真与验证:把解码器从“能跑”调到“能出图”

4.1 Testbench 搭建:码流读取、完成判定与 YUV 比对

解码器有没有出图,不能只靠波形里看断言。标准流程是把测试码流喂进去,解码完成之后把重建帧写成 YUV 文件,再和软件参考解码器输出的 YUV 逐像素比对 PSNR。这里要注意 YUV 格式是 4:2:0 planar,宽高要按 SPS 对齐到 CTU 的倍数,否则文件长度对不上,后续比对全错。

`timescale 1ns/1ps module tb_h265_decoder; reg clk = 0; reg rst_n = 0; integer fd_in; byte mem_stream [0:8191]; integer stream_len; // 读码流文件到内存数组 initial begin fd_in = $fopen("streams/seq_832x480.hevc", "rb"); if (fd_in == 0) begin $fatal(1, "stream file open failed"); end stream_len = $fread(mem_stream, fd_in); $fclose(fd_in); // 复位时序:先拉低 100 拍再释放 repeat (100) @(posedge clk); rst_n = 1; end // 像素时钟 10ns always #5 clk = ~clk; // 喂码流:每个时钟送 4 字节,直到 TLAST integer idx = 0; always @(posedge clk) begin if (rst_n && s_axis_tready) begin s_axis_tvalid <= 1; if (idx == stream_len - 1) s_axis_tlast <= 1; s_axis_tdata <= {mem_stream[idx], mem_stream[idx+1], mem_stream[idx+2], mem_stream[idx+3]}; idx = idx + 4; end end endmodule

这段代码的逻辑是先把 HEVC 码流以二进制方式读进字节数组,然后模拟 AXI-Stream 主端,每个时钟送 4 字节,最后一拍把 TLAST 拉高。用$fread直接读二进制比$readmemh省一次格式转换。注意tdata的高字节顺序要与 RTL 侧约定的字节序一致,否则 NAL 头会被拆反,解析结果完全乱掉。

完成判定我用两类信号一起看:一类是m_axis_tlast出现并且帧数计数达到预期;另一类是dec_status[0]拉高表示解码完成。两种方式都接上,测试脚本里同时检查,防止某一帧丢了但状态机还停在正常态。

注意:码流发送逻辑放在always块里属于简化写法,实际做回归建议把发送封装成 task,方便在异常场景里控制停发或者插入错误字节。

4.2 仿真参数与回归:超时看门狗和波形定位

跑回归时偶尔会遇到仿真器长时间不结束的情况,大多数不是 RTL 死锁,而是 testbench 自己不知道什么时候该停。我习惯给测试平台加一个超时看门狗。

initial begin #200_000; // 200us 超时 if (frame_cnt < expect_frames) begin $display("TIMEOUT: expected %0d frames, got %0d", expect_frames, frame_cnt); $fatal(1); end else begin $display("PASS: %0d frames decoded", frame_cnt); $finish; end end

超时设置的依据是每帧最坏解码时间。1080p 的码流在仿真里跑 200us 不一定够,仿真速度比硬件慢几个数量级。我一般先跑一个几百帧的序列测出基线时间,再把超时设成基线的三倍,这样既不会漏掉死锁,也不会空等太久。

仿真调试里最有用的信息不是波形里的像素数据,而是状态机的状态。强烈建议把dec_status、err_code和 CABAC 状态机的状态信号加入波形,死锁的时候直接看状态机卡在哪个 state,不用从头翻代码。有一个常见误判是:发现仿真不结束就怀疑 RTL 死锁,改了一通之后发现是 testbench 的结束条件设错了。先看波形里的err_code,再决定是 RTL 问题还是测试平台问题,这个顺序能省很多时间。

5. 移植避坑:仿真到板卡的高频问题记录

5.1 白屏或绿屏:先查 SPS 参数和宏定义是否对齐

现象:输入一段标准的 IDR 码流,仿真结果第一帧重建图是纯色,没有对象轮廓,但码流解析接口看起来输出正常。

原因:最常见有两种。一种是 RTL 里把 CTU_SIZE、BIT_DEPTH 写死成 64 和 8,而码流 SPS 里是另一组值,参数表错位导致后面全部解错;另一种是 4:2:0 的 UV 平面地址偏移算错,色度分量读到相邻帧的数据,画面整体偏色或者整片灰绿。

解决:参数集解析完成后,在仿真里打印 SPS 的关键字段,与顶层模块的 parameter 做断言比对;再把 UV 平面的起始地址换算出来,手工核对一遍偏移量。这两个动作都做掉,白屏类问题基本可以根治。

5.2 仿真固定位置卡死:多半是 slice 边界上下文没复位

现象:多段码流都在同一个 CTU 之后停住,err_code报 CABAC 解码错误,波形里状态机停在某个查询状态不再跳转。

原因:CABAC 上下文要在每个 slice 起始重新初始化,如果 RTL 只在 IDR 帧复位、非 IDR 切片不复位,累积的上下文概率会持续漂移,最终解码出非法 bin,状态机进入死等。slice 头里的dependent_slice_segment_enabled_flag也会影响上下文初始化方式,部分码流切了 dependent slice 就会触发这个问题。

解决:在 slice 起始加显式的上下文 RAM 清零状态,给状态机增加超时跳转,同一个 CTU 超过预设周期数没返回完成,直接拉错误标志,避免仿真卡死干等。

5.3 上板时序不过:in-loop filter 的行缓冲是长路径

现象:仿真完全正常,逻辑综合 fmax 只有目标值的一半,时序报告里最差路径集中在去块滤波和 SAO 模块的跨行数据访问。

原因:这两个模块的垂直方向要对邻近行做滤波,实现时用了整行 buffer 的直接组合比较,数据通路穿过大量比较器和选择器,路径延迟被拉长。

解决:把垂直滤波拆成两级流水,中间打一拍寄存器;SAO 的 offset 表提前一拍从参数 RAM 预取,不要等数据齐了再去查。带宽允许时,把行缓冲从普通 RAM 改成简单双口 RAM,也能明显降低读延迟。

5.4 1080p60 掉帧:参考帧读突发太碎

现象:板卡上 1080p30 没问题,切到 1080p60 开始掉帧,DDR 控制器负载率显示接近满,但理论带宽还有不少余量。

原因:帧间插值每次只读 8x8 或 16x16 的参考块,这些请求在 DDR 端是零散的短突发,行激活开销占比太大,有效带宽远低于理论峰值。

解决:让参考帧读取按 CTU 对齐到 64 字节边界,把同一 CTU 需要的参考区域合并成一次长突发一次性读入;片内行缓冲加大,让插值引擎复用已经读进来的数据。带宽计算脚本里的参考读重复次数,从默认的 2 改成 3 再算一次余量,按最坏情况留余量。

5.5 与软件解码比对有系统性偏色:检查色度采样相位

现象:PSNR 不低,比如 40dB 以上,但画面看起来偏色,边缘处有彩色条纹,亮度平面完全一致,问题只出现在色度平面。

原因:HEVC 解码重建的色度平面采样位置由 SPS 里的chroma_sample_loc_type字段决定,FPGA 重建流程在做 4:2:0 上采样或写回时,默认了另一种采样相位,导致色度和亮度没有对齐。

解决:比对时把 UV 平面分别做 0 像素和 1 像素的平移后再算 PSNR,找出相位偏移量;然后在输出级把 UV 平面的写地址统一偏移对应像素即可。这个修法只需要动输出级地址换算,不用改动整条预测链路,风险很小。

6. 一个进阶技巧:用帧级带宽核算决定架构余量

拿到解码器工程后,我先不算 fmax,先算带宽。公式不复杂:每帧像素数 × 帧率 × 每像素字节数,4:2:0 下 Y 和 UV 平均每个像素 1.5 字节,得到重建写带宽。帧间预测的参考读带宽按 CTU 参考区域估算:1080p 的 CTU 是 64x64,参考搜索窗口写死在工程里的话,最坏要读出约 144x144 的 luma 窗口,面积是 CTU 的 2.25 倍。

按这个系数算:1920x1080x60x1.5x2.25,结果是每秒约 400MB,这还只是 luma 部分的参考读。再加上重建写约 187MB/s、显示通路读取,以及 DDR 刷新开销,总量很容易到 1.2GB/s 附近。把 DDR 控制器的实际效率按 60% 到 70% 折算,立刻就能知道当前架构有没有余量。

如果算出没余量,有两条常见调整路径:一是把 CTU_SIZE 从 64 降到 32,参考窗口面积系数能降不少,但块数量翻倍,状态机压力会上来;二是把 B 帧数量降下来,减少参考帧周转次数。我一般先按默认参数跑通,把 ffmpeg 解出来的参考 YUV 和硬件输出做一次全帧比对,确认没有隐性偏色和相位问题之后,再动手评估带宽优化。

从那以后我每次拿到新的解码器工程,都会先花十分钟做一遍这个带宽折算,再把测试码流的 SPS 打出来看一眼。这两个小习惯帮我躲过了至少两次大改架构级别的翻车,一次是低估了参考帧读带宽,另一次是 CTU 尺寸和码流不匹配。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询