拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

纯Verilog硬解PNG:10套FPGA工程源码与实战避坑指南

纯Verilog硬解PNG:10套FPGA工程源码与实战避坑指南

PNG 这种格式在 PC 端属于"随手就能打开"的东西,但一旦把它搬到 FPGA 里,事情就完全不是一回事了。PNG 用的是 DEFLATE 无损压缩,里面套着 LZ77 和 Huffman 两层编码,还要处理 zlib 头、Adler-32 校验、逐行滤波反变换、可能的隔行扫描,最后才是 RGB 像素输出。整套流程对纯硬件逻辑来说,难点不在"算力够不够",而在"数据流怎么控、状态机怎么切、BRAM 怎么省"。我这次做的就是一个纯 Verilog 的 PNG 解码工程,不依赖任何软核、不调用厂商 IP 里的图像库,从文件头解析到像素输出全部用 RTL 写死,并且整理了 10 套可综合、可上板的工程源码,覆盖从仿真验证到实际显示的完整链路。这篇就把我在这个项目里踩过的坑、做过的取舍、以及每一块逻辑为什么这么设计,尽量讲透,适合正在做 FPGA 图像处理、想把压缩格式解码搬进硬件、或者单纯想找一个有含金量的 Verilog 实战项目练手的人参考。

1. 为什么要在 FPGA 里硬解 PNG,而不是先转成 BMP

很多人第一反应是:既然 PNG 解码这么麻烦,为什么不提前在 PC 上把图片转成 BMP 或者原始 RGB 数据,直接烧进 ROM 或者从 SD 卡读?这个思路在"图片固定不变"的场景下确实成立,但只要你的应用里图片是动态的、来源是网络的、或者存储空间紧张,这个前提就崩了。

1.1 存储带宽和容量的现实账

先算一笔账。一张 1920×1080 的 24 位真彩色图片,原始像素数据是 1920×1080×3 = 6,220,800 字节,差不多 6MB。如果转成 BMP,还要加上文件头,基本就是这个量级。而同样一张图存成 PNG,根据内容复杂度,通常在 500KB 到 2MB 之间,压缩比普遍在 3:1 到 10:1。对于用 SPI Flash 或者小容量 SD 卡做图库的设备来说,这个差距直接决定了你能存 20 张图还是 100 张图。

再看带宽。如果图片要从外部存储实时读进来显示,BMP 方案下每帧要搬运 6MB,按 60fps 算就是 360MB/s,这个带宽对大多数中低端 FPGA 的外部存储接口来说是吃不消的。PNG 压缩后每帧可能只有 1MB 左右,带宽压力直接降到六分之一。解码虽然要消耗逻辑资源,但换来的存储和带宽收益是实打实的。

1.2 纯 Verilog 解码的边界在哪

需要说清楚的是,纯 RTL 解 PNG 不是没有代价。DEFLATE 解码里的 Huffman 译码需要变长码字匹配,LZ77 需要维护 32KB 的滑动窗口字典,这两块是资源消耗的大头。我的做法是:Huffman 译码用查表加逐位比较的混合结构,滑动窗口用片上 BRAM 实现,不占用外部存储。这样一套下来,在中端 FPGA 上大概占用 3000 到 6000 个 LUT、若干块 BRAM,具体数字取决于你支持的色深和是否支持隔行扫描。

提示:如果你的图库是固定的、容量也不紧张,老老实实转 BMP 存 ROM 是最省事的选择。纯 Verilog 解 PNG 的价值在于"动态图源"和"存储受限"这两个场景,不要为了炫技而炫技。

1.3 这套工程适合谁

我把 10 套工程按难度和场景做了分层:有只做仿真、跑通单张图片解码的入门版;有接 SD 卡、从文件系统读 PNG 再解码显示的完整版;也有接以太网、把网络传来的 PNG 流实时解码的进阶版。如果你刚学完 Verilog 语法,想找一个能真正锻炼状态机和数据流控制的项目,从入门版开始最合适。如果你已经在做图像处理,需要把压缩图源接入现有管线,可以直接看进阶版的接口设计。

2. PNG 文件结构拆解:解码器要盯住哪几个块

写解码器之前,必须把 PNG 的文件组织方式吃透。PNG 是"块"结构,每个块有固定的格式,解码器本质上就是按顺序读块、识别块类型、对特定块做处理。搞不清块结构,后面状态机根本没法写。

2.1 文件签名和块的基本格式

PNG 文件开头是 8 个固定字节的签名:89 50 4E 47 0D 0A 1A 0A。这 8 个字节的作用是让解码器快速判断"这是不是 PNG",同时也能检测文件在传输中是否被文本模式损坏(因为里面有换行符和 EOF 字符)。解码器第一步就是比对这 8 个字节,不匹配直接报错退出。

