news 2026/9/30 6:33:24

纯Verilog实现FPGA硬解PNG:DEFLATE与Huffman解码实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
纯Verilog实现FPGA硬解PNG:DEFLATE与Huffman解码实战

1. 项目缘起与整体设计思路

1.1 为什么要在FPGA里硬解PNG

做图像处理的朋友大概率都遇到过这个场景:上位机或者摄像头给过来一张PNG图片,想在FPGA内部直接参与后续的缩放、叠加、滤波等流水线处理,结果发现PNG是压缩格式,没法像BMP那样直接按像素读。常规做法是先在PC端把PNG转成RAW或者BMP再喂给FPGA,但这样一来整个系统就离不开上位机,产品化的时候很别扭。

PNG解码本身不是什么新课题,zlib、libpng这些库在软件端已经非常成熟。但把PNG解码搬到FPGA上,用纯Verilog从零实现,难点集中在三块:第一是DEFLATE解压,涉及Huffman变长码和LZ77滑动窗口回溯,天然带反馈依赖,跟FPGA擅长的流水线思路相冲;第二是滤波反演,PNG有五种滤波类型,逐像素依赖前一个像素,必须串行处理;第三是内存带宽,解码中间数据量比原始压缩数据大几十倍,怎么用有限的片上RAM配合外部DDR把数据流转起来,是决定方案能不能落地的关键。

这个项目的定位很明确:给需要把PNG解码集成进FPGA图像处理链路的工程师一套可直接复用的纯Verilog方案,不依赖任何软核、不调用厂商IP,从比特流解析到最终像素输出全部自己实现。适合有一定Verilog基础、做过图像采集或显示项目、想深入理解压缩格式硬件解码的开发者。哪怕你只是想搞明白Huffman解码在硬件里到底怎么落地,这套代码也值得啃一遍。

1.2 整体架构怎么切分

整个解码链路我按数据流方向切成四级流水,每一级职责单一,接口用简单的valid/ready握手,方便单独仿真和替换。

第一级是PNG文件解析与块提取。PNG文件由8字节固定头加若干数据块(chunk)组成,关键块有IHDR(图像头)、PLTE(调色板,索引色才有)、IDAT(压缩数据,可能多个)、IEND(结束)。这一级负责扫描块结构,把IHDR里的宽高、位深、颜色类型、隔行方式提取出来,把多个IDAT的数据拼成一条连续的压缩码流写进外部DDR的输入缓冲区。

第二级是DEFLATE解压。这是最重的一块,内部再分Huffman解码、LZ77回溯、输出组装三个子模块。输入是压缩码流,输出是滤波后的原始扫描行数据。这里我用的是双Huffman表(literal/length表和distance表)配合一个深度足够的滑动窗口缓存。

第三级是滤波反演。PNG每行数据在压缩前会做滤波,类型有None、Sub、Up、Average、Paeth五种。反演时必须按行顺序、按像素顺序串行做,因为Sub依赖左边像素、Up依赖上一行同位置像素、Paeth还依赖左上角像素。这一级把滤波后的数据还原成真实像素值。

第四级是像素格式转换与输出。根据颜色类型(灰度、真彩、索引、带Alpha等)和位深(1/2/4/8/16),把像素组装成统一的RGB888或RGBA8888格式,写入输出帧缓存,供后续模块读取。

四级之间用DDR做数据缓冲,片上只保留当前行和上一行的必要数据。这样做的原因是PNG解码的中间数据量远大于片上BRAM容量,比如一张1024x768的真彩图,一行原始数据就是3KB,加上滤波需要的上一行,再算上滑动窗口,片上根本放不下,必须借外部存储。

1.3 方案选型背后的取舍

有人会问,为什么不直接用Xilinx或Intel的Huffman IP?原因有两个:一是这类IP通常绑定特定器件系列,换平台就得重来;二是PNG的DEFLATE有它自己的细节,比如动态Huffman表的码长限制、距离码的额外位,通用IP不一定完全匹配。纯Verilog实现虽然工作量大,但可移植性拉满,从低端Artix到高端Kintex,甚至国产FPGA都能跑。

