☰
FPGA纯Verilog硬解PNG:DEFLATE流水线设计与10套工程源码实战
2026/9/30 6:27:03 网站建设 项目流程

1. 为什么要在FPGA里硬解PNG

做图像处理的朋友大概率都遇到过这个场景:上位机或者摄像头给过来一帧数据,格式是PNG,压缩过的,想在FPGA里直接做后续的缩放、滤波、叠加,结果发现FPGA根本不认识这玩意儿。常规做法是先在PC端或者ARM端把PNG解成RGB裸数据再喂给FPGA,但这样做的代价是延迟高、链路长,而且一旦系统要求独立运行、没有操作系统支撑,这条路就直接堵死了。

PNG解码放到FPGA里做,核心价值就在于把解压这一步从软件搬到硬件流水线上。PNG用的是DEFLATE无损压缩,内部包含LZ77滑动窗口匹配和Huffman熵编码两层,纯逻辑实现起来不算轻松,但一旦跑通,整条图像链路就能做到全硬件、低延迟、可流水。这套工程用纯Verilog实现,不依赖任何软核或者第三方IP,从文件解析到像素输出全部自己写,配套10套工程源码,覆盖了从仿真验证到上板调试的完整流程。

这篇文章适合两类人看:一类是正在做FPGA图像处理项目、需要把压缩图像接入硬件流水线的工程师;另一类是想找一个完整、有难度的Verilog实战项目来练手的学习者。PNG解码涉及状态机设计、跨时钟域处理、存储器管理、位流解析等多个硬核知识点,啃下来对Verilog工程能力的提升非常明显。

2. PNG解码的整体架构与方案选型

2.1 PNG文件格式的层次拆解

PNG文件不是一整块压缩数据,它是按块(Chunk)组织的。每个块有固定的结构:4字节长度、4字节类型标识、数据区、4字节CRC校验。解码器首先要做的就是按块遍历,把关键块挑出来。

关键块有这么几个:IHDR存放图像宽高、位深、颜色类型等元信息;PLTE是调色板,只有索引色图像才有;IDAT是真正存放压缩像素数据的块,可能有好几个,需要拼接;IEND是结束标志。除此之外还有tRNS、gAMA、pHYs等辅助块,解码时可以跳过,但遍历时必须正确识别长度,否则会读偏。

这里有个容易踩的坑:IDAT块可能被拆成多个,而且不保证按顺序连续存放。我见过有人只读第一个IDAT就开始解压,结果图像只出来一条。正确做法是把所有IDAT的数据区按顺序拼成一个完整的压缩流,再送进解压模块。

2.2 为什么选纯Verilog而不是HLS

市面上做PNG解码的方案大致三种:软核跑zlib库、HLS综合C代码、纯Verilog手写。软核方案最省事,但需要处理器和内存,延迟不可控;HLS方案开发快,但生成的电路资源利用率往往不理想,而且调试时信号可观测性差,出了问题很难定位到具体哪一级流水。

纯Verilog的优势在于每一拍信号都在你掌控之中。DEFLATE解码里的Huffman树遍历、位流读取、滑动窗口回填,这些操作的时序关系非常紧密,用状态机加流水线的方式写出来,资源可以压得很低,时序也容易收敛。代价是开发周期长,需要把算法拆到寄存器传输级。这套工程选择纯Verilog,本质上是为了追求确定性的时序和最小的资源占用,适合对实时性有要求的嵌入式图像场景。

2.3 模块划分与数据流设计

整个解码器我把它拆成四个大模块,数据像流水一样从前往后走:

  • 文件解析模块:负责按块遍历PNG,提取IHDR参数,拼接IDAT数据流,输出连续的压缩比特流。
  • Huffman解码模块:从比特流中逐符号解出LZ77的literal/length和distance码,这是整个解码里最考验时序的部分。
  • LZ77解压模块:维护一个32KB的滑动窗口,根据length和distance从窗口里回填数据,同时把新数据写入窗口。
  • 像素重组模块:把解压出来的原始字节按位深和颜色类型还原成RGB像素,处理滤波反变换,输出标准像素流。

模块之间用FIFO衔接,这样各级可以独立跑自己的节奏,不会被上下游卡死。FIFO深度需要根据最坏情况下的吞吐差来定,后面会细说。

3. 核心模块的Verilog实现细节

3.1 文件解析与IDAT拼接的状态机设计

