☰
纯Verilog在FPGA上实现可移植的H.264编解码系统
2026/10/10 7:47:27 网站建设 项目流程

1. 为什么要在FPGA上用纯Verilog做H.264编解码

先说说我自己的经历。前两年做一款高清视频采集卡,输入是SDI信号,后端要实时把1080p60的画面压缩成H.264流推给上位机。最初方案很简单,用ARM核跑软件编码器,结果一测,CPU占用飙到90%多,发热严重,而且视频数据经过内存拷贝、编码、再拷贝,延迟高得离谱。换硬件编码芯片?H.264硬编芯片也不少,但接口固定、寄存器配置复杂,想定制码流格式、插入自定义SEI、把编码延迟压到几毫秒,基本没戏。

后来咬咬牙,决定在FPGA里自己写H.264编码器。项目用的是Xilinx Kintex-7平台,代码全部用纯Verilog编写,不依赖任何Xilinx专有的视频IP,这样可以确保代码能很方便地移植到Intel、Lattice或者国产FPGA平台。标题里提到的“可移植”,其实是整个项目最核心的约束——所有逻辑都自己写,寄存器传输级代码里不出现原语级依赖。这套代码从2018年开始写,经历了两代板卡迭代,目前已经在多个项目中稳定跑过,包括视频采集、无线图传、医学影像传输等场景。

这篇博文把整个项目的核心设计思路、编码器和解码器的Verilog实现、Kintex-7上的工程化适配、以及调试中踩过的坑,都做一个梳理。适合正在做FPGA视频处理、或者打算从零搭建视频编解码系统的朋友参考。

2. H.264算法映射到FPGA的整体设计思路

2.1 为什么不用现成IP核,非要自己写Verilog

很多朋友一听说FPGA做H.264,第一反应就是“直接用Xilinx的Video Codec IP不就行了?”是,Xilinx确实提供H.264/H.265的IP核,但有几个问题:

第一,IP核是加密的,仿真和调试受限,出了问题只能盲猜参数。第二,IP核的接口规范很复杂,尤其是AXI4-Stream的视频格式,要额外写一大堆适配逻辑去处理行场同步、像素握手。第三,IP核的码流结构是固定的,你想在SPS里加自定义信息、做帧级码率控制、输出Open-GOP格式,基本要依赖IP提供的选项,很多底层细节不开放。

自己写纯Verilog则有完全不同的体验。所有模块都开源可读,仿真时想看哪级信号看哪级;码流生成完全可控,编码参数想怎么调整都可以;而且代码不依赖厂商原语,最多在顶层例化PLL和BUFG这类通用时钟资源,换平台时只需要换顶层约束,核心算法模块一行都不用动。

当然,自己写编码器的代价也不小。H.264编码流程复杂,把预测、变换、量化、熵编码全部在硬件里做出来,工作量以月为单位计算。我的体会是,如果是做产品原型、做定制化视频方案,或者想深入理解H.264标准,自己写是值得的;但如果只是想在板子上快速跑通一个编码功能,对码流结构又没什么特殊要求,直接调IP核确实省事。

2.2 H.264算法分层与FPGA流水线结构

H.264编码器在标准里被分为两层:视频编码层(VCL)和网络抽象层(NAL)。VCL负责把人眼看到的图像转换成压缩的码流数据,NAL负责把编码后的数据封装成适合传输或者存储的格式。在FPGA实现中,这个分层结构可以进一步细化为更具体的硬件模块:

  • 输入采集模块:接收行场同步信号和像素数据,完成色彩空间转换(YCbCr4:2:0)
  • 帧内预测模块:利用当前帧已重建像素预测当前块,产生预测残差
  • 帧间预测模块:在当前帧之前的参考帧中搜索匹配块,做运动估计和运动补偿
  • 变换量化模块:对残差做4x4整数DCT,再进行量化,把高频分量压掉
  • 熵编码模块:对量化后的系数做CAVLC编码,生成最终的H.264码流
  • 重建环路模块:把量化后的系数反变换、反量化,加上预测值重建图像,供后续块参考
  • 去块滤波模块:对重建图像的块边界做滤波,消除块效应
  • 参考帧存储模块:用外部DDR或内部BRAM存储重建的参考帧