另一个取舍是滑动窗口放片上还是放DDR。放片上用BRAM实现,访问延迟低,但窗口大小受BRAM容量限制,PNG的LZ77窗口最大32KB,用BRAM实现32KB窗口在中等规模FPGA上可行,但会占用大量BRAM资源。放DDR则容量无忧,但每次回溯都要读DDR,延迟高、带宽压力大。我的做法是折中:窗口用片上BRAM实现,但只缓存最近若干KB,超出部分回退到DDR,通过地址映射统一管理。这样既保证了常见情况下的低延迟,又不会因为窗口太大把BRAM吃光。

2. 核心细节解析与实操要点

2.1 PNG块结构解析的硬件实现

PNG文件头是固定的8字节:89 50 4E 47 0D 0A 1A 0A。硬件里用一个8字节的移位寄存器逐字节比对,全部匹配才进入块解析状态。块的结构是:4字节长度(大端)+ 4字节类型 + 数据 + 4字节CRC。长度和类型都要做字节序转换,因为PNG是大端,而FPGA内部通常按小端处理。

解析状态机我设计成五个状态:IDLE、READ_LEN、READ_TYPE、READ_DATA、READ_CRC。每个块解析完根据类型分派:IHDR把宽高、位深、颜色类型、压缩方法、滤波方法、隔行方式锁存到配置寄存器;PLTE把调色板数据写入片上RAM;IDAT把数据流写入DDR输入缓冲,同时维护一个写指针;IEND触发解码完成标志。

这里有个容易踩的坑:IDAT可能有多个,它们的数据在逻辑上是连续的,必须按顺序拼接。我在实现时用一个连续的DDR地址空间,每收到一个IDAT就接着上次的写指针继续写,不重新对齐。另外CRC校验在硬件里做完整计算比较费资源,如果对数据完整性要求不是极致,可以只做长度和类型的合法性检查,CRC跳过或者只做抽样校验。我在工程里提供了带CRC和不带CRC两个版本,按需选用。

注意:IHDR必须是第一个块,且只能出现一次。解析时如果第一个块不是IHDR,直接报错退出,不要继续往下跑,否则后面全是垃圾数据。

2.2 Huffman解码的硬件落地

DEFLATE的Huffman解码是变长码,每个符号的码长不固定,硬件里没法像软件那样用查表循环。我的做法是构建一棵规范Huffman树,然后用逐位比较的方式解码。具体来说,先把码表按码长排序,生成每个码长对应的起始码值和符号索引,解码时从码流里逐位读入,每读一位就检查当前累积的码值是否落在某个码长的有效范围内。

为了提速,我用了一个两级查表结构:第一级用固定位数(比如9位)做快速查表,如果码长不超过9位,一次就能解出符号;如果超过9位,再走第二级慢速路径逐位比较。实测下来,大部分符号的码长都在9位以内,所以平均每个符号1到2个时钟周期就能解出,吞吐率足够支撑1080p级别的图像解码。

Huffman表本身在动态模式下是从码流里读出来的,需要先解析码长数组,再重建码表。码长数组本身也是Huffman编码的,用的是固定的码长码表。这一层嵌套容易绕晕,我的建议是先把固定Huffman表的解码跑通,再上动态表。固定表只有两张,码长固定,调试起来直观得多。

LZ77回溯部分,长度和距离都有额外位要读。长度码29到31对应的是带额外位的长匹配,距离码30到31也是。额外位的读取要在Huffman解码之后紧接着做,不能漏。我见过有人在这里漏读额外位,导致后续码流全部错位,解出来的图像花屏,排查了半天才发现是少读了几位。

2.3 滤波反演的串行处理技巧

滤波反演必须严格按行、按像素顺序做,因为每种滤波都依赖前一个像素或上一行像素。硬件里用一个行缓冲存上一行反演后的像素,当前行反演时同时读上一行对应位置和左边已反演的像素。

五种滤波的计算方式不同:None直接输出;Sub加左边像素;Up加上边像素;Average加左边和上边的平均;Paeth用左边、上边、左上三个像素做预测。Paeth的预测函数稍微复杂,但硬件里就是几个加法和比较,组合逻辑能搞定。

位深小于8的时候,一个字节里打包了多个像素,反演前要先拆包。比如4位灰度,一个字节两个像素,反演要按像素粒度做,做完再打包回去或者直接输出。这里容易出错的是行尾对齐:PNG每行数据按字节对齐,如果一行像素数乘以位深不是8的倍数,末尾会补位,反演时要跳过这些补位,不能当成有效像素。

