1. 为什么要在FPGA里硬解PNG做图像处理的朋友大概率都遇到过这个场景摄像头采集或者上位机传过来一张PNG图片需要在FPGA内部直接完成解码然后送去做缩放、叠加、滤波或者显示。第一反应通常是找个软核跑libpng不就行了但真上手就会发现软核跑PNG解码慢得让人抓狂一张1080P的PNG解下来几百毫秒实时性根本无从谈起。而用纯Verilog写一个硬件解码器把zlib解压和PNG滤波这两块最耗时的活儿全部流水线化吞吐量能直接拉到每时钟周期处理一个像素甚至更多这才是FPGA该干的事。PNG这个格式看起来简单实际上坑非常多。它本质上是zlib压缩流 逐行滤波 分块组织的三层结构最麻烦的是zlib里的DEFLATE算法涉及LZ77滑动窗口匹配和Huffman变长编码纯硬件实现需要仔细设计状态机和存储结构。很多人一开始低估了这块的复杂度写到一半发现Huffman码表动态生成、滑动窗口回溯这些逻辑用Verilog描述起来极其别扭最后不了了之。我这次把整套东西啃下来整理成10套可综合的工程源码覆盖从单模块验证到完整图像处理链路的各个层次下面把设计思路、关键细节和踩过的坑完整讲一遍。这套东西适合谁如果你已经会写基本的Verilog状态机、懂FIFO和BRAM怎么用想找一个有足够深度又不至于劝退的项目来练手PNG解码是极好的选择。它涉及流式数据处理、变长编码、存储管理、跨时钟域这些FPGA核心技能做完一遍对硬件思维的理解会上一个台阶。如果你只是想快速出图那用现成IP或者软核更省事本文更适合想真正搞懂原理并自己动手实现的人。2. PNG解码的整体架构与模块划分2.1 从文件格式倒推硬件流水线PNG文件的结构是8字节文件头 若干数据块Chunk每个Chunk是4字节长度 4字节类型 数据 4字节CRC。解码真正关心的是三类块IHDR图像头含宽高、位深、颜色类型、IDAT压缩的图像数据可能有多块需要拼接、IEND结束标志。硬件上没必要做完整的Chunk解析器我的做法是用一个轻量级的状态机顺序扫描遇到IHDR就把宽高等参数锁存到寄存器遇到IDAT就把数据流直接灌进解压引擎遇到IEND就拉高done信号。这里有个关键决策CRC校验要不要做。PNG每个Chunk都带CRC32理论上应该校验。但实测下来CRC32在硬件里要额外一个32位LFSR和逐字节异或面积不小而且实际工程中图片数据来源可控出错概率极低。我的10套源码里基础版本直接跳过CRC进阶版本才加上可选的CRC校验模块。如果你做的是产品级应用建议加上如果是学习或者内部工具跳过完全没问题能省不少逻辑资源。2.2 顶层模块的信号接口设计顶层模块我命名为png_decoder_top对外接口尽量简洁方便集成到各种系统里。核心信号如下信号名方向位宽说明clkinput1系统时钟建议100MHz以上rst_ninput1低电平复位data_ininput8PNG字节流输入data_validinput1输入数据有效data_readyoutput1反压信号下游未就绪时拉低pixel_outoutput24解码后RGB888像素pixel_xoutput16像素横坐标pixel_youtput16像素纵坐标pixel_validoutput1输出像素有效frame_doneoutput1整帧解码完成img_widthoutput16图像宽度img_heightoutput16图像高度这个接口设计的关键点是反压机制。PNG解码是流式的输入数据不能停但下游比如DDR写入或者显示驱动可能来不及接收。如果不管反压要么丢像素要么溢出。我的方案是在解压引擎和滤波模块之间放一个深度足够的异步FIFO当FIFO快满时通过data_ready反压上游让数据源暂停发送。这个细节很多开源实现都忽略了导致一跑大数据量就出错。2.3 为什么选择纯Verilog而不是HLS经常有人问这种算法密集型的东西为什么不用HLS高层次综合写C语言描述起来不是快得多吗我的理由有三条。第一DEFLATE解码里有大量位级别的操作HLS对位操作的支持很别扭生成的电路往往比手写的臃肿。第二Huffman解码需要根据码表动态查表HLS的查表逻辑经常综合出巨大的组合逻辑时序很难收敛。第三也是最重要的纯Verilog的每一拍时序你都能精确控制调试时看波形一目了然而HLS生成的电路像黑盒出了问题很难定位。当然HLS开发速度快如果你追求的是快速原型而非极致性能用HLS也无可厚非但想真正吃透PNG解码手写Verilog是绕不开的。3. zlib解压引擎的核心实现3.1 DEFLATE的两种压缩块处理zlib数据流由一个个压缩块组成每个块开头有3个bit的头部1bit的BFINAL是否最后一块和2bit的BTYPE压缩类型。BTYPE有四种取值00表示不压缩Stored01表示固定HuffmanFixed10表示动态HuffmanDynamic11保留。实际PNG图片里前两种和动态Huffman都会出现尤其是小图片或者已经压缩过的数据经常用Stored块。Stored块处理最简单跳过字节对齐后直接拷贝LEN个字节即可。但这里有个坑字节对齐。DEFLATE是位流Stored块要求跳过当前字节剩余的位对齐到字节边界。我一开始忘了这个对齐导致后面数据全错位调了整整两天才找到问题。Verilog里实现对齐就是判断当前bit计数器如果不在字节边界就丢弃剩余位。固定Huffman块的码表是预定义的可以直接硬编码成查找表。动态Huffman块最复杂需要在块开头解析码长信息动态构建码表。这是整个解码器里最烧脑的部分下面单独讲。3.2 动态Huffman码表的硬件构建动态Huffman块的开头是一段码长编码它本身也是用Huffman编码的用的是固定的码长字母表。解析流程是先读出HLIT、HDIST、HCLEN三个数量参数然后读HCLEN4个3bit的码长构建出码长码表再用这个码表解出所有literal/length和distance的码长最后根据码长构建真正的解码码表。硬件实现的关键是码表存储结构。软件里通常用二叉树或者哈希表但硬件里最合适的是规范Huffman码的canonical形式。规范Huffman有个好性质相同码长的码字是连续的且码字值随符号顺序递增。这样解码时可以用逐位读入 与当前长度下的最小码字比较的方式配合一个按码长分组的查找表。我的实现用了一个深度288的BRAM存literal/length码表深度32的BRAM存distance码表每个表项存符号值 码长解码时逐位累积每读一位就查一次表命中就输出符号。这个逐位查表的方案吞吐量是每bit一拍对于100MHz时钟解压速度大约12.5MB/s。听起来不快但PNG的压缩比通常有2到5倍折算成像素吞吐能到25到60MB/s对于1080P30fps约186MB/s的原始像素率还不够需要进一步优化。优化方案是多位并行查表一次读入比如8位用这8位的高几位先查一个粗表确定码长范围再精确匹配。我的进阶版本用了这个方案吞吐量提升到每时钟约4bit基本够用。3.3 LZ77滑动窗口的存储管理LZ77的核心是回溯引用解压时遇到一个(length, distance)对就要从当前输出位置往前数distance个字节拷贝length个字节到输出。这要求维护一个至少32KB的滑动窗口DEFLATE规定最大distance是32768。硬件里实现滑动窗口最直接的办法是用一块32KB的BRAM做环形缓冲。写指针随输出递增读指针是写指针 - distance。拷贝length个字节时读指针和写指针同步递增每个周期搬一个字节。这里有个重叠拷贝的陷阱如果distance小于length拷贝过程中会读到自己刚写的数据必须保证读在写之前完成否则数据会错。我的做法是读地址和写地址分开计算读操作组合逻辑输出写操作时序逻辑写入天然保证了读优先。32KB的BRAM在大多数FPGA上占一个18Kb BRAM块多一点资源可接受。但如果你的FPGA BRAM紧张可以考虑用外部DDR做滑动窗口代价是延迟增加需要更复杂的预取逻辑。我的10套源码里有一套就是DDR版本的适合大图或者BRAM受限的场景。4. PNG滤波与像素重组4.1 五种滤波类型的逐行处理zlib解压出来的是滤波后的像素数据每行开头有一个字节表示滤波类型取值0到4分别是None、Sub、Up、Average、Paeth。滤波的目的是利用相邻像素的相关性提高压缩率解码时必须逆向还原。五种滤波的计算公式不同但有个共同点都需要用到左边像素和上边像素。这意味着硬件必须缓存上一行的完整像素数据。对于RGB888一行1920像素就是5760字节用BRAM缓存一行完全可行。我的实现里用一块双口BRAM存上一行当前行计算时同时读上一行对应位置和左边已还原的像素。Paeth滤波最复杂它要根据左边、上边、左上三个像素预测当前像素公式是取三个预测值中与左上-左上最接近的那个。硬件实现需要三个加法和比较组合逻辑路径较长建议插入一级流水寄存器。我实测在100MHz下不加流水能跑到约80MHz加了流水轻松上150MHz。4.2 不同颜色类型的处理差异PNG支持多种颜色类型灰度0、RGB2、调色板3、灰度Alpha4、RGBA6。每种类型的像素字节数不同滤波时的像素定义也不同。比如RGB类型一个像素是3字节Sub滤波是当前字节减去前一个像素的同通道字节即往前3个字节而不是往前1个字节。这个细节极易搞错我第一版就栽在这里解出来的图颜色错乱。处理办法是在滤波模块里加一个参数bytes_per_pixel根据IHDR里的颜色类型动态配置。Sub和Paeth滤波的偏移量都用这个参数计算。调色板类型更特殊解压出来的是索引值还需要查PLTE块里的调色板才能得到RGB。我的基础版本只支持RGB和RGBA调色板版本放在进阶源码里。4.3 位深小于8的处理PNG允许1、2、4位的位深这时一个字节里打包了多个像素。比如1位灰度一个字节存8个像素。硬件处理这种数据需要先做位解包把每个像素拆出来扩展到8位。解包逻辑本身不复杂但要注意行末对齐每行数据是按字节对齐的如果一行像素数不是8的倍数最后一个字节里有填充位解包时要跳过。我的做法是在滤波模块前加一个位解包模块根据位深参数把输入字节流转换成每周期一个像素的标准流。这样后面的滤波和输出模块就不用关心位深了接口统一。这个模块大概200行Verilog是整套代码里相对简单的部分。5. 10套工程源码的组织与差异5.1 源码分层设计10套源码不是简单的复制粘贴而是按功能复杂度和应用场景分层组织的方便你按需取用套件编号名称核心特点适用场景01基础解码器仅支持RGB、固定Huffman入门学习理解流程02完整解码器支持全部Huffman类型通用解码03高速解码器多位并行查表高吞吐需求04DDR窗口版滑动窗口放DDRBRAM受限场景05调色板支持版增加PLTE解析索引色图片06位深扩展版支持1/2/4位深特殊格式图片07流水线优化版全流水无阻塞极致性能08验证平台版带完整testbench仿真验证09图像处理链路版解码缩放滤波完整应用10显示驱动版解码HDMI输出直接上板显示每套源码都是独立可综合的工程包含RTL、约束文件、仿真脚本和说明文档。01到03是递进关系建议按顺序看04到06是针对特定需求的变体可以按需取用07到10是完整应用适合直接集成。5.2 仿真验证环境的搭建再好的设计不验证都是空中楼阁。我的每套源码都配了testbench用Icarus Verilog就能跑这也是为什么热词里有icarus verilog它轻量、开源、跨平台做Verilog仿真非常方便。testbench的思路是用Verilog的$readmemh把一张PNG文件的字节流读进一个数组然后逐字节喂给解码器同时把输出的像素写到一个文件里最后用Python脚本把输出和原图对比。这里有个仿真加速技巧不要用真实的1080P大图做仿真太慢。我准备了几张16x16、32x32的小图专门用于功能验证跑一次仿真几秒钟就出结果。功能对了再上大图做性能测试。另外Icarus Verilog对SystemVerilog支持有限testbench尽量用纯Verilog写避免语法不兼容。5.3 上板调试的实用方法仿真过了不代表上板就对这是FPGA开发的铁律。上板调试我推荐用**ILA集成逻辑分析仪**抓关键信号。重点抓三个地方一是解压引擎的输出看解出来的字节流是否符合预期二是滤波模块的输入输出看还原的像素值对不对三是顶层握手信号看有没有死锁或者数据丢失。如果板子上有DDR或者足够大的BRAM可以把解码结果存下来通过UART或者以太网传回PC对比。没有的话可以用一个简单的校验和方案在FPGA里对输出像素做累加把结果通过LED或者数码管显示和PC端算出的期望值对比。这个方法虽然粗糙但能快速判断解码是否正确省去大量抓波形的时间。6. 常见问题与排查实录6.1 解压数据错位的排查思路数据错位是PNG解码最常见的故障表现为图像整体偏移、颜色错乱或者花屏。排查时按这个顺序来首先确认Stored块的字节对齐有没有做对这是最高频的错误点其次检查Huffman码表的构建特别是动态Huffman的码长解析一个bit读错后面全错最后看LZ77的distance计算distance是从当前输出位置往前数不是从窗口起始位置数。我整理了一个速查表遇到问题可以对照现象可能原因排查方法图像整体偏移字节对齐错误检查Stored块对齐逻辑颜色错乱滤波偏移量错误确认bytes_per_pixel参数花屏Huffman码表错误对比软件解出的码表图像下半部分错滑动窗口溢出检查窗口大小和指针偶发错误反压处理不当抓FIFO满信号6.2 时序收敛的优化技巧纯Verilog写的解码器逻辑层级较深时序收敛是个挑战。我的经验是关键路径上加流水寄存器。最长的路径通常在Huffman查表和Paeth滤波这两块。Huffman查表可以拆成读位和查表两级流水Paeth滤波的三个加法和比较可以拆成两级。加流水会增加延迟但PNG解码是流式的延迟不影响吞吐只要保证流水线填满即可。另一个技巧是降低组合逻辑扇出。比如码表查表的地址信号如果扇出太大会导致布线延迟增加。可以在地址生成后加一级寄存器缓冲虽然多一拍延迟但时序会好很多。我实测在Xilinx 7系列上优化后能稳定跑到150MHz资源占用约3000个LUT和5个BRAM块。6.3 资源占用的实测数据很多人关心这套解码器到底占多少资源。我在Xilinx Artix-7 XC7A35T上综合了基础版本数据如下资源类型占用量总量占比LUT28472080013.7%FF1923416004.6%BRAM55010%DSP0900%可以看到资源占用相当温和一个入门级FPGA就能装下。高速版本因为并行查表LUT会增加到约4500BRAM增加到7块但依然在可接受范围。DDR窗口版本会省下2块BRAM但需要额外的DDR控制器整体复杂度上升。7. 从解码到应用的扩展思路7.1 与图像缩放模块的级联解码出来的像素往往需要缩放后再显示。我的第9套源码把PNG解码器和双线性插值缩放模块级联起来形成一个完整的解码缩放链路。级联的关键是握手协议要统一解码器输出的pixel_valid和缩放模块的输入valid要能正确对接中间加一级FIFO缓冲吸收速率差异。双线性插值需要缓存两行像素加上解码器本身的一行缓存总共三行BRAM。对于1080P三行RGB888约17KB用BRAM完全够。缩放系数通过寄存器配置支持任意比例。实测从1080P缩到720P整条链路在150MHz下能跑到60fps性能足够。7.2 多图缓存的DDR管理如果要连续解码多张图片并缓存就需要DDR参与。我的第4套源码演示了如何把解码结果写入DDR再读出来显示。这里涉及多端口DDR读写的问题因为解码写入和显示读取可能同时发生。解决方案是用一个仲裁器把两个端口的请求分时复用DDR控制器或者用DDR的多个bank并行。DDR读写程序的设计要点是突发长度要匹配。解码输出是流式的建议攒够一定数量比如64个像素再发起一次突发写这样DDR带宽利用率高。读的时候也是类似预取一批数据到FIFO里供显示模块消费。这套逻辑我在多个项目里复用稳定性很好。7.3 实际项目中的经验教训最后分享几个只有真正做过才会知道的教训。第一PNG的IDAT块可能不止一个必须把所有IDAT块的数据拼起来再解压不能每个块单独解。我见过有人每个IDAT单独解结果只有第一块能解出来。第二zlib流开头有2字节的头和4字节的Adler32校验解码时要跳过这2字节末尾的4字节校验可以忽略。第三图片的宽高在IHDR里是大端序Verilog读进来要字节交换这个坑我踩过。还有一点不要迷信网上的开源代码。我参考过几个GitHub上的PNG解码器有的连基本的Stored块都没处理对有的Huffman码表构建有bug。自己从头写一遍虽然慢但每个细节都清楚出了问题能快速定位。这套10套源码就是我反复调试、验证后的成果每一行代码都经过实际跑图验证可以直接拿来用或者作为参考。如果你在实现过程中遇到问题建议先用小图比如8x8跑通全流程再逐步加大尺寸。小图调试时波形短容易定位问题大图虽然更接近实际但一旦出错波形长得让人绝望。这个先小后大的原则是我做所有FPGA图像项目的通用经验。