1. 项目缘起与整体设计思路PNG图片解码这件事放在PC端或者手机端随便调个库就完事了但在FPGA上纯用Verilog从头实现完全是另一回事。我最早接触这个需求是因为一个工业相机项目前端CMOS传感器输出的图像需要叠加一层UI图标和Logo水印这些素材以PNG格式存储在Flash里要求上电后实时解码并叠加到视频流上。用软核跑PNG解码速度跟不上用专用解码芯片又增加BOM成本和布线面积最后决定用纯Verilog写一个PNG解码器直接烧进FPGA逻辑资源里。这个项目的核心目标很明确在FPGA内部用纯Verilog实现PNG图片的完整解码流程不依赖任何软核处理器、不调用厂商IP、不借助外部解码芯片。输入是存储在外部存储器如Flash、SD卡或通过UART下发的PNG文件数据流输出是解码后的RGB像素数据可以直接送入视频时序生成模块或者DDR帧缓存。整套工程提供10套不同平台和分辨率的源码覆盖Xilinx、AlteraIntel、国产安路等主流FPGA平台方便不同背景的开发者直接移植和二次开发。为什么选择纯Verilog而不是HLS或者软核方案这里有几个很实际的考量。第一确定性时序。PNG解码涉及大量的变长Huffman解码和LZ77滑动窗口回溯用软核跑的话每帧解码时间波动很大对于需要严格帧同步的工业场景来说不可接受。纯硬件流水线虽然设计复杂但一旦跑通每个像素的解码周期是确定的。第二资源可控。HLS生成的电路往往资源利用率不高而手写Verilog可以精确控制BRAM、DSP和LUT的使用量。第三移植性强。纯Verilog代码不依赖特定厂商的IP核换平台只需要改顶层管脚约束和时钟配置核心解码逻辑完全复用。整个解码器的架构我拆成了几个独立的模块文件头解析模块负责读取PNG签名和IHDR块提取宽高、位深、颜色类型等关键参数块数据缓存模块用BRAM缓存IDAT块数据因为PNG的Deflate压缩是流式的需要先攒够一定数据才能开始解码Huffman解码模块是核心中的核心负责从比特流中还原出Literal/Length和Distance码LZ77回溯模块维护一个32KB的滑动窗口根据Distance和Length从历史数据中复制字节反滤波模块对每行像素执行五种滤波方式的逆运算颜色转换模块处理调色板、灰度、RGB、RGBA等不同颜色类型最终统一输出RGB888格式。这个架构的关键在于流水线划分。Huffman解码和LZ77回溯是串行依赖的但反滤波和颜色转换可以流水化。我的做法是在LZ77输出端加一个FIFO攒够一行像素后再启动反滤波这样反滤波模块可以按行流水处理每个时钟周期输出一个像素。实测下来在100MHz时钟下解码一张1024x768的PNG图片大约需要8ms左右换算下来帧率能到120fps以上完全满足实时叠加的需求。注意PNG解码的瓶颈不在反滤波和颜色转换而在Huffman解码的比特流对齐。很多初学者在这里踩坑后面我会详细讲怎么优化。2. PNG解码核心细节与实操要点2.1 文件头与块结构解析的坑PNG文件的结构是固定的8字节签名后面跟着一系列Chunk。每个Chunk的结构是4字节长度、4字节类型、数据区、4字节CRC。IHDR必须是第一个ChunkIDAT可以出现多次IEND是结束标志。听起来很简单但实操中有几个细节容易翻车。第一个坑是字节序。PNG所有多字节整数都是大端序Big-Endian而FPGA内部通常用小端序处理数据。我在第一版代码里忘了做字节序转换结果解析出来的宽度是0x00000400变成了0x00040000直接导致地址溢出。解决办法是在读取4字节长度和宽高时用移位寄存器做一次字节反转。第二个坑是IDAT块可能不连续。标准允许IDAT块之间插入其他辅助块虽然实际编码器很少这么干但解码器必须能处理。我的做法是用一个状态机遇到IDAT就进入数据缓存状态遇到非IDAT就暂时跳过等下一个IDAT继续往同一个BRAM地址写。这里需要一个块计数器来跟踪已经写入的IDAT数据总量避免地址错乱。第三个坑是CRC校验。虽然很多解码器为了省资源直接跳过CRC但在工业场景下Flash存储的数据可能因为擦写次数过多出现位翻转CRC校验能提前发现坏数据。我用了一个简单的LFSR结构实现CRC-32每字节处理一个时钟周期资源开销很小。如果CRC校验失败就输出一个错误标志上层可以决定是重试还是用上一帧数据顶替。2.2 Huffman解码的硬件实现策略PNG的Deflate压缩使用了两套Huffman表一套用于Literal/Length码一套用于Distance码。每套表的码长从1到15比特不等标准做法是先读取码长数组然后根据码长构建规范Huffman表。在软件里这就是个查表操作但在硬件里变长比特流的对齐是个麻烦事。我的实现方案是用一个比特流缓存器每次从BRAM里读32位数据进来然后根据当前需要的码长从缓存器里截取。Huffman解码用两级查找表第一级用9比特索引如果码长小于等于9直接查出符号如果大于9第一级表给出一个偏移量第二级用剩余的比特继续查。这样最坏情况下两次查表就能出结果每个符号平均1.5个时钟周期。这里有个关键优化Huffman表的存储。Literal/Length表最多288个条目Distance表最多32个条目。如果每个条目存符号值、码长、码字资源消耗不小。我的做法是只存码长和符号的映射关系解码时用规范Huffman的数学性质反推码字。具体来说给定码长L和该码长下的第N个码字可以计算出实际码字值。这样BRAM里只需要存码长数组和符号数组节省了将近40%的存储资源。实操心得Huffman解码的状态机一定要处理好比特流耗尽的情况。当缓存器里的有效比特不足当前码长时必须暂停解码从BRAM里再读32位进来。我见过有人在这里没做流控结果解码出来的符号全是乱的。2.3 LZ77滑动窗口的资源取舍LZ77回溯是PNG解码里最吃BRAM的部分。标准要求32KB的滑动窗口如果用分布式RAM实现一个32KB的窗口需要大量的LUT不划算。我的方案是用一个36Kb的BRAM配置成32Kx8的简单双口RAM写端口用于写入新解码的字节读端口用于回溯复制。回溯复制的逻辑是当Huffman解码出一个Length/Distance对时从写指针减去Distance的位置开始读连续读Length个字节同时把这些字节也写入窗口。这里有个细节Length可能大于Distance也就是说复制的数据可能包含刚刚写入的数据。比如Distance1Length10就是把同一个字节重复10次。硬件实现时不能简单地先读后写必须边读边写每读一个字节就立刻写到写指针位置然后读写指针同时加一。我用了一个小状态机来控制这个循环每个时钟周期完成一个字节的复制。窗口大小的选择也有讲究。标准规定最大32KB但实际PNG图片的压缩窗口往往用不到这么大。如果资源紧张可以缩小到16KB甚至8KB但要注意如果编码时用了更大的窗口解码时窗口不够会导致回溯数据错误。我的建议是至少保留16KB因为大多数编码器默认窗口是32KB但实际Distance超过16KB的情况很少。如果确实资源受限可以在文件头解析阶段检查一下IDAT数据里最大的Distance值动态调整窗口大小。2.4 反滤波与颜色转换的流水线设计PNG的滤波有五种类型None、Sub、Up、Average、Paeth。每行像素的第一个字节是滤波类型后面的字节是滤波后的数据。反滤波需要用到上一行对应位置的像素和当前行前一个像素所以必须缓存至少两行数据。我的做法是用两个行缓冲BRAM乒乓操作一行用于当前解码一行用于上一行参考。反滤波的计算本身不复杂但Paeth预测器涉及三个方向的梯度计算需要做绝对值和比较。在硬件里我用组合逻辑实现Paeth的预测值计算关键路径大概在5ns左右100MHz时钟下能跑通。如果时序紧张可以插入一级流水寄存器代价是增加一个时钟周期的延迟。颜色转换模块要处理多种颜色类型灰度1/2/4/8/16位、真彩色8/16位、索引色1/2/4/8位、灰度Alpha、真彩色Alpha。我的做法是统一转换成RGB888输出对于索引色需要先查调色板PLTE块对于16位深度需要做位截断或缩放。这里有个容易忽略的点16位到8位的转换不是简单取高8位标准建议用公式(value * 255 32767) / 65535来做线性映射但实际实现时用移位加近似就够了人眼分辨不出差别。3. 完整实操流程与核心环节实现3.1 工程目录结构与模块划分拿到源码后你会发现工程目录是这样的结构rtl/放所有Verilog源文件sim/放测试平台和仿真脚本constr/放各平台的管脚约束和时钟约束doc/放设计文档和寄存器手册testbench/放PNG测试图片和对应的期望输出数据。10套工程分别对应不同的FPGA平台和分辨率配置每套工程的核心RTL代码完全一样区别只在顶层封装和约束文件。核心模块的例化关系是这样的顶层模块png_decoder_top例化了file_parser、idat_buffer、huffman_decoder、lz77_window、defilter、color_convert六个子模块。数据流是file_parser从输入FIFO读取PNG字节流解析出IHDR参数后启动idat_buffer缓存IDAT数据huffman_decoder从idat_buffer读取压缩数据解码出Literal和Length/Distance对lz77_window根据Length/Distance从窗口复制数据输出解压后的字节流defilter按行做反滤波color_convert输出最终RGB像素。每个模块的接口都做了标准化输入输出都是简单的Valid-Ready握手数据位宽统一为8位或32位。这样做的目的是方便单独仿真和替换。比如你想把Huffman解码换成自己的实现只要接口时序对得上直接替换模块就行不用动其他部分。3.2 关键模块的Verilog实现细节先看huffman_decoder的核心状态机。它有三个状态IDLE、DECODE、OUTPUT。IDLE状态下等待idat_buffer有足够数据DECODE状态下从比特流缓存器里取比特查两级Huffman表OUTPUT状态下把解码出的符号输出到FIFO。关键代码如下// 比特流缓存器每次从BRAM读32位 always (posedge clk) begin if (bit_cnt need_bits !bram_empty) begin bit_buf {bit_buf[31:0], brma_dout}; bit_cnt bit_cnt 32; end end // 第一级查表 wire [8:0] first_idx bit_buf[bit_cnt-1 -: 9]; wire [3:0] first_len huff_len_lut[first_idx]; wire [8:0] first_sym huff_sym_lut[first_idx]; // 第二级查表 wire [14:0] second_idx bit_buf[bit_cnt-1 -: 15]; wire [3:0] second_len huff_len_lut2[second_idx]; wire [8:0] second_sym huff_sym_lut2[second_idx];这里bit_cnt是当前缓存器里的有效比特数need_bits是当前解码需要的比特数。当有效比特不足时从BRAM读数据补充。注意bit_buf是左移拼接新数据放在低位这样取比特时从高位取符合PNG的大端序。再看lz77_window的回溯复制逻辑// 回溯复制状态机 always (posedge clk) begin case (state) COPY: begin if (copy_cnt length) begin rd_addr wr_addr - distance; copy_data window_ram[rd_addr]; window_ram[wr_addr] copy_data; wr_addr wr_addr 1; copy_cnt copy_cnt 1; end else begin state IDLE; end end endcase end注意这里rd_addr和wr_addr是分开的读地址始终是写地址减去Distance。当Distance小于Length时读地址会追上写地址但因为每个周期先读后写读到的数据是上一周期写入的所以能正确实现重复复制。3.3 仿真验证与上板调试仿真平台我用的是Icarus Verilog配合GTKWave测试用例是一张64x64的PNG图片用Python脚本生成对应的期望RGB数据。仿真流程是读取PNG文件到内存逐字节送入png_decoder_top的输入FIFO等待解码完成信号然后比较输出的RGB数据与期望值。如果全部匹配打印PASS否则打印第一个不匹配的像素位置和值。上板调试时我建议先用SignalTap或ILA抓几个关键信号huffman_state、lz77_state、defilter_state、output_valid。如果解码卡住不动先看huffman_state是不是停在DECODE状态如果是检查bit_cnt是不是一直小于need_bits这通常意味着idat_buffer空了或者BRAM读使能没接对。如果解码出来的像素颜色不对先看color_convert的输入数据确认反滤波输出是否正确。实操心得上板前一定要在仿真里跑通至少三张不同颜色类型灰度、索引色、真彩色的PNG图片。我见过有人只仿真了灰度图就上板结果真彩色图片解码出来全是花屏原因是颜色转换模块的调色板地址算错了。3.4 10套工程的平台适配要点10套工程覆盖了Xilinx Artix-7、Zynq-7000、Spartan-6Altera Cyclone IV、Cyclone V以及国产安路EG4等平台。每套工程的适配工作主要是三件事时钟配置、BRAM例化、管脚约束。时钟配置方面Xilinx平台用MMCMAltera平台用PLL安路平台用PLL。输入时钟频率根据板子晶振不同有50MHz、100MHz、200MHz几种。我的建议是解码器核心逻辑统一跑100MHz如果输入时钟不是100MHz先用PLL倍频或分频到100MHz再送入解码器。这样核心代码不用改只改PLL参数就行。BRAM例化方面Xilinx用xpm_memory_sdpramAltera用altsyncram安路用bram_sdp。为了代码可移植我写了一个bram_wrapper模块用宏定义区分不同平台例化对应的原语。这样上层代码只调用bram_wrapper不用关心底层实现。管脚约束方面主要是输入FIFO的接口和输出RGB接口。输入FIFO可以接UART、SPI、SD卡控制器或者DDR读端口输出RGB接视频时序生成模块或者DDR写端口。每套工程的约束文件里都写了详细的注释说明每个管脚对应的板子接口。4. 常见问题与排查技巧实录4.1 解码输出全黑或全白这是最常见的问题通常有三个原因。第一IHDR解析错误宽度或高度读成了0导致后续状态机直接跳过解码。排查方法是抓file_parser输出的width和height信号确认是否与图片实际尺寸一致。第二IDAT缓存地址错乱数据写到了错误的BRAM地址导致Huffman解码读到的全是0或0xFF。排查方法是抓idat_buffer的写地址和写数据确认写入的数据与PNG文件里的IDAT数据一致。第三颜色转换模块的调色板未初始化对于索引色PNG如果PLTE块没有正确解析调色板全是0输出就是黑色。排查方法是抓color_convert的调色板读地址和读数据。4.2 图像出现规律性条纹或错位这种问题通常是反滤波的上一行参考数据错误。PNG的滤波是逐行进行的每一行的反滤波需要用到上一行的原始像素值。如果行缓冲BRAM的乒乓切换逻辑有问题比如上一行数据还没写完就切换了或者切换时机不对就会导致下一行反滤波用错参考数据。排查方法是抓defilter模块的prev_line_addr和prev_line_data确认读出的上一行数据与上一行解码输出一致。另一个可能的原因是LZ77窗口的回溯地址计算错误。当Distance大于当前已写入窗口的数据量时标准规定应该从窗口的环形缓冲区里取模读取。如果实现时直接做了减法地址会下溢读到错误的数据。我的做法是在lz77_window里加一个判断如果wr_addr distance则rd_addr wr_addr WINDOW_SIZE - distance否则rd_addr wr_addr - distance。4.3 解码速度不达标如果解码一帧的时间超过预期先看Huffman解码的吞吐率。理想情况下每个时钟周期能解码一个符号但如果比特流缓存器经常需要补充数据实际吞吐率会下降。优化方法是增大比特流缓存器的深度从32位增加到64位这样补充数据的频率降低一半。另一个优化点是预取Huffman表在解码当前符号的同时提前把下一个符号可能用到的表项读出来减少查表延迟。还有一个容易被忽略的点是BRAM的读延迟。如果idat_buffer的读延迟是2个时钟周期而Huffman解码每个周期都需要新数据就会产生气泡。解决办法是用双口BRAM一个端口专门用于预取另一个端口用于正常读取或者用分布式RAM替代BRAM读延迟只有1个周期。4.4 常见问题速查表现象可能原因排查方法解决方案输出全黑IHDR解析错误抓width/height信号检查字节序转换输出全白IDAT缓存为空抓idat_wr_addr检查IDAT块状态机规律条纹行缓冲切换错误抓prev_line_data修正乒乓切换时机颜色错乱调色板未初始化抓palette_rd_data检查PLTE解析解码卡住比特流耗尽抓bit_cnt增大缓存器深度速度慢BRAM读延迟抓huffman_state改用分布式RAMCRC报错Flash数据损坏抓crc_error重试或降级处理最后分享一个小技巧如果手头没有逻辑分析仪可以用UART打印调试信息。在关键状态机的状态切换处加一个UART发送模块把状态码和关键信号值打印出来波特率设成115200用串口助手看。虽然速度慢但胜在简单不需要额外硬件。我在早期调试Huffman解码时就是用这个方法定位到比特流对齐问题的。这个PNG解码器后续还可以扩展支持APNG动图解码只需要在文件头解析阶段识别acTL块然后按帧循环解码fdAT块就行。另外如果资源允许可以加一个双线性缩放模块把解码后的图片缩放到任意尺寸再输出这样UI叠加就更灵活了。