实操心得:滤波反演的行缓冲建议用双口BRAM实现,一个口读上一行,一个口写当前行,读写可以同时进行。如果只用单口RAM,读写要分时,吞吐率会掉一半。

3. 实操过程与核心环节实现

3.1 工程目录结构与模块划分

十套工程源码我按平台和功能做了分类,目录结构统一如下:

png_decoder/ ├── rtl/ │ ├── png_parser.v # 块解析 │ ├── inflate_top.v # DEFLATE解压顶层 │ ├── huffman_dec.v # Huffman解码 │ ├── lz77_window.v # 滑动窗口 │ ├── filter_reverse.v # 滤波反演 │ ├── pixel_convert.v # 像素格式转换 │ └── ddr_ctrl.v # DDR读写控制 ├── sim/ │ ├── tb_png_parser.v │ ├── tb_inflate.v │ └── tb_top.v ├── con/ │ └── top.xdc # 引脚约束 └── doc/ └── register_map.md # 寄存器映射说明

十套工程的区别主要在顶层封装和DDR控制器:有的用Xilinx MIG,有的用Intel UniPHY,有的用国产器件的DDR控制器,还有两套是纯片上BRAM版本,适合小图或者低端器件。每套工程的RTL核心是一样的,换平台只需要替换DDR控制器和约束文件。

3.2 关键参数计算与配置

DDR输入缓冲的大小要按最大压缩比估算。PNG的压缩比通常在2:1到10:1之间,极端情况可能更高。假设最大支持1920x1080真彩图,原始数据约6MB,按10:1压缩比算,压缩数据约600KB。输入缓冲分配1MB足够。输出帧缓存按原始数据算,1920x1080x4字节(RGBA)约8MB,分配16MB留余量。

滑动窗口大小我配置成32KB,这是DEFLATE规范允许的最大值。片上BRAM实现32KB窗口,在Xilinx 7系列上大约占16个36Kb BRAM,对中等规模器件可以接受。如果BRAM紧张,可以降到8KB,但压缩率会受影响,因为距离超过8KB的匹配就找不到了。

Huffman解码的查表深度我设成9位,对应512个表项。两级查表的第二级用逐位比较,最多支持15位码长(DEFLATE规范上限)。这个配置在Artix-7上综合后大约占2000个LUT,频率能跑到150MHz以上。

3.3 仿真验证流程

仿真我分三步走:先用小图(比如16x16)验证功能正确性,再用中等图(256x256)验证吞吐率,最后用大图(1920x1080)验证DDR带宽和整体时序。

测试激励的生成方法:用Python的PIL库把BMP转成PNG,同时导出原始像素数据作为参考。仿真时把PNG文件读进testbench,解码输出跟参考数据逐像素比对。Icarus Verilog和Vivado自带的仿真器我都试过,Icarus启动快、适合小规模调试,Vivado仿真器对时序和DDR模型支持更好。

# Icarus Verilog 编译仿真示例 iverilog -o tb_top tb_top.v ../rtl/*.v vvp tb_top

比对脚本我用Python写,读仿真输出的像素文件和参考文件,逐字节比较,输出差异位置和数量。差异为0才算通过。

注意:仿真时DDR模型要加延迟,不能设成0延迟,否则综合后的时序可能对不上。我一般设读延迟20个周期、写延迟10个周期,接近实际DDR控制器的表现。

4. 常见问题与排查技巧实录

4.1 解码花屏的几种典型原因

花屏是最常见的现象,原因五花八门。我整理了一个排查表,按出现概率从高到低排列:

现象可能原因排查方法
整幅图花屏Huffman表解析错误检查码长数组读取顺序
图像上半部分正常下半部分花滑动窗口溢出检查窗口地址回绕逻辑
图像有规律条纹滤波反演行对齐错误检查行尾补位处理
颜色错乱像素格式转换错误核对颜色类型和位深配置
图像偏移隔行扫描未处理检查隔行方式配置

Huffman表解析错误是最隐蔽的,因为码流本身没报错,只是解出来的符号不对。我的排查方法是把解出的符号序列打印出来,跟软件端zlib的解码结果比对,看从第几个符号开始分叉,分叉点往前就是问题所在。

4.2 DDR带宽不够导致的丢帧

大图解码时DDR带宽是瓶颈。输入压缩数据读、滑动窗口回溯读写、输出像素写,三股流量叠加。我实测1920x1080真彩图解码,DDR有效带宽需要约400MB/s才能实时。如果DDR控制器配置的位宽或频率不够,就会出现丢帧或者解码卡顿。

优化手段有几个:一是滑动窗口尽量放片上,减少DDR回溯;二是输入压缩数据用突发读,一次读一大块缓存到片上FIFO;三是输出像素用突发写,攒够一行再写DDR。这三招下来,DDR带宽需求能降一半左右。

4.3 时序不收敛的解决思路

Huffman解码的组合逻辑比较深,容易成为时序瓶颈。我的做法是把逐位比较拆成流水线,每级只处理一位,虽然延迟增加,但频率能上去。另外查表RAM用分布式RAM而不是BRAM,读延迟更小。

滤波反演的Paeth预测函数组合逻辑也不浅,我在中间插了一级寄存器,把三个像素的读取和预测计算分开,时序明显改善。综合时如果还差一点,可以给关键路径加max_delay约束,让工具重点优化。

实操心得:先保证功能正确,再优化时序。我见过有人一上来就追求高频,结果功能都没跑通,调时序纯属浪费时间。功能对了,时序慢慢磨总能收敛。

5. 十套工程的差异化说明与选用建议

5.1 按平台分类的工程清单

十套工程我按平台和存储方案做了区分,方便不同背景的开发者直接选用:

工程编号平台DDR方案适用场景
01Xilinx Artix-7MIG通用入门
02Xilinx Kintex-7MIG高性能
03Xilinx Zynq-7000PS DDR软硬协同
04Intel Cyclone IVUniPHY低成本
05Intel Cyclone VUniPHY中端
06国产安路自研控制器国产化
07纯片上BRAM无DDR小图低端
08纯片上BRAM无DDR小图低端
09Xilinx Artix-7MIG带CRC校验
10Xilinx Kintex-7MIG带CRC校验

01到06是带DDR的完整版,支持大图。07和08是纯片上版本,只支持小图(比如256x256以内),适合BRAM资源少或者不想折腾DDR的场景。09和10在01和02基础上加了CRC校验,对数据完整性要求高的选这两套。

5.2 移植到新平台需要改什么

移植的核心工作是替换DDR控制器和约束文件。RTL部分跟平台无关,只要DDR控制器提供标准的读写接口(地址、数据、使能、应答),直接对接就行。我定义的DDR接口是简单的类SRAM接口,读写各一套,移植时写个适配层即可。

约束文件要改时钟频率、引脚分配、时序例外。如果新平台的BRAM结构不同,滑动窗口的RAM推断可能要调整,比如改用厂商提供的RAM IP或者调整读写模式。这些在doc目录的移植指南里有详细说明。

5.3 技术支持与代码获取

十套工程的源码我打包在一起,附带仿真脚本、测试图片和比对工具。代码注释我写得比较密,关键状态机和计算逻辑都有说明。遇到问题可以先看doc目录的FAQ,大部分常见问题都有记录。

如果FAQ解决不了,可以把仿真波形和日志发过来,我帮忙定位。技术支持的范围包括:编译报错、仿真不通过、时序不收敛、移植适配。不包含:帮你改需求、帮你写论文、帮你做非PNG格式的解码。

我在实际调试这套代码的过程中,最大的体会是:PNG解码的难点不在单个模块,而在模块之间的数据流配合。Huffman解出的符号怎么喂给LZ77,LZ77的输出怎么对齐到滤波反演的行边界,滤波后的数据怎么按像素格式打包,这些接口如果没定义清楚,单个模块仿真都过,连起来就花屏。我的建议是先把接口时序图画出清楚,再动手写代码,能省很多返工时间。另外,小图调通再上大图,功能调通再优化时序,这个顺序别乱,乱了就是给自己找麻烦。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 6:32:53

VMware搭建Windows Server:DNS与IIS Web站点配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:32:38

Linux面试题大全:覆盖命令、权限、进程、网络与Shell脚本

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:30:39

Vue3中安全获取当前路由的四种方法与实战选型指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:30:12

面积法到消点法:几何定理机器证明的底层逻辑与解题实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:29:42

嵌入式内存管理实战:从malloc/free到RTOS内存池与泄漏排查

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/30 6:28:24

Keil MDK下载安装配置教程:STM32嵌入式开发环境搭建与避坑

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华