1. 项目缘起与整体设计思路
PNG图片解码这件事,放在PC端或者手机上,随便调个库就完事了,但在FPGA上纯用Verilog从头撸一遍,完全是另一回事。我做这个项目的出发点很直接:很多嵌入式视觉链路里,图像源是PNG格式的截图、图标或者上位机下发的素材,而显示终端往往是RGB接口的LCD或者HDMI,中间需要一个不依赖软核、不依赖外部DDR大缓存的轻量解码方案。用ARM或者软核跑libpng当然可以,但启动慢、占用资源、实时性受调度影响,遇到高帧率小图切换的场景就露怯了。纯Verilog硬解PNG,数据流是确定的,延迟是固定的,资源占用可控,这才是FPGA该干的事。
这个项目标题里说的“10套工程源码”,不是凑数,而是针对不同FPGA平台、不同图片规格、不同接口形态做的差异化实现。有的工程面向小分辨率图标解码,资源占用极低,适合塞进小容量FPGA;有的工程支持真彩色大图,配合外部DDR做行缓存;有的工程把解码输出直接对接RGB LCD时序;还有的工程做了AXI-Stream输出,方便接入图像处理流水线。整体设计思路可以概括为:以Deflate解压为核心,以Zlib数据流解析为主线,以滤波重建为收尾,用状态机把整个流程串起来,用Block RAM和DDR分别应对不同规模的缓存需求。
为什么强调“纯Verilog”?因为一旦引入软核或者HLS生成的IP,代码的可移植性和可读性就会打折扣,尤其在一些国产FPGA或者老款器件上,工具链支持参差不齐。纯Verilog意味着你只要有一个能综合的FPGA开发环境,就能把工程跑起来,不挑厂商、不挑器件系列。这也是我做这套源码的初衷:让做FPGA图像处理的人,手里有一个能直接复现、能改、能扩展的PNG解码参考实现。
PNG解码的难点不在某一个模块,而在于整条链路的协同。从文件头解析、IHDR读取、IDAT数据提取,到Zlib头解析、Deflate块解码、Huffman树重建,再到滤波反变换、像素重排、色彩空间转换,每一步都有坑。尤其是Deflate的动态Huffman解码,码长不固定、码表动态生成,用硬件描述起来比软件麻烦得多。我的做法是把整个解码流程拆成多个独立的状态机模块,每个模块只负责一件事,模块之间用FIFO或者简单的握手信号连接,这样调试的时候可以逐级抓波形,定位问题非常快。
这套工程适合谁?如果你刚接触FPGA图像处理,想找一个有挑战性但又不是完全无从下手的项目,PNG解码是一个很好的练手题材,因为它涉及状态机设计、存储器管理、数据流控制、协议解析等多个核心技能点。如果你已经做过一些图像处理项目,比如双线性插值、滑动窗口滤波、DDR多端口读写,那这套源码可以帮你把PNG解码这一环补上,形成从图像输入到显示的完整链路。下面我从设计思路、核心细节、实操过程和问题排查几个方面,把整个项目拆开来讲。
2. PNG解码核心细节与硬件实现要点
2.1 PNG文件结构与解码流程总览
PNG文件本质上是一个个数据块(Chunk)串起来的,每个块有长度、类型、数据和CRC校验四部分。解码器首先要做的就是按块解析,找到IHDR拿到图像宽高、位深、颜色类型、压缩方法、滤波方法和隔行扫描方式,然后从IDAT块里把压缩数据提取出来。IDAT可能被拆成多个块,需要按顺序拼接。IEND是结束标志。CRC校验在硬件里可以选择性跳过,因为图像数据本身有Adler-32校验,但如果你要做严谨实现,CRC32模块也不难写,一个线性反馈移位寄存器就能搞定。
拿到IDAT数据后,真正的硬仗才开始。PNG用的是Zlib压缩格式,Zlib头两个字节,然后是Deflate压缩数据,最后四个字节是Adler-32校验。Deflate又分两种块类型:非压缩块和压缩块,压缩块里又分固定Huffman和动态Huffman。硬件实现时,我建议先支持固定Huffman,把链路跑通,再扩展动态Huffman。固定Huffman的码表是固定的,解码逻辑简单很多,适合验证整体数据通路。动态Huffman需要先解析码长序列,重建码表,再用码表解码,复杂度高一个量级,但实际PNG图片绝大多数用的是动态Huffman,所以最终必须支持。
解码出来的数据是滤波后的像素残差,PNG定义了五种滤波类型:None、Sub、Up、Average、Paeth。每个扫描行的第一个字节是滤波类型,后面是滤波后的数据。硬件里做反滤波,需要缓存上一行像素,因为Up和Paeth都要用到上一行对应位置的像素值。对于小图,一行像素可以存在Block RAM里;对于大图,一行像素可能几百上千字节,Block RAM放不下,就需要用DDR做行缓存。这也是为什么有些工程要配合DDR,有些工程只用片内RAM就能搞定。
2.2 Deflate解压的硬件架构选择
Deflate解压是PNG解码里最耗资源的模块。软件实现可以用查表、递归、动态分配内存,硬件不行,硬件必须把数据流和控制流都固定下来。我的做法是采用两级流水:第一级做Huffman解码,把压缩比特流转换成Literal/Length和Distance符号;第二级做LZ77回溯复制,根据Length和Distance从滑动窗口里把数据复制出来。滑动窗口大小固定32KB,用Block RAM实现,读写指针循环移动。
Huffman解码用逐位比较法还是查表法?逐位比较法资源省,但每个比特都要比较一次,吞吐率低;查表法用Block RAM存码表,一次可以解出多个比特,吞吐率高,但需要预先构建码表。我在这套工程里两种都实现了,小资源工程用逐位比较,高性能工程用查表法。查表法的关键是码表构建,需要把Huffman码的码长和码字映射成一张查找表,输入是比特流的前N位,输出是符号和实际消耗的比特数。N一般取9到12,太大浪费RAM,太小查表冲突多。
LZ77回溯复制模块的难点在于距离可能跨越当前正在写入的位置,也就是说复制源和复制目标在滑动窗口里可能重叠。硬件里处理重叠复制,需要控制读写地址的递增节奏,确保读出的数据是已经写入的。我的做法是用一个简单的状态机,先读一个字节写一个字节,如果Length大于Distance,就循环读同一段数据,直到Length耗尽。这个逻辑用Verilog写起来不复杂,但时序要仔细约束,否则容易在跨时钟域或者RAM读写冲突时出错。
2.3 滤波反变换与像素重排
反滤波模块的输入是解码后的残差数据,输出是重建后的像素。五种滤波类型里,None最简单,直接输出;Sub需要加上左边像素;Up需要加上上边像素;Average加上左边和上边的平均值;Paeth最复杂,需要计算左边、上边、左上三个像素的预测值。硬件实现时,可以用一个组合逻辑计算预测值,然后和残差相加。注意像素位深可能是8位、16位,颜色类型可能是灰度、RGB、RGBA、调色板,这些都要在反滤波之前或者之后做相应的处理。
像素重排是指把解码后的像素数据按照显示接口的要求重新组织。比如RGB888的PNG,解码出来是R、G、B三个字节连续排列,但LCD接口可能需要RGB565,那就需要截位和拼接。如果PNG是调色板类型,还需要查调色板表,把索引转换成RGB值。调色板表存在Block RAM里,大小一般是256×3字节或者256×4字节。这些转换逻辑不复杂,但要注意数据位宽和时序对齐,否则显示出来会偏色或者错位。
隔行扫描的PNG(Adam7)需要额外处理,因为像素不是按行连续存储的,而是分成7个pass,每个pass的像素位置不同。硬件实现Adam7比较麻烦,需要计算每个pass的起始坐标和步长,然后逐点填充到帧缓存里。我的建议是,如果你的应用不需要支持隔行PNG,可以在IHDR解析时直接判断并报错,避免不必要的复杂度。如果必须支持,那就老老实实写一个地址生成器,把每个pass的像素映射到正确的帧缓存地址。
2.4 存储资源规划与DDR读写策略
存储资源是PNG解码硬件设计里最需要提前规划的部分。解码过程中的主要存储需求有三块:一是压缩数据缓存,IDAT数据可能很大,需要先存起来再慢慢解压;二是滑动窗口,Deflate解压需要32KB窗口;三是行缓存或者帧缓存,反滤波和像素重排需要缓存像素数据。
对于小图,比如128×128的图标,压缩数据可能只有几KB,滑动窗口32KB,行缓存128字节,全部用Block RAM就能搞定,不需要外部DDR。这种工程适合资源紧张的低端FPGA,比如Lattice的iCE40系列或者安路的小容量器件。对于大图,比如1920×1080的截图,压缩数据可能几百KB甚至上MB,滑动窗口32KB,行缓存1920×4字节,Block RAM肯定不够,必须用DDR。这时候就需要设计一个多端口DDR读写控制器,把压缩数据、滑动窗口、行缓存都放在DDR里,用仲裁器管理访问优先级。
DDR读写策略上,我采用的是突发传输+乒乓缓存。压缩数据从外部接口(比如SD卡或者UART)写入DDR时,用突发写;解码器读压缩数据时,用突发读,一次读一大块到片内FIFO,减少DDR访问次数。滑动窗口如果放在DDR里,访问会很频繁,影响效率,所以我的做法是滑动窗口仍然用Block RAM,但只保留最近32KB的数据,超出的部分通过DDR换页。行缓存用DDR里的两块区域做乒乓,当前行写入一块,上一行从另一块读出,反滤波完成后交换。
3. 实操过程与核心环节实现
3.1 工程目录结构与模块划分
这套源码的工程目录结构是统一的,方便你在不同平台之间迁移。顶层目录下分rtl、sim、constr、ip、doc五个文件夹。rtl里放所有Verilog源码,按功能分子目录:parser放文件头和块解析模块,inflate放Deflate解压模块,filter放反滤波模块,pixel放像素重排和色彩转换模块,mem放存储控制器和FIFO,top放顶层例化。sim里放Testbench和测试图片的十六进制数据文件。constr里放不同平台的约束文件,比如Xilinx的XDC、Altera的SDC、Lattice的LPF。ip里放DDR控制器、PLL等IP核的配置文件。doc里放模块框图、时序图和调试记录。
模块划分上,顶层模块png_decoder_top负责例化所有子模块并连接握手信号。chunk_parser模块负责从输入数据流里提取IHDR和IDAT,输出图像参数和压缩数据。zlib_parser模块负责解析Zlib头,提取Deflate数据。inflate_core模块是核心,内部又分huffman_decoder和lz77_decoder两个子模块。filter_reverse模块负责反滤波,pixel_remap模块负责像素格式转换。存储方面,idat_buffer用Block RAM或者DDR控制器实现,window_buffer用Block RAM实现,line_buffer根据图像大小选择Block RAM或DDR。
这种模块划分的好处是每个模块可以独立仿真。比如你可以只给inflate_core喂一段Deflate数据,看它解出来的字节流对不对,不用管前面的文件解析和后面的像素处理。调试的时候,先确保每个模块单独工作正常,再串起来跑完整链路,问题定位会快很多。
3.2 关键模块的Verilog实现细节
先看chunk_parser模块。它的输入是8位数据流和有效信号,输出是图像宽高、位深、颜色类型、压缩数据流。状态机设计上,我用一个计数器跟踪当前块内的字节位置,用比较器判断块类型。IHDR块的长度固定13字节,直接按位置提取字段。IDAT块的长度不固定,需要把数据写入FIFO或者DDR。CRC校验我选择跳过,因为PNG的Adler-32校验已经能保证数据完整性,CRC32模块会增加资源占用和延迟。如果你要做严谨实现,可以在每个块结束时计算CRC并比对,不匹配就报错。
inflate_core里的huffman_decoder是资源消耗大户。以动态Huffman为例,首先需要解析码长序列。码长序列本身也是用Huffman编码的,用的是固定码表,解码相对简单。解析出码长后,需要构建码表。我的做法是用一个双端口Block RAM,写端口把码字和码长写入,读端口用当前比特流的前12位作为地址读出符号和码长。构建码表时,按照Huffman码的规范,从码长1到15逐级填充,每个码字对应一个符号。填充算法用状态机实现,循环遍历所有符号,计算每个符号的码字,写入RAM。
lz77_decoder模块的滑动窗口用Block RAM实现,大小32KB,地址位宽15位。写指针在解码过程中递增,读指针根据Distance计算。复制逻辑用一个计数器控制Length,每个周期读一个字节写一个字节。如果Length大于Distance,读指针会在写指针后面追,这时候需要确保读出的数据是已经写入的。我的做法是,当读地址等于写地址时,暂停读操作,等写操作完成后再继续。这个逻辑用比较器和状态机实现,时序上要留足够的余量,避免在高速时钟下出现亚稳态。
filter_reverse模块的Paeth预测器是组合逻辑最复杂的部分。Paeth预测值的计算公式是:p = a + b - c,然后比较p与a、b、c的绝对差值,取差值最小的那个作为预测值。硬件实现时,用三个减法和三个绝对值比较器,再加一个多路选择器。这个组合逻辑的延迟比较大,如果时钟频率高,可能需要插入流水线寄存器。我的做法是在预测值计算和残差相加之间插入一级寄存器,这样虽然增加了一个周期的延迟,但时序更容易收敛。
3.3 仿真验证与上板调试流程
仿真验证是PNG解码项目里最耗时间的环节。我的流程是:先用Python生成测试图片,用软件解码器得到参考像素数据,存成十六进制文件;然后在Testbench里读取PNG文件,喂给解码器,把输出像素和参考数据逐字节比较,打印不匹配的位置和值。Testbench用Icarus Verilog或者Vivado Simulator都可以跑,Icarus Verilog免费且轻量,适合快速迭代;Vivado Simulator和工具链集成好,适合最终验证。
Testbench的关键是数据流控制。PNG文件可能很大,不能一次性读入内存,需要按字节流式输入。我用一个$fscanf循环,每次读一个字节,在时钟上升沿赋给输入数据信号,同时拉高有效信号。解码器的输出像素写入另一个文件,仿真结束后用Python脚本比对。如果发现不匹配,就定位到具体的像素坐标和通道,反推是哪个模块出了问题。比如如果只有第一行像素错误,可能是反滤波的Up类型处理有问题;如果所有像素都偏移一个字节,可能是像素重排的位宽拼接错了。
上板调试时,我建议先用小图测试,比如32×32的RGB图标,这样即使出问题,抓波形也能很快定位。输入数据可以用UART或者SD卡,输出直接接RGB LCD或者用ILA抓取像素数据。ILA的触发条件设置成“像素有效且数据不等于预期值”,这样一旦出错就能抓到现场。调试过程中,我遇到过因为Block RAM读写冲突导致像素错位的问题,后来在读写端口之间加了仲裁逻辑,确保同一地址不会同时读写。还遇到过因为DDR突发长度设置不当导致数据丢失的问题,后来把突发长度从8改成16,配合FIFO深度调整,问题就解决了。
3.4 10套工程源码的差异化配置
这10套工程不是简单的复制粘贴,而是针对不同应用场景做了差异化配置。第一套是最小资源版,只支持固定Huffman、非隔行、灰度或RGB888小图,全部用Block RAM,适合iCE40UP5K这种小器件。第二套是标准版,支持动态Huffman、非隔行、RGB888,滑动窗口和行缓存用Block RAM,压缩数据用DDR,适合Xilinx Artix-7或者Altera Cyclone IV。第三套是高性能版,支持动态Huffman、隔行、RGBA,用查表法Huffman解码,DDR多端口读写,适合Zynq-7000或者更高端的器件。
第四到第六套是针对特定接口的变体:第四套输出RGB565,直接对接常见LCD;第五套输出AXI-Stream,方便接入图像处理流水线;第六套输出HDMI时序,配合TMDS编码器直接显示。第七套是低延迟版,牺牲部分资源换取更短的解码延迟,适合实时性要求高的场景。第八套是多图缓存版,支持多张PNG图片预解码并缓存,适合UI切换场景。第九套是调色板版,专门支持调色板PNG,适合图标和表情包解码。第十套是验证版,包含完整的Testbench和Python比对脚本,适合学习和二次开发。
每套工程的差异主要体现在top模块的例化和constr约束文件上,核心解码模块是共用的。这样你只需要根据目标平台和需求,选择合适的顶层和约束,就能快速搭建工程。源码里我加了详细的注释,关键状态机和计算逻辑都有说明,方便你理解和修改。
4. 常见问题与排查技巧实录
4.1 解码结果错位或颜色异常
这是最常见的问题,表现是显示出来的图像偏移、错位或者颜色不对。排查思路是从后往前查:先看像素重排模块的输出位宽和拼接顺序对不对,RGB888是不是按R、G、B的顺序输出,RGB565是不是截位正确。然后看反滤波模块的滤波类型判断对不对,每个扫描行的第一个字节是不是被正确识别为滤波类型。再看Deflate解压的输出字节数对不对,和IHDR里的宽高、位深、颜色类型计算出来的期望字节数是否一致。
我遇到过一次颜色异常,查了半天发现是调色板PNG的调色板表没有正确加载,索引查表时读到了默认值。后来在chunk_parser里增加了PLTE块解析,把调色板数据写入Block RAM,问题就解决了。还有一次是RGB888显示成BGR,原因是像素重排时字节顺序搞反了,调整了拼接顺序后正常。这类问题一般不是逻辑错误,而是数据流顺序或者位宽映射的问题,仔细对照PNG规范和显示接口时序就能找到。
4.2 Deflate解压卡死或输出数据不足
Deflate解压卡死通常是因为Huffman码表构建错误,导致解码器一直在等待一个不存在的码字。排查方法是抓取Huffman解码状态机的波形,看它是否在某个状态循环。如果是动态Huffman,检查码长序列解析是否正确,码表填充算法是否有遗漏。我建议在码表构建完成后,用一个简单的测试向量验证几个已知符号的解码结果,确保码表正确。
输出数据不足可能是LZ77复制逻辑有问题,比如Length或Distance计算错误,导致复制提前结束。检查方法是统计解码输出的字节数,和期望值比较。如果少了,看是哪个块解码不完整。还有一种可能是压缩数据缓存溢出,IDAT数据没有完整写入FIFO或者DDR,导致解码器读不到后续数据。这时候需要检查输入数据流的握手信号,确保每个字节都被正确接收。
4.3 时序不收敛与资源溢出
时序不收敛一般出现在高频时钟下,尤其是Huffman解码和Paeth预测器这些组合逻辑复杂的模块。解决方法有两种:一是插入流水线寄存器,把长组合逻辑切成多级;二是降低时钟频率,牺牲吞吐率换时序余量。我倾向于第一种,因为PNG解码对吞吐率有一定要求,降频会影响显示帧率。插入流水线时要注意数据对齐,每一级流水都要有对应的有效信号和坐标信号,否则输出会错位。
资源溢出常见于小容量FPGA,比如Block RAM不够用。这时候需要优化存储策略:滑动窗口能不能缩小?行缓存能不能用DDR?Huffman码表能不能用分布式RAM代替Block RAM?我的经验是,滑动窗口32KB是Deflate规范要求的,不能缩小;行缓存可以根据图像宽度动态分配,小图用Block RAM,大图用DDR;Huffman码表如果符号数不多,可以用LUT实现,节省Block RAM。资源优化是一个权衡过程,需要根据具体器件和应用需求来调整。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决措施 |
|---|---|---|---|
| 图像整体偏移 | 像素重排位宽错误 | 检查RGB拼接顺序 | 调整拼接逻辑 |
| 颜色异常 | 调色板未加载或字节序错误 | 检查PLTE解析和输出顺序 | 增加PLTE解析,调整字节序 |
| 解码卡死 | Huffman码表构建错误 | 抓取解码状态机波形 | 修正码表填充算法 |
| 输出数据不足 | LZ77复制逻辑错误 | 统计输出字节数 | 修正Length/Distance计算 |
| 时序不收敛 | 组合逻辑过长 | 查看时序报告 | 插入流水线寄存器 |
| 资源溢出 | Block RAM不足 | 查看资源利用率报告 | 优化存储策略,用DDR或LUT |
| 上板无显示 | 时序约束缺失或接口配置错误 | 检查约束文件和引脚分配 | 补充约束,核对接口时序 |
| DDR读写错误 | 突发长度或仲裁优先级不当 | 用ILA抓DDR接口波形 | 调整突发长度和仲裁策略 |
4.5 独家避坑经验分享
第一个坑是Block RAM的读写冲突。在反滤波模块里,行缓存需要同时读写,如果读写地址相同,Block RAM的输出会不确定。我的做法是给Block RAM加一个写优先或者读优先的逻辑,确保冲突时有一个确定的行为。具体实现是在写使能有效时,把读数据旁路成写数据,这样读出的就是刚写入的值。
第二个坑是DDR控制器的突发长度和FIFO深度不匹配。如果突发长度是8,FIFO深度只有4,那么一次突发就会溢出。我的经验是FIFO深度至少是突发长度的两倍,留足够的余量应对仲裁延迟。另外,DDR的刷新操作会占用带宽,如果解码器对带宽需求高,需要提高DDR控制器的仲裁优先级,或者增加数据缓存深度。
第三个坑是仿真和上板结果不一致。仿真时数据流是理想的,没有抖动和延迟;上板时输入数据可能有抖动,时钟可能有偏斜。我的做法是在输入接口加一级同步寄存器,消除亚稳态;在输出接口加一级寄存器,改善时序。另外,仿真时用的测试图片要覆盖各种边界情况,比如宽高为1的图片、全黑全白的图片、滤波类型交替的图片,这样才能在上板前发现潜在问题。
第四个坑是隔行PNG的地址计算。Adam7隔行扫描的7个pass,每个pass的起始坐标和步长都不一样,如果地址计算错误,图像会呈现马赛克或者条纹。我的建议是先用软件生成一个隔行PNG,用软件解码器输出每个pass的像素坐标,然后在硬件里对照验证。如果项目不需要隔行支持,直接在IHDR解析时判断并报错,省去很多麻烦。
这套PNG解码工程我从第一版跑通到整理出10套源码,前后花了差不多半年时间,中间踩过的坑远不止上面这些。但正是这些坑,让我对FPGA图像处理的数据流控制、存储管理、时序优化有了更深的理解。如果你正在做类似的项目,或者想找一个有挑战性的FPGA实战题材,这套源码应该能帮你省下不少时间。后续我还打算把JPEG解码也加进来,形成完整的图片解码库,到时候再和大家分享。