纯Verilog实现PNG硬件解码:10套FPGA工程源码与实战经验分享
发布时间:2026/9/29 10:40:27 作者:尧图编辑部 阅读量:1,286

1. 项目背景与核心需求拆解1.1 为什么要在FPGA上做PNG解码PNG这种图片格式做图像处理的朋友都不陌生。它最大的特点是无损压缩用的是DEFLATE算法LZ77变体加霍夫曼编码同时还支持Alpha透明通道。在医疗影像、工业检测、高端显示这些领域PNG几乎是默认的存储格式因为谁都不想在压缩环节丢掉任何一个像素的精度。但问题来了这些领域往往又要求实时处理。比如工业相机每秒要抓几十帧图像医疗设备要在毫秒级完成图像预处理。这时候如果用CPU软解延迟和功耗都扛不住。把PNG解码搬到FPGA上用纯硬件流水线来做就成了一个很自然的需求。我最早接触这个方向是因为一个红外热成像的项目。前端传感器输出的是RAW数据但叠加的UI层和图标全是PNG格式。如果让ARM核去解这些PNG整个系统的启动时间要多出好几百毫秒体验很差。后来把解码逻辑做成Verilog模块直接挂在AXI总线上解码延迟压到了微秒级整个方案才跑通。1.2 纯Verilog实现意味着什么市面上不少FPGA图像处理方案底层其实调用了厂商的IP核或者用了HLS高层次综合生成的代码。这样做开发快但有几个硬伤一是移植性差换个厂商的芯片就得重来二是调试困难HLS生成的RTL可读性很差出了问题很难定位三是资源占用往往偏大因为综合工具不一定能做出最优的硬件结构。纯Verilog实现说白了就是从零手写每一个模块。从字节流解析、霍夫曼解码、LZ77字典回溯到反量化、反滤波、颜色空间转换全部用可综合的RTL代码完成。这样做的好处是资源可控、时序可预测、移植方便坏处是开发周期长对设计者的数字电路功底要求高。我提供的这10套工程源码覆盖了从入门验证到完整系统集成的不同层次。有的是纯仿真工程方便你理解算法流程有的是带DDR缓存的完整视频通路可以直接上板跑。每套工程都配了testbench和仿真脚本用Icarus Verilog或者Vivado自带的仿真器都能跑。1.3 适合哪些人参考这个项目适合三类人第一类是FPGA初学者想找一个有挑战性但又不太离谱的项目来练手PNG解码涉及状态机、流水线、存储器管理是很好的综合训练第二类是图像处理工程师手头有FPGA平台需要把PNG解码集成到自己的视频通路里第三类是IC验证工程师想看看一个完整的协议解析模块该怎么写testbench、怎么覆盖边界情况。需要提前说明的是这个项目不涉及任何敏感的网络传输内容纯粹是本地图像数据的硬件解码。所有工程源码都是基于标准PNG规范RFC 2083实现的不依赖任何外部库或特殊工具链。2. PNG解码算法的硬件化思路2.1 PNG文件结构回顾在动手写RTL之前得先把PNG的文件结构吃透。一个标准的PNG文件由8字节固定头和若干个数据块Chunk组成。固定头是89 50 4E 47 0D 0A 1A 0A用来标识这是一个PNG文件。数据块分为两类关键块和辅助块。关键块有四个IHDR图像头包含宽高、位深、颜色类型等信息、PLTE调色板索引颜色模式才需要、IDAT图像数据可能有多块、IEND文件结束。辅助块包括tEXt、tIME、gAMA等解码时可以跳过。每个数据块的格式是固定的4字节长度 4字节类型 数据 4字节CRC。长度字段不包括自身和类型字段CRC是对类型和数据一起计算的。在硬件里CRC校验可以并行计算不影响主流水线。2.2 解码流程的硬件映射PNG解码的完整流程可以拆成这几个阶段字节流解析从输入数据中提取出各个Chunk识别IHDR和IDAT。zlib解压IDAT里的数据是zlib格式的需要先去掉2字节的zlib头然后做DEFLATE解压。霍夫曼解码DEFLATE的核心把变长编码映射回原始符号。LZ77回溯遇到长度-距离对时从之前的输出中复制数据。反滤波PNG对每一行做了滤波处理None、Sub、Up、Average、Paeth需要逆向恢复。颜色空间转换根据颜色类型把像素数据转换成RGB或RGBA。在硬件里这几个阶段可以做成流水线。字节流解析和zlib头处理是轻量级的霍夫曼解码和LZ77回溯是计算密集型的反滤波和颜色转换是存储密集型的。我的做法是把霍夫曼解码和LZ77放在一个模块里用双端口BRAM做滑动窗口反滤波单独一个模块用行缓存Line Buffer实现。2.3 关键设计决策与取舍决策一霍夫曼表怎么存。DEFLATE的动态霍夫曼表最多有288个码字每个码字长度1到15位。如果直接用查找表需要2^1532768个条目太浪费BRAM。我的做法是用两级查找第一级用9位索引覆盖大多数短码第二级用线性搜索处理长码。实测下来BRAM占用从32KB降到了4KB左右。决策二LZ77窗口大小。PNG规范允许的最大窗口是32KB。用BRAM实现的话32KB正好是8个36Kb BRAM块Xilinx平台。如果资源紧张可以缩小到8KB但压缩率会下降。我提供的工程里两种配置都有你可以根据实际需求选。决策三反滤波的流水线深度。反滤波需要访问上一行的像素所以必须缓存整行。对于1920宽度的图像一行就是1920字节。用BRAM做行缓存读写冲突是主要问题。我的方案是乒乓缓存当前行写入时上一行读出用两个BRAM交替工作。注意PNG的滤波是按字节做的不是按像素。对于16位深度的图像每个像素占2字节滤波时要把高低字节分开处理。这一点很容易搞错我在第一次实现时就踩了这个坑导致图像出现规律性的条纹。3. 核心模块的Verilog实现细节3.1 字节流解析与Chunk提取字节流解析模块的状态机设计比较直接。上电后先检测8字节固定头然后进入Chunk循环。每个Chunk先读4字节长度再读4字节类型然后根据类型决定是缓存数据还是跳过。这里有个细节IDAT块可能不连续。有些编码器会在IDAT之间插入辅助块所以不能假设所有IDAT都是连着的。我的做法是把所有IDAT的数据都写入一个FIFO由后面的解压模块统一读取。CRC校验我用了并行计算。每来一个字节用查表法更新CRC寄存器。表的大小是256×4字节存在分布式RAM里。实测下来CRC计算不会成为瓶颈因为IDAT的数据速率本来就不高。// CRC32并行计算核心 always (posedge clk) begin if (crc_en) begin crc_reg crc_table[crc_reg[7:0] ^ data_in] ^ {8h00, crc_reg[31:8]}; end end3.2 霍夫曼解码器的流水线设计霍夫曼解码是整個解码器的性能瓶颈。DEFLATE的码字是位对齐的不是字节对齐的所以需要一个位缓冲器来管理输入流。我的设计里位缓冲器是一个64位的移位寄存器。当有效位数少于32位时从FIFO里读入新的字节。解码时先取低15位做查找如果命中短码表直接输出符号和码长如果没命中进入长码搜索状态。短码表用BRAM实现深度5129位索引每个条目存符号值和码长。长码搜索用组合逻辑逐位比较。为了时序收敛长码搜索分成了两级流水。// 霍夫曼解码第一级短码查找 always (posedge clk) begin if (decode_en) begin short_code bit_buffer[14:0]; short_valid 1b1; end end // 第二级结果输出 always (posedge clk) begin if (short_valid) begin if (short_table[short_code[8:0]].valid) begin symbol_out short_table[short_code[8:0]].symbol; code_len short_table[short_code[8:0]].length; end else begin // 进入长码搜索 long_search_en 1b1; end end end3.3 LZ77滑动窗口的实现LZ77回溯需要维护一个32KB的滑动窗口。在硬件里这个窗口就是一块BRAM写指针不断前进读指针根据距离值回溯。关键点是读写冲突。当解码器输出一个字面量时需要同时写入窗口当遇到长度-距离对时需要从窗口读出数据同时把读出的数据再写回窗口因为匹配可能重叠。我的方案是用双端口BRAM一个端口专门写一个端口专门读读出的数据经过一个FIFO再写回去。// 滑动窗口读写控制 always (posedge clk) begin if (write_en) begin window_bram[write_ptr] write_data; write_ptr write_ptr 1b1; end if (read_en) begin read_data window_bram[read_ptr]; read_ptr read_ptr 1b1; end end实操心得BRAM的读延迟是1个周期所以读指针要提前一个周期给出。我在第一次实现时忘了这个延迟导致回溯的数据总是差一个字节图像上出现了斜向的错位。后来在地址通路上加了一级寄存器才解决。3.4 反滤波模块的行缓存设计反滤波有五种模式每种的计算方式不同。None模式直接输出Sub模式用左邻像素Up模式用上邻像素Average模式用左邻和上邻的平均Paeth模式用左、上、左上三个像素做预测。硬件实现时行缓存是核心。我用两个BRAM做乒乓一个存当前行一个存上一行。每处理完一行交换角色。对于Sub和Paeth模式还需要缓存左邻像素用一个寄存器就够了。// 行缓存乒乓控制 always (posedge clk) begin if (line_done) begin buf_sel ~buf_sel; end end // 反滤波计算 always (*) begin case (filter_type) 3d0: recon raw_byte; 3d1: recon raw_byte left_pixel; 3d2: recon raw_byte up_pixel; 3d3: recon raw_byte ((left_pixel up_pixel) 1); 3d4: recon raw_byte paeth_predictor(left_pixel, up_pixel, upleft_pixel); endcase endPaeth预测器的组合逻辑比较深我加了一级流水线来保证时序。实测在100MHz时钟下时序余量还有2ns左右。4. 工程源码的组织与使用4.1 10套工程的层次划分这10套工程不是简单的重复而是按照学习曲线设计的。前3套是基础验证工程只包含解码核心输入输出都是文件用仿真器跑。中间4套是带AXI接口的IP核工程可以集成到SoC里。最后3套是完整的视频通路工程包含DDR缓存、显示驱动和测试图案生成。工程编号类型主要模块适用场景01仿真验证字节解析霍夫曼理解算法流程02仿真验证完整解码器验证边界情况03仿真验证多图像批量测试回归测试04AXI IP解码器AXI4-LiteSoC集成05AXI IP解码器AXI4-Stream视频通路06AXI IP带DDR缓存高分辨率07AXI IP多通道解码多路视频08完整系统解码显示上板演示09完整系统解码缩放自适应显示10完整系统解码叠加UI合成4.2 仿真环境的搭建仿真我用的是Icarus Verilog GTKWave的组合。Icarus是开源工具安装方便跑RTL仿真足够用。GTKWave看波形也很直观。每个工程里都有一个sim目录里面有run.sh脚本。执行./run.sh就会编译RTL、跑testbench、生成VCD波形文件。testbench里用$readmemh读取测试图像数据解码结果写入文件然后用Python脚本比对。# 仿真脚本示例 iverilog -o sim.out -I ./rtl ./tb/tb_top.v ./rtl/*.v vvp sim.out gtkwave wave.vcd 注意Icarus对SystemVerilog的支持有限所以我的RTL全部用Verilog-2001语法没有用任何SV特性。这样也能保证在Vivado和Quartus里都能综合。4.3 上板验证的步骤上板验证需要一块带DDR的FPGA开发板。我用的是一块Zynq-7000的板子PL端跑解码逻辑PS端负责通过SD卡加载PNG文件到DDR。步骤是这样的先把PNG文件转换成二进制格式用objcopy或者Python脚本都行然后通过JTAG下载到DDR的指定地址PL端的解码器从DDR读取数据解码后写入另一个DDR地址最后显示控制器从DDR读出RGB数据送到HDMI输出。# PNG转二进制脚本 from PIL import Image import numpy as np img Image.open(test.png) data np.array(img) data.tofile(test.bin)实测下来1920×1080的PNG图像解码耗时约8ms时钟频率100MHz。这个速度对于大多数工业应用足够了。5. 常见问题与排查技巧5.1 图像出现规律性条纹这是最常见的问题通常有以下几个原因原因一滤波模式判断错误。PNG的滤波类型存在每个扫描行的第一个字节里。如果这个字节解析错了整行的反滤波都会错。排查方法是把滤波类型打印出来和Python的png库对比。原因二行缓存乒乓切换时机不对。如果切换太早或太晚会导致某一行用了错误的上邻数据。我的经验是在行结束标志后延迟一个周期再切换。原因三字节序问题。PNG是大端格式而FPGA内部通常是小端。在解析多字节字段如宽度、高度时一定要做字节交换。5.2 解码结果部分正确部分乱码这种情况通常是LZ77窗口管理出了问题。重点检查两个地方一是写指针和读指针的位宽是否足够32KB窗口需要15位地址二是当匹配长度超过剩余窗口时是否正确处理了环绕。还有一个隐蔽的坑距离值可能大于当前已输出的数据量。虽然规范不允许这样但有些编码器会生成这种流。我的做法是加一个保护逻辑如果距离超限就输出0并置错误标志。5.3 时序不收敛霍夫曼解码的长码搜索和Paeth预测器是时序瓶颈。如果综合报告显示负余量可以尝试把长码搜索从组合逻辑改成两级流水把Paeth预测器拆成两个周期计算降低时钟频率从150MHz降到100MHz在BRAM输出后加一级寄存器我在Zynq-7020上跑100MHz资源利用率大概是LUT 35%FF 28%BRAM 45%DSP 12%。这个占用率还有优化空间但已经能满足大多数应用。5.4 常见问题速查表现象可能原因排查方法解决方案图像全黑固定头检测失败检查输入数据前8字节确认数据源格式图像上半部分正常下半部分乱行缓存溢出检查行计数器位宽扩大行缓存深度颜色偏差颜色类型解析错误对比IHDR字段修正颜色转换逻辑解码速度慢霍夫曼表未命中率高统计长码比例优化表结构仿真通过上板失败时序问题看时序报告加流水线或降频6. 资源优化与性能提升的实操经验6.1 BRAM的合理分配整个解码器里BRAM主要用在三个地方霍夫曼表约4KB、LZ77窗口32KB、行缓存约4KB。总共40KB左右对于大多数FPGA来说不算多。但如果要做多通道解码BRAM就会紧张。我的优化方法是共享霍夫曼表多个解码通道共用一个表用仲裁器分配访问权。这样表只占一份节省了75%的BRAM。LZ77窗口也可以共享但实现起来复杂一些因为每个通道的窗口状态不同。如果通道数不多2-4个可以每个通道独立窗口用BRAM的多个端口来支持。6.2 流水线深度的权衡流水线越深时钟频率越高但延迟也越大。对于PNG解码延迟不是主要问题因为图像数据本来就是批量处理的。所以我倾向于加深流水线来换取更高的频率。霍夫曼解码我用了5级流水位缓冲、短码查找、长码搜索、符号输出、窗口更新。LZ77用了3级地址计算、数据读出、数据写回。反滤波用了2级预测计算、重建输出。这样下来关键路径被切得很短100MHz下余量充足。如果降到50MHz甚至可以把流水线合并节省寄存器资源。6.3 与DDR控制器的配合高分辨率图像4K以上的PNG文件可能超过10MBFPGA内部的BRAM肯定存不下必须用DDR。这时候解码器的输入输出都要通过DDR控制器。我的做法是用AXI4-Stream接口解码器从DDR读数据时用AXI4-Stream Master发起读请求解码结果也用Stream接口写回DDR。中间加一个异步FIFO做时钟域转换因为DDR控制器和解码器可能跑在不同时钟域。实操心得DDR的突发长度Burst Length要设成8或16太小了效率低太大了延迟高。我实测下来BL8在大多数场景下最平衡。另外读写要分时复用不要同时发起否则DDR控制器的仲裁会很复杂。6.4 测试图案的生成调试的时候有一个可配置的测试图案生成器会方便很多。我写了一个模块可以生成渐变、棋盘、彩条等图案直接注入解码器的输入端绕过PNG解析。这样可以快速定位问题是在前端解析还是后端重建。// 测试图案生成 always (posedge clk) begin case (pattern_sel) 2d0: test_data x_cnt[7:0]; // 水平渐变 2d1: test_data y_cnt[7:0]; // 垂直渐变 2d2: test_data {x_cnt[3:0], y_cnt[3:0]}; // 棋盘 2d3: test_data color_bar[x_cnt[9:7]]; // 彩条 endcase end这个模块帮我省了很多调试时间。有一次图像出现周期性噪点用测试图案一跑发现是行缓存的问题而不是霍夫曼解码的问题很快就定位了。7. 从工程源码到实际项目的移植建议7.1 接口适配我提供的工程用的是自定义的简单接口输入是8位数据加有效信号输出是24位RGB加行列同步。这种接口最容易理解也最容易适配到不同的系统里。如果你要集成到现有的视频通路里可能需要转换成AXI4-Stream或Avalon-ST。转换逻辑不复杂核心就是加一个FIFO做缓冲然后把同步信号映射到TUSER或TLAST上。7.2 时钟域处理解码器内部可以跑在100MHz但视频输出可能是148.5MHz1080p60或297MHz4K30。跨时钟域的地方有两个输入数据写入和输出数据读出。输入侧我用的是异步FIFO深度256足够吸收突发。输出侧也是异步FIFO但深度要更大一些因为视频输出的时序要求严格不能断流。我一般用512深度。7.3 参数化配置工程里的主要参数都做成了parameter方便你在例化时修改。常用的参数有IMG_WIDTH图像最大宽度默认1920IMG_HEIGHT图像最大高度默认1080WINDOW_SIZELZ77窗口大小默认32768PIPELINE_DEPTH流水线深度默认5修改这些参数后综合工具会自动调整BRAM和寄存器的数量。需要注意的是WINDOW_SIZE必须是2的幂否则地址计算会出问题。7.4 验证策略移植到新平台后一定要做回归测试。我通常准备一组测试图像纯色、渐变、照片、带Alpha通道的、16位深度的、隔行扫描的。每种跑一遍比对输出和Python解码的结果。如果资源允许可以在FPGA里加一个CRC校验模块对解码输出做校验和软件计算的CRC对比。这样不用把数据读回电脑就能判断对错。// 输出CRC校验 always (posedge clk) begin if (out_valid) begin out_crc next_crc(out_crc, out_data); end end这个CRC值可以通过AXI4-Lite读出来和Python的zlib.crc32结果对比。一致就说明解码正确。8. 关于PNG解码的扩展思考8.1 支持APNGAPNG是PNG的动画扩展基本结构还是PNG但多了acTL和fcTL块来控制帧序列。如果要支持APNG需要在解码器外面加一个帧控制器管理帧的播放顺序和延迟。硬件实现上可以把每一帧解码后存入DDR的不同区域然后显示控制器按顺序读取。帧延迟用计数器实现精度到毫秒级就够了。8.2 与JPEG解码的对比JPEG是有损压缩解码流程和PNG完全不同DCT变换、量化、熵解码。但两者在硬件架构上有相似之处都需要霍夫曼解码JPEG用的是变种、都需要存储管理、都需要颜色转换。如果要做同时支持PNG和JPEG的解码器可以共享霍夫曼解码模块和颜色转换模块只把前端解析和后端重建做成可配置的。这样资源占用不会翻倍但灵活性大大提高。8.3 更高位深的支持PNG支持1、2、4、8、16位深度。我目前的工程主要针对8位和16位1/2/4位的索引颜色模式支持得不够完善。如果要支持这些模式需要在反滤波之前加一个位解包模块把紧凑的位流展开成字节。这个模块不复杂但要注意位序。PNG的位序是从高位到低位和大多数FPGA的思维习惯相反。我在实现1位模式时就因为位序搞反了导致图像黑白颠倒。8.4 性能的进一步提升如果要把解码速度再提升一个数量级可以考虑多符号并行解码。DEFLATE的霍夫曼码是变长的理论上不能并行但可以用猜测-验证的方式同时猜测多个可能的码长然后选择正确的那个。这种方法在软件里有实现如zlib-ng的并行解码硬件里也有论文讨论。但实现复杂度很高对于大多数应用来说当前的性能已经足够了。我在实际项目里遇到的需求最高也就是4K30当前的解码器完全能胜任。如果将来有8K的需求再考虑并行化也不迟。这个项目从最初的想法到10套工程成型前后花了大概半年时间。中间踩过的坑、调过的波形、改过的代码都沉淀在了这些工程里。如果你在使用的过程中遇到问题可以先跑仿真用测试图案定位再对比Python的解码结果。大多数问题都能通过这三步找到原因。