ARTICLE DETAIL

资讯详情

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

纯Verilog FPGA实现PNG硬件解码:从DEFLATE到像素流的完整工程指南

纯Verilog FPGA实现PNG硬件解码:从DEFLATE到像素流的完整工程指南 1. 为什么要在FPGA里硬解PNG做图像处理的朋友大概率都遇到过这个场景上位机或者摄像头给过来一张PNG图片需要在FPGA内部直接把它还原成RGB像素流送去做缩放、滤波、叠加或者显示。第一反应通常是丢给ARM核用软件解不就行了但真到了项目里你会发现软件解码的延迟、CPU占用、内存带宽占用在实时性要求高的场合根本扛不住。尤其是纯FPGA平台没有硬核处理器或者对功耗、确定性延迟有硬指标的项目软件方案直接出局。PNG这个格式说简单也简单说坑也真坑。它本质上是DEFLATE压缩LZ77Huffman加上一系列预处理滤波再套一层chunk容器。软件端有zlib、libpng这种成熟库一行调用就完事但要在FPGA里用纯Verilog从零实现等于把zlib的核心逻辑用硬件重新造一遍。这里面涉及变长Huffman解码、滑动窗口字典匹配、多级流水线握手每一个都是时序和面积的老大难。我这次做的这套PNG解码工程目标很明确纯Verilog、无软核、无第三方IP、可综合、能上板跑通。支持标准PNG8bit灰度、24bit真彩、带Alpha的32bit支持非隔行扫描解码输出标准RGB888像素流加行场同步信号直接对接后续图像处理链路。整套工程我整理了10套不同平台和配置的源码从入门验证到实际项目落地都有覆盖下面把设计思路、核心模块、实操步骤和踩过的坑一次性讲透。这套东西适合谁如果你已经会写Verilog、懂基本的时序逻辑想找一个有足够复杂度、又能真正跑通的FPGA图像项目练手或者直接用到产品里那这套PNG解码非常合适。它不像点灯、串口那种玩具项目里面每一个模块都是真实工程里会遇到的问题。如果你是完全零基础建议先把Verilog的阻塞非阻塞、状态机、跨时钟域这些基础打牢再来看不然会有点吃力。2. 整体架构与方案选型拆解2.1 PNG解码的完整数据流先理清楚一张PNG从字节流到像素要经过哪些步骤这决定了硬件架构怎么切分。PNG文件的结构是8字节固定文件头然后是一连串chunk每个chunk有长度、类型、数据、CRC四部分。解码真正关心的是三个chunkIHDR图像头宽高、位深、颜色类型、IDAT压缩的图像数据可能有好几个、IEND结束标志。拿到IDAT里的压缩数据后流程是先做zlib头的剥离PNG的IDAT数据前面有2字节zlib头和4字节adler32校验然后进DEFLATE解码。DEFLATE解码分两步走先Huffman解码把变长码还原成LZ77的literal/length和distance符号再根据这些符号做滑动窗口的字典回溯把压缩数据还原成原始的滤波后像素字节。最后一步是反滤波PNG在压缩前对每一行做了滤波None/Sub/Up/Average/Paeth五种要按行还原成真实像素值。整个链路是串行的、有强数据依赖的所以硬件上必须做成流水线每一级用FIFO或者握手信号衔接。我把它拆成四个核心模块Chunk解析器、Huffman解码器、LZ77回溯引擎、反滤波与像素重组。下面逐个说选型考量。2.2 为什么不用现成IP非要纯Verilog手写市面上确实有Xilinx的DEFLATE IP或者一些第三方压缩IP但问题在于第一很多IP只做DEFLATE不管PNG的chunk封装和反滤波你还是得自己补一大半第二商业IP授权费用不低学习项目或者小批量产品用不起第三也是最关键的PNG用的DEFLATE是固定格式Huffman表是动态生成的存在BTYPE10的动态Huffman通用IP的接口和时序未必贴合你的图像流水线改起来比自己写还麻烦。纯Verilog手写的好处是完全可控。你可以精确控制每一级的吞吐、FIFO深度、位宽可以针对PNG的特点做优化比如Huffman表只有最多288个符号码长最多15位这些边界条件都能硬编码优化。而且调试的时候每一级信号你都能抓出了问题定位快。代价就是工作量大Huffman解码的状态机、LZ77的窗口管理都得自己啃。2.3 10套工程源码的平台划分逻辑我整理的10套工程不是简单复制粘贴而是按平台和用途分了层次方便不同需求的人直接取用工程编号目标平台用途定位关键差异01纯仿真Icarus Verilog算法验证无板级约束重点跑testbench02通用FPGAAltera Cyclone入门上板用片上RAM做窗口03Xilinx Artix-7主流实战对接DDR做窗口缓存04Xilinx Zynq-7000软硬协同PS配置PL解码05安路FPGA国产平台资源受限优化版06大窗口DDR版高分辨率外挂DDR存滑动窗口07多端口DDR读写版多路图像复用DDR带宽08带SPI配置加载版独立运行Flash存PNG上电自解09双线性插值后级版完整链路解码缩放一体10边缘网关集成版系统级解码通信终端整合这个划分的核心逻辑是从纯算法验证到系统集成逐步增加外部依赖。01号工程最重要因为所有算法逻辑先在仿真里跑通后面上板只是换存储和接口。很多人一上来就上板结果时序问题、数据问题混在一起根本没法调。先把仿真跑绿再上板这是铁律。3. 核心模块的细节与实操要点3.1 Chunk解析器字节流的分拣员Chunk解析器看着简单其实是最容易出边界bug的地方。PNG的chunk长度字段是大端序的4字节很多新手直接按小端拼结果长度全错。我的做法是用一个移位寄存器每来一个字节左移8位再或上新字节凑够4字节就是一个完整的长度值。解析状态机大致是这样先等8字节文件头固定值89 50 4E 47 0D 0A 1A 0A然后进入chunk循环。每个chunk先读4字节长度再读4字节类型根据类型决定后续动作。IHDR要提取宽高和颜色类型IDAT要把数据推给下游的DEFLATE模块其他chunk比如tEXt、pHYs直接跳过。注意IDAT可能有多个它们是逻辑上连续的压缩流不能每个IDAT单独解。我的处理是把所有IDAT的数据拼成一个连续流喂给DEFLATE用一个计数器记录总长度。这个细节很多开源实现都搞错了导致大图解码失败。实操上Chunk解析器我用了两级FIFO做缓冲。第一级存原始字节第二级做类型判断后的分流。FIFO深度设成64字节足够因为chunk头最多也就几十字节。这里有个技巧CRC校验可以先跳过因为解码正确性最终由像素结果验证CRC只影响错误检测不影响功能。等主链路跑通了再补CRC能省不少调试时间。3.2 Huffman解码器变长码的硬件难题这是整个项目最硬核的部分。DEFLATE的Huffman码是从高位到低位读取的和很多人的直觉相反码长1到15位不等。硬件上没法像软件那样一位一位试必须用查表法。我的方案是构建一个规范Huffman解码表。DEFLATE的Huffman码是规范的也就是说给定每个符号的码长码字是可以唯一确定的。解码时我预先根据码长分布生成一张查找表表的索引是接下来N位的值表项里存符号和实际码长。为了平衡面积和速度我用的是两级查表第一级查9位如果码长≤9直接出结果如果9第一级给出一个偏移再查第二级。具体实现上用一个位缓冲器bit buffer持续从上游FIFO取字节维护一个当前可用位数计数器。每次解码从缓冲器顶部取15位不够就等查表得到符号和码长然后把码长数量的位从缓冲器消费掉。这里的关键是位缓冲器的填充和消费要严格同步否则会错位。// 位缓冲器核心逻辑简化示意 always (posedge clk) begin if (fill_en bit_cnt 16) begin bit_buf {bit_buf[14:0], next_byte}; // 左移补新字节 bit_cnt bit_cnt 8; end if (consume_en) begin bit_buf bit_buf code_len; // 消费掉已解码的位 bit_cnt bit_cnt - code_len; end end实操心得位缓冲器的位宽一定要留够余量。我一开始用16位结果遇到连续短码时填充跟不上消费出现空读。后来改成24位缓冲配合位数低于8就预填充的策略才彻底稳定。这个坑我调了整整两天抓波形才看出来是缓冲深度不够。另外动态Huffman的码表是存在IDAT数据开头的HLIT、HDIST、HCLEN三个字段定义了码长分布所以解码器要先解析这段码表定义把码长数组读进来再生成查找表。这段逻辑我单独做了一个小状态机和主解码状态机分开避免耦合。3.3 LZ77回溯引擎滑动窗口怎么放Huffman解出来的符号有两类literal直接输出一个字节和length/distance对表示从前面distance个字节处复制length个字节。这个从前面复制就是LZ77的核心需要一个滑动窗口保存最近解码出的数据。窗口大小标准是32KB32768字节。在FPGA里放32KB的窗口用片上RAM的话对资源紧张的平台有点吃力所以我的10套工程里做了区分小平台用8KB窗口牺牲一点压缩率兼容性但大部分PNG能解大平台用完整32KB高分辨率工程直接把窗口放DDR。回溯引擎的工作流程是收到length/distance对后计算源地址当前写指针-distance然后从窗口里连续读length个字节一边读一边也写回窗口因为复制的数据也要进窗口。这里有个重叠复制的坑如果distance小于length复制的数据会覆盖到正在读的区域必须逐字节处理不能批量读。比如distance1、length100就是把自己重复100次硬件上要保证读和写的指针正确推进。// 重叠复制处理逐字节 always (posedge clk) begin if (copy_active) begin rd_data window[rd_ptr]; window[wr_ptr] rd_data; // 同时写回 rd_ptr rd_ptr 1; wr_ptr wr_ptr 1; if (rd_ptr wr_ptr) rd_ptr rd_ptr - distance; // 回绕处理 end end注意窗口的读写指针回绕wrap around一定要用模运算或者位与操作处理别用减法判断否则边界会出错。我用的是窗口大小是2的幂直接对指针做位与效率最高。3.4 反滤波与像素重组最后一道关DEFLATE解出来的数据是滤波后的像素每行开头有一个滤波类型字节0-4后面是滤波后的数据。反滤波要按行处理五种滤波方式None(0)直接用Sub(1)当前像素 滤波值 左边像素Up(2)当前像素 滤波值 上一行同位置像素Average(3)当前像素 滤波值 (左上)/2Paeth(4)当前像素 滤波值 Paeth预测值Paeth预测是最复杂的要计算左、上、左上三个像素的预测选最接近的那个。硬件上就是几个加法和比较但要注意每行的第一个像素没有左边要用0代替这个边界条件必须处理对。反滤波需要缓存上一行的数据用于Up和Paeth所以需要一个行缓冲。对于24bit真彩一行宽度最大可能几千像素行缓冲用片上RAM或者DDR。我的做法是行缓冲深度等于图像宽度位宽等于像素位宽用双口RAM实现读写分离。反滤波完成后把字节流按颜色类型组装成RGB888。灰度图直接复制三通道真彩图按R、G、B顺序拼带Alpha的取前三个通道。最后输出像素流加hsync/vsync/de信号对接下游。4. 完整实操流程与上板验证4.1 仿真验证先把算法跑绿第一步永远是仿真。我用Icarus Verilog搭了testbench把一张小尺寸PNG比如64x64的字节流通过$readmemh读进内存逐字节喂给解码器然后把输出的像素和软件解码的结果逐像素比对。比对脚本用Python写调用PIL解码同一张图生成参考像素再和Verilog输出的hex文件对比。# 仿真流程 iverilog -o png_sim tb_png_decoder.v png_decoder_top.v huffman_dec.v lz77_engine.v unfilter.v vvp png_sim # 生成输出像素文件后用Python比对 python compare_pixels.py output.hex reference.hex实操心得仿真阶段一定要用多种类型的PNG测试灰度、真彩、带Alpha、不同压缩级别、不同滤波组合都要覆盖。我一开始只用一张图测上板后发现另一张图花屏查了半天是Paeth滤波的边界没处理对。后来我准备了20张测试图覆盖各种情况仿真全绿才上板。4.2 综合与实现时序收敛的关键纯Verilog代码综合到FPGA最大的挑战是时序收敛。Huffman解码和LZ77回溯都有比较长的组合逻辑路径容易成为关键路径。我的优化手段有几个第一把查表逻辑做成寄存器输出也就是查表结果打一拍再输出牺牲一个周期换时序余量。第二位缓冲器的移位操作改成分段移位避免一次移15位的大组合逻辑。第三LZ77的地址计算拆成两级流水先算基地址再算偏移。综合报告里重点看几个指标LUT利用率、BRAM使用量、最大时钟频率。我的目标平台Artix-7上完整32KB窗口版本大概用掉2000个LUT、8个BRAM能跑到100MHz以上解码一张1024x768的PNG大概几毫秒完全满足实时显示需求。4.3 上板调试从ILA波形到像素输出上板后第一件事是接ILA集成逻辑分析仪抓关键信号chunk解析状态、Huffman解码符号、LZ77读写指针、反滤波输出。我一般先抓chunk解析确认IHDR的宽高读对了再抓Huffman确认符号流正常最后抓像素输出。显示验证最直接把解码输出的RGB流接到HDMI或者VGA控制器直接看屏幕。如果花屏先看是不是行同步对不上再看是不是颜色通道顺序错了最后才怀疑解码逻辑。我遇到过屏幕整体偏色查了半天发现是RGB顺序接反了这种低级错误反而最容易忽略。注意上板时PNG数据从哪来很关键。简单做法是用ROM预存一张小图复杂做法是从SD卡或者Flash读。我的08号工程就是Flash存PNG、上电自动加载解码这个在独立运行的场合很实用。5. 常见问题与排查技巧实录5.1 解码花屏、错位的排查顺序花屏是最常见的现象排查要有顺序别乱抓。我的经验是从后往前查先确认反滤波输出的像素对不对用ILA抓几个已知位置的像素值再往前查LZ77输出的字节流再查Huffman输出的符号最后查chunk解析。哪一级开始错问题就在那一级。如果是整体错位图像斜着或者偏移大概率是行缓冲的读写地址算错了或者反滤波的边界处理有问题。如果是局部花屏多半是LZ77的重叠复制没处理好或者Huffman码表生成有误。5.2 常见问题速查表现象可能原因排查方法完全无输出chunk解析卡住抓状态机看是否停在等文件头图像全黑颜色类型判断错检查IHDR的颜色类型字段解析图像斜切行缓冲地址错检查行宽计数器和地址计算局部彩色噪点LZ77重叠复制错抓distancelength的case图像上下颠倒行顺序反了检查行计数器方向颜色偏色RGB通道顺序错对比参考图的通道值大图解码失败窗口太小确认窗口大小是否够32KB时序不收敛组合逻辑太长加流水线寄存器5.3 独家避坑技巧第一个坑PNG的zlib头别忘剥。IDAT数据开头有2字节的zlib头0x78开头和末尾4字节adler32DEFLATE解码要从第3个字节开始。我一开始没剥Huffman解码直接乱套。第二个坑动态Huffman的码表顺序。码长数组的读取顺序是HLITliteral码数、HDISTdistance码数、HCLEN码长码数然后才是码长数据而且码长数据是按特定顺序排列的16、17、18是重复码。这个顺序错了整张表就废了。第三个坑位序问题。DEFLATE的Huffman码是MSB先出但字节内部的位是LSB先读。这个字节内LSB、码字内MSB的混合规则极其反直觉我在这上面栽过跟头。解决办法是写个小Python脚本手工构造几个已知的压缩流逐位对照验证。第四个坑FIFO的almost full/full信号。上下游握手如果只用一个full信号容易出现丢数据或者死锁。我的做法是用almost_full提前一个周期通知上游暂停留出反应时间。6. 工程源码的组织与二次开发建议6.1 目录结构与模块划分10套工程我统一了目录结构方便互相移植png_decoder/ ├── rtl/ # 核心RTL源码 │ ├── png_decoder_top.v │ ├── chunk_parser.v │ ├── huffman_dec.v │ ├── lz77_engine.v │ ├── unfilter.v │ └── pixel_pack.v ├── sim/ # 仿真testbench ├── con/ # 约束文件 ├── ip/ # 平台相关IPBRAM/DDR └── doc/ # 说明文档核心RTL是平台无关的换平台只需要改con约束和ip部分。这个设计让移植成本降到最低比如从Artix-7换到安路核心逻辑一行不用动。6.2 二次开发的扩展方向这套解码器跑通后可以往几个方向扩展。一是加隔行扫描支持Adam7这个需要改反滤波的地址映射工作量中等。二是加缩放后级我的09号工程已经集成了双线性插值解码完直接缩放。三是多路复用07号工程的多端口DDR读写就是为多路图像准备的可以同时解多张图。如果你要做产品建议在解码器后面加一个像素缓存和仲裁模块因为解码是突发的下游显示是持续的中间需要缓冲来平滑。这个缓冲用DDR或者大容量BRAM都行看你的带宽需求。6.3 资源与性能的权衡最后说说资源优化。如果你的平台资源紧张可以砍这几个地方窗口从32KB降到8KB牺牲部分兼容性、Huffman查表从两级降到一级牺牲速度、行缓冲用更窄的位宽牺牲精度。反过来如果追求性能可以把Huffman解码做成多符号并行一次解两个符号代价是面积翻倍。我个人在实际项目里的体会是先保证功能正确再谈优化。很多新手一上来就想省资源结果功能都没跑通优化无从谈起。先把完整版跑通有了baseline再针对性地砍这样每一步优化都能量化对比心里有底。这套PNG解码工程我前后迭代了几个月踩的坑基本都写在上面的排查表里了希望能帮你少走点弯路。
返回列表