签名之后就是一个个块。每个块的结构是固定的四段:

字段长度说明
Length4 字节数据段长度,不含类型和 CRC
Type4 字节块类型,ASCII 字符
Data变长块的实际数据
CRC4 字节对 Type 和 Data 的校验

这里有个容易踩的坑:Length 字段是大端序(Big-Endian),也就是高位字节在前。Verilog 里读进来如果直接按小端拼,长度就全错了。我在第一版里就栽在这,解码器一直读到错误的块边界,仿真波形上看数据全是乱的,查了半天才发现是字节序问题。

2.2 关键块类型和处理优先级

PNG 的块分两类:关键块(Critical)和辅助块(Ancillary)。关键块是解码必须的,辅助块可以跳过。解码器要重点处理的是这几个:

  • IHDR:图像头,必须第一个出现。里面包含宽度、高度、位深、颜色类型、压缩方法、滤波方法、隔行方法。这几个参数决定了后面所有解码逻辑的分支走向。
  • PLTE:调色板,只有索引色(颜色类型 3)才需要。里面是 RGB 三元组列表。
  • IDAT:图像数据,可能有一个或多个。多个 IDAT 块的数据要按顺序拼接起来,形成一个完整的 zlib 压缩流。这是解码的核心输入。
  • IEND:图像结束标志,读到它就可以停止解析。
  • tRNS:透明度信息,处理带透明通道的图片时需要。
  • gAMA、cHRM、sRGB:色彩相关辅助块,纯显示场景可以忽略。

我的解码器状态机就是围绕这几个块设计的:先等 IHDR,拿到图像参数后配置后续模块;然后循环读块,遇到 IDAT 就把数据送进 zlib 解码通道,遇到 IEND 就收尾。

2.3 IHDR 里的参数怎么影响硬件设计

IHDR 里的几个字段直接决定硬件资源分配,必须提前想清楚支持范围:

  • 位深:支持 1、2、4、8、16 位。位深越小,一个字节里塞的像素越多,解包逻辑越复杂。我建议第一版只支持 8 位,跑通后再扩展。
  • 颜色类型:0 是灰度,2 是真彩色,3 是索引色,4 是灰度加透明,6 是真彩加透明。真彩色(类型 2)最直观,每个像素 3 字节,适合入门。
  • 隔行方法:0 是非隔行,1 是 Adam7 隔行。隔行扫描把图像分成 7 个 pass,每个 pass 只传部分行,显示时要做行重排。这个逻辑很绕,建议第一版只支持非隔行。

注意:很多 PNG 图片默认是隔行扫描的,如果你只支持非隔行,测试时一定要用工具确认图片的隔行标志,否则解码出来会是花的。我一般用 Python 的 Pillow 库读一下img.info就能看到。

3. zlib 与 DEFLATE 解码:整个工程最硬的一块骨头

IDAT 块拼起来之后,得到的是一个标准的 zlib 流。zlib 流的结构是:2 字节头 + DEFLATE 压缩数据 + 4 字节 Adler-32 校验。DEFLATE 数据内部又是 Huffman 编码和 LZ77 的混合。这一块是整个解码器里逻辑最复杂、最容易出错的部分,我把它单独拆成一个模块,输入是压缩字节流,输出是解压后的滤波数据。

3.1 zlib 头部的两个字节别小看

zlib 头是两个字节,第一个字节的低 4 位是压缩方法(CM),PNG 里固定是 8,表示 DEFLATE。第一个字节的高 4 位是窗口大小(CINFO),表示 LZ77 滑动窗口的大小,通常是 7,对应 32KB 窗口。第二个字节是标志位,最低位 FCHECK 要满足一个校验条件:把头两个字节当成 16 位大端整数,要能被 31 整除。

硬件里这个校验可以做,也可以跳过,因为实际图片基本都是合法的。但如果你想做健壮的解码器,建议加上,逻辑不复杂,就是一个模 31 的余数判断。我第一版跳过了,后来遇到一个损坏的文件,解码器直接跑飞,加了校验之后至少能优雅报错。

3.2 DEFLATE 的三种块类型

DEFLATE 数据是按"块"组织的,每个块开头有 3 位头信息:1 位 BFINAL 表示是不是最后一块,2 位 BTYPE 表示块类型。BTYPE 有三种:

  • 00:不压缩存储。数据直接按字节跟在头后面,长度由 LEN 和 NLEN 两个字段给出。这种块最好处理,直接搬运。
  • 01:固定 Huffman 编码。码表是预先定义好的,不用动态生成,译码逻辑简单。
  • 10:动态 Huffman 编码。码表在块开头动态给出,要先解析码表再译码。这是最复杂的一种,也是实际压缩里最常用的。

