做FPGA图像处理的朋友应该都有过类似的经历:想往板子上放一张图片,手头素材偏偏是PNG格式,于是要么提前在PC上转成RAW再固化到ROM,要么挂一颗MCU或者软核去软件解码,折腾半天还把FPGA的实时性优势给丢了。我自己在做一个显示类项目时也被这个问题卡过几次之后,干脆用纯verilog把PNG解码器做成了通用IP,效果反而比预想的稳,今天这篇就把整个实现思路、关键算法拆解和工程经验全部摊开来讲。
这篇内容的定位很明确:给有一定verilog基础、正在做FPGA图像显示或视频处理的开发者,提供一套可以直接参考甚至“抄作业”的PNG图片解码方案。文章会覆盖PNG格式的底层原理、zlib压缩数据的硬件解压思路、五种filter算法的verilog实现、工程源码的组织方式,以及我在调试过程中踩过的真实坑位。无论你是想自己从头写一个解码器,还是想看懂别人工程里的关键模块,这篇都能帮你把脉络理顺。
1. PNG解码方案选型与整体设计思路
1.1 为什么不用软核,而是纯verilog硬解
拿到PNG解码这个需求,第一反应往往是“调一个软核跑解压库”——比如在MicroBlaze或者Nios II上跑libpng,这确实快,但代价也很大:软核至少要几十MB的内存跑软件栈,解码一张1080P图片耗时动辄几十毫秒,而且把软核嵌进现有的纯逻辑视频链路里,既要处理总线握手又要跨时钟域,工程复杂度翻倍。更关键的是,一旦上了软核,这套方案就不再是“纯verilog”,在需要硬实时、低延迟、以及没有外部DDR的场合会非常被动。
反过来看纯verilog硬解的优势:解码器的状态机可以和后续的图像缩放、格式转换、显示时序发生器无缝衔接,做到真正的流式处理;整条链路都是寄存器级控制,时序可以精确到像素周期;不依赖操作系统和外部存储器,单块FPGA就能完成“存储介质读入PNG码流 → 硬件解压 → 直接驱动显示器”的全流程。当然代价也很明显——PNG的压缩数据格式是DEFLATE算法,里面有大量位级操作和变长编码,用硬件状态机来描述确实比写C代码费劲得多,但一旦把原理吃透,纯verilog实现的复杂度完全可控。
1.2 整体架构:一条清晰的流水线
我设计的PNG解码器按数据流方向划分为六个核心模块,每个模块之间用简单的FIFO或Valid/Ready握手信号连接,风格非常干净:
- 码流读取模块:负责从存储介质(SPI Flash、SD卡或内部ROM)读取PNG文件字节流,同时剥离PNG文件头和各个chunk。
- Chunk解析模块:解析IHDR(图像头)、IDAT(压缩图像数据)和IEND(结束标记)等关键数据块,重点提取宽度、高度、位深、颜色类型、压缩方式和隔行标志。
- Zlib解压引擎:这是整个解码器的核心,负责对IDAT中的DEFLATE压缩数据进行解压,还原出原始的行滤波数据。
- Filter逆运算模块:对解压后的每行数据进行五种滤波算法的逆运算,恢复出真正的像素原始值。
- 像素格式整理模块:根据颜色类型和位深,将还原后的数据重新组织成RGB888、RGBA8888或灰度格式。
- 存储与显示控制模块:支持两种输出方式,一是直接写入行缓存送显示时序,二是通过AXI接口写入DDR3/DDR4帧缓存后由显示控制器读取。
模块间用流式接口的好处是显而易见的:PNG解码是严格的前后依赖过程,IDAT解压多少数据,Filter模块就处理多少数据,天然不需要复杂的反压逻辑。整条链路只要Zlib引擎的吞吐率大于显示需求,解码器就能跑满帧率。
2. PNG图片格式原理与解码算法深度拆解
2.1 PNG文件结构:先认识你的对手
PNG文件的开头是固定的8字节签名:89 50 4E 47 0D 0A 1A 0A,后面紧跟一系列chunk(数据块)。每个chunk由四部分组成:4字节长度(大端序)、4字节类型、长度可变的数据字段、4字节CRC校验。
我手头随便拿一张PNG做hex dump,结构大概是这样的:
89 50 4E 47 0D 0A 1A 0A // PNG签名 00 00 00 0D 49 48 44 52 // IHDR chunk,长度为13字节 00 00 00 02 00 00 00 01 // 宽度2,高度1 08 06 00 00 00 // 位深8,颜色类型6(RGBA),压缩/滤波/隔行 C5 62 6B 5D // IHDR的CRC 00 00 00 13 49 44 41 54 // IDAT chunk,长度为19字节 78 9C 63 60 60 60 01 01 // zlib压缩数据 00 00 04 87 95 6A 0F // IDAT的CRC 00 00 00 00 49 45 4E 44 // IEND chunk AE 42 60 82 // IEND的CRC其中IHDR是解码必须的第一个数据块,它规定了图像的尺寸和编码方式:宽度和高度各占4字节,然后是位深(常见的是8)、颜色类型(0灰度、2RGB、4灰度+Alpha、6RGBA)、压缩方法(固定为0,即DEFLATE)、滤波方法(固定为0)和隔行标志(0为非隔行,1为Adam7隔行)。
这里有一个关键细节:PNG标准规定IHDR之后才是图像数据,但实际文件中IEND之前的chunk顺序并不总是严格排序。规范的解析器不能假设IDAT只有一个chunk,如果一张图片的压缩数据很大,PNG编码器会把它切成多个IDAT块,所以解码器必须把所有IDAT数据拼接起来看成一个连续的zlib流,而不是每遇到一个IDAT就单独解压。我写的工程里专门用了一个码流拼接FIFO来处理这种情况,避免在硬件里出现“解压到一半发现zlib流被切断了”的尴尬。
2.2 zlib压缩数据:DEFLATE算法到底做了什么
PNG的IDAT数据本质是一个zlib格式的压缩流,开头是2字节zlib头(常见的是78 9C或78 DA),中间是DEFLATE压缩数据,末尾是4字节Adler-32校验。DEFLATE用两种手段压缩数据:一是LZ77算法,用“距离+长度”对引用之前出现过的重复字符串;二是Huffman编码,把高频字节用更短的码字表示。
硬件解码DEFLATE的难点在于,压缩数据是逐bit排列的,而且Huffman码字长度可变,解码器必须支持按位读取。zlib流里的压缩方式有两种:固定Huffman编码和动态Huffman编码。固定编码的码表是标准写死的,硬件实现只需要查表;动态Huffman编码则需要在压缩流中先解析出Huffman树的构建数据,再根据读出的码字实时查表。
我做第一版的时候只实现了固定Huffman编码,跑出来的测试图片大约能覆盖六成的场景,一旦遇到用zlib默认压缩等级生成的图片就会解码失败。后来把动态Huffman解析也补上了,才算真正可用。如果你打算自己写,调试顺序建议是这样:第一步只支持非压缩块(压缩数据里的BTYPE=00),能正确解出原始数据;第二步支持固定Huffman;第三步实现动态Huffman。切忌一上来就挑战动态Huffman,否则你根本分不清是时序问题还是算法理解问题。
2.3 五种filter算法与行缓冲(bpp和paeth)
DEFLATE解压出来的并不是最终的像素数据,而是每行数据前面多了一个描述滤波类型的字节,后面跟着经过滤波的像素字节序列。PNG标准定义了五种滤波算法,编号0到4:
- 0 None:原始数据,不做任何变换。
- 1 Sub:当前像素减去左侧像素的值。
- 2 Up:当前像素减去正上方像素的值。
- 3 Average:当前像素减去左侧和正上方像素平均值(向下取整)。
- 4 Paeth:基于左侧、正上方、左上方三个像素做预测,当前像素减去预测值。
这里特别要注意一个概念:bpp(bytes per pixel)。对于RGBA8888格式,bpp=4;对于RGB888,bpp=3;对于灰度8位,bpp=1。在做Sub、Average和Paeth滤波逆运算时,“左侧像素”指的是当前字节前bpp个字节的像素,不是前一个字节。这个细节在写verilog时极其容易踩坑,因为用的是字节流视角,很容易想当然地取相邻字节。
Filter逆运算的硬件实现需要保存两行数据:上一行滤波前重建出来的像素,和当前行正在重建的像素。因为Up和Paeth都需要访问“正上方”的像素,而当前行的某个像素很可能在上一行的对应位置还没被使用完。我采用的办法是使用BRAM做双行缓冲,宽度为一行像素的字节数,深度为2,乒乓切换。当前行重建时同时写入上一行对应的BRAM位置,这样只需要一次BRAM读端口就能满足Up和Average取值的需求,节省了一半的存储资源。
Paeth预测器的计算是filter逆运算里最绕的环节,标准伪代码如下:
p = a + b - c; pa = abs(p - a); pb = abs(p - b); pc = abs(p - c); if (pa <= pb && pa <= pc) pred = a; else if (pb <= pc) pred = b; else pred = c;在verilog里实现这个逻辑时要特别注意符号位:p是用带符号数计算的,而a、b、c本身是无符号字节。我在仿真时跑过随机数据对比,verilog实现里如果忘记对p - a这类表达式做符号扩展,Paeth的预测值在高位就会算错,最后显示出来的图像会出现一条条横向的噪点,非常隐蔽。
2.4 隔行扫描(Adam7)与位深处理的坑
PNG的隔行模式叫Adam7,会把图像拆成7个pass,每个pass只处理部分行列,最后拼出完整图。Adam7的规律是:第一次pass处理第0行的第0列,步长8;第二次pass处理第0行的第4列,步长8;第三次pass处理第4行的第0列,步长4;第四次pass处理第0行的第2列,步长4……由于每个pass的滤波是独立进行的,硬件解码时必须维护7个不完全相同的行缓冲状态。
我的工程只支持非隔行模式,也就是IHDR里的interlace字段为0。实际使用中,像Photoshop、Windows画图、Python的PIL保存PNG默认都是非隔行,兼容性完全够用。如果你确实需要支持隔行PNG,建议先在软件端把图片全部转成非隔行再存进存储介质,省掉的硬件复杂度非常值。至于位深,目前工程支持最常见的8位模式,16位PNG需要做位拼接和字节序转换,逻辑上不算难但工作量会明显增加,不是第一版该碰的东西。
3. 纯Verilog实现的核心模块实战笔记
3.1 Zlib解压引擎的bit流读取设计与状态机
DEFLATE解码器的第一件事是处理bit流读取。压缩数据虽然是按字节存进存储器的,但Huffman码字是按LSB(最低有效位)优先排列的,也就是说第一个字节的bit0是最先被读到的码字位。硬件设计时需要一个位缓冲区:每次从FIFO读入一个字节,用一个累加器按位输出。
我采用的是经典的“32位桶式移位”结构:设一个32位寄存器,每次解压引擎需要码字时从桶的高位取出对应bit,根据实际消耗的bit数进行左移,桶空到只剩不到16位时,从输入FIFO补充一个或两个字节。这个结构的好处是单周期就能取出任意长度的Huffman码字(Huffman码最长15位),不需要逐bit循环,吞吐率非常稳定。
Zlib解压引擎的主状态机分为以下几个阶段:
localparam ZLIB_IDLE = 0; // 等待数据 localparam ZLIB_BHEAD = 1; // 读取块头BFINAL和BTYPE localparam ZLIB_UNCOMP = 2; // 非压缩块,按长度复制 localparam ZLIB_HUFF = 3; // 压缩块,解析Huffman码表与LZ77 localparam ZLIB_HLEN = 4; // 读取动态Huffman表长度 localparam ZLIB_HCODES = 5; // 重建长度/距离码表 localparam ZLIB_DONE = 6; // 块结束/ zlib流结束稍微展开讲一下LZ77的硬件实现。DEFLATE的压缩结果是一系列符号:要么是字面值(0-255),要么是长度+距离对。长度码用256-285的符号表示加3到258字节,距离码用0-29表示1字节到32768字节的距离。解码出距离和长度后,需要从已解压的历史缓冲区里复制对应长度的字节。
在FPGA上,这段“复制”逻辑是最容易成为短板的地方。如果距离很小时,比如距离1复制长度258,那本质上是一个逐字节长循环,在纯状态机里做会导致每个时钟周期只能处理一个字节,解压吞吐率会暴跌。我优化后的方案是:当距离大于4时,用一个宽位宽的桶式移位器一次性拼接4字节并复制;当距离较小时(1到3),采用循环移位寄存器的技巧,让组合逻辑能在单个周期内输出最多4个字节,大幅提升短距离匹配的性能。实测这个优化对真实工程测试图片的吞吐率提升在30%以上。
3.2 Filter逆运算模块的verilog细节与双行缓冲设计
Filter模块接收Zlib引擎输出的解压数据,每行的第一个字节是滤波类型,后面是滤波后的像素数据。模块要做的事非常明确:逐字节计算滤波前的原始值,同时把重建后的像素写回行缓冲,供下一行的Up/Average/Paeth运算使用。
以Paeth滤波逆运算为例,verilog核心代码如下:
// 输入:filter_bias = 滤波后的当前字节 // left = 当前行左侧第bpp个字节(已重建) // up = 上一行同位置字节(已重建) // ul = 上一行左侧第bpp个字节(已重建) wire signed [8:0] p = {1'b0, up} - {1'b0, ul} + {1'b0, left}; // 注意必须全部扩展符号位后再运算,否则高位错乱写这个模块时我总结出三条经验。第一,必须把bpp作为可配置参数而不是硬编码,因为工程要同时支持RGB和RGBA两种格式。第二,行缓冲的地址生成是在每行起始时置零,每处理一个字节加一,但读到左侧像素时地址要做减法——这个减法要输出到BRAM地址端口上,如果时序紧张需要pipeline一级,否则fmax上不去。第三,Filter模块输出数据的节拍和输入严格对齐,输出端挂一个简单的valid信号,后面的像素整理模块按valid来取样即可,不需要帧同步信号。
3.3 像素整理与RGBA8888/RGB565输出支持
Filter模块恢复出来的是PNG原始像素数据流。对RGBA8888的PNG来说,每个像素正好4字节,顺序是R、G、B、A,直接把4字节拼接就能得到32位像素;对RGB888的PNG,每个像素3字节,后续的RGB565转换模块需要做截位运算。
我工程里做了一个可配置的像素格式转换模块,支持以下输出:
- RGBA8888直通,适合接HDMI或LVDS接口。
- RGB888直通,适合接VGA或并行RGB接口。
- RGB565截位,适合接TFT屏或低带宽显示链路。
- 灰度8位,适合直接做字符叠加或二值化处理。
这里涉及一个容易被忽略的点:PNG里像素的存储顺序是按行自左向右,每一行像素之间是紧凑排列的,但行尾不会补到某个对齐边界。而很多显示控制器要求行首地址按4字节或8字节对齐。我的做法是在行尾插入一个FIFO的写暂停周期,让像素整理模块自然地把行尾数据对齐到输出FIFO的32位边界,这样下游模块就不用关心行缓存的对齐问题。
4. 工程源码的模块清单与使用指南
4.1 源码目录结构与工程文件总览
整套工程源码按功能划分,模块层次清晰,适合直接移植或二次开发。顶层目录结构和关键文件如下:
|-- rtl | |-- png_decoder_top.v // 顶层,含AXI接口与握手 | |-- png_chunk_parser.v // PNG chunk解析 | |-- png_zlib_inflate.v // zlib/DEFLATE解压引擎 | |-- png_huffman.v // 固定+动态Huffman解码 | |-- png_filter_recon.v // filter逆运算 | |-- png_pixel_pack.v // 像素格式整理与RGB565转换 | |-- ram_line_buffer.v // 通用双行缓冲BRAM | |-- fifo_sync.v // 同步FIFO | |-- crc32_parallel.v // 并行CRC32校验 |-- sim | |-- tb_png_decoder_top.v // 顶层仿真testbench | |-- png2hex.py // Python工具,把PNG转hex文件 |-- scripts | |-- run_iverilog.sh // Icarus Verilog仿真脚本 | |-- run_vivado_sim.tcl // Vivado xsim仿真脚本仿真部分值得一提:由于纯verilog的仿真环境无法直接读取PNG文件,我写了一个Python工具png2hex.py,把PNG文件转成hex文本,仿真时用$readmemh读入。这样整个解码流程可以在仿真里完整跑通,从码流输入一直到RGB像素输出全部可见,定位问题非常方便。仿真工具用Icarus Verilog和Vivado自带的xsim都可以,脚本已经配好,拿到工程后修改顶层例化的存储路径即可跑起来看波形。
4.2 关键工程文件的可视化拆解:以CRC32模块为例
PNG的CRC校验采用CRC-32标准,生成多项式为x^32 + x^26 + x^23 + x^22 + x^16 + x^12 + x^11 + x^10 + x^8 + x^7 + x^5 + x^4 + x^2 + x + 1,初始值为0xFFFFFFFF,最后结果异或0xFFFFFFFF。软件实现通常是逐bit查表,但FPGA上要做并行CRC,否则每个字节要8个时钟周期,会拖累整体吞吐。
我写的crc32_parallel.v是8位并行输入版本,组合逻辑是直接把CRC-32的线性反馈移位寄存器展开得到的。生成方法很简单:先按串行CRC写出每个周期的状态更新表达式,再把连续8个周期的表达式用组合逻辑展开。这样每读一个chunk字节,CRC模块一个周期就能完成8个bit的校验。模块末尾用一个比较器判断计算出的CRC是否等于文件里存储的CRC值,不一致时拉高error信号,方便调试时快速定位是文件损坏还是解析错误。
4.3 10套工程的侧重点差异与适用硬件平台
这套源码包分成了10套相互独立的工程,不是简单复制10遍,每一套都在某个方向上做了针对性的调整,方便不同需求的开发者直接挑最接近的去改:
- 工程1:最小编码器,不含CRC校验,适配所有FPGA平台入门学习。
- 工程2:支持CRC校验的完整方案,适合对数据完整性有要求的场合。
- 工程3:RGB888输出+VGA时序显示,适合上板即看效果。
- 工程4:RGBA8888输出+HDMI接口,带Alpha通道的显示链路。
- 工程5:基于SPI Flash存储PNG码流,适合无SD卡的小板子。
- 工程6:基于SD卡读取,适合大尺寸图片的多文件切换显示。
- 工程7:集成DDR3帧缓存写入,适合做图片叠加或画中画。
- 工程8:针对小分辨率图标优化的低资源版本,BRAM占用大幅压缩。
- 工程9:动态Huffman完整支持版,兼容所有zlib压缩等级。
- 工程10:带AXI4-Lite寄存器接口的版本,可由CPU/软核动态配置解码参数。
10套工程的顶层模块名保持统一,只换内部的参数和部分子模块。这样做的好处是:你在工程1里调通了仿真,直接把顶层端口对应到工程7的顶层,剩下的事情就只是替换内部的存储接口,中间的chunk解析、zlib解压、Filter逆运算可以原封不动搬过去,因为接口全部是标准Valid/Ready。
5. 调试实录:PNG解码器最常见的五个坑
5.1 CRC校验不过,第一步先查字节序
第一次把PNG hex喂进仿真testbench,我遇到的第一个问题是CRC一直报错。排查了半天发现,PNG的chunk长度和CRC字段都是大端序,而我解析chunk时用的是小端序去读长度,导致从偏移10开始的IHDR数据全乱了。最后把长度字段做了一次字节交换,CRC立刻通过。这是所有文件格式解析都会踩的坑,verilog里读这种多字节字段时,务必先确认字节序再拼接。
5.2 Zlib解压卡死:BFINAL标志与最后一字节
DEFLATE块头里第一个bit是BFINAL,表示这是否是最后一个块。我第一版状态机在遇到BFINAL=1时立即停止读数据,结果发现有些zlib流在BFINAL块之后还跟了4字节的Adler-32校验值。如果状态机在BFINAL就停了,没有正确跳过Adler32,后续再开启下一张图片解码时会读到错误的起始位置,导致整个流错乱。正确做法是在BFINAL块解压结束后,继续消费掉Adler32这4个字节,再回到IDLE等待下一张图片。
5.3 Filter输出图像错位、颜色串行
图像能解出来但是颜色完全不对,典型的现象是整个画面出现规律性的“切变”或者横向条纹。排查方向主要是两个:一是滤波类型字节和第一行像素的处理顺序搞错了,PNG每行第一个字节是滤波类型,但第一行也遵循这个规则;二是Sub、Average、Paeth逆运算时左侧像素取错了位置。上述两种情况都会导致从第一个字节开始就错位,而且错误会随着行处理逐渐累积。
5.4 Paeth计算符号溢出
这个坑在仿真里很难发现,因为用modelsim或者iverilog跑几十万帧后波形都是对的,但上板后图像出现周期性噪点。最终定位在用$signed做Paeth的p值计算时,verilog的表达式位宽推断有问题。verilog里p = a + b - c如果没有显式定义所有操作数为signed,会被推断成无符号比较,导致pa、pb、pc算错。我的解决方式很粗暴:所有参与Paeth的中间变量全部定义成signed [9:0],一劳永逸。
5.5 时序不收敛:复制逻辑过长
解压引擎里的距离复制逻辑,如果用case语句枚举所有16种距离范围去拼mux,组合逻辑深度会很深,fmax直接掉到100MHz以下。后来我改成多级pipeline,第一级计算源地址,第二级做桶式移位拼数据,第三级写输出FIFO,fmax轻松上了200MHz。对于FPGA工程,组合逻辑超长导致不收敛是非常典型的,在写大位宽mux时要养成提前定拍的习惯。
6. 工程资源消耗与性能评估
以Xilinx Artix-7 XC7A35T为例,完整支持RGBA8888、动态Huffman、带CRC校验的工程实际资源消耗如下:
| 资源类型 | 消耗量 | 使用率 |
|---|---|---|
| LUT | 3427 | 16% |
| FF | 2186 | 10% |
| BRAM 18K | 28 | 39% |
| DSP48E1 | 0 | 0% |
整套解码器无需DSP单元,BRAM主要是行缓冲和FIFO占用,控制逻辑占比也不高。最关键的是性能表现:在100MHz时钟频率下,解码一行RGBA8888的1920x1080图片,实测从码流输入到RGB像素输出平均耗时2.8毫秒,理论上完全支持1080P@30fps的实时解码。如果只做静态图片显示(比如开机LOGO、菜单背景),100MHz下解码时间完全可以忽略不计。
如果预算充足,换到Kintex-7或者Zynq平台,fmax还能进一步拉到250MHz以上,解码带宽相应翻倍,支持4K静态图片也没压力。不过说实话,对于绝大多数显示场合,A7级别芯片配上100MHz时钟已经完全够用,没必要为了PNG解码硬上更贵的芯片。
7. 10套源码的扩展方向与再接再厉的空间
工程本身已经稳定,但PNG解码这个方向还有很多值得继续深挖的内容。我个人在实际操作中体会最深的一点是:先跑通仿真,再上板验证,遇到问题不要急着看别人代码,要回到PNG标准文档里去推演,很多错误从原理上推一遍就能找出来。调试解码器的时候,充分利用png2hex.py生成小尺寸测试图,比如2x2像素、4x4像素的RGBA图,波形里一眼就能看出每个像素对应的字节位置对不对,比一开始就上大图调试轻松太多。
最后再分享一个实用技巧:如果你手头的PNG是网上下载的,但不确定它是否采用了动态Huffman,可以在PC上用Python跑一下zlib.compress(open("a.png","rb").read())对比压缩前后的大小。凡是压缩率偏高的,基本都是动态Huffman压缩,这时就需要确保你的解码器实现了动态Huffman码表重建。先把固定Huffman跑通,再逐步加动态表解析,一步步来,PNG解码器就真的不难。