文件解析模块的核心是一个三段式状态机。第一段负责读块头,解析出长度和类型;第二段根据类型决定是解析数据还是跳过;第三段处理CRC并跳到下一个块。

跳过非关键块时,不能简单地把数据丢掉,因为长度可能不是4的倍数,需要按字节精确跳过。我的做法是用一个计数器记录已跳过的字节数,和块长度比较,相等才进入下一块。这里要注意长度字段是大端序,Verilog里读进来需要做字节序转换。

IDAT拼接用一个双口RAM做缓冲。每读到一个IDAT块,就把数据写进RAM的连续地址,同时更新写指针。所有IDAT读完后,读指针从0开始把整段压缩流送给Huffman解码模块。RAM深度按最大图像尺寸估算,一般1080P的PNG压缩后也就几百KB到1MB出头,用片外DDR或者大容量Block RAM都行。

注意:IDAT块之间可能夹杂其他辅助块,拼接时不能假设它们连续。必须严格按块遍历,遇到IDAT才写RAM。

3.2 Huffman解码的位流读取与码表构建

DEFLATE的Huffman码是变长的,最短7位,最长可能到15位。解码时不能一次读固定位数,必须逐位比较。常规做法是维护一个位缓冲寄存器,每次从比特流里补充数据,然后从根节点开始遍历Huffman树,读到0走左子树,读到1走右子树,直到叶子节点。

PNG里的Huffman码表不是直接存在文件里的,而是用码长序列(Code Length Sequence)间接编码的。需要先解出每个符号的码长,再用规范Huffman算法重建码表。这一步容易出错的地方是码长序列本身也是Huffman编码的,存在嵌套。我的处理方式是分两遍:第一遍解出码长数组,第二遍根据码长生成实际的码字查找表。

查找表用组合逻辑实现,把15位位缓冲的高位作为索引,直接查出符号和实际码长。这样一拍就能出一个符号,吞吐率比逐位遍历高得多。代价是查找表占用一些LUT,但现代FPGA的LUT资源足够,换来的是时序上的从容。

3.3 LZ77滑动窗口的存储器管理

LZ77解压的核心是一个32KB的滑动窗口。每解出一个literal,就把它写入窗口;每解出一对length/distance,就从窗口的当前位置往前推distance个字节,连续拷贝length个字节到输出,同时这些拷贝出来的数据也要写回窗口。

窗口用Block RAM实现,读写地址由一个环形指针管理。写指针每写一个字节加一,超过32KB就回绕。读指针等于写指针减去distance。拷贝操作需要length个周期,如果length很大(最大258),会阻塞流水线。优化方法是把拷贝做成突发模式,一次读多个字节,减少地址计算开销。

这里有个隐蔽的坑:当distance小于length时,拷贝会出现重叠,也就是刚写进去的数据马上要被读出来。这在Verilog里必须用组合逻辑或者提前一拍预取来处理,否则会读到旧数据。我的做法是在拷贝状态机里判断,如果读地址追上了写地址,就用刚写入的数据直接旁路到输出,不走RAM。

3.4 像素重组与滤波反变换

解压出来的数据是滤波后的字节流,每个像素的每个通道都经过五种滤波之一处理过。滤波类型有None、Sub、Up、Average、Paeth五种,每行的第一个字节标识该行用的滤波类型。

反变换需要保存上一行的像素数据,因为Up和Average、Paeth都要用到上一行对应位置的像素。我用一个行缓冲RAM存上一行,当前行计算时同时读上一行和当前行已还原的左侧像素。Paeth滤波涉及三个预测值的计算和选择,组合逻辑路径较长,需要插入流水寄存器来保证时序。

像素输出格式根据IHDR里的颜色类型决定。灰度图每像素1字节,RGB每像素3字节,带Alpha的RGBA每像素4字节。位深可能是1、2、4、8、16位,小于8位的需要做位扩展。这部分用参数化的移位和掩码逻辑实现,保证不同配置下都能正确输出。

4. 10套工程源码的组织与差异化设计

4.1 工程分类与适用场景

10套工程不是简单复制,而是按验证层次和应用场景做了区分。大致可以分成三类:

工程编号类型主要用途特点
01-03仿真验证功能验证与调试带完整Testbench,波形可查
04-07上板验证实际硬件跑通含约束文件,适配常见开发板
08-10应用集成系统级联调含DDR读写、视频输出接口