我的实现里三种都支持了,但调试顺序是:先跑通 00,再跑 01,最后啃 10。这个顺序很重要,因为 00 和 01 能帮你验证后面的 LZ77 和输出逻辑,把问题隔离在 Huffman 译码这一块。

3.3 Huffman 译码的硬件实现思路

Huffman 码是变长码,译码的核心是"逐位读入,边读边匹配码表"。软件里用二叉树或者查表都行,硬件里我用了两级查表加逐位回退的方案。

具体做法是:先读固定位数(比如 9 位)去查一张主表,如果主表里命中的码字长度不超过 9 位,直接输出符号;如果命中一个"需要更多位"的标记,就再读若干位查副表。这样大多数短码字一次查表就能出结果,只有长码字才需要二次查表,兼顾了速度和资源。

动态 Huffman 的码表解析是另一个难点。块开头会给出码长序列,用游程编码压缩过(比如连续多个相同码长用 16、17、18 这几个特殊符号表示)。硬件里要先解出完整的码长数组,再根据码长生成规范 Huffman 码表。这一步我用了一个小状态机,把码长数组存在 BRAM 里,然后按规范算法逐符号计算码字。

提示:规范 Huffman 码表的生成有个固定套路——先统计每个码长的符号个数,算出每个码长的起始码字,然后按符号顺序分配。这个算法在 RFC 1951 里有详细描述,硬件实现时建议先把软件版本写对,再逐行翻译成 Verilog,不要直接上手写 RTL。

3.4 LZ77 滑动窗口的 BRAM 实现

LZ77 的核心是一个 32KB 的滑动窗口。译码时遇到"字面量"就直接输出该字节并写入窗口;遇到"长度-距离对"就从窗口里回退 distance 个位置,复制 length 个字节输出,同时把这些字节也写入窗口。

32KB 用 BRAM 实现,写地址是一个循环递增的指针,读地址是"当前写指针减去 distance"。这里有个细节:复制长度可能超过 distance,也就是复制的数据里包含刚刚写进去的内容,这叫"重叠复制"。硬件里必须一个字节一个字节地复制,不能并行搬,否则会读到还没写入的数据。我一开始想用突发读来提高吞吐,结果重叠复制场景直接出错,后来改成逐字节状态机才稳定。

窗口大小 32KB 对应 BRAM 深度 32768、位宽 8,这个配置在中端 FPGA 上一般能放下。如果你的器件 BRAM 紧张,可以考虑把窗口缩小,但要注意 zlib 头里的 CINFO 字段会声明窗口大小,缩小窗口可能导致某些图片解不了。

4. 滤波反变换与像素重组:从滤波字节到真实像素

DEFLATE 解压出来的数据还不是像素,而是"滤波后的扫描行数据"。PNG 在压缩前对每一行做了滤波,目的是提高压缩率。解码时必须把滤波逆向还原,才能得到真实像素值。这一步逻辑不算特别复杂,但边界条件多,容易出错。

4.1 五种滤波类型和它们的意图

每一行的开头有一个字节表示滤波类型,取值 0 到 4:

  • 0 None:不滤波,直接就是原始数据。
  • 1 Sub:当前字节减去左边像素的对应字节。
  • 2 Up:当前字节减去上一行同位置字节。
  • 3 Average:当前字节减去左边和上边像素的平均值。
  • 4 Paeth:当前字节减去一个基于左、上、左上三个像素的预测值。

滤波的目的是让相邻像素的差值变小,这样 Huffman 编码时更容易出现重复的小数值,压缩率更高。解码时就是反过来,把预测值加回去。

4.2 逐字节还原的状态机设计

滤波还原必须逐字节进行,因为每个字节的还原都依赖左边和上边的已还原值。硬件里我用了一个行缓冲:保存上一行还原后的像素,当前行还原时同时访问左边像素(当前行缓冲)和上边像素(上一行缓冲)。

对于真彩色图片,每个像素有 R、G、B 三个字节,滤波是按字节做的,不是按像素做的。也就是说,Sub 滤波时,R 减的是左边像素的 R,G 减的是左边像素的 G,以此类推。这个细节如果搞错,颜色会整体偏移。我在调试时用了一张纯色渐变图,一眼就能看出颜色对不对,比用复杂图片调试快得多。

