1. 为什么要在FPGA里硬解PNG
做图像处理这行的朋友多半都碰过这个场景:摄像头采集、上位机传输、或者从SD卡里读出来的图片,格式五花八门,JPEG、PNG、BMP都有。BMP好办,几乎不用解码,直接按像素往外吐就行;JPEG复杂但生态成熟,很多FPGA厂商和第三方都提供了IP核。唯独PNG,位置有点尴尬——它无损、带Alpha通道、压缩率还不错,在UI素材、图标、医疗影像、工业检测截图这些场景里用得特别多,但真正能在FPGA上纯逻辑跑起来的开源方案却不多。
我自己最早接触这个需求,是做一个工业相机的实时叠加显示项目。上位机把带透明通道的PNG图标下发到板子上,FPGA要在视频流上做OSD叠加。一开始想偷懒,用软核跑zlib解压,结果主频上不去,一帧图标解出来要几十毫秒,实时性完全没法看。后来咬牙用纯Verilog把PNG解码整条链路啃下来,才发现这事虽然繁琐,但完全可行,而且一旦跑通,吞吐和延迟都吊打软核方案。
这篇就围绕“FPGA纯verilog实现PNG图片解码”这个项目,把整套思路、关键模块、踩过的坑、以及10套工程源码怎么组织,掰开揉碎讲一遍。不管你是刚入门想找个综合性的实战项目练手,还是已经在做图像处理需要落地PNG解码,都能从里面抄到能直接用的东西。核心关键词就四个:FPGA、verilog、PNG图片解码、工程源码,全文围绕它们展开,不跑题。
先说清楚这个项目能干什么:它把一张标准PNG图片(8位/24位/32位色深,非隔行扫描)从字节流一路解到RGB或RGBA像素流,全程纯Verilog RTL实现,不依赖任何软核、不依赖DDR做中间缓存(小图可以,大图可选配DDR缓存),可以直接对接视频时序输出模块。适合谁?适合已经会写Verilog、懂基本时序逻辑、想往图像处理方向深入的人;也适合做FPGA项目实战、需要一套完整可复现工程的人。小白也能看,但前提是你得先把Verilog语法和状态机写明白。
2. PNG解码的整体架构与模块拆解
2.1 PNG文件格式到底长什么样
要解码,先得把PNG的“解剖图”画出来。PNG文件本质是一串大端字节序的数据块(chunk)拼接,每个chunk结构固定:4字节长度 + 4字节类型 + 数据 + 4字节CRC。关键chunk有这么几个:
- IHDR:图像头,包含宽、高、位深、颜色类型、压缩方法、滤波方法、是否隔行。这是解码的起点,所有后续逻辑都依赖它。
- PLTE:调色板,只有索引色(颜色类型3)才需要。
- IDAT:真正的压缩图像数据,可能分成多个chunk,需要按顺序拼接。
- IEND:结束标志。
- 还有tRNS(透明色)、gAMA、pHYs等辅助块,解码时可以跳过,但解析器得能正确识别长度并跳过。
颜色类型决定了像素怎么组织:0是灰度,2是RGB,3是索引,4是灰度+Alpha,6是RGBA。位深支持1/2/4/8/16。实际工程里最常用的是8位RGB(类型2)和8位RGBA(类型6),这两个必须先支持,其他作为扩展。
提示:PNG规定所有多字节整数都是大端。FPGA里习惯小端处理,字节拼接时一定要做字节序转换,否则宽高会读成天文数字,这个坑我踩过不止一次。
2.2 为什么解码链路要拆成这几级
整条链路我拆成了五级,每一级职责单一,方便单独仿真和替换:
- 字节流接收与chunk解析:从输入FIFO或AXI-Stream拿字节,识别chunk边界,提取IHDR参数,把IDAT数据连续输出。
- zlib解压(Inflate):PNG的IDAT是zlib格式,包含2字节头和4字节Adler-32校验,中间是DEFLATE压缩数据。这一级是整条链路最复杂、最耗资源的部分。
- 反滤波(Unfilter):DEFLATE解出来的数据是经过滤波的,每行开头有1字节滤波类型(0-4),需要按行做反滤波还原原始像素。
- 像素重组与色彩转换:把字节流按位深和颜色类型拼成像素,索引色查表,灰度转RGB等。
- 输出时序与缓存:按视频时序输出,或者写入行缓存/FIFO供后续模块使用。
这么拆的理由很直接:Inflate是纯组合逻辑+状态机的重活,反滤波依赖行缓冲,像素重组依赖IHDR参数,三者耦合度低,拆开后每级都能独立验证。你要是把它们揉成一个巨型状态机,调试时会痛不欲生。
2.3 10套工程源码是怎么组织的
既然标题说提供10套工程源码,那这10套不能是简单复制粘贴,得有梯度。我的组织思路是这样的:
| 工程编号 | 定位 | 核心差异 |
|---|---|---|
| 01 | 最小可运行 | 固定尺寸、固定颜色类型,验证Inflate通路 |
| 02 | 参数化IHDR | 支持动态宽高,验证chunk解析 |
| 03 | 支持RGB888 | 颜色类型2完整支持 |
| 04 | 支持RGBA8888 | 颜色类型6,带Alpha |
| 05 | 支持索引色 | 颜色类型3 + PLTE查表 |
| 06 | 支持灰度 | 颜色类型0/4 |
| 07 | 行缓存优化 | 用BRAM做行缓冲,降低延迟 |
| 08 | DDR缓存版 | 大图分块解码,对接DDR |
| 09 | 视频时序输出 | 直接驱动HDMI/VGA时序 |
| 10 | 综合演示 | 多图切换+OSD叠加 |
这样从简到繁,每套都能单独综合上板,也能逐级对比学习。源码里每个模块都有独立testbench,用Icarus Verilog就能跑仿真,不依赖昂贵的商业仿真器,这点对新手特别友好。
3. Inflate解压:整条链路最硬的一块骨头
3.1 DEFLATE的两种压缩块
PNG用的DEFLATE(RFC 1951)有两种块类型,这是理解Inflate的钥匙:
- 固定霍夫曼块(BTYPE=01):码表是固定的,不用传,直接按规范查。实现简单,但压缩率一般。
- 动态霍夫曼块(BTYPE=10):码表在数据流里动态定义,需要先解析码长、构建霍夫曼树。压缩率高,但解码逻辑复杂。
- 非压缩块(BTYPE=00):直接存原始数据,长度字段+反码校验。
实际PNG图片里,动态霍夫曼块占绝大多数。所以Inflate的核心工作量在于:动态构建霍夫曼解码表,然后用它做LZ77解码。
3.2 霍夫曼表怎么在硬件里建
软件里建霍夫曼树很随意,指针、递归都行。硬件里不行,必须用**规范霍夫曼码(Canonical Huffman)**的特性:码长相同的符号,码字是连续递增的。这样只需要一张“码长表”和“首码表”,就能用比较器阵列做解码。
我的做法是:先解析动态块头部的HLIT、HDIST、HCLEN,读出码长序列,然后用一个计数排序的思路统计每个码长有多少个符号,算出每个码长的首码,存进BRAM。解码时,逐位读入比特流,和当前码长的首码区间比较,命中就输出符号。
这里有个关键参数:最大码长15。所以比较器最多15级,可以流水化。我实测在100MHz下,单周期能处理1个比特,吞吐约12.5MB/s;如果做多比特并行(一次读4-8位),吞吐能上到50-100MB/s,足够1080p静态图秒解。
注意:动态块里码长序列本身也是霍夫曼编码的,用的是固定的码长码表。这一层嵌套容易绕晕,建议先把固定块跑通,再上动态块。
3.3 LZ77滑动窗口的硬件实现
DEFLATE的第二层是LZ77:遇到“长度+距离”对,就从前面已解出的数据里复制一段。硬件里需要一个滑动窗口缓冲,典型大小32KB。用BRAM实现,写指针和解压输出同步推进,读指针 = 写指针 - 距离。
复制长度范围3-258,距离范围1-32768。复制时如果长度跨过窗口边界,要处理回绕。我的经验是:用双端口BRAM,一个口写新数据,一个口读历史数据,复制过程用状态机逐字节搬,虽然慢一点但逻辑清晰。想提速可以做多字节并行复制,但回绕逻辑会复杂不少,新手先别碰。
3.4 比特流读取的位序陷阱
DEFLATE的比特流是LSB优先,而霍夫曼码字是MSB优先。这两个方向相反,是新手最容易翻车的地方。我的处理方式是:字节接收时先做位反转,或者干脆在状态机里维护一个比特计数器,按需取位。实测下来,用一个32位移位寄存器 + 比特计数,配合位反转查找表,是最省资源的方案。
4. 反滤波与像素重组:细节决定成败
4.1 五种滤波类型的反算
PNG每行像素在压缩前会做滤波,滤波类型有5种:
- 0 None:不滤波,直接用。
- 1 Sub:当前像素减去左边像素。
- 2 Up:当前像素减去上一行同位置像素。
- 3 Average:减去左和上的平均值。
- 4 Paeth:减去左、上、左上三者的Paeth预测值。
反滤波就是把这些减法加回去。硬件实现时,需要两行缓冲:当前行和上一行。每解出一个像素,同时更新当前行缓冲,供下一行使用。
Paeth预测器是重点,它的逻辑是:p = a + b - c,然后比较p与a、b、c的距离,取最近的。硬件里用几个加法和比较器就能实现,但要注意有符号运算,因为减法可能出负数。我一般统一转成有符号数处理,最后再截断回无符号。
提示:滤波是按字节做的,不是按像素。对于16位色深,每个通道2字节,滤波要逐字节进行。这个细节很多开源实现都搞错了,导致高色深图片解码花屏。
4.2 行缓冲的容量计算
行缓冲大小 = 图像宽度 × 每像素字节数。比如1920宽的RGBA8888,每行7680字节。用BRAM实现,一块36Kb的BRAM能存4KB左右,所以一行需要2块BRAM。如果图像更宽,就得用多块拼接或者上DDR。
我的建议是:宽度小于1024的图,纯BRAM搞定;超过的,老老实实上DDR做帧缓存。别硬扛,BRAM资源很宝贵,留给其他模块用。
4.3 像素重组的位操作
不同位深和颜色类型,像素重组逻辑完全不同:
- 8位灰度:1字节1像素,直接输出。
- 8位RGB:3字节1像素,按R、G、B顺序拼。
- 8位RGBA:4字节1像素。
- 索引色:1字节是索引,查PLTE表得到RGB。
- 1/2/4位:需要按位拆分,一个字节里塞多个像素。
这部分用case语句按IHDR参数分支即可,但要注意位序:PNG里高位在前。比如4位灰度,一个字节的高4位是第一个像素,低4位是第二个。
5. 实操流程:从仿真到上板的完整路径
5.1 环境准备与工具链
这套工程我验证过的工具链组合:
- 仿真:Icarus Verilog + GTKWave,全免费,跨平台。testbench里用
$readmemh把PNG文件读成字节数组喂给解码器。 - 综合:Xilinx Vivado(Artix-7/Kintex-7/Zynq-7000都验证过),安路Tang系列也跑过。
- 上板:黑金AX7010、米联客MZ7020,HDMI输出用现成的时序IP。
Icarus Verilog的好处是轻量,一条命令就能跑:
iverilog -o png_decode_tb png_decode_tb.v png_decoder.v inflate.v unfilter.v vvp png_decode_tb gtkwave dump.vcd5.2 关键参数的计算过程
假设目标图片是1920×1080 RGBA8888,我们来算几个关键参数:
- 原始像素数据量 = 1920 × 1080 × 4 = 8,294,400字节 ≈ 8MB。
- 每行滤波后数据 = 1920 × 4 + 1 = 7681字节。
- 行缓冲需求 = 7681 × 2(当前行+上一行)≈ 15KB,需要4块36Kb BRAM。
- 如果Inflate吞吐按50MB/s算,解压8MB需要约0.16秒。这个速度对静态图够用,对视频流就不够了,所以视频场景建议预解码存DDR。
5.3 仿真验证的步骤
- 先用一张极小的PNG(比如8×8纯色)跑通,确认IHDR解析正确。
- 换成带固定霍夫曼块的图,验证Inflate基础通路。
- 上动态霍夫曼块,对比软件解压结果,逐字节核对。
- 加入反滤波,用GTKWave看行缓冲波形。
- 最后接像素重组,输出RGB流,和原图逐像素比对。
每一步都要有独立的testbench,别想着一步到位。我见过太多人直接上大图,结果出错后根本不知道是哪一级的问题。
5.4 上板调试的现场记录
上板后第一个要看的信号是chunk解析状态机的跳转。用ILA抓IHDR的宽高字段,确认字节序没错。第二个看Inflate的输出有效信号,确认数据在流动。第三个看行缓冲的读写指针,确认没有越界。
我遇到过一次诡异的花屏,查了两天才发现是CRC校验模块把IDAT数据误判成坏块给丢了。后来干脆把CRC校验做成可选,调试阶段先关掉,跑通再打开。
6. 常见问题与排查速查表
6.1 解码结果花屏或错位
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 整体花屏 | 字节序错误 | 检查IHDR宽高是否合理 |
| 隔行错位 | 滤波类型解析错 | 抓每行首字节,确认0-4范围 |
| 颜色偏差 | 通道顺序错 | 对比RGB和BGR输出 |
| 边缘撕裂 | 行缓冲回绕错 | 检查读写指针边界 |
| 部分区域黑 | IDAT拼接漏块 | 统计IDAT chunk数量 |
6.2 Inflate卡死或输出异常
最常见的是霍夫曼表建错。动态块的码长序列解析时,如果HLIT/HDIST读错,后面全乱。建议在仿真里把建好的码长表打印出来,和软件(比如Python的zlib)对比。
另一个坑是距离超出窗口。LZ77的距离最大32768,如果滑动窗口小于这个值,复制时会读到未初始化数据。窗口一定要开满32KB。
6.3 资源占用过高
Inflate是资源大户,主要吃BRAM和LUT。优化手段:
- 霍夫曼表用分布式RAM而非BRAM,省块RAM。
- 滑动窗口用单口BRAM + 时分复用,省一半BRAM但降速。
- 比特流读取用移位寄存器而非FIFO,省资源。
我实测在Artix-7上,完整解码器约占3000 LUT、8块BRAM,跑100MHz没问题。
6.4 独家避坑技巧
- 先关CRC再调试,跑通再开,能省一半调试时间。
- testbench里直接读PNG文件,别手动构造字节流,容易错。
- 用Python生成参考输出,逐像素比对,比肉眼看波形快得多。
- 行缓冲初始化清零,否则第一行反滤波会用到垃圾数据。
- 多图切换时复位状态机,别让上一张图的状态残留。
7. 工程源码的使用与二次开发建议
7.1 目录结构与命名规范
10套工程统一目录结构:
project_XX/ ├── rtl/ # 所有Verilog源码 ├── tb/ # testbench ├── sim/ # 仿真脚本 ├── constr/ # 约束文件 ├── ip/ # 厂商IP(如需要) └── doc/ # 说明文档模块命名统一用png_前缀,比如png_chunk_parser、png_inflate、png_unfilter,一眼能看出层级。
7.2 参数化配置的接口
顶层模块暴露这些参数,方便裁剪:
module png_decoder #( parameter MAX_WIDTH = 1920, parameter MAX_HEIGHT = 1080, parameter SUPPORT_RGBA = 1, parameter SUPPORT_INDEX = 0, parameter USE_DDR = 0 )( // 输入字节流 input wire clk, input wire rst_n, input wire [7:0] data_in, input wire data_valid, // 输出像素流 output wire [23:0] pixel_rgb, output wire [7:0] pixel_alpha, output wire pixel_valid, output wire frame_done );这样同一套代码,改参数就能适配不同场景,不用改逻辑。
7.3 对接视频时序的注意事项
解码输出是“来一个像素吐一个像素”,而视频时序要求严格的像素时钟节拍。中间必须加异步FIFO做跨时钟域,或者用乒乓行缓冲做速率匹配。我一般用FIFO,深度设成两行像素,能吸收解码速率的抖动。
如果解码速率低于像素时钟,会出现欠载,画面撕裂。解决办法是预解码整帧到DDR,然后按视频时序从DDR读。这就是工程08和09的区别。
8. 性能优化与扩展方向
8.1 提升Inflate吞吐的几种手段
单比特解码是瓶颈。想提速,核心思路是一次解多个符号。具体做法:
- 多比特预读:一次从比特流取8位,用查找表直接映射到符号,命中率高时能一次解一个符号。
- 并行霍夫曼解码:复制多份解码表,同时尝试解多个符号,适合高压缩比数据。
- 流水线化:把建表、解码、复制分成三级流水,提高时钟频率。
我做过一个8比特预读的版本,吞吐从12.5MB/s提到约80MB/s,代价是LUT翻倍。值不值,看你的场景。
8.2 支持隔行扫描(Adam7)
标准PNG支持Adam7隔行,分7个pass。每个pass的宽高和起始位置都不同,解码逻辑要跑7遍。实现上就是把IHDR解析出的宽高按pass规则重新计算,然后循环调用解码核心。工作量不小,但逻辑清晰,属于体力活。
8.3 对接DDR做帧缓存
大图或视频流场景,必须上DDR。做法是:解码器把像素写入DDR的一块区域,视频输出模块从另一块区域读,乒乓切换。DDR控制器用厂商IP,自己写个简单的读写仲裁即可。注意突发长度要设成最大,否则带宽利用率上不去。
8.4 多图预加载与切换
做UI叠加时,经常需要多张图快速切换。我的方案是:上电时把所有PNG解码后存进DDR的不同区域,运行时只切换读地址。这样切换是零延迟的,代价是DDR容量要够。
9. 我在这个项目里踩过的坑和真实体会
说实话,纯Verilog解PNG这事,第一次做的时候我低估了Inflate的复杂度。动态霍夫曼表的构建逻辑,我前后重构了三版才稳定。第一版用纯组合逻辑建表,综合出来时序完全过不了;第二版改成状态机逐级建,能跑但慢;第三版用计数排序+流水线,才在资源和速度之间找到平衡。
另一个深刻的体会是:仿真覆盖率决定上板成功率。我一开始只测了几张小图,上板后遇到一张带tRNS透明块的图直接挂掉,因为解析器没处理这个chunk。后来我把PNG规范里所有chunk类型都列出来,逐个写testbench,才把坑填平。
还有一点,别迷信“纯Verilog”这四个字。纯Verilog指的是核心解码逻辑不用软核、不用HLS,但该用的厂商IP(比如BRAM、DDR控制器、时钟管理)还是要用,没必要重复造轮子。把精力花在Inflate和反滤波这些真正有技术含量的地方,才是正道。
最后分享一个小技巧:调试Inflate时,我会在关键节点插入一个“影子FIFO”,把中间数据同时写进去,仿真结束后dump出来和Python的zlib输出逐字节比对。这个方法帮我定位了至少五个隐蔽的位序bug,比看波形高效太多。这套工程源码后续还可以往JPEG解码、GIF解码方向扩展,解码框架是通用的,换的只是熵解码和反量化部分。