仿真工程适合刚开始啃代码的阶段,可以逐模块跑波形,看每个状态机的跳转是否符合预期。上板工程加了时序约束和引脚分配,直接综合就能下载。应用集成工程把PNG解码和DDR控制器、视频时序生成器连起来,形成一个完整的图像显示链路。

4.2 仿真工程的Testbench搭建要点

Testbench的写法直接决定调试效率。我的习惯是给每个模块单独写一个Testbench,用Icarus Verilog跑,因为IVerilog轻量、启动快,适合频繁迭代。顶层再写一个集成Testbench,把PNG文件读进来,跑完整解码,最后把输出像素写成BMP文件,用图片查看器直接看结果。

读PNG文件用$readmemh或者$fread都行,但要注意文件是二进制,$readmemh按十六进制读会出问题。我一般用$fopen打开文件,$fread按字节读进一个数组,再逐字节送给解码器。输出BMP时手动写文件头,把像素数据按BGR顺序写进去,这样Windows图片查看器能直接打开。

提示:仿真时把关键状态机的状态、FIFO的读写指针、Huffman解码的符号都加到波形里,出问题时一眼就能看出卡在哪一级。

4.3 上板工程的时序约束与资源评估

上板工程最关键的是时序约束。PNG解码器通常跑在100MHz到150MHz,具体取决于器件速度等级。约束文件里要对时钟做周期约束,对输入输出做虚假路径或者多周期路径设置。

资源占用方面,以Xilinx 7系列为例,一个完整的PNG解码器大概占:

  • LUT:3000到5000
  • FF:2000到4000
  • Block RAM:10到20块(主要用在滑动窗口和行缓冲)
  • DSP:基本不用

这个规模在中低端FPGA上完全放得下。如果资源紧张,可以把滑动窗口放到片外SRAM或者DDR里,但会引入额外的读写延迟,需要重新平衡流水线。

4.4 应用集成工程的DDR读写配合

应用集成工程里,PNG解码后的像素数据要写进DDR,再由视频输出模块读出来显示。这里涉及跨时钟域和带宽分配。解码器输出速率和DDR读写速率不匹配时,需要加FIFO做缓冲。

DDR控制器的读写仲裁要小心。如果视频输出是实时刷新的,读优先级要高于写,否则会出现画面撕裂。我的做法是给读通道更高的仲裁权重,写通道用突发模式攒够一批再写,减少对读的干扰。

5. 实操流程与关键参数计算

5.1 从零跑通第一套仿真工程的步骤

拿到源码后,建议按这个顺序走:

  1. 先跑Huffman解码模块的独立Testbench,喂一段已知的压缩数据,看解出的符号序列对不对。
  2. 再跑LZ77解压模块,用固定的length/distance序列,检查窗口回填是否正确。
  3. 然后跑文件解析模块,用一个简单的PNG文件,看IHDR参数和IDAT拼接结果。
  4. 最后跑顶层集成仿真,输入完整PNG,输出BMP,用图片查看器验证。

每一步都要看波形,不要只看最终结果。中间某一级出错,最终结果可能只是轻微偏差,但波形能直接定位到问题。

5.2 FIFO深度的计算与选择

模块间FIFO深度不是随便定的。以Huffman解码到LZ77这一级为例,Huffman解码的吞吐率是每周期一个符号,LZ77解压遇到length时可能需要多个周期。最坏情况下,LZ77处理一个length=258的匹配需要258个周期,这期间Huffman会产出258个符号。如果FIFO深度小于258,Huffman就会阻塞。

实际设计中,我会把FIFO深度设为512,留一倍余量。深度太大浪费Block RAM,太小会频繁反压,降低整体吞吐。计算方法是:找出下游最坏情况的处理周期数,乘以上下游时钟频率比,再乘一个1.5到2的安全系数。

5.3 位流读取的跨字节处理

PNG的压缩流是比特级的,但存储器是按字节读的。位流读取模块需要维护一个位缓冲,每次从存储器读一个字节补充到缓冲低位,然后从高位取需要的位数。

这里的关键是位缓冲的移位方向要统一。我习惯用左移:新字节放到低位,取数据时从高位取。这样码字在缓冲里的顺序和比特流顺序一致,不容易搞反。每次取完n位,缓冲左移n位,同时更新有效位数。有效位数不足时触发读存储器补充。

注意:比特流的位序是MSB先出,和常规的字节序不同。读进来的字节要先做位反转,或者直接在移位逻辑里按MSB优先处理。