Paeth 滤波的预测函数稍微绕一点,它要在左、上、左上三个值里选一个"最接近"的。具体算法是计算三个预测值,然后选差值绝对值最小的那个。硬件里就是几个加减和比较,逻辑不复杂,但要小心有符号数的处理。

4.3 位深小于 8 时的解包

如果图片位深是 1、2、4,一个字节里会打包多个像素。比如位深 4 时,一个字节里有两个像素,高 4 位是第一个,低 4 位是第二个。解包时要按位深提取,然后根据颜色类型决定是查调色板还是直接当灰度值。

索引色图片还要查 PLTE 调色板,把索引值转成 RGB。调色板最多 256 项,每项 3 字节,用一块小 BRAM 存就行。查表逻辑很简单,但要注意调色板可能不足 256 项,越界索引要处理。

注意:位深和颜色类型的组合是有限制的。比如颜色类型 2(真彩色)只允许位深 8 或 16,颜色类型 3(索引色)只允许位深 1、2、4、8。解码器在 IHDR 阶段就要校验这些组合,不合法的直接报错,不要硬着头皮往下解。

5. 10 套工程源码的分层设计与选型逻辑

标题里说的"10 套工程源码",不是简单复制粘贴改改参数,而是按不同应用场景和技术难度做了分层。每一套解决一个具体问题,接口和资源占用都不一样。下面说说这个分层是怎么想的,以及每套适合什么场景。

5.1 从仿真验证到上板显示的递进

第一层是纯仿真工程,不涉及任何外部接口,输入是一段预先把 PNG 文件转成的十六进制文本,通过 testbench 喂给解码器,输出像素写到文件里,再用脚本转成图片对比。这一层的价值是让你在没有硬件的情况下把解码逻辑调通,波形看得清清楚楚。

第二层是接 ROM 的工程,把一张小尺寸 PNG 的字节流烧进片上 ROM,解码后直接送 VGA 或者 HDMI 显示。这一层引入了时序约束和显示接口,但图源是固定的,适合验证解码器在真实时钟下的行为。

第三层是接 SD 卡的工程,通过 SPI 读 SD 卡上的 PNG 文件,经过文件系统解析后送解码器。这一层引入了存储接口和文件系统,复杂度上了一个台阶,但也是最接近实际产品的形态。

第四层是接以太网的工程,把网络传来的 PNG 数据流实时解码。这一层对数据流的缓冲和背压处理要求最高,因为网络速率和显示速率不匹配,必须有足够的 FIFO 做缓冲。

5.2 资源占用和器件选型的对应关系

不同层次的工程对 FPGA 资源的要求差别很大。我整理了一个大致的对应关系:

工程层次主要资源消耗建议器件档次典型 LUT 占用
纯仿真无任意3000-5000
ROM 显示解码器 + 显示接口入门级4000-6000
SD 卡解码器 + SPI + 文件系统中端6000-9000
以太网解码器 + MAC + 大容量 FIFO中高端8000-12000

这个数字是粗略估计,实际占用跟你的图片尺寸、色深、是否支持隔行都有关系。我的建议是先用仿真工程确认逻辑正确,再根据你的实际器件资源决定上哪一层。

5.3 接口设计的统一与差异

为了让 10 套工程能共用同一个解码核心,我把解码器做成了一个标准接口的模块:输入是字节流加有效信号,输出是像素加行列坐标加有效信号。上层的差异全部封装在"数据源适配"这一层里——ROM 版就是从 ROM 读,SD 卡版就是从 SPI 读,以太网版就是从 FIFO 读。这样解码核心只写一遍,改数据源就行。

这个设计的好处是调试时可以把问题隔离:如果仿真工程能出正确图片,说明解码核心没问题,上板出问题就一定是数据源或者时序的问题。我实际调试时就是靠这个分层,把大部分问题都定位在了数据源侧,省了很多时间。

6. 调试 PNG 解码器的实战排查链路

PNG 解码器出错时,现象往往是"图片花了"或者"完全没输出",但原因可能藏在任何一个环节。我总结了一套从后往前的排查方法,能快速定位问题在哪一层。

6.1 先确认压缩流本身是否完整

第一步不是看解码器,而是确认你喂进去的压缩数据是不是完整的。PNG 的 IDAT 可能有多个块,必须按顺序全部拼接。我遇到过一次,SD 卡读取时只读了第一个 IDAT 块,后面的没读,结果解压到一半数据就没了,输出自然是残缺的。

验证方法很简单:在 PC 上用 Python 把 IDAT 数据提取出来,手动做 zlib 解压,看能不能成功。如果 PC 上都解不了,硬件肯定也不行。这一步能排除掉文件本身和数据搬运的问题。

6.2 用固定 Huffman 块做隔离测试

