1. 为什么要在FPGA里硬解PNG
做图像处理的朋友大概率都遇到过这个场景:上位机或者摄像头给过来一帧图像,格式是PNG,压缩比高、体积小,但FPGA内部要拿它做后续的缩放、滤波、叠加,就必须先把这层压缩壳子剥掉。常规做法是丢给ARM或者软核去解,跑个zlib库慢慢啃,但一旦分辨率上去、帧率要求上来,CPU那点算力立刻见底,延迟抖动也压不住。这时候纯硬件解码的价值就出来了——用FPGA的并行流水线把Deflate解压和滤波重建全部吃进逻辑里,做到逐像素流式输出,延迟可控、吞吐稳定。
PNG这个格式,全称是Portable Network Graphics,它本身不是单纯的一种压缩,而是"预测滤波 + Deflate无损压缩"两层结构叠起来的。解码要干的事说白了就三步:先把压缩数据流用Deflate算法还原成滤波后的原始字节,再按行做逆滤波把预测值加回去,最后按颜色类型(灰度、RGB、调色板、带Alpha等)拼成像素。听起来不复杂,但真落到Verilog里,每一步都有坑。
这套工程我前后打磨了挺长时间,最终整理出10套可综合、可上板的源码,覆盖从纯仿真验证到实际视频通路的不同需求。适合谁看?如果你已经写过UART、SPI这类基础模块,想往图像处理方向进阶,或者手头正好有个项目需要把PNG解码塞进FPGA,那这篇内容应该能帮你少走不少弯路。下面我按设计思路、核心细节、实操流程、问题排查几个维度,把整套东西拆开讲。
2. 整体架构与方案选型拆解
2.1 为什么不用现成IP而选择纯Verilog手写
市面上确实有厂商提供的图像解码IP,但PNG这块,尤其是Deflate解压,能直接买到的成熟硬核并不多,而且授权费不便宜。更关键的是,很多IP是黑盒,你没法针对自己的数据流特点去优化。比如你的PNG是固定尺寸、固定颜色类型,那完全可以把一些通用逻辑裁掉,省出大量LUT和BRAM。
纯Verilog手写的另一个好处是可移植性。这套代码我在Xilinx 7系列、UltraScale,还有国产安路、紫光的器件上都跑过,只要改改BRAM的例化原语和时钟约束,核心解码逻辑一行不用动。如果用厂商IP,换平台基本等于重做。
当然代价也有,就是开发周期长、调试痛苦。Deflate的Huffman解码涉及变长码流,状态机要处理各种边界情况,仿真波形能看瞎眼。但一旦跑通,这套逻辑就是你自己的,后续想加什么特性都自由。
2.2 三层流水线架构的设计考量
整套解码器我拆成了三级流水:Deflate解压层 → 逆滤波层 → 像素重组层。为什么这么分?因为这三步的数据依赖关系是严格的顺序关系,但每一步内部都有并行空间,分层之后可以各自做流水优化,层与层之间用FIFO或者乒乓BRAM衔接。
Deflate解压层是绝对瓶颈,它要处理Huffman变长码,一个时钟周期不一定能吐一个字节,所以后面必须挂一个弹性缓冲。逆滤波层相对规整,每行像素做固定的加减运算,可以做成全流水。像素重组层负责把字节流按颜色类型拼成32位RGBA或者16位RGB,输出给后续模块。
这里有个关键决策:中间数据用BRAM还是用FIFO。我最初用异步FIFO衔接,后来发现逆滤波需要按行访问,而FIFO只能顺序读,遇到滤波类型为Paeth或者Average时,需要同时拿到左边、上边、左上三个参考像素,FIFO根本不够用。所以改成用双口BRAM做行缓存,写端口接解压输出,读端口给逆滤波,这样能同时读出上一行和当前行的数据。
2.3 10套工程源码的差异化定位
10套源码不是简单复制粘贴,而是针对不同应用场景做了裁剪和扩展,大致分这么几类:
| 工程编号 | 定位 | 特点 | 适用场景 |
|---|---|---|---|
| 01-02 | 纯仿真验证 | 带完整Testbench,覆盖各类PNG样本 | 学习算法、验证逻辑 |
| 03-04 | 基础解码 | 支持灰度/RGB,无Alpha | 简单图像显示 |
| 05-06 | 全功能解码 | 支持调色板、Alpha、隔行 | 通用图像处理 |
| 07-08 | 视频通路集成 | 带AXI-Stream输出,接VDMA | 视频叠加、OSD |
| 09-10 | 资源优化版 | 裁剪Huffman表,固定尺寸 | 低成本器件 |
这样分的好处是,新手可以从01开始跑仿真,看波形理解每一步;有经验的可以直接拿07去搭视频链路。每套工程都配了约束文件和上板说明,不是那种只给源码不管死活的。
3. Deflate解压的核心细节与实操要点
3.1 Huffman变长码的硬件解码策略
Deflate里用了两种Huffman编码:固定表和动态表。固定表的码长是预设的,实现简单;动态表需要在码流开头先解析出码长序列,再重建码表。这是整个解码器最烧脑的部分。
硬件解变长码,常规做法是查表法。但Huffman表可能很大,直接查表会吃掉大量BRAM。我的做法是构建一棵规范Huffman树,用逐位比较的方式解码。具体来说,维护一个码字累加器,每来一个bit就左移一位并或上当前bit,然后跟当前长度的最小码字和最大码字比较,落在区间内就命中,否则长度加一继续。
// 简化的Huffman解码状态机核心逻辑 always @(posedge clk) begin if (state == DECODE) begin code_reg <= {code_reg[14:0], bit_in}; code_len <= code_len + 1; if (code_reg >= min_code[code_len] && code_reg <= max_code[code_len]) begin symbol <= symbol_table[code_reg - min_code[code_len]]; state <= SYMBOL_OUT; end end end这里有个坑:码字比较要用无符号数,而且要注意位宽对齐。我一开始用有符号比较,结果遇到高位为1的码字就出错,查了两天才发现是符号位的问题。另外,min_code和max_code表要在解析动态表时预先算好,不能边解边算,否则时序根本收敛不了。
提示:动态Huffman表的码长序列本身也是用Huffman编码的,叫"码长码"。解析它需要先建一棵19个符号的固定表,这层嵌套容易绕晕,建议先在纸上画清楚再写代码。
3.2 LZ77滑动窗口的BRAM实现
Deflate的另一半是LZ77,它把重复的字符串用"距离+长度"对来表示。硬件实现需要一个滑动窗口来存历史数据,窗口大小典型是32KB。32KB用BRAM存没问题,但关键是读写冲突:解压时既要往窗口里写新数据,又要从窗口里读匹配串。
我的方案是用真双口BRAM,一个端口专门写,一个端口专门读,地址独立。写地址是循环递增的,读地址根据距离计算。这里要注意距离是相对于当前写位置的偏移,所以读地址 = 写地址 - 距离,要做模运算。
// 滑动窗口地址计算 wire [14:0] read_addr = write_addr - match_distance; // 注意:match_distance可能大于当前已写入的数据量,需要做边界保护边界保护很重要。如果距离超过了已经写入的数据量,说明码流有问题,这时候要报错而不是读出垃圾数据。我在实际调试中就遇到过因为码流截断导致距离越界,结果解出一堆乱码,加了保护逻辑后就能准确定位问题。
3.3 块类型与边界处理
Deflate数据是按块组织的,块类型有三种:不压缩块、固定Huffman块、动态Huffman块。不压缩块最好处理,直接拷贝;后两种要走Huffman解码。每个块开头有3个bit的标志位,最后一块有结束标志。
硬件状态机要能在这三种块之间无缝切换。我的做法是顶层状态机维护一个块类型寄存器,解码完一个块后回到块头解析状态,读新的块类型。这里容易出错的地方是位流对齐:不压缩块要求跳过当前字节剩余位,对齐到字节边界,这个细节如果漏掉,后面全乱。
注意:Deflate的位序是LSB优先,也就是先来的bit是低位。这跟很多人的直觉相反,写移位寄存器时千万别搞反,否则解出来的全是错的。
4. 逆滤波与像素重组的实现细节
4.1 五种滤波类型的统一处理
PNG的每一行在压缩前会做一次滤波,滤波类型有五种:None、Sub、Up、Average、Paeth。解码时要按行读取滤波类型字节,然后对整行做逆运算。这五种滤波的逆运算公式不同,但结构相似,都是基于左边、上边、左上三个参考像素做加减。
硬件实现时,我用了一个可配置的运算单元,根据滤波类型选择不同的运算路径。这样比写五个独立模块省资源,而且时序好收敛。
| 滤波类型 | 逆运算公式 | 硬件实现要点 |
|---|---|---|
| None | Raw(x) = Filt(x) | 直通 |
| Sub | Raw(x) = Filt(x) + Raw(x-bpp) | 需要行内前向依赖 |
| Up | Raw(x) = Filt(x) + Prior(x) | 需要上一行缓存 |
| Average | Raw(x) = Filt(x) + floor((Raw(x-bpp)+Prior(x))/2) | 需要行内和上行 |
| Paeth | Raw(x) = Filt(x) + PaethPredictor(...) | 需要三个参考值 |
Sub和Average有行内前向依赖,也就是当前像素的计算依赖左边已经算好的像素。这意味着不能全并行,必须按bpp(每像素字节数)做流水。比如RGB是3字节每像素,那就要等前3个字节算完才能算当前字节。我的做法是用一个移位寄存器保存最近bpp个已算好的字节,这样每个周期都能出一个结果。
4.2 行缓存的乒乓设计
逆滤波需要同时访问当前行和上一行,所以至少需要两行缓存。我用的是乒乓BRAM,写当前行的时候读上一行,一行结束后交换角色。这样能保证连续处理,不用等整行写完再开始下一行。
行缓存的宽度要按最大行字节数来定。比如1920宽的RGB图像,一行是1920*3=5760字节,加上滤波类型字节是5761。BRAM深度要取2的幂次,所以配8192深度。如果图像更宽,就要考虑用多块BRAM拼接。
// 乒乓行缓存控制 always @(posedge clk) begin if (line_done) begin wr_bank <= ~wr_bank; // 切换写bank rd_bank <= wr_bank; // 读bank变成刚才写的那个 end end这里有个时序细节:切换bank的时机要精确到行的最后一个字节写完之后,早一个周期晚一个周期都会导致读出的数据错位。我是在写使能的下降沿检测行结束,实测很稳。
4.3 颜色类型与位深的适配
PNG支持多种颜色类型:灰度、RGB、调色板、灰度+Alpha、RGBA。位深也有1、2、4、8、16位。这组合起来情况很多,但实际项目里常用的就那几种。我的10套源码里,基础版只支持8位RGB和灰度,全功能版支持调色板和Alpha。
调色板处理需要额外一块BRAM存调色板数据,解码时用索引去查。调色板大小最多256项,每项3或4字节,用一块小BRAM就够。这里要注意调色板数据的字节序,PNG里是RGB顺序,但有些显示通路要BGR,需要在输出级做交换。
16位位深的处理比较麻烦,因为Deflate解出来是字节流,16位样本要两个字节拼。而且16位样本的滤波是按16位为单位做的,不是按字节。这个细节如果搞错,图像会出现规律性的条纹。我在全功能版里单独做了一个16位重组模块,把两个字节拼成16位后再做逆滤波。
5. 完整实操流程与上板验证
5.1 从仿真到上板的完整步骤
拿到源码后,建议按这个顺序推进,不要跳步:
跑通仿真:先用工程01的Testbench,里面预置了几张不同格式的PNG,跑一遍看波形,确认解压输出的字节流跟预期一致。这一步不用上板,纯仿真就能验证算法正确性。
综合看资源:用Vivado或者安路的工具综合一遍,看LUT、BRAM、DSP的占用。如果BRAM超了,说明行缓存或者滑动窗口配大了,可以按实际图像尺寸裁剪。
加约束:约束文件里主要配时钟周期和IO引脚。时钟频率先设低一点,比如50MHz,跑通了再往上超。PNG解码的关键路径通常在Huffman比较器那里,频率上不去就先做流水切割。
上板验证:把解码后的像素数据接到HDMI或者VGA输出,或者通过UART打印几个像素值对比。我一般会先用一张纯色图测试,确认颜色通道没搞反,再换复杂图。
性能调优:如果帧率不够,看瓶颈在哪一级。Deflate解压通常是瓶颈,可以考虑把Huffman解码做成两级流水,或者用更宽的位宽并行处理。
5.2 关键参数的计算与配置
行缓存深度和滑动窗口大小是两个最影响资源的参数,需要根据实际图像算。
行缓存深度:取大于等于一行字节数的最小2的幂。比如1280宽RGB,一行3840字节,加1字节滤波类型是3841,向上取整到4096。如果图像宽度不固定,就按最大宽度算。
滑动窗口大小:Deflate标准允许最大32KB,但实际PNG压缩时不一定用满。可以统计一下你的PNG样本里最大距离是多少,如果都不超过8KB,那窗口就可以砍到8KB,省一半BRAM。
Huffman表大小:固定表很小,动态表最大也就288个符号。码长表19个符号。这些用分布式RAM或者小BRAM都能存。
提示:如果资源实在紧张,可以把动态Huffman表解析后的码表存到LUTRAM里,比BRAM省资源,但深度不能太大。
5.3 仿真Testbench的写法要点
Testbench要能自动读取PNG文件并比对解码结果。我的做法是用Verilog的$readmemh把PNG文件读成字节数组,然后逐字节喂给解码器。比对时用一个参考模型(可以用Python提前跑一遍)生成期望输出,存成文件,仿真时逐字节比较。
// 简化的Testbench结构 initial begin $readmemh("test.png.hex", png_data); // 喂数据 for (i = 0; i < png_len; i = i + 1) begin feed_byte(png_data[i]); end // 比对输出 for (i = 0; i < expected_len; i = i + 1) begin if (out_byte !== expected[i]) begin $display("Mismatch at %d", i); end end end这里有个实用技巧:用Python生成hex文件。PNG是二进制,直接读进Verilog不方便,先用Python转成hex文本,每行一个字节,仿真时直接读。期望输出也一样处理。这样整个验证流程就自动化了,改图不用改代码。
6. 常见问题与排查技巧实录
6.1 解码输出乱码的排查思路
乱码是最常见的问题,原因可能出在好几层。我整理了一个排查顺序,从前往后查:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 全黑或全白 | 像素重组颜色类型配错 | 检查颜色类型寄存器 |
| 规律性条纹 | 滤波类型判断错或位深处理错 | 抓波形看每行滤波类型字节 |
| 随机噪点 | Huffman解码错位 | 对比解压字节流和参考 |
| 图像偏移 | 行缓存地址算错 | 检查行结束检测逻辑 |
| 颜色通道互换 | RGB/BGR顺序问题 | 看输出级是否做了交换 |
我遇到最多的是Huffman解码错位,表现是解出来的字节流前面几个对,后面全乱。这种通常是码表重建时某个码长算错了,导致后续所有码字都偏移。解决办法是在解析动态表时把每个符号的码字打印出来,跟Python的zlib库对比,很快就能定位。
6.2 时序不收敛的优化手段
PNG解码器的关键路径通常在Huffman比较器和逆滤波的加法器链上。如果频率上不去,可以试这几招:
- 切割比较器:把码字比较拆成两级,第一级比较高位,第二级比较低位,中间加寄存器。
- 预计算:把min_code和max_code在解析阶段就算好存起来,解码时直接查,不要实时算。
- 逆滤波流水:把Paeth预测器的三个候选值并行算出来,再用选择器选,比串行算快。
- 降低位宽:如果图像位深是8位,就不要用16位的加法器,省一半逻辑。
我在7系列上跑,不加流水大概能到80MHz,加了流水能到150MHz以上。对于1080p60的视频,像素时钟148.5MHz,所以流水是必须的。
6.3 资源占用的优化经验
BRAM是PNG解码器的大头,滑动窗口32KB加两行缓存,轻松吃掉十几块BRAM。如果器件BRAM紧张,可以这么省:
- 滑动窗口用分布式RAM:如果窗口小于4KB,用LUTRAM比BRAM划算。
- 行缓存复用:如果图像宽度不大,两行缓存可以合并成一块BRAM的两个bank。
- 调色板存ROM:调色板是只读的,综合成ROM比BRAM省。
- 裁剪Huffman表:如果确定PNG只用固定表,动态表逻辑可以整个删掉。
我有一套针对小器件的优化版,把32KB窗口砍到8KB,行缓存用分布式RAM,整体BRAM占用从18块降到4块,代价是不支持大距离匹配的PNG,但对于摄像头直出的图基本够用。
6.4 跨平台移植的注意事项
这套代码在Xilinx和安路上都跑过,移植时主要改这几个地方:
- BRAM原语:Xilinx用
RAMB36E1,安路用DPB,接口不一样,要换例化。 - 时钟资源:Xilinx用
BUFG,安路用GCLK,约束写法也不同。 - 复位极性:有些器件内部复位是高有效,有些是低有效,要统一。
- 综合属性:
(* ram_style = "block" *)这种属性Xilinx认,安路可能不认,要去掉。
核心解码逻辑是纯RTL,不依赖任何原语,所以移植工作量主要在存储和时钟上。我一般会把BRAM例化单独放一个文件,移植时只改这个文件。
7. 工程源码的使用建议与扩展方向
7.1 怎么根据自己的需求选工程
10套源码不是让你全跑一遍,而是按需选。如果你只是想学习PNG解码算法,从01开始,仿真跑通,波形看懂,基本就掌握了核心。如果是要做实际项目,直接看07的视频通路版,把AXI-Stream输出接到你的后续模块上。
选工程时重点看两个参数:支持的颜色类型和最大图像尺寸。基础版只支持8位RGB,最大宽度按行缓存深度定。如果你的图是调色板格式,必须用全功能版。如果图特别宽,要改行缓存深度。
7.2 后续可以怎么扩展
这套解码器跑通之后,可以往几个方向扩展。一个是编码,把解码反过来做就是PNG编码,压缩部分可以用简单的固定Huffman,复杂度低很多。另一个是其他图像格式,比如BMP、JPEG的无损模式,架构类似,改改解析层就行。
还可以做多路并行,如果有多张图要同时解,可以例化多个解码核,共享滑动窗口BRAM,用仲裁器分配。这个在视频墙或者多图层叠加场景很有用。
最后再分享一个小技巧:调试PNG解码时,先用小图。找一张16x16的PNG,解出来的字节流很短,波形一屏就能看完,比拿1080p的图调效率高十倍。等小图跑通了,再换大图验证边界情况。这个习惯帮我省了大量调试时间。