6. 常见问题与排查技巧实录

6.1 解码结果花屏或颜色错乱的排查

花屏最常见的原因是滤波反变换出错。先检查滤波类型字节有没有正确读取,再检查上一行缓冲的读写地址有没有错位。如果颜色整体偏了,多半是颜色类型解析错误,比如把RGB当成RGBA处理,导致通道错位。

还有一种情况是IDAT拼接时漏了某个块,或者块长度解析错误导致数据错位。用波形看IDAT写RAM的地址是否连续,中间有没有跳变,能快速定位。

6.2 时序不收敛的常见原因与解决

时序违例通常出在Huffman查找表的组合逻辑上。15位索引的查找表如果直接综合成LUT,路径可能太长。解决办法是插入流水寄存器,把查找分成两级:第一级查高8位,第二级查低7位,两级之间打一拍。代价是增加一个周期的延迟,但时序能轻松收敛。

另一个常见违例点是Paeth滤波的预测值计算,三个候选值的比较和选择逻辑较深。同样用流水线切开,或者用查找表预计算部分结果。

6.3 仿真通过但上板失败的差异分析

仿真通过上板失败,九成是时序或者复位问题。先检查约束文件有没有漏掉时钟或者输入输出延迟约束。再检查复位信号是否同步释放,异步复位同步释放是基本要求,否则状态机可能在上电时进入非法状态。

还有一种情况是仿真时用的PNG文件太小,没有触发某些边界条件。上板时换一张大图,IDAT块多、length值大,就可能暴露出FIFO深度不够或者窗口回绕处理错误的问题。建议仿真时就用大图跑,覆盖各种边界。

6.4 常见问题速查表

现象可能原因排查方向
图像全黑像素输出使能未拉高检查输出状态机
图像上半部分正常下半部分花行缓冲地址回绕错误检查行计数器
颜色偏红或偏蓝通道顺序错误检查RGB/BGR映射
解码中途卡死FIFO满且下游不读检查反压逻辑
时序违例组合逻辑过深插入流水寄存器
上板无输出复位未释放检查复位同步电路

7. 工程扩展与性能优化方向

7.1 支持隔行PNG与Adam7交错

标准PNG支持Adam7交错模式,图像被分成7个pass,每个pass包含不同密度的像素。解码交错PNG需要在像素重组阶段增加pass解析逻辑,按pass顺序把像素填到正确位置。这部分我目前工程里没做,因为实际项目中交错PNG很少见,但如果要完整支持PNG规范,这是必须补的一块。

实现思路是在IHDR里读交错标志,如果开启,像素重组模块要维护7个pass的坐标计数器,每个pass结束后跳到下一个。输出像素时按最终坐标写RAM,全部pass跑完再统一读出。

7.2 多像素并行解码的可行性分析

当前设计是单像素流水,每周期出一个像素。如果要提高吞吐,可以考虑多符号并行Huffman解码。DEFLATE允许一次解多个符号,只要位流足够。实现上是把查找表复制多份,同时查多个位置,然后做冲突检测和合并。

这个优化复杂度较高,收益在像素率要求极高的场景才明显。一般1080P60的像素率是148.5MHz,单像素流水跑150MHz刚好够。4K场景才需要考虑并行。

7.3 与DDR控制器的带宽匹配优化

应用集成工程里,PNG解码写DDR的带宽要和DDR实际带宽匹配。如果DDR是16位@400MHz,理论带宽800MB/s,实际有效带宽可能只有60%到70%。解码器输出如果是1080P60 RGB888,带宽需求是148.5M×3≈445MB/s,加上视频读取的带宽,总需求接近DDR上限。

优化方法是写DDR时用突发长度8或16,减少命令开销。读视频时用双缓冲,一块读的时候另一块写,避免读写冲突。仲裁器要给视频读最高优先级,保证不丢帧。

这套工程我断断续续调了挺久,最深的体会是PNG解码的难点不在单个模块,而在模块之间的握手和反压。每一级都要能独立暂停和恢复,否则一处卡住整条流水线就死了。另外仿真一定要用真实的大图跑,小图跑通不代表没问题,边界条件往往在大图里才暴露。源码里我把每个模块的接口都做了参数化,换图像尺寸或者颜色格式时只需要改顶层参数,不用动内部逻辑,这一点在实际项目里省了很多事。

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

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

立即咨询