如果压缩流没问题,但解码结果不对,下一步是用固定 Huffman(BTYPE=01)的图片做测试。固定 Huffman 的码表是预定义的,不涉及动态码表解析,能把问题范围缩小到 Huffman 译码和 LZ77 这两块。

我一般会构造一张特别简单的图,比如 8×8 的纯色图,这种图压缩后数据量很小,波形上能一眼看完整个解码过程。如果纯色图能解对,再换渐变图,最后换真实照片。这个从简到繁的顺序能帮你快速定位是哪类数据触发了 bug。

6.3 逐行对比滤波还原结果

如果 Huffman 和 LZ77 都对了,但像素颜色不对,问题多半在滤波还原。这时候可以把解压后的滤波数据 dump 出来,在 PC 上用 Python 手动做滤波还原,和硬件输出逐行对比。

对比时重点看每一行的第一个像素。因为滤波还原是从左到右、从上到下进行的,第一个像素只依赖滤波类型,不依赖左边像素,是最容易验证的。如果第一个像素对了,后面的错了,那多半是行缓冲的读写地址有问题。

提示:调试滤波还原时,把每一行的滤波类型也 dump 出来,对照着看。有时候是滤波类型字节被当成了像素数据,导致整行偏移一个字节,这种错误在波形上很明显,但如果不看滤波类型就容易忽略。

6.4 时序和跨时钟域的坑

上板之后如果仿真对、实际不对,八成是时序或者跨时钟域的问题。解码器的数据源(比如 SD 卡 SPI)和显示接口往往在不同时钟域,FIFO 的读写指针同步没做好,就会出现偶发的数据错位。

我的经验是:所有跨时钟域的信号都用两级触发器同步,FIFO 用厂商提供的异步 FIFO IP,不要自己手写。手写异步 FIFO 在仿真里可能看不出问题,上板后随着温度和电压变化,亚稳态就会冒出来。这个坑我在早期项目里踩过,后来一律用 IP,省心很多。

7. 工程源码的使用方式和二次开发建议

拿到 10 套源码之后,怎么用、怎么改,直接决定了你能不能把它变成自己的东西。这里说说我的建议。

7.1 先跑仿真再上板,不要跳步

很多人拿到源码第一件事就是上板,结果板子不亮就开始怀疑代码。正确的顺序是:先用仿真工程跑通,确认输出图片和原图一致;再上最简单的 ROM 显示工程,确认时序没问题;最后才上 SD 卡或者以太网工程。每一步都确认无误再往下走,出问题时才能快速定位。

仿真工程里我提供了 testbench 和对比脚本,跑完之后会自动生成解码后的图片,和原图做像素级对比。这个脚本能帮你省掉大量肉眼比对的时间。

7.2 按需裁剪功能来省资源

如果你的应用不需要隔行扫描、不需要 16 位色深、不需要透明度,完全可以把这些逻辑裁掉。解码器里这些功能都是独立的分支,裁掉之后资源占用能降不少。我建议先保留全部功能跑通,确认没问题后再逐项裁剪,每裁一项就重新验证一次,确保没裁错。

裁剪时要注意 IHDR 的校验逻辑也要同步改,否则遇到不支持的图片格式,解码器可能进入未定义状态。我的做法是:不支持的特性直接在 IHDR 阶段报错,输出一个错误标志,让上层决定怎么处理。

7.3 扩展方向:从单张解码到图库管理

这套工程目前是"解一张图"的形态。如果你要做图库,可以在外面套一层管理逻辑:维护一个图片索引表,根据索引从存储里读出对应的 PNG 数据,送进解码器。索引表可以存在 Flash 的固定位置,启动时读进 BRAM。

再进一步,如果要做图片切换动画,可以在解码器前面加一个双缓冲:一张图在显示的同时,另一张图在后台解码,解完之后切换。这样切换时不会闪屏。双缓冲的切换逻辑要注意时序,切换点最好选在帧消隐期,避免撕裂。

我在实际项目里还遇到过一个需求:图片需要缩放显示。这个可以在解码输出后接一个缩放模块,用双线性插值把像素重采样到目标尺寸。缩放模块和解码器之间用一个 FIFO 解耦,因为两者的吞吐率不一定匹配。这部分逻辑我在另一套工程里有实现,思路和这里的数据流控制是一致的。

最后分享一个我在调试时养成的小习惯:每次改完代码,先跑仿真对比脚本,确认输出图片的 MD5 和原图一致,再上板。这个习惯帮我挡掉了至少一半的低级错误,尤其是改地址计算或者状态机跳转的时候,肉眼很难发现的问题,MD5 一比对就露馅了。

返回列表