
1. 为什么要在FPGA里硬解PNG做图像处理的朋友大概率都遇到过这个场景上位机或者摄像头给过来一张PNG图片想在FPGA内部直接做后续的缩放、叠加、滤波结果卡在第一步——PNG是压缩格式FPGA拿到的是压缩码流没法直接当像素用。常规做法是先在PC端用软件把PNG转成BMP或者RAW再喂给FPGA但这样整个链路就多了一道人工预处理产品化的时候非常别扭。PNG解码放到FPGA里做核心价值就在于把解压这一步下沉到硬件让整个图像链路真正闭环。你想想工业相机、医疗内窥镜、车载显示这些场景图像源本身可能就是PNG压缩存储的如果FPGA能直接吃PNG码流省掉一颗处理器、省掉一次内存搬运延迟和功耗都能压下来。这也是为什么“FPGA纯verilog实现PNG图片解码”这个方向一直有人在做——它不是炫技是真实需求驱动的。PNG解码本身不算特别复杂的算法但它的坑在于几个环节耦合得比较紧zlib解压涉及Huffman变长码、LZ77回溯窗口滤波涉及五种滤波类型的逐像素递推再加上CRC校验、块结构解析任何一个环节没处理好整张图就花屏。用纯Verilog写意味着你不能调库、不能用现成的软核所有状态机、存储器接口、时序收敛都得自己扛。这套工程源码提供10套不同配置的实现覆盖了从入门验证到实际产品落地的不同需求适合已经会写Verilog、想啃一个完整图像处理项目的朋友。我先把结论放前面PNG解码在FPGA里最难的从来不是算法本身而是存储带宽和状态机调度。Huffman解码是串行的、逐bit的而像素输出是并行的、按行的这两者之间的速率匹配和缓冲设计才是决定你能不能跑通、能跑多快的关键。下面我按实际做项目的思路把整个设计拆开讲。2. 整体架构与方案选型拆解2.1 PNG文件结构决定了模块划分PNG文件是块Chunk结构每个块有长度、类型、数据、CRC四部分。解码真正关心的是三个块IHDR图像头宽高、位深、颜色类型、IDAT压缩的图像数据可能有多块、IEND结束。所以顶层模块划分基本是跟着这个结构走的块解析模块逐字节读入识别块类型把IHDR的宽高、位深、颜色类型提取出来把IDAT的数据流连续输出给解压模块。zlib解压模块这是重头戏包含Huffman解码和LZ77滑动窗口回溯。滤波重建模块对解压出来的扫描线做反滤波恢复原始像素。像素输出模块按颜色类型做通道拆分输出RGB或灰度。这个划分不是拍脑袋定的是因为每个模块的时钟域和数据速率差异很大。块解析是字节级的Huffman解码是bit级的滤波是像素级的如果全塞一个状态机里时序会非常难看。分开之后模块之间用FIFO或者简单的握手信号衔接各自跑各自的节奏时序收敛容易得多。2.2 为什么选纯Verilog而不是HLS现在很多人做FPGA图像处理喜欢用HLS写C然后综合。但PNG解码这个场景我不建议用HLS原因有三个。第一Huffman解码是典型的变长码逐bit处理HLS对这种细粒度位操作的支持很别扭综合出来的电路往往比手写Verilog大一圈。第二LZ77回溯窗口需要精确控制存储器的读写地址和时序HLS的存储器推断经常给你整出意想不到的BRAM配置。第三也是最现实的——纯Verilog的代码可移植性极强从Xilinx到Altera到国产FPGA改改原语就能跑HLS的IP核跨平台迁移是个噩梦。这套工程提供10套源码我理解它的用意是覆盖不同的资源约束和性能目标。比如有的版本用单端口BRAM做滑动窗口省资源但慢有的版本用双端口BRAM加乒乓缓冲快但吃BRAM。你拿到源码之后先看自己板子上BRAM够不够、目标分辨率多大、帧率要求多少再选对应的版本改。2.3 关键参数位深、颜色类型与滤波PNG支持多种位深1/2/4/8/16和颜色类型灰度、真彩、索引、带Alpha。位深和颜色类型直接决定了解码后每个像素占多少bit也决定了滤波时的字节对齐方式。这里有个容易踩的坑位深小于8的时候一行像素不是字节对齐的。比如1bit灰度图一行如果有13个像素那这一行只占2个字节13bit向上取整到16bit但滤波是按字节做的你得先把bit流按行打包成字节滤波完再拆回bit。很多开源实现直接假设8bit遇到1bit图就崩了。滤波有五种类型None、Sub、Up、Average、Paeth。每一行扫描线的第一个字节是滤波类型后面才是数据。反滤波是逐像素递推的Sub依赖左边像素Up依赖上一行同位置像素Average和Paeth依赖左边和上边。这意味着你至少得缓存上一行像素才能做当前行的反滤波。这个“上一行缓存”就是一块行缓冲RAM宽度等于图像宽度乘以每像素字节数。3. 核心模块的Verilog实现细节3.1 Huffman解码的状态机设计zlib的Huffman解码分两步先解动态Huffman头如果块类型是动态的得到码长表再根据码长表构建解码树或者码表最后逐bit解码。纯Verilog实现通常不建树而是用码长计数偏移的方式做规范Huffman解码因为建树需要指针和动态内存硬件里不划算。规范Huffman解码的核心思路是先统计每个码长的码字数量算出每个码长的起始码值然后逐bit读入边读边比较当前累积码值是否落在某个码长的范围内。这个过程是串行的每个符号至少读1bit最多读15bitzlib限制最大码长15。所以一个符号的解码周期数是不固定的这就是为什么Huffman解码模块的吞吐率是变化的。我的做法是给Huffman解码器配一个输出FIFO解出来的符号先塞FIFO后面的LZ77模块从FIFO里取。这样Huffman解码器可以全速跑不用等下游。FIFO深度要算一下最坏情况下一个符号解码要15个周期而LZ77处理一个符号可能只要1个周期所以FIFO深度至少得能缓冲几十个符号防止上游饿死下游或者下游撑爆上游。实际工程里我一般给64深度够用了。// Huffman解码核心状态机片段简化示意 localparam S_IDLE 3d0; localparam S_READBIT 3d1; localparam S_COMPARE 3d2; localparam S_OUTPUT 3d3; always (posedge clk) begin case (state) S_IDLE: begin if (bit_valid) begin cur_code 0; cur_len 0; state S_READBIT; end end S_READBIT: begin cur_code {cur_code[13:0], bit_in}; cur_len cur_len 1b1; state S_COMPARE; end S_COMPARE: begin // 查表判断当前码值是否对应有效符号 if (code_match) begin sym_out matched_symbol; state S_OUTPUT; end else if (cur_len 15) begin // 超过最大码长报错 state S_ERROR; end else begin state S_READBIT; end end S_OUTPUT: begin fifo_wr_en 1b1; state S_IDLE; end endcase end这段代码的关键在于code_match的查表逻辑。实际工程里我不会用这么大的组合逻辑去比较而是预先把码长表和起始码值算好存在寄存器里比较的时候用减法判断范围这样时序好收敛。3.2 LZ77滑动窗口的存储器实现LZ77解压的核心是一个32KB的滑动窗口zlib规定窗口大小最大32KB。解压时遇到两种符号字面量直接输出和长度-距离对从窗口里回溯复制。这个32KB窗口在FPGA里只能用BRAM实现因为分布式RAM根本放不下。问题来了32KB的BRAM如果用一个双端口BRAM一个端口写存新解出的数据一个端口读回溯复制读写可能同时发生而且回溯复制的读地址和写地址可能重叠。这时候如果读写冲突BRAM的输出是不确定的。解决办法有两种一是用真双端口BRAM读写独立二是用两个单端口BRAM做乒乓但这样窗口管理会复杂很多。我一般选真双端口BRAM因为现在主流FPGA的BRAM都支持真双端口。但要注意回溯复制的时候如果复制的长度超过距离比如距离是3长度是10意味着你要复制刚刚写进去的数据这时候读地址会追上写地址。硬件里必须处理这种情况要么等写完成再读要么用旁路逻辑直接从写数据通路取数。我踩过的坑是没处理这个结果解压出来的图像在重复区域出现规律性花屏查了好久才发现是读写冲突。// LZ77回溯复制地址生成简化示意 // dist为回溯距离len为复制长度 always (posedge clk) begin if (copy_start) begin rd_addr wr_addr - dist; copy_cnt len; copying 1b1; end else if (copying) begin if (copy_cnt 0) begin copying 1b0; end else begin rd_addr rd_addr 1b1; copy_cnt copy_cnt - 1b1; end end end这里wr_addr是当前写指针rd_addr是回溯读指针。注意地址是模32K回绕的所以实际代码里地址位宽是15bit加法自然溢出就是回绕。3.3 反滤波的逐像素递推反滤波是逐像素递推的每个像素的计算依赖前一个像素Sub、Average、Paeth和上一行同位置像素Up、Average、Paeth。这意味着你没法并行处理一行里的多个像素只能一个接一个算。这是PNG解码的固有串行性没法绕过。但你可以做流水线把“取上一行像素”“取左边像素”“计算滤波值”“写回行缓冲”拆成几级流水这样虽然单个像素还是要多个周期但整体吞吐率能上去。我的经验是反滤波模块跑在像素时钟下每个像素3到4个周期对于1080p60Hz来说像素时钟大概148.5MHz一行1920像素一行时间约13微秒反滤波一行需要1920*47680周期约52微秒明显跟不上。所以实际工程里要么提高时钟频率要么用多个反滤波单元并行处理多行——但PNG的滤波是行间依赖的并行处理多行需要更复杂的行缓冲管理。这也是为什么这套工程提供10套源码不同版本在资源和速度之间做了不同的取舍。有的版本牺牲帧率保资源有的版本堆资源冲帧率。你得根据自己的实际需求选。滤波类型依赖关系计算复杂度硬件实现要点None无最低直接透传Sub左像素低需要左像素寄存器Up上像素低需要行缓冲Average左上中需要左寄存器和行缓冲Paeth左上左上高需要三个像素值含绝对值比较Paeth滤波是五种里最复杂的它要计算三个预测值然后选最接近的那个。硬件里做绝对值比较和选择组合逻辑会比较深建议插一级寄存器。4. 完整解码流程与实操步骤4.1 从文件到像素的完整数据通路整个解码流程我按数据流顺序捋一遍。第一步文件读入。你可以从SD卡读、从Flash读、从UART收甚至从网络收但不管来源是什么最终都是字节流。块解析模块逐字节扫描找到IHDR后把宽高、位深、颜色类型锁存到寄存器找到IDAT后把数据透传给zlib解压模块。第二步zlib解压。IDAT数据前面有2字节的zlib头后面有4字节的Adler-32校验。zlib头里有个FCHECK字段用来验证头是否正确实际工程里可以跳过校验直接解压但Adler-32建议保留用来验证解压数据完整性。解压出来的数据是滤波后的扫描线每行开头一个字节是滤波类型。第三步反滤波。解压数据按行送入反滤波模块模块根据每行第一个字节的滤波类型对后续像素做反滤波恢复原始像素值。反滤波后的像素写入行缓冲供输出模块读取。第四步像素输出。根据颜色类型把像素值拆成RGB通道或者灰度值加上行同步、场同步信号输出给后续的图像处理模块或者显示接口。这个通路里块解析和zlib解压之间、zlib解压和反滤波之间都需要FIFO做速率匹配。FIFO深度根据最坏情况下的速率差来定我一般给块解析到解压之间128深度解压到反滤波之间256深度实测够用。4.2 关键配置参数的计算过程假设你要解一张1920x1080的24位真彩PNG颜色类型是2真彩位深8。每像素3字节一行5760字节。滤波后的扫描线每行5761字节多一个滤波类型字节。32KB的滑动窗口能缓存约5.7行够LZ77回溯用。行缓冲需要缓存上一行反滤波后的像素大小是5760字节。用BRAM实现的话一个18Kb的BRAM是2048字节需要3个BRAM拼起来。如果你用的是Xilinx 7系列一个36Kb BRAM可以配置成两个18Kb所以两个36Kb BRAM就够了。Huffman解码的FIFO深度我前面说64这里再算一下。最坏情况下一个符号解码15周期LZ77处理一个符号1周期速率差15倍。如果LZ77连续处理FIFO会在15个周期内被读空所以FIFO深度至少15。但考虑到LZ77遇到长度-距离对时复制一个长度可能持续几十个周期这期间Huffman解码器可以继续往FIFO里塞符号所以FIFO深度要能缓冲这期间解出的所有符号。我实测64深度能覆盖绝大多数情况极端情况可能溢出但PNG的Huffman码长分布通常不会那么极端。4.3 仿真验证与上板调试纯Verilog工程最怕的是仿真过了上板挂。我的做法是分三级验证第一级用Icarus Verilog做行为仿真喂一张小图比如16x16看解出来的像素和PC端软件解的是否一致。第二级用Vivado或者Quartus做综合后仿真检查时序违例和BRAM推断是否正确。第三级上板用ILA抓关键信号看实际运行时FIFO有没有溢出、状态机有没有卡死。Icarus Verilog的好处是轻量跑仿真快适合前期功能验证。但它的时序模型比较简单综合后仿真还是得用厂商工具。我一般用Icarus跑功能用Vivado跑时序。上板调试的时候ILA抓的信号我建议包括Huffman解码状态机的状态、FIFO的读写指针、LZ77的读写地址、反滤波的行号和像素号。这几个信号能覆盖90%的问题。如果图像花屏先看FIFO有没有溢出如果图像错位看行号和像素号对不对如果颜色不对看颜色类型解析对不对。提示上板前务必用一张已知内容的测试图比如纯色渐变或者棋盘格这样花屏的时候容易定位是哪个环节出的问题。纯色图花屏通常是滤波问题棋盘格花屏通常是Huffman解码或者LZ77回溯问题。5. 常见问题与排查技巧实录5.1 图像花屏的典型原因花屏是PNG解码最常见的故障现象但原因可能分布在多个环节。我整理了一个排查顺序按概率从高到低现象可能原因排查方法整屏随机噪点Huffman解码错误检查码长表构建对比软件解码的符号序列规律性条纹LZ77回溯地址错误检查读写指针回绕逻辑抓回溯时的读写地址图像上下错位行缓冲管理错误检查行号计数和行缓冲切换逻辑颜色偏色颜色类型解析错误检查IHDR解析确认颜色类型和位深图像右侧缺失行像素计数错误检查每行像素数计算注意位深小于8的情况图像整体偏移滤波类型解析错误检查每行第一个字节的滤波类型读取我遇到最多的是LZ77回溯地址错误。具体表现是图像里出现重复的条纹而且条纹间距和回溯距离有关。原因是读写指针回绕的时候没有正确处理模运算导致读地址算错。解决办法是在地址生成逻辑里明确用15bit位宽让加法自然溢出。5.2 时序收敛的实战经验PNG解码模块的时序收敛难点在Huffman解码的组合逻辑和LZ77的地址计算。Huffman解码的码值比较如果用纯组合逻辑15bit的比较链延迟很大在高速时钟下容易违例。我的做法是把码长表和起始码值预计算好比较的时候用减法而不是逐位比较这样组合逻辑深度从15级降到1级减法。LZ77的地址计算涉及减法和模运算组合逻辑也不浅。我一般在这里插一级流水把地址计算和BRAM读分开。代价是回溯复制多一个周期延迟但时序能好很多。还有一个容易忽略的点是跨时钟域。如果你的图像源时钟和解码时钟不同频FIFO的读写指针跨时钟域同步必须做格雷码转换否则亚稳态会导致FIFO指针错乱。这个坑我在早期项目里踩过现象是偶发性花屏概率很低但很难查。5.3 资源优化的取舍10套源码里资源占用差异主要来自三个地方滑动窗口的BRAM配置、行缓冲的数量、Huffman解码器的并行度。如果你板子BRAM紧张可以选单端口BRAM版本代价是解压速度慢一半。如果逻辑资源紧张可以选串行反滤波版本代价是帧率低。我的建议是先用中等配置的版本跑通看资源报告里BRAM和LUT的余量再决定往哪个方向优化。不要一上来就冲最高性能版本那样调试难度大容易卡住。注意BRAM的初始化是个坑。滑动窗口和行缓冲在上电后内容是随机的如果不初始化第一帧解码可能会因为读到随机数据而出错。我的做法是在复位期间把BRAM全部写0虽然多花几个周期但能保证第一帧正确。6. 工程源码的使用与二次开发6.1 10套源码的选型建议这10套源码我按适用场景分个类。入门验证选第1、2套它们用最小的资源实现了基本解码功能适合先跑通流程。性能优化选第5、6套它们用了双端口BRAM和流水线反滤波帧率能上去。资源受限选第8、9套它们用单端口BRAM和串行反滤波BRAM占用少。第10套通常是完整版带所有优化和调试接口。你拿到源码后先看README里的资源报告和时序报告对比自己板子的资源。如果BRAM差一点就选BRAM少的版本如果时序差一点就选流水线深的版本。不要盲目改代码先跑通再优化。6.2 移植到不同FPGA平台的注意事项从Xilinx移植到Altera或者国产FPGA主要改三个地方BRAM原语、时钟管理、IO标准。BRAM原语不同厂商的接口不一样但功能类似改改端口名和配置参数就行。时钟管理如果是用MMCM或者PLL换成对应厂商的IP核。IO标准根据板子原理图改约束文件。移植的时候最容易出问题的是BRAM的读写模式。Xilinx的BRAM默认是读优先或者写优先Altera的可能不一样。如果读写冲突时的行为不同解码结果可能不一致。我的做法是在代码里显式处理读写冲突不依赖BRAM的默认行为这样移植到哪个平台都一样。6.3 后续扩展方向这个解码器跑通之后可以往几个方向扩展。一是加缩放解码出来的像素直接送双线性插值模块实现任意分辨率输出。二是加叠加解码出来的图像和摄像头图像做Alpha混合。三是加压缩解码后再用JPEG或者H.264编码输出适合网络传输场景。我个人觉得最有价值的是加缩放因为PNG图往往分辨率固定但显示设备分辨率多样能实时缩放就省了外部处理器。双线性插值的Verilog实现网上很多但要注意和PNG解码的时钟域衔接别引入额外的FIFO延迟。最后分享一个小技巧调试PNG解码的时候把解压出来的原始扫描线数据存到文件里和PC端zlib解压的结果做逐字节对比。这样能快速定位是解压错了还是反滤波错了。我一般用Verilog的$fwrite把数据写到文本文件然后用Python脚本对比。这个方法比抓波形直观得多尤其是数据量大的时候。