整个编码器是一个大的流水线结构。以1080p@60为例,像素时钟148.5MHz,一个像素进来后依次经过预测、变换、量化、熵编码等处理,每级流水线都在处理不同的宏块,整体吞吐量是固定的:一个时钟周期处理一个像素。这个流水线的设计关键在于均衡各模块的延迟——比如帧内预测需要等到左上方相邻块重建完成才能开始,那么变换量化模块就得设计成允许这种空窗存在,不然会掐断整条流水。

2.3 架构选择:编解码一体还是只做编码

这个项目最开始只写了编码器,后来发现调试编码器必须要验证码流正确性,总不能每次都用软件播放器看花屏。于是又写了配套解码器,每个编码模块对应一个解码模块,双向对照调试。整套系统做下来,差不多等于把H.264的VCL层完整实现了。

编码器和解码器共享了大量模块:反量化、反变换、帧内预测、去块滤波都是编码器重建环路里就有的部分。解码器额外需要的是CAVLC熵解码和残差重建。所以如果项目周期紧,只做编码器也可以——验证时用ffmpeg软解就行。但如果要做一个嵌入式视频传输系统的闭环,编解码一体就能在原地完成视频的收发互测,调试效率高一个数量级。

3. 编码器核心模块的Verilog实现

3.1 宏块级流水线设计

H.264编码的基本单位是宏块(Macroblock),一个宏块是16x16的亮度像素块和对应的8x8色度块。编码器真正处理的时候,不是一整个宏块一起处理,而是把16x16拆成16个4x4块(或者8x8帧间块),逐个做变换量化。这个“逐个”的特性让FPGA实现变得很麻烦——因为H.264帧内预测时,当前4x4块要依赖左边和上边已重建的相邻块像素,天然存在数据依赖。

我的做法是,编码器采用“块级流水线”,每个时钟周期处理一个4x4块。宏块进入后,先把16x16的像素存到寄存器阵列里,然后用状态机控制块处理顺序:按光栅扫描顺序处理16个4x4亮度块和8个4x4色度块,每个块依次经过模式决策、变换、量化、重建。这样做的优点是,相邻块的参考数据一定已经处理完毕,不需要插入等待周期;缺点是控制状态机比较复杂,代码里充满了case语句的状态跳转。

实际实现中一个比较重要的经验是,把所有模块的握手信号统一成一个模板。每个模块都有valid_in、valid_out、ready这三个信号,内部处理完了就把valid_out拉高,下一级ready拉低时数据保持不动。这个简单的握手机制让整个流水线的每个模块都能独立调试,只用ModelSim单独仿真某个模块,把上一级的激励文件存成文本喂进去,输出也和预期比对。

3.2 帧内预测:4x4亮度块9种模式的硬件实现

帧内预测是编码器中第一个要攻克的难点。4x4亮度块有9种预测模式:DC模式和8个方向模式。DC模式取上方和左方像素的平均值,方向模式沿着对应角度复制参考像素值。听起来简单,但在硬件里实现,要考虑怎么把参考像素组织起来。

当前块的参考像素是上方4个已重建像素(A、B、C、D)、左侧4个像素(I、J、K、L),再加上左上角像素M。这些像素在FPGA里必须从重建像素寄存器阵列中读出来。我的做法是,为每个宏块维护一个24x16大小的重建像素缓存(存当前宏块已重建的像素以及右方、下方已经重建的相邻宏块像素),然后用一个索引状态机根据当前块的位置生成对应的参考像素地址。

方向模式的Verilog实现,其实是个mux逻辑。以模式4(左下对角线)为例,预测像素p[x][y] = (p[x+y+1][y] + 2*p[x+y+2][y] + p[x+y+3][y] + 2) >> 2,这是一个三抽头滤波。为了简化,我预先对所有位置的多抽头系数算好,存成查找表,然后用组合逻辑实现。9种模式里8个方向模式用查表加移位完成,DC模式用加法树实现。整个帧内预测模块在K7上大概消耗不到300个LUT,时序能跑到250MHz以上。

