ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FPGA纯Verilog实现PNG图片解码:从文件解析到像素重建全攻略

FPGA纯Verilog实现PNG图片解码:从文件解析到像素重建全攻略 做FPGA图像处理这些年我见过不少人在摄像头采集、色彩空间转换、边缘检测这些常规流程上花了大把功夫却很少碰“静态图片解码”这个方向。原因也好理解平时大家更习惯在上位机或者嵌入式CPU里把PNG、JPEG解码成裸RGB数据再扔给FPGA去处理或者显示。可一旦你这么干问题就来了——存储成本翻倍、传输带宽被白占更别提高帧率下CPU还要分心去干解码这种脏活。所以当我看到“FPGA纯verilog实现PNG图片解码”这种项目时第一反应是这条路子很野但确实是刚需。这个项目不依赖任何ARM软核、不调用厂商封装好的图像解码IP完全用Verilog把PNG从字节流到像素级重建的解码链路打通还打包了10套不同层次的工程源码。无论你是想在板子上直接显示PNG图片、给图像处理流水线喂数据还是单纯想挑战一把“硬件实现有损/无损压缩算法”这个方向都值得认真拆一遍。1. 先聊聊为什么要用FPGA硬解PNG1.1 PCB上静态图片的第一性需求很多嵌入式显示场景里开机Logo、菜单图标、操作界面底图都是预先烧好的静态图片。如果这些图片以PNG格式存在Flash里理论上可以省不少存储空间——有损压缩的JPEG可能被压缩得很小但矢量界面、图标、文字这类内容一旦出现锯齿或色斑就会非常明显PNG的无损压缩就成了更稳妥的选择。可代价是解码复杂度远高于BMP这类裸格式。你要是在MCU上跑软解PNG一张1920x1080的图可能得等上好几秒钟而且在低端处理器上要额外维护一套解码库、分配大块RAM。而在FPGA上做纯硬件解码事情就变成了另一番光景像素可以流水线式地重建出来解码只是整个显示链路里的一级流水吞吐量由时钟频率决定完全不用等CPU去“慢慢算”。所以这类项目的目标本质上就是“不用软核用逻辑把图片从Flash里搬到屏幕上”。1.2 为什么强调“纯verilog”而不是买IP核关于“纯Verilog实现”这句话我多说两句。市面上确实有少数商用IP能解PNG但要么是按授权收费要么被厂商绑定在特定芯片上。你如果做的是国产FPGA、或者用的是一款比较冷门的芯片IP移植的坑能把人埋了。纯Verilog的意义在于三点第一代码不依赖任何厂商原语换芯片平台可以直接改约束文件重新综合第二代码可读、可审计客户验收的时候能一条条讲清楚逻辑第三你可以把解码模块嵌进自研的图像处理链路里而不需要接受IP核固定的接口协议。当然纯Verilog也意味着所有算法都得自己消化。PNG解码里最硬的骨头是zlib压缩数据流的解压里面涉及Huffman编码、LZ77指针匹配、滑动窗口缓存这些可都不是一两页代码能糊弄过去的。后面我会把这块拆开细讲。1.3 10套工程源码背后的组织逻辑我之前接过不少FPGA项目也出过类似的“套件型”源码包。所谓“10套工程源码”我的理解是它不是一个孤零零的解码IP而是一套从浅到深、从仿真到上板的完整工程族。通常这种包会划分成这样几个层次基础搭建只实现PNG文件解析和IDAT提取输出到仿真波形文件软核协同用串口读取PNG字节流通过FIFO送入解码模块再把结果写回DDR单色显示解码后输出RGB565直接驱动VGA或HDMI时序高分辨率显示加入DDR缓存、AXI总线适配大尺寸屏幕跨界组合解码后接双线性插值缩放、灰度转换、边缘检测等图像处理模块。这种分层设计特别适合两类人一类是刚开始接触FPGA图像处理的同学可以对照仿真工程理解每一级数据处理另一类是工程开发人员直接从上板工程的顶层模块里裁剪出自己需要的部分即可。我后面也会按照这个思路去讲实现细节保证每一步都能落在地面上。2. PNG解码的核心原理先吃透再动手2.1 PNG文件的骨架结构你在SD卡里、Flash里看到的一个PNG文件本质就是一串连续的字节。这些字节按照固定的块结构组织必须严格按顺序解析。最核心的几个块PNG签名文件开头固定8字节89 50 4E 47 0D 0A 1A 0A是识别PNG的标准IHDR块固定13字节数据包含图片宽度、高度、位深、颜色类型、压缩方法、滤波方法、隔行扫描标志。这些参数决定了后续所有解码策略IDAT块真正存图像压缩数据的地方可能连续出现多个IDAT块所有IDAT块的数据需要拼接成连续的zlib流PLTE块调色板信息仅索引色图片需要IEND块文件结束标记出现后解析器即可停止。值得留意的是每一个chunk前面有4字节长度、4字节类型名后面还有4字节CRC校验。对硬件解析器来说CRC校验既要保证文件完整又不宜成为瓶颈。通常的做法是把长度、类型和数据字节一边收一边算CRC等到收完数据再和文件里的CRC值比较。2.2 从IDAT到像素解开的三层壳拿到IDAT数据块之后简单把字节拼起来是没用的因为里面的数据还是“压缩过滤”双重套娃。完整的还原顺序是zlib解压IDAT数据是个标准zlib流开头有2字节头CMF/FLG中间是Deflate压缩数据末尾有4字节Adler-32校验值。Deflate编码本身又是两阶段先LZ77去冗余再对产生的符号做Huffman编码反滤波解压之后得到的是“滤波后的扫描线数据”。每一行像素在压缩前先做了一次滤波滤波器类型共有五种None、Sub、Up、Average、Paeth。硬件解码时必须逐行、逐像素、按颜色通道和位深恢复出原始像素值像素重组根据IHDR里的颜色类型灰度、灰度Alpha、真彩、索引色等和位深1/2/4/8/16位把字节拆成一个个像素值。这里最折磨人的是第一步。软件上写一个zlib inflate已经很绕硬件上则不仅要处理Huffman表的动态重建、码流变长解析还要维护LZ77的滑动窗口。很多PNG解码项目卡就卡在“Huffman表需要先从码流里读出来然后再用它去解后面的符号”这种先有鸡还是先有蛋的顺序约束上。2.3 反滤波的硬件友好性反滤波反而是整个流程里最容易硬件化的部分。因为滤波是在“行内”和“行间”做的运算没有跨帧的依赖。只要上一行像素已经解码出结果当前行的Up、Average、Paeth滤波都能用简单的加法器/比较器完成。Paeth是里面逻辑最重的一个预测器需要算三个邻居的预测值再选误差最小的但也就是几步整数运算没有除法综合出来的资源开销很低。我做过硬件解码的朋友都知道一个心法凡是数据流是“从左到右、从上到下”扫描的算法都天然适合流水线。PNG的图像数据正好是逐行存储的一行行反滤波时上一行的数据用一组行缓存Line Buffer保持即可。关键是控制好解压模块输出的字节流与反滤波模块消费速度之间的同步——这里涉及到后文会讲的FIFO反压设计。3. FPGA侧的整体架构与模块划分3.1 顶层数据流从Flash到屏幕的一整条链路看一个FPGA图像项目我最先看的永远是顶层数据流。PNG解码工程整体上是一条单向流水线大致分成五个环节图片源管理从SPI Flash、SD卡或串口UART读入PNG字节流文件解析识别块结构把IDAT数据剥离出来解压模块对IDAT流做zlib inflate输出解压后的原始滤波数据反滤波模块逐行恢复真实像素值缓存与显示写入DDR或直接进FIFO队列再按显示时序读出。实际工程里第三步和第四步会做成“推拉式”接口。解压模块解出有效数据时拉高valid反滤波模块在处理完当前像素时拉高ready两边的握手信号控制数据流动避免模块间速度不匹配。直接给个实际经验在第一版验证时解码模块和反滤波模块之间一定要加深度至少为2KB的异步FIFO。因为解压器是突发式输出一次可能吐出非常长的连续字节流而反滤波是按行处理的一行数据没攒够之前不能开始计算FIFO能起到天然的蓄水池作用。3.2 内存策略为什么绕不开DDR和BRAMPNG解码涉及两种不同性质的数据缓存。一是行级缓存用于反滤波模块保持上一行像素数据这类数据量不大一张1920x1080真彩图每行约5760字节BRAM就能放得下二是整帧缓存用于将解码后的完整图像帧交给显示控制器或图像处理模块这种大缓存必须用外部存储。10套工程里有一个明显分水岭是否引入DDR控制器。不带DDR的工程通常只支持小分辨率或者直接输出流水线带DDR的工程才具备“整帧回读”能力也能衔接后面的HDMI显示。DDR这块我要提醒一个细节。解码模块写DDR和显示模块读DDR两边的地址通常都按行组织。写入侧要尽量保持“行连续突发写”显示侧要按扫描顺序顺序读这能最大化利用DDR带宽。很多新手写成“一个像素写一次”那种零散访问结果DDR带宽利用率惨不忍睹最后又反过来怀疑显示时序出问题。3.3 10套工程怎么选按芯片和接口匹配“10套工程”不是10个同样代码换个目录名而已通常是根据目标芯片资源和接口类型做出差异化适配。选工程时先看三个维度芯片规模纯逻辑解码一个1080p PNG大约需要几十个DSP和不少BRAM如果你的板子上BRAM只有几十KB那就只能降分辨率或者简化行缓存策略输入来源PNG文件放哪SPI Flash读取慢但接线少SD卡读取快但需要SD协议模块串口最慢但调试最方便输出接口VGA时序简单、资源占用低HDMI需要TMDS编码MIPI则要看芯片是否有硬核。所以拿到这套源码不要一上来就开最大那个工程。正确做法是先跑仿真再用最小输出接口的工程上板确认解码链路然后逐步加DDR、加图层、加图像处理模块。4. 核心模块的Verilog实现细节4.1 文件解析与CRC校验接口复杂但逻辑简单文件解析模块看起来不起眼但它决定了系统能不能正确地从Flash流中认出“哦PDATA开始了”。状态机按照PNG块结构一条路走到底检测签名后进入chunk循环读当前chunk的长度和类型再做分叉处理。这里的关键点是“流式解析”而不是一次性把文件读进内存。FPGA板上不可能把一张几MB的PNG全放在BRAM里所以解析模块要边读边判断把IDAT数据包的内容直接转交到下一级其他块要么跳过、要么把参数寄存器记录下来存入配置寄存器组。CRC校验也不难就是标准的CRC32多项式0xEDB88320每收一个字节迭代一次核心控制在20行Verilog以内。有个坑需要特别说明PNG规范允许一个文件包含多个IDAT块但所有IDAT块的数据必须被视作连续的。也就是说你的状态机在解析到非IDAT块之后不能说“IDAT流结束了”必须把后续所有IDAT块的数据原样接入zlib解压器中间不能夹带任何块头字节。我第一次实现时就在这儿栽过跟头——解码出来的图像中间总有一段花屏后来打印IDAT拼接边界才发现是漏了第二段。4.2 Inflate硬件化的重头戏Huffman解码与LZ77窗口zlib/DEFLATE解压是硬件实现的“珠穆朗玛峰”。DEFLATE算法用到了两种Huffman编码一种是动态Huffman码表本身也编码在数据流里面另一种是静态Huffman码表由标准固定给出。算法流程大致是读取码流中的Huffman表定义如果BIT类型标记为动态表重建出“字面/长度”码表和“距离”码表逐比特解码遇到0~255的字面量直接输出遇到符号256表示块结束遇到符号257~285表示一次LZ77匹配需要再读距离码表得到回指距离维护一个长度为32KB的滑动窗口输出匹配时把窗口内对应位置的历史字节复制到输出端。硬件实现时Huffman解码最忌讳“逐比特循环”。正确做法是使用查表法将当前码流中的一定长度比如16位作为索引查一个预构建的映射表一次确定码长和符号。代价是存储器开销但速度极快。动态Huffman表每次重建时要重新生成映射表需要用Block RAM存储并配套一个“表项有效”标记。LZ77的32KB滑动窗口在FPGA里同样是一笔不小的资源。常见做法是采用双端口BRAM写端口按顺序写入刚刚解码出的字节读端口按匹配距离回溯读取。需要注意字节复制可能重叠——比如距离是1时要连续复制同一个字节很多次此时必须让读地址和写地址都推进读取而不是简单地“读一次复制一遍”。我强烈建议你先把软件参考代码读懂再用状态机重写别一上来就画RTL。解码器本身就是个微型CPU——有码流缓冲、有状态跳转、有表更新。把它理顺成一个“专用处理器”来设计会比硬堆状态机更清晰。4.3 反滤波模块五种滤波类型的硬件处理zlib解压完成之后拿到的是滤波后的逐行字节流。反滤波模块要处理的五种滤波器如下None当前像素原样输出Sub用当前像素减去同一行左侧像素解码后值Up用当前像素减去上一行同列像素Average用当前像素减去左右两参考值的平均值向下取整Paeth取左、上、左上三个邻居的Paeth预测值用当前像素去减。需要注意的是每个“像素”并不一定是一个字节。RGBA8图片每像素4字节滤波运算是按“字节”独立进行的也就是解码时要把每个颜色通道拆开分别做预测。类似地位深小于8位的索引图需要按像素打包处理更繁琐。硬件设计上这一模块非常简单一行像素数据进入后先放在一个与行宽相同的移位寄存器或小BRAM里同时读出上一行的缓存值按滤波器类型通过一个多路选择器选择运算路径结果一方面输出到下一级另一方面回写进“上一行缓存”供下一行使用。由于每行的滤波类型存储在这一行数据的第一个字节中解析模块必须先把该字节取出再让后续数据进入运算单元。4.4 输出联动给图像处理模块留好口子解码得到的像素数据如果不送去显示那这个工程就只完成了一半。实操中输出侧常见需求是RGB888转RGB565、加入缩放、叠加OSD、以及做一些简单的图像增强亮度/对比度调整。这些都是标准FPGA图像处理逻辑。我提供的思路是在解码输出端定义一个统一的数据包协议sop、eop、valid、ready、data[23:0]。这样下游不管接VGA控制器、HDMI发送器、还是双线性插值模块都只需要按同一套握手协议对接即可。很多“10套工程源码”之所以看起来复杂其实只差在输出接口不同核心解码模块几乎可以原封不动地复用。5. 实操过程从仿真到上板验证5.1 Testbench解码模块最容易被忽视的一环PNG解码是数据密集型设计验证难度远大于普通接口逻辑。如果你不上来写一个能高效生成PNG测试文件的脚本调试过程会让人崩溃。我的做法是分为两层第一层用Python/Pillow生成一系列PNG文件覆盖不同颜色类型、不同分辨率、带或不带Alpha通道、隔行和非隔行、包含多IDAT块的文件。文件名里直接体现图片规格作为仿真激励输入到Testbench。第二层Testbench里用一个任务task按字节读取PNG文件经过简单模拟的Flash/串口接口送入DUTDUT解码后把输出的像素数据写入一个新的文本文件。仿真跑完后再用Python脚本把输出文本文件和原图做逐像素比对输出差异位置与数量。Testbench的框架大致是initial begin // 读取PNG文件到内存 $readmemh(test_img.hex, file_buf); // 按时序把数据送入解码模块 ... end这里我特别想强调测试不能只测“能不能出图”必须做像素级比对。有时候解码器输出的图看起来完全正常但个别像素值差了个1、2这种细微错误在用肉眼看屏幕时根本发现不了但在工业检测场景就是废片。5.2 仿真全流程用Icarus Verilog跑一遍纯Verilog代码有个好处就是可以直接用Icarus Verilog这种开源仿真器快速验证不需要一上来就开Vivado/Quartus的图形化环境。命令行里写一行iverilog -o png_decode_tb.vvp png_decode_tb.v png_decode_top.v vvp png_decode_tb.vvp gtkwave waveform.vcd先用波形查看器确认几个关键信号块解析状态机的跳转、IDAT有效信号的翻转、解压模块的握手时序、反滤波模块的像素输出。这些信号稳定后再考虑跑综合。顺序一定要遵守仿真验证后再上板。PNG文件是存储在Flash里的每次更新图片就得重新写Flash这在调试阶段慢得可怕。仿真阶段把逻辑调通上板只是查时序和硬件接口问题能节省大量时间。5.3 综合时序与资源约束注意BRAM和时序收敛经过仿真调通后就可以在Vivado或Quartus里建工程综合了。几条实践中总结的约束建议给不同时钟域添加时序约束Flash读取时钟、解码工作时钟、显示像素时钟通常不是同一个域中间所有跨域信号都必须走异步FIFO或打拍处理解压模块的Huffman表查找用BRAM实现要留意BRAM端口数和RAM读延迟。用得不好会拖慢主频行缓存长度要跟最大行宽匹配。如果你的工程要支持1920宽度而BRAM只有512深度那就要拆分成多块拼接或者考虑把行缓存搬到DDR里。资源方面1080p RGBA8的PNG解码在Artix-7级别芯片上大概要消耗60%左右的BRAM和20%左右的LUT。具体数字因配置而异但至少要心里有数这不是一个“资源贪吃”的模块是可以和ISP、显示控制器共存的。6. 常见问题与排查技巧实录6.1 典型故障速查表现象常见原因排查方法解码出的图像整体偏绿/偏蓝RGB通道顺序或位宽映射错误查看通道输出位置确认RGB888到RGB565的截位方式图像中某一段出现花屏/噪声多IDAT块拼接错误或解压模块FIFO溢出丢数在IDAT边界加调试标志检查拼接流程图像上方正常、下方错乱上一行缓存没有被正确缓冲Up/Paeth滤波缺数据检查反滤波模块的行切换时序确认行首标志被正确传递图像整个顺时针/逆时针扭曲或左右颠倒像素地址映射问题可能在DDR写入/读出顺序不一致打印首行前N个像素地址和预期比较仿真正常、上板显示不完全跨时钟域毛刺导致偶发丢数检查FIFO的读写时钟是否配置正确复位释放是否同步解码速度慢到肉眼可见主频太低或握手时序等待过长检查握手信号是否因ready持续为低而阻塞6.2 踩过的坑和避坑方法先说IDAT拼接的问题。PNG规范允许IDAT块分成多个并且允许中间插着其他辅助块。我第一次在状态机里只要看到非IDAT块就认为数据解压结束结果压缩流被活生生截断zlib解压器收到不完整数据一连排错拍了一整天。解决办法很简单所有IDAT块的数据交给解压器时只传数据payload不要传块头判断解压是否结束应该由zlib流内部的块结束标志决定而不是文件块的边界。再说LZ77窗口回拷逻辑。有的图片压缩后必须回拷超过32KB距离如果你的窗口BRAM只有8KB深度就会出现匹配失败、图像出现整块“崩坏”。调这类问题不能只看着屏幕猜要解压到一半时打印当前匹配距离跟软件解码器的输出对比。还有一点关于复位。图像处理链路特别怕复位信号不同步。解码模块、FIFO、显示控制器如果共用同一个异步复位但时钟不同很容易出现其中一个模块还在复位、另一个已经开始请求数据的现象。我习惯在顶层用一个复位同步器生成统一复位的本地复位信号再分发到各模块。6.3 提高调试效率的几个小经验在关键总线旁边加调试选择器平时输出正常像素通过一个寄存器开关可以把厂商解码出的中间数据比如滤波类型、Huffman符号计数引出到几个复用IO口用逻辑分析仪观察准备一张固定的测试图片色彩渐变图最容易暴露通道错位带文字的字幕图最容易暴露边缘色差和滤波错误棋盘格适合检查缩放和DDR地址映射善用$dumpfile配合Python仿真时把中间数据导出用Python离线还原一遍能快速定位是算法还是时序的锅不要过度依赖仿真看波形。PNG解码波形是海量的动辄几百万个时钟周期逐周期看波形不现实正确方式是“打印关键节点的统计计数”。我实际花费时间最长的工程调试往往不是逻辑错误而是接口握手死了——某个模块在异常情况下把valid拉高但数据端没准备好结果FIFO写满阻塞了整条流水线。所以验收源码时一定要仔细检查每个FIFO的prog_full和almost_empty是否都接到了正确的反压逻辑上。7. 这个项目还能扩展成什么7.1 落地场景一览PNG硬解最直接的落地是开机Logo与界面底图显示。很多带屏幕的工业设备、仪器仪表、医疗设备不支持跑大型嵌入式系统只靠FPGA带上一个屏幕。此时把Logo、警告图标、操作菜单以PNG格式存在SPI Flash里FPGA上电后只花几十毫秒就硬解完成体验比从BMP裸数据读出来好太多因为存储占用能低一个量级。另一个场景是工业视觉的预处理链路。设备从图像传感器拿到的是RAW/YUV数据但工业上位机下发到设备里的往往还有参数模板、叠加图层甚至参考图像。这些图片如果走PNG压缩FPGA硬解后直接进入图像融合/差分比较模块可以省掉中间CPU转发的延迟。7.2 与更多FPGA图像处理模块的组合解码模块一旦做扎实向外扩展就非常顺手。比如在解码输出后加一个双线性插值缩放器就能实现“把任意尺寸PNG无级缩放到屏幕分辨率”的效果再接一个滑动窗口滤波模块可以做个轻量级去噪配合UART或者I2C配置寄存器还能动态切换解码图像和实时摄像头画面实现画中画。这些模块我之前都做过惊喜地发现它们之间只需要统一好握手协议拼起来就完事。所以如果你手上正好有FPGA图像处理的整体需求与其单独写解码器不如把解码、缩放、混合、显示一条龙做成标准流水线后面接什么应用都好办。7.3 对学习路径的价值还有一个不能忽视的价值——学习意义。纯Verilog PNG解码几乎是“硬件工程综合练习”的集大成者文件解析锻炼状态机设计CRC实现锻炼移位运算和查表Huffman解码锻炼存储资源和时序的权衡LZ77窗口锻炼读改写地址管理反滤波锻炼流转发和行缓冲。把这些全做一遍你对“数据如何在FPGA里流动”的理解会远超只会调用IP核的人。如果你准备从这套工程入门或者进阶我建议的顺序是先下载Icarus Verilog仿真环境挑最小的那个工程把Testbench跑通然后尝试修改图片尺寸参数观察资源报告的变化。等熟悉了模块接口再尝试自己删除DDR缓存、改成纯流水线输出以此来理解缓存策略的边界。最后再分享一个自己实际开发中的心得不要一上来就追求“万能解码”。先把固定格式、固定分辨率的PNG跑通再逐步放开颜色类型、位深、隔行扫描这些高级选项。这个项目里的10套工程源码本质就是给了一条由简到繁的路线图你顺着它走一遍比自己在网上拼凑碎片知识高效太多。真正上手之后你会发现FPGA并不是只能做高速信号处理只要数据流足够规整图像压缩解压这种活它也能干得漂漂亮亮。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进