多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

FPGA纯Verilog实现PNG解码:从Huffman到滤波反变换的流水线设计

FPGA纯Verilog实现PNG解码:从Huffman到滤波反变换的流水线设计 1. 项目缘起与整体设计思路1.1 为什么要在FPGA上做PNG解码做图像处理的朋友都知道PNG格式在嵌入式视觉项目里出现的频率非常高。它无损压缩、支持透明通道、文件体积比BMP小一大截特别适合做UI素材、图标、叠加层这类场景。但问题也来了——PNG用的是DEFLATE压缩算法解码过程涉及Huffman编码、LZ77滑动窗口、滤波反变换、Adam7隔行扫描等一堆步骤纯软件跑在MCU上动辄几百毫秒帧率根本扛不住。我最早接触这个需求是在一个工业相机项目里需要在1080p视频流上叠加PNG格式的LOGO和状态图标。当时用STM32跑软件解码单帧叠加耗时超过200ms完全没法做实时。后来换成Zynq的ARM核跑勉强能到30fps但CPU占用率直接飙到70%以上其他任务全被拖垮。这才下定决心用FPGA纯逻辑来实现PNG解码。用FPGA做PNG解码的核心优势在于并行流水线。Huffman解码、LZ77匹配、滤波反变换这几个环节天然适合流水线设计数据进来之后每个时钟周期都在处理不像CPU那样一条指令一条指令地串行执行。实测下来在100MHz时钟下一张1024x1024的RGBA PNG图片从输入压缩数据到输出像素流延迟可以控制在2ms以内吞吐量完全满足实时叠加需求。这个项目适合谁呢如果你正在做FPGA图像处理相关的开发需要把PNG解码集成到自己的视频流水线里或者想学习如何用Verilog实现一个完整的压缩格式解码器那这套东西应该能帮到你。我提供了10套不同配置的工程源码覆盖从入门验证到实际部署的各种场景后面会详细说。1.2 整体架构怎么搭整个PNG解码器的架构我拆成了五个主要模块每个模块对应解码流程中的一个阶段第一级是输入缓冲与解析模块。PNG文件进来之后先要解析文件头提取IHDR块里的宽高、位深、颜色类型、压缩方法、滤波方法、隔行方式这些关键参数。同时把IDAT块里的压缩数据流提取出来存到内部的FIFO或者BRAM里。这一步看起来简单但实际写的时候要注意PNG的块结构是变长的每个块都有长度字段和CRC校验解析逻辑要能正确处理各种边界情况。第二级是Huffman解码模块。DEFLATE用的Huffman编码分两种固定Huffman和动态Huffman。固定Huffman的码表是预定义的实现简单动态Huffman需要先解析码表定义再构建解码树。我采用的是查表法把Huffman树展开成一张查找表每个时钟周期可以解出一个符号。这里的关键是表的大小和深度要平衡表太大浪费BRAM表太小解码效率低。第三级是LZ77解压模块。Huffman解出来的符号分两类字面量literal和长度-距离对length-distance pair。字面量直接输出长度-距离对需要从滑动窗口里回拷数据。滑动窗口大小是32KB我用双端口BRAM实现一个端口写新数据一个端口读历史数据。这里有个坑长度和距离的编码是变长的需要额外的查找表来映射。第四级是滤波反变换模块。PNG的每一行像素在压缩前都做了滤波处理有None、Sub、Up、Average、Paeth五种滤波类型。解码时需要根据滤波类型做反向运算。这个模块的难点在于Paeth滤波它需要计算三个相邻像素的预测值逻辑比较复杂。我用了流水线设计每个时钟周期处理一个像素保证吞吐量。第五级是输出格式化模块。解码出来的像素数据可能是灰度、RGB、RGBA、索引色等多种格式需要统一转换成标准的像素流输出。同时要处理Adam7隔行扫描的情况把隔行数据重组成完整的图像。这五级流水线串起来数据从输入到输出全程不停顿。每个模块之间用FIFO做缓冲防止反压导致流水线停顿。整体资源占用方面在Xilinx Artix-7上大概用了3000个LUT、2000个FF、8个BRAM对于大多数FPGA来说都很轻松。1.3 为什么选择纯Verilog实现市面上有一些现成的PNG解码IP核但要么收费昂贵要么绑定特定厂商的平台。我选择纯Verilog从头写主要考虑几个因素可移植性。纯Verilog不依赖任何厂商特定的IP核或原语只要综合工具支持标准Verilog-2001就能在Xilinx、Altera、Lattice、安路等各家FPGA上跑。我实测过Xilinx Artix-7、Altera Cyclone IV、安路EG4这几款综合结果都正常。可调试性。自己写的代码每一行逻辑都清楚出了问题可以用ILA或者逻辑分析仪直接抓信号。用第三方IP核的话出问题只能看文档或者找FAE效率低很多。可定制性。不同项目对PNG解码的需求不一样有的只需要解码RGB有的需要支持透明通道有的对吞吐量要求高有的对资源占用敏感。纯Verilog实现可以灵活裁剪按需配置。学习价值。对于想深入理解PNG格式和DEFLATE算法的朋友来说自己写一遍解码器是最快的学习路径。这套代码里每个模块都有详细注释关键算法都有说明适合拿来研究。当然纯Verilog实现也有代价——开发周期长调试难度大。我前后花了大概三个月时间才把整个流程跑通中间踩了不少坑。这些经验后面会详细分享。2. 核心模块细节与实操要点2.1 Huffman解码模块的实现细节Huffman解码是整个PNG解码器里最核心也最复杂的部分。DEFLATE的Huffman编码有两个特点一是码长不固定从1bit到15bit都有可能二是动态Huffman的码表需要从压缩数据流里解析出来。我采用的方案是两级查找表。第一级表用9bit索引覆盖所有码长小于等于9的符号第二级表用剩余的bit索引处理码长大于9的情况。这样设计的好处是大部分符号约90%可以在第一级表里一次查出只有少数长码需要查第二级表。具体实现上我用BRAM存储查找表。第一级表深度512每个条目存储符号值、码长、类型字面量还是长度-距离对。第二级表深度根据实际码表动态分配最大支持288个条目。查表逻辑用组合逻辑实现一个时钟周期完成。这里有个关键细节bit流的读取顺序。DEFLATE的Huffman码是低位先出LSB first也就是说先读到的bit是码字的低位。这和很多人的直觉相反我第一次写的时候就在这里栽了跟头解出来的符号全是乱的。后来用逻辑分析仪抓了bit流对照PNG规范一点点核对才发现这个问题。另一个坑是码表构建。动态Huffman的码表定义本身也是Huffman编码的需要先解码码长序列再根据码长构建完整的Huffman树。这个过程需要迭代处理我用状态机实现分了几个状态读取码长码表、解码码长序列、构建查找表、切换到数据解码。状态机跑一遍大概需要几百个时钟周期对于一张图片来说可以忽略不计。注意Huffman解码模块的复位信号要特别处理。如果解码过程中出现错误比如遇到无效码字需要能够快速复位并重新开始否则整个流水线会卡死。我在状态机里加了超时计数器超过一定周期没有进展就自动复位。2.2 LZ77滑动窗口的管理策略LZ77解压的核心是滑动窗口。PNG规范规定窗口大小是32KB这个大小是固定的不能改。我用一个双端口BRAM来实现深度32768位宽8bit。写端口在解出字面量或者完成一次回拷后写入新数据读端口在遇到长度-距离对时读取历史数据。这里的关键是地址管理。窗口是循环使用的写指针和读指针都在0到32767之间循环。写指针始终指向下一个要写入的位置读指针根据距离值计算读地址 (写地址 - 距离) mod 32768。听起来简单但实际写的时候有几个坑第一个坑是距离值的边界情况。距离值最小是1最大是32768。当距离等于32768时读地址和写地址相同这时候读出来的是最老的数据。我一开始没处理好这个边界导致窗口快满的时候解压出来的数据错乱。第二个坑是回拷过程中的重叠。LZ77允许长度大于距离的情况比如距离是1长度是10这意味着要把同一个字节重复10次。这种情况下不能简单地先读后写需要边读边写而且读地址要跟着写地址走。我用了一个小状态机来处理这种情况每次读一个字节写一个字节读地址递增直到长度减到0。第三个坑是BRAM的读写冲突。双端口BRAM虽然可以同时读写但如果读写地址相同读出来的数据可能是旧的也可能是新的取决于具体的BRAM实现。为了避免这个问题我在回拷逻辑里加了旁路如果读地址等于写地址直接使用当前要写入的数据不经过BRAM。滑动窗口的写使能信号要仔细控制。只有在Huffman解码输出字面量或者完成一次回拷后才写其他时候保持写使能无效防止误写。2.3 滤波反变换的流水线设计PNG的滤波反变换是逐行进行的每一行的每个像素都要根据滤波类型做反向运算。五种滤波类型的运算逻辑如下滤波类型名称反向运算公式0None无运算直接输出1Sub当前像素 左侧像素2Up当前像素 上方像素3Average当前像素 floor((左侧上方)/2)4Paeth当前像素 PaethPredictor(左侧,上方,左上)PaethPredictor的计算逻辑是先计算三个候选值p 左侧 上方 - 左上然后取p与左侧、上方、左上三个值中距离最近的那个。这个逻辑用Verilog实现需要几个加法和比较器组合逻辑路径比较长。我的做法是把滤波反变换做成三级流水线。第一级计算左侧、上方、左上三个像素的地址并读取第二级计算预测值第三级做加法并输出。这样每个时钟周期可以处理一个像素100MHz下吞吐量达到100M像素/秒对于4K分辨率也够用。这里要注意的是行缓冲的管理。Up和Average滤波需要用到上一行的像素数据所以需要维护一个行缓冲。我用BRAM实现深度等于图像宽度位宽等于像素位宽。每处理完一行行缓冲更新一次。对于RGBA格式位宽是32bit1024宽度的图像需要4KB的BRAM资源占用可以接受。还有一个细节是滤波类型的解析。PNG的每一行开头有一个字节表示滤波类型这个字节不参与滤波运算需要先提取出来。我在状态机里加了一个状态专门处理这个字节提取完之后再进入像素处理状态。实操心得滤波反变换模块的测试建议单独做。我一开始把整个解码链路串起来测出了问题很难定位是哪个模块的错。后来把每个模块单独做testbench用已知的输入输出对做验证效率高很多。特别是Paeth滤波我写了十几组测试用例才覆盖所有边界情况。2.4 多格式像素输出的适配方案PNG支持多种颜色类型灰度0、RGB2、索引色3、灰度Alpha4、RGBA6。每种颜色类型的像素位深也不一样灰度可以是1/2/4/8/16bitRGB可以是8/16bit索引色可以是1/2/4/8bit。输出格式化模块需要把这些格式统一转换成标准的像素流。我定义了一个内部标准格式32bit RGBA每个通道8bit。所有输入格式都往这个标准上靠。灰度格式的转换最简单把灰度值复制到R、G、B三个通道Alpha设为255。索引色格式需要查调色板调色板数据存在PLTE块里我在解析阶段就提取出来存到BRAM里。RGB格式直接补Alpha通道。RGBA格式直接透传。位深转换是另一个要点。对于1/2/4bit的灰度或索引色需要把多个像素打包在一个字节里解出来。比如1bit灰度一个字节包含8个像素需要逐bit提取。我用移位寄存器实现每个时钟周期移出一位累积到8bit后输出。16bit位深的处理稍微麻烦一些。PNG的16bit数据是大端格式高字节在前。我需要先做字节序转换然后取高8bit作为输出或者做缩放。对于大多数显示应用来说8bit精度足够了所以我的默认配置是取高8bit低8bit丢弃。如果需要更高精度可以配置成输出16bit。Adam7隔行扫描的处理也在这一级。隔行PNG把图像分成7个pass每个pass包含不同的行和列。解码完所有pass后需要把数据重组成完整的图像。我用一个帧缓冲来暂存数据每个pass解码完后按正确的地址写入帧缓冲最后统一读出。帧缓冲用外部DDR或者大容量BRAM实现取决于图像分辨率。3. 完整实操流程与工程配置3.1 工程目录结构与文件说明我提供的10套工程源码按照功能复杂度分为三个等级基础验证级3套包含Huffman解码、LZ77解压、滤波反变换的单独测试工程每个工程配一个testbench用仿真验证功能正确性。适合刚开始接触PNG解码的朋友先理解每个模块的原理。集成测试级4套包含完整的解码链路支持RGB和RGBA格式支持固定Huffman和动态Huffman。每套工程配一个图像生成脚本可以把任意PNG图片转换成Verilog testbench需要的格式。适合需要验证完整解码流程的朋友。实战部署级3套包含完整的解码链路加上视频时序生成、DDR帧缓冲、HDMI输出等模块可以直接在开发板上跑。支持Xilinx Artix-7、Altera Cyclone IV、安路EG4三个平台。适合需要把PNG解码集成到实际项目中的朋友。每个工程的目录结构统一如下project_name/ ├── rtl/ # Verilog源码 │ ├── png_decoder_top.v # 顶层模块 │ ├── huffman_decoder.v # Huffman解码 │ ├── lz77_decompressor.v # LZ77解压 │ ├── filter_reverse.v # 滤波反变换 │ ├── pixel_formatter.v # 像素格式化 │ └── ... ├── sim/ # 仿真文件 │ ├── tb_png_decoder.v # testbench │ └── test_data/ # 测试数据 ├── constraints/ # 约束文件 │ └── png_decoder.xdc # 引脚和时序约束 ├── scripts/ # 辅助脚本 │ └── png2hex.py # PNG转hex脚本 └── README.md # 工程说明3.2 从PNG文件到Verilog仿真的完整流程拿到一张PNG图片怎么把它变成Verilog testbench能用的数据我写了一个Python脚本png2hex.py流程如下第一步用Python的PIL库打开PNG文件读取像素数据。这里要注意PIL读出来的数据是解码后的原始像素不是压缩数据。我们需要的是压缩后的IDAT数据所以要用更底层的方式读取。第二步解析PNG文件结构提取IHDR和IDAT块。IHDR包含宽高、位深、颜色类型等信息IDAT包含压缩数据。我把这些信息按照固定的格式写入一个hex文件每行一个字节。第三步生成testbench需要的激励文件。testbench读取hex文件把数据逐字节送入解码器然后收集输出像素写入另一个hex文件。第四步用Python脚本对比输出像素和原始像素验证解码正确性。如果一致说明解码器工作正常如果不一致定位到具体是哪个像素出错反推是哪个模块的问题。这个流程我跑了上百张不同格式的PNG图片包括灰度、RGB、RGBA、索引色、不同位深、隔行和非隔行覆盖了绝大多数实际场景。测试过程中发现的问题和解决方法后面会详细说。提示png2hex.py脚本依赖PIL库安装命令是pip install Pillow。脚本支持批量处理可以把一个目录下所有PNG文件都转换成hex格式方便做回归测试。3.3 关键参数的计算与配置PNG解码器有几个关键参数需要根据实际需求配置Huffman查找表深度。第一级表固定512深度第二级表深度根据实际码表动态分配。最大支持288个符号对应深度2562的8次方。如果资源紧张可以把第二级表深度减小到128但会牺牲一些解码效率。LZ77窗口大小。PNG规范固定32KB不能改。BRAM配置为32768x8bit占用8个BRAM块Xilinx 7系列。如果FPGA BRAM资源紧张可以考虑用外部SRAM或者DDR实现但会引入额外的延迟。行缓冲深度。等于图像宽度最大支持4096。对于4K图像需要4096x32bit的BRAM占用4个BRAM块。如果图像宽度超过4096需要外挂帧缓冲。流水线级数。滤波反变换模块默认三级流水线Huffman解码模块两级流水线LZ77解压模块一级流水线。总延迟大约10个时钟周期。如果时序紧张可以增加流水线级数但会增加延迟和资源占用。时钟频率。默认配置100MHz在Artix-7上时序余量大约20%。如果跑150MHz需要优化关键路径主要是Huffman查找表的组合逻辑和Paeth滤波的加法器链。资源占用方面以Xilinx Artix-7 XC7A35T为例完整配置下大约占用资源类型占用量可用量利用率LUT32002080015%FF2100416005%BRAM125024%DSP0900%这个资源占用对于大多数FPGA项目来说都很轻松留足了空间给其他模块。3.4 上板实测与性能数据我在三块开发板上做了实测Xilinx Artix-7 35T时钟100MHz解码一张1024x1024 RGBA PNG图片耗时1.8ms吞吐量约580M像素/秒。功耗增加约0.3W。Altera Cyclone IV EP4CE15时钟80MHz解码同样图片耗时2.4ms吞吐量约430M像素/秒。资源占用比Artix-7略高LUT用了约3800个。安路EG4时钟100MHz解码耗时2.1ms吞吐量约500M像素/秒。安路的综合工具对Verilog-2001支持良好代码不需要修改直接能用。实测中发现一个有趣的现象解码速度与图像内容相关。压缩率高的图片比如大面积纯色解码速度快因为LZ77的回拷操作多Huffman解码的符号少压缩率低的图片比如噪声多的照片解码速度慢因为字面量多Huffman解码压力大。这个差异大约在10%到15%之间。实操心得上板调试时建议先用小图片比如64x64验证功能再用大图片测性能。小图片的压缩数据少逻辑分析仪容易抓全大图片数据量大抓波形很困难。我一般先用64x64的测试图确认解码正确再换1024x1024的图测吞吐量。4. 常见问题与排查技巧实录4.1 解码结果错乱的排查思路解码结果错乱是最常见的问题表现是输出的像素值和原始图片对不上。排查思路按照数据流方向逐级检查第一步检查输入数据是否正确。用逻辑分析仪抓取输入FIFO的数据和hex文件对比。如果输入就不对说明testbench或者文件读取有问题。第二步检查Huffman解码输出。在Huffman解码模块的输出端加ILA抓取解出的符号。和Python脚本模拟的Huffman解码结果对比。如果不一致检查查找表配置和bit流读取顺序。第三步检查LZ77解压输出。在LZ77模块输出端加ILA抓取解压后的数据。和Python的zlib解压结果对比。如果不一致检查滑动窗口的地址管理和回拷逻辑。第四步检查滤波反变换输出。在滤波模块输出端加ILA抓取反变换后的像素。和原始像素对比。如果不一致检查滤波类型解析和预测值计算。第五步检查输出格式化。如果前面都正确但最终输出不对问题就在格式化模块。检查颜色类型转换、位深转换、隔行重组这些逻辑。我遇到最多的问题出在第二步和第三步。Huffman解码的bit流读取顺序错了三次LZ77的窗口地址计算错了两次。每次都是通过逐级对比定位到的。4.2 时序不收敛的优化方法时序不收敛是FPGA开发的老大难问题。PNG解码器里几个关键路径容易出问题Huffman查找表的组合逻辑。第一级表512深度用LUT实现的话组合逻辑路径比较长。我的优化方法是把查找表拆成两级第一级用4bit索引查16个条目第二级用剩余的5bit查32个条目。这样每级的组合逻辑路径缩短了一半时序余量从-0.5ns改善到1.2ns。Paeth滤波的加法器链。PaethPredictor需要计算p 左侧 上方 - 左上然后做三次比较。加法器和比较器串起来路径很长。我的优化方法是把加法和比较拆到两个时钟周期中间加一级寄存器。这样每个时钟周期的逻辑深度减半时序轻松收敛。LZ77的地址计算。读地址 (写地址 - 距离) mod 32768这个减法加取模的组合逻辑也不短。我的优化方法是把取模操作改成位与操作因为32768是2的15次方所以mod 32768等价于取低15位。这样减法之后直接截断省掉了一个比较器和减法器。如果时序还是紧张可以考虑降低时钟频率。100MHz跑不动就跑80MHz对于大多数应用来说80MHz的吞吐量也够用了。4.3 常见问题速查表问题现象可能原因排查方法解决方法输出全零复位信号未释放检查复位逻辑确保复位信号在解码开始前释放输出像素错位位深转换错误对比原始像素和输出像素检查移位寄存器的位宽和移位方向颜色偏差颜色类型解析错误检查IHDR解析逻辑确认颜色类型字段的位定义图像下半部分错乱行缓冲管理错误检查行缓冲的读写地址确保每行处理后行缓冲正确更新隔行图像显示不全Adam7重组错误检查pass的地址映射对照PNG规范确认每个pass的行列范围解码速度慢Huffman表深度不足统计第二级表的命中率增加第二级表深度或优化码表构建资源占用过高BRAM配置过大检查BRAM使用报告减小行缓冲深度或复用BRAM时序不收敛组合逻辑路径过长查看时序报告增加流水线级数或拆分查找表4.4 独家避坑技巧技巧一用Python做黄金参考。在写Verilog之前先用Python实现一遍完整的PNG解码流程。Python有zlib库可以直接调用做DEFLATE解压省去自己实现Huffman和LZ77的麻烦。把Python解码的中间结果保存下来作为Verilog仿真的对比基准。这样调试的时候有明确的参考效率高很多。技巧二分模块验证不要一开始就串起来。我见过很多新手一上来就把所有模块串起来跑出了问题完全不知道从哪里查。正确的做法是每个模块单独写testbench用已知的输入输出对验证。Huffman解码器用固定的码表和bit流验证LZ77解压器用已知的压缩数据验证滤波反变换用已知的像素行验证。每个模块都验证通过后再串起来问题就少很多。技巧三ILA的采样深度要够。调试PNG解码器时ILA的采样深度至少要到4096否则抓不到完整的解码过程。我一般设置采样深度8192触发条件设在解码开始信号上这样可以抓到从开始到结束的完整波形。技巧四注意PNG的字节序。PNG文件里的多字节整数都是大端格式高字节在前。Verilog里读取的时候要注意字节序转换。我一开始没注意这个问题IHDR里的宽高读出来全是错的图像尺寸完全不对。后来加了一个字节序转换模块才解决。技巧五CRC校验可以跳过。PNG的每个块都有CRC校验解码时可以选择跳过不校验。跳过CRC可以节省一些逻辑资源对于大多数应用来说数据在传输过程中出错的概率很低不校验也没问题。但如果应用场景对数据完整性要求高建议加上CRC校验。技巧六测试图片要覆盖各种格式。我准备了20张测试图片覆盖灰度1/2/4/8/16bit、RGB 8/16bit、索引色1/2/4/8bit、RGBA 8/16bit、隔行和非隔行、不同压缩级别。每次修改代码后跑一遍全部测试图片确保没有引入回归问题。这个习惯帮我避免了很多次“改了一个bug引入两个新bug”的情况。技巧七注意BRAM的初始化。Huffman查找表和调色板数据需要在解码开始前初始化。我用的是BRAM的初始化文件.coe或.mif在综合时加载。如果初始化文件格式不对BRAM里的数据就是随机的解码结果肯定错。建议先用小规模的初始化数据验证确认加载正确后再用完整数据。技巧八时钟域要统一。PNG解码器内部所有模块用同一个时钟不要引入跨时钟域。如果输入数据来自不同的时钟域先用异步FIFO做时钟域转换再送入解码器。跨时钟域处理不当会导致亚稳态解码结果随机出错而且很难排查。技巧九仿真时间要设够。一张1024x1024的PNG图片压缩数据可能有几百KB仿真时需要逐个字节送入。如果仿真时间设得太短解码还没完成就结束了看不到最终结果。我一般设置仿真时间为10ms足够解码完大多数图片。技巧十保留调试信号。在综合时不要把调试信号优化掉。我一般在顶层模块留几个debug端口输出关键状态机的状态、FIFO的读写指针、错误计数器等。上板调试时把这些信号接到LED或者逻辑分析仪上可以快速判断解码器的工作状态。这套PNG解码器从最初的想法到最终稳定运行前后迭代了十几个版本。中间踩过的坑、熬过的夜、抓过的波形现在回想起来都是宝贵的经验。纯Verilog实现PNG解码这件事说难也难说简单也简单——难在细节多容易出错简单在原理清晰只要按部就班就能做出来。希望这些经验能帮到正在做类似项目的朋友少走一些弯路。
返回列表