帧内预测模式选择逻辑,是把16x16宏块分别用4x4帧内和16x16帧内遍历一遍,计算出率失真代价,选最小的。这个过程在纯逻辑里很费资源,因为要做很多次SATD(绝对变换差和)运算。我做了一个简化:只用Hadamard变换近似估计残差的能量,来代替完整的DCT+量化循环。实测下来码率只增加不到2%,但资源省掉了至少40%。

3.3 整数DCT变换与量化:纯移位实现乘法

H.264标准里的变换是4x4整数DCT,变换公式为Y = C * X * C^T,其中C矩阵为:

[ 1 1 1 1] [ 2 1 -1 -2] [ 1 -1 -1 1] [ 1 -2 2 -1]

这个矩阵最大的好处是,变换过程中不存在无理数,所有乘法都能用加法和移位实现。举个例子,变换的第一级一维变换,对每一列数据x0、x1、x2、x3,计算:

t0 = x0 + x3 t1 = x1 + x2 t2 = x1 - x2 t3 = x0 - x3 mid0 = t0 + t1 mid1 = (t2 << 1) + t3 mid2 = t0 - t1 mid3 = t2 - (t3 << 1)

这个过程看到没有,只有t2 << 1这种移位操作,连乘法器都用不到。这个蝶形结构在FPGA里实现就是几级加法器和移位寄存器,一个4x4块做二维变换,总共需要8次一维蝶形运算,大约10个时钟周期就能算完。

量化部分相对复杂。H.264的量化公式是Z = round(W * MF / 2^(qbits)),其中W是DCT变换后的系数,MF和qbits由量化参数QP决定。为了省DSP资源,我把MF乘法和2的幂次除法拆成查表和移位:所有可能的MF值预先存在一个ROM里,根据QP查表取数,然后做乘法和移位。这个方案只用了K7上1个DSP48E1,大部分情况纯LUT就能完成。

不过要提醒一下,量化时要注意对系数符号的处理。Verilog的整数除法对有符号数是截断的,也就是负数除以2的多少次方会向零取整,但H.264标准的量化是四舍五入。我踩过这个坑,后来改成先用符号位取绝对值,量化完再恢复符号。这部分逻辑看起来小,但一旦出错,画面上就会出现满屏的横纹噪声,而且这种噪声很难看出来是哪一级出的问题。

3.4 CAVLC熵编码:状态机与桶形移位器

熵编码是H.264编码器里最“串行”的部分,因为码流是逐比特语法元素串起来的,很难做纯并行流水。我实现的是CAVLC(上下文自适应可变长编码),处理的是4x4残差块量化后的系数。

CAVLC对每个4x4块按照Z扫描顺序(之字形扫描)读取16个系数,然后统计:

  • coeff_token:非零系数个数和拖尾系数个数联合编码
  • trailing_ones_sign:拖尾系数符号位
  • level:非零系数幅值
  • total_zeros:最后一个非零系数之前的零个数
  • run_before:每个零系数前连续零的个数

在Verilog里,我先把16个系数按Z扫描顺序存入一个16位的寄存器数组,然后一个状态机依次处理上述5个语法元素。每个语法元素查表生成对应的码字和码长,码字通过一个桶形移位器拼接到输出码流。

桶形移位器是这个模块的核心。它维护一个64位的码流缓冲,一次拼接16比特,当缓冲区里累积的比特数大于32位时,就把高32位输出到外部FIFO。这个结构的Verilog代码量不大,但要注意时序——桶形移位器的组合逻辑路径比较长,在K7-325T上如果主频跑到200MHz会有一点紧张。解决办法是,把桶形移位器打两拍流水,读取码字和输出码流在两个周期内完成。

CAVLC查表涉及的语法元素非常多,比如coeff_token有几十张表,根据nC(上下文变量)选择不同表。我的做法是用ROM存储所有码表和码长表,nC作为地址高位。整个熵编码模块在K7上大约消耗1000个LUT和8个BRAM36K,是编码器里资源占比比较大的模块之一,但性能确实能满足1080p60的需求。

4. 解码器核心模块的Verilog实现

4.1 码流解析与CAVLC解码

解码器这边,第一个模块是NAL层解析器。它从输入比特流中识别起始码00 00 01,然后解析NAL头,根据nal_unit_type区分SPS、PPS、IDR帧和非IDR帧。识别起始码时要注意防止伪起始码——如果码流里连续出现超过两个字节的0,要看后面跟的是不是01,否则就是普通数据里的0字节。这个逻辑用移位寄存器加状态机实现,比较简单。

真正的难点在CAVLC解码。它和编码器完全相反,是逐比特从码流里解析出元素,再用反查表还原出系数值。我在实现时,解码模块先根据nC查表判断当前块使用哪张coeff_token表,然后从码流移位寄存器取一定长度的位,查表得到非零系数个数和拖尾系数个数,再继续解拖尾符号、level值和run信息。

解码的过程天然是串行的,因为一个语法元素的解出依赖前一个元素提供的信息(比如total_zeros确定了后面零的位置范围)。在FPGA里,我这个模块的吞吐量设计是每个宏块最多分配256个时钟周期。实测CAVLC解码16个4x4块(一个宏块)在最坏情况下的周期数会在200左右,所以整个解码器跑1080p30时,主频可以降到100MHz以下,放到K7上有很大余量。

4.2 反量化反变换与重建环路

解码器的反量化反变换是编码器变换量化的逆操作。反变换的公式为X = C_i^T * Y * C_i,其中C_i是整数DCT逆变换矩阵。系数矩阵里只有1和0.5这种半数的乘法,所以全部用移位加实现,不需要DSP。

重建环路需要注意的问题是中间数据的位宽。H.264标准里,反变换后还要加上预测值,得到的重建像素范围是0到255,但中间计算过程中的临时数据会比这个范围大得多。比如反变换后的系数范围是-2048到2048,如果在这个地方用的寄存器位宽不够,就会出现溢出,表现为画面上的大块亮斑。

我的方法是每个中间计算节点预留4比特的延展。输入系数是12比特有符号数,反变换第一级输出用16比特,第二级输出用16比特,加上预测值后转为9比特无符号数,最后做截断到8比特。每一级都做饱和操作而不是简单的截断——负值裁到0,正值超过255就裁到255。这个细节虽然占了一些LUT,但避免了所有因溢出导致的图像质量问题。

4.3 去块滤波器的Line Buffer设计

去块滤波是H.264标准中计算量最大的模块之一,它要对所有4x4块边界做滤波处理。滤波强度由边界两侧块的编码模式决定,如果两侧是帧内块且边界是宏块边界,强度最高;如果两侧都用帧间预测且残差较小,强度就低。滤波的过程是先判断边界强度BS,然后根据BS选择滤波方式,对边界像素做加权平均。

在FPGA里实现去块滤波器,最让人头疼的是数据组织。因为滤波要对水平和垂直两个方向的边界分别处理,当处理垂直边界时,需要同时读取当前宏块的右侧4列像素和右边相邻宏块的左侧4列像素。如果等右边宏块重建完再滤波,延迟太大;如果提前滤波,又拿不到右边宏块的像素。

解决方案是Line Buffer。我用两个32x24的BRAM分别缓存当前宏块行和下一宏块行的重建像素,每处理完一行宏块,就把右侧边缘的4列像素写入Line Buffer,等到下一个宏块重建完成后,把Line Buffer里的数据和当前宏块的像素拼接成完整的一行,再做水平滤波。垂直滤波则是按照4x4块的列顺序,每4列一组逐行处理。

去块滤波模块在K7-325T上用了大约15个BRAM36K和1600个LUT,时序可以跑到180MHz。这个模块是工程中最容易疏忽性能的地方——如果Line Buffer的读写冲突没处理好,滤波效率会急剧下降,甚至把解码器的整体帧率拖掉一半。

5. Kintex-7平台上的工程化适配

5.1 资源评估与时钟规划

K7平台的选择不是随意的。一个1080p30的H.264编码器+解码器,纯Verilog方案需要的资源大致如下:

资源类型编码器解码器总量(K7-325T)K7-325T可用量占用率
LUT~28000~18000~4600020380023%
FF~15000~10000~250004076006%
BRAM36K48358344519%
DSP48E14048400%

注意,这个估算是纯编码核+解码核,不包括DDR控制器、DMA、视频输入输出接口的开销。如果完整系统还要加MIPI输入、HDMI输出、以太网/UDP传输,整体LUT占用会加到30%到35%左右,在K7-325T上仍然可以接受。

时钟规划上,我把设计分成了三个时钟域:

  • 像素时钟域(148.5MHz):视频输入输出接口
  • 编码时钟域(200MHz):编码器和解码器核心逻辑
  • 存储时钟域(400MHz DDR3):参考帧和码流缓冲

跨时钟域的数据传输统一走异步FIFO,这是我用纯Verilog实现多时钟域时的骨架。每个FIFO的深度按最坏情况下的背压大小计算,比如编码器和解码器之间的参考帧交互,FIFO深度至少要容纳2行像素的数据,我设的是512x64位。

5.2 DDR3参考帧存储与多端口读写

1080p30的YUV420图像,一帧大小是约3.1MB。如果参考帧全放BRAM,需要好几MB存储,K7的BRAM根本不够。所以参考帧必须放在外部DDR3里。

我用Xilinx MIG生成的DDR3控制器,用户接口是AXI4。但MIG的AXI接口只支持一个主机,而我需要三个端口同时访问DDR:编码器写参考帧、编码器读参考帧、解码器读参考帧。解决方法是,自己做一个简单的AXI仲裁器,按时间片轮询三个端口。每个端口一次突发读写固定为8个字(64字节),轮询周期是3个时间片。这样每个端口实际上获得的带宽是DDR总带宽的约1/3。

以DDR3-1600为例,理论带宽12.8GB/s,三分之后仍然有4GB/s,远超1080p30参考帧的读写需求(大约是每帧读取16MB、写入16MB,每秒不到1GB)。所以带宽不是瓶颈,关键是对突发长度的管理——如果每个端口的一次传输少于4个字,DDR的效率会指数下降,因为行激活和预充电的开销占比太大了。

实际代码里,仲裁器的实现是每个端口维护一个请求队列,队列非空就置高请求仲裁信号,仲裁器用一个3比特的循环移位寄存器决定当前授权给谁。授权后,对应端口的读写状态机接管AXI通道,完成一次突发后释放总线。这个逻辑大概200行Verilog,是我整个设计中最像“中间件”的模块,但它直接决定了系统能不能流畅跑起来。

5.3 与视频接口的对接:行场同步信号处理

FPGA视频处理项目绕不开行场同步信号的计算。我的方案里,视频输入接口接收的是标准的BT.1120或者MIPI CSI-2信号,进入FPGA后先做像素对齐,然后转换成内部的vsync、hsync、de(数据有效)信号。

这里有一个容易被忽略的坑:H.264编码器并不要求处理消隐期数据——消隐期的像素对压缩没有意义,反而浪费码率。所以在进入编码器之前,我先用一个降消隐模块,只把有效行的有效像素送入编码器,同时在码流里标记帧边界。这个模块看似简单,但处理不好会导致编码器帧错位,表现就是解码后的画面上下半帧错开,或者偶尔出现绿色条纹。

降消隐的关键是维护一个状态机,记录当前行的de信号宽度,把有效像素打包成连续的宏块流,遇到一行结束就插入行标记,遇到帧结束就插入帧标记。编码器内部再根据行标记和帧标记重置宏块计数。我一直觉得这部分逻辑简单到不值得单独拆出来讲,但它就是编码器能否正确出流的基础。

6. 仿真调试与常见问题排查

6.1 自底向上的三层验证策略

写FPGA视频编解码这种大规模逻辑,一上来就跑全系统仿真,一旦出错根本没法定位。我采用的方法是三层验证。

第一层是模块级仿真。每个模块独立测试,激励用testbench生成。以整数DCT变换模块为例,testbench里随机生成10000组4x4像素值,同时用C语言模型算出一模一样的变换结果,把结果对比,不一致就报错退出。这一层能快速抓掉90%的基础逻辑错误。

第二层是编码器回路自检。把编码器的输出码流直接接回配套的解码器,解码器输出重建帧,编码器内部重建帧和解码器重建帧做逐像素比对。如果两边完全一致,说明编码器里的重建环路和解码器逻辑都没问题。这个验证方案的好处是,它不需要参考外部软件解码器,只验证FPGA内部的一致性,能快速迭代。

第三层才是全系统验证。码流从FPGA出来,送到PC上用ffmpeg或者VLC软件解码,通过PSNR和主观观察判断图像质量。这一步通常会发现编码参数不合理、QP选择不对、参考帧管理有缺陷等问题,需要回到算法层面去调整。

6.2 常见故障速查

做这个项目的过程中,整理了下面这些常见问题,基本覆盖了FPGA视频编解码调试的大多数场景。

故障现象可能原因排查方法
画面全绿/全灰色彩空间转换csc系数配置错误;像素对齐错误先打印输入的YUV值,确认是否还停留在RGB域或对齐偏移
画面上下错位帧边界标记丢失;降消隐模块行计数错位单步检查帧起始信号的时序,看marker是否准确落入状态机
花屏但有部分可辨认参考帧DDR读写address配置错;BRAM缓存的行数不对对比编码器重建帧和解码器重建帧的差值,定位是第几行开始错位
马赛克严重,PSNR低于30dBQP过大;帧内预测模式选择丢失最优点;量化表配置错把QP调低一级,观察是否有明显改善,然后用ILA抓量化前DCT系数的幅值分布
码流不合法,播放器打不开SPS/PPS参数未按标准写入;NAL头解析错误用H264码流分析工具逐字节检查SPS的profile、level、分辨率字段
帧率只有要求的1/3熵编码吞吐量不足;DDR带宽被某个端口占死统计CLKA和ENCA时钟域的有效信号占空比,看哪个模块的valid拉高太少
偶发性画面闪烁参考帧管理在IDR帧前后出错;POC逻辑混乱检查GOP结构,确认IDR帧后参考列表是否正确刷新

6.3 仿真技巧:把C模型搬进testbench

最后一次分享实操心得。在验证过程中,我建立了一个“C参考模型”,用C语言实现了H.264编码器的核心算法。这个C模型不是完整软件编码器,而是和FPGA模块严格对应的C函数——比如h264_intra_predict(dst, ref, mode)对应Verilog的帧内预测模块。

然后我用SystemVerilog的DPI接口,或者更简单地用文件读写方式,在testbench里调用C模型做数据对比。比如量化模块,testbench随机生成DCT系数,同时向C模型函数传同样输入,两个结果比对。有了这套机制,每次改完代码跑回归,几千个用例只要几分钟,bug能被快速抓出来并定位到具体模块。

如果你没有SystemVerilog环境,Icarus Verilog配合C模型的文本互操作也能做,只是效率低一点,我最初的版本就是在Icarus上做的。关键是,一定要让C参考模型保持与你正在修改的Verilog模块同步更新,否则你会发现比对失败不是Verilog的错,而是C模型自己过时了。

7. 这套方案后续还可以怎么扩展

现在这套纯Verilog的H.264编解码器已经算是比较成熟的方案了,我后来还做过几个方向的扩展:一是往H.265演进,把HEVC的32x32变换、更大的帧内预测块加进去;二是把码率控制从固定QP升级成带反馈的CBR控制,通过统计前帧的编码比特数动态调整QP;三是把编码器核重新整合进SoC芯片的前端验证环境,用来做视频硬核IP的功能验证。

如果你也想在FPGA上试水H.264,我的建议是先跑通编码器,再写配套解码器。别想着直接用现成的IP核,否则你永远不知道宏块依赖、CAVLC码流拼接这些细节有多折磨人。从最简单的QP固定的I帧编码器开始,一帧能编码出来,播放器能打开,你已经赢了一半。后面慢慢加P帧、加运动估计,整个视频编解码的世界在FPGA里就是任你掌控的了。

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

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

立即咨询