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存储重建的参考帧整个编码器是一个大的流水线结构。以1080p60为例像素时钟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[xy1][y] 2*p[xy2][y] p[xy3][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-325TK7-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里就是任你掌控的了。