资讯动态

纯Verilog实现PNG解码:从Huffman到LZ77的FPGA硬件方案

发布时间:2026/9/29 13:33:42 来源:尧图企业网站定制
PNG 解码这件事放在 PC 上就是一句stb_image调用的事但搬到 FPGA 里用纯 Verilog 从零撸一遍性质就完全变了。你要面对的是 DEFLATE 的变长哈夫曼码流、Zlib 的滑动窗口回溯、逐字节的位序翻转、以及滤波器对像素的重建——这些在软件里靠指针和递归几行搞定在硬件里全得拆成状态机、BRAM 和流水线。我前后做过几版 PNG 解码的 FPGA 工程从最初只能解固定 Huffman 的玩具版本到后来能跑通绝大多数标准 PNG 的完整实现中间踩的坑足够写一本小册子。这篇就把这套纯 Verilog 的 PNG 解码方案完整拆开讲包括整体架构怎么切、关键模块怎么写、10 套工程源码分别覆盖什么场景、以及实际调试时那些文档里不会告诉你的细节。不管你是刚入门想找个有挑战性的图像处理项目练手还是已经在做视频采集链路需要把 PNG 素材直接喂进 FPGA这套东西都能直接拿去改。1. 为什么要在 FPGA 里硬解 PNG1.1 先想清楚这件事的动机很多人第一反应是PNG 解码用 CPU 或者软核不就行了为什么非要用纯 Verilog 硬扛这个问题必须先回答清楚否则项目做到一半就会怀疑人生。核心动机有三个。第一是实时性。在高速图像采集或者视频叠加的场景里如果每帧都要经过软核解码CPU 的算力会被吃光帧率直接掉下来。用纯硬件流水线解码一个像素时钟出一个像素吞吐量是软核方案比不了的。第二是资源受限场景。有些低端 FPGA 根本没有足够的逻辑资源去跑一个带 MMU 的软核或者你压根不想引入 CPU 这个复杂度纯硬件方案反而更干净。第三是学习价值。PNG 解码几乎把数字设计里所有硬骨头都凑齐了变长编码、状态机、存储器管理、位操作、跨时钟域做完一遍对 Verilog 的理解会上一个台阶。我最早做这个项目是因为一个工业相机的预处理需求相机输出的图像以 PNG 压缩存储需要在 FPGA 内部直接解出来做实时滤波再送显示。软核方案延迟太大最后逼着走了纯硬件路线。1.2 PNG 解码到底难在哪PNG 的压缩链路是两层嵌套的外层是 ZlibRFC 1950内层是 DEFLATERFC 1951。DEFLATE 本身又混合了 LZ77 字典压缩和 Huffman 编码。这意味着你不能简单地读一段解一段而是要先解析块头、构建 Huffman 码表、然后按位流逐符号解码、遇到回溯还要去滑动窗口里捞数据。具体到硬件实现的难点我列一下位流是 MSB-first 的而 Verilog 里移位操作习惯是 LSB-first位序处理稍不注意就全错。Huffman 码是变长的没有固定边界必须逐位比较或者用查表法前者慢后者费 BRAM。LZ77 回溯有距离和长度两个参数距离最大可以到 32768意味着滑动窗口至少要 32KB 的存储。PNG 的滤波器是逐行依赖的当前行的重建依赖上一行不能完全并行。IDAT 数据可能跨多个块解码器要能连续处理。把这些串起来一个完整的解码器状态机少说十几个状态多则几十个。下面这张表是我总结的各阶段难点和对应策略解码阶段主要难点硬件应对策略块解析块长度、CRC 校验状态机逐块读取CRC 可选用Zlib 头两字节头 可选字典简单状态跳过Huffman 建表码长表到码表的转换用规范 Huffman 算法BRAM 存表符号解码变长码逐位匹配逐位状态机或并行查表LZ77 回溯32KB 窗口随机读双口 BRAM 做滑动窗口反滤波逐行依赖行缓冲 上一行缓存像素输出位深、颜色类型多样参数化输出格式1.3 纯 Verilog 方案的边界需要提前说清楚纯 Verilog 方案不是万能的。它适合的是标准 PNG、非隔行、位深 8 位、颜色类型为灰度/RGB/RGBA这类主流情况。对于 16 位深、调色板模式、Adam7 隔行扫描这些边缘情况要么额外加逻辑要么直接不支持。我提供的 10 套工程里基础版本只覆盖 8 位 RGB/RGBA进阶版本才逐步加上灰度、调色板支持。另外纯 Verilog 意味着不依赖任何 IP 核除了 BRAM 这种原语所有逻辑自己写。好处是可移植性极强从 Xilinx 到 Altera 到国产 FPGA 都能跑代价是代码量大调试周期长。2. 整体架构怎么切分才合理2.1 从数据流角度划分模块一个 PNG 解码器的数据流是线性的输入是压缩字节流输出是解压后的像素。中间要经过块解析 → Huffman 解码 → LZ77 回溯 → 反滤波 → 像素输出这几级。我建议按这个数据流切模块每个模块职责单一接口用简单的 valid/ready 握手。具体模块划分如下png_parser负责解析 PNG 签名、IHDR、IDAT、IEND 等块提取图像宽高、位深、颜色类型并把 IDAT 数据流引出来。zlib_inflate核心解码模块内部又分 Huffman 建表、符号解码、LZ77 回溯三个子模块。unfilter对解压后的原始扫描线做反滤波重建真实像素。pixel_out把像素按目标格式比如 RGB888输出处理位深转换和通道对齐。模块之间用 FIFO 或者简单的握手信号连接。我实测下来用 valid/ready 握手比用 FIFO 更省资源因为解码本身是流式的不需要大缓冲。2.2 状态机的粒度选择状态机粒度是这个项目最容易做错的地方。粒度太粗一个状态里塞太多逻辑时序跑不上去粒度太细状态跳转开销大吞吐量掉下来。我的经验是按一个时钟周期能稳定完成的最小操作来切。比如读一个字节、比较一位、写一次 BRAM这些各算一个状态。Huffman 逐位解码就是典型的需要细粒度状态机的地方——每读一位判断一次虽然慢但逻辑简单、时序好收敛。如果你追求吞吐量可以用并行查表法一次读入 Huffman 码表的最大码长比如 15 位用这 15 位去查表直接得到符号和实际码长。这样一位一位的循环就变成了一次查表速度快很多代价是 BRAM 消耗大。10 套工程里基础版用逐位法高性能版用查表法你可以按需选。2.3 时钟域与复位策略整个解码器我建议跑在单一像素时钟域下避免跨时钟域的麻烦。输入字节流如果来自其他时钟域在入口处加一个异步 FIFO 隔离即可。复位用同步复位高有效所有状态机统一复位避免异步复位带来的亚稳态和复位释放问题。有一点要注意BRAM 的初始化。滑动窗口 BRAM 在解码开始前不需要清零因为 LZ77 回溯只会引用已经写入的位置未写入的位置不会被读到。但 Huffman 码表 BRAM 必须在每帧开始前清空或者用有效标志位标记否则残留数据会导致解码错误。我一般用有效位标记比清空整个 BRAM 快。3. Huffman 解码整个项目的心脏3.1 规范 Huffman 的建表逻辑DEFLATE 用的是规范 Huffman 编码Canonical Huffman这个特性是硬件实现的福音。所谓规范就是码字不是随便定的而是由码长唯一确定的给定每个符号的码长码字按符号顺序、码长递增的方式依次分配。建表的算法是这样的统计每个码长有多少个符号bl_count数组。计算每个码长的起始码字code (code bl_count[len-1]) 1。按符号顺序给每个符号分配当前码长的下一个码字。这套算法在软件里就是一个循环在硬件里我用一个状态机实现逐码长、逐符号处理。核心代码如下简化版// 计算各码长起始码字 always (posedge clk) begin if (build_state CALC_START) begin code 0; bl_count[0] 0; for (i 1; i MAX_BITS; i i 1) begin code (code bl_count[i-1]) 1; next_code[i] code; end build_state ASSIGN_CODE; end end这里有个坑bl_count和next_code是数组在 Verilog 里综合成寄存器数组或者 BRAM 都行但要注意位宽。码长最大 15码字最大 15 位所以用 16 位宽比较保险。3.2 逐位解码状态机怎么写逐位解码的思路很朴素维护一个当前累积的码字cur_code和当前码长cur_len每读一位就左移一位并加上新位然后检查当前码长下是否存在这个码字。// 逐位解码核心逻辑 always (posedge clk) begin case (dec_state) DEC_READ_BIT: begin if (bit_valid) begin cur_code {cur_code[13:0], next_bit}; cur_len cur_len 1; dec_state DEC_CHECK; end end DEC_CHECK: begin // 查表当前码长下 cur_code 是否对应有效符号 if (code_valid[cur_len] code_match[cur_len]) begin symbol decoded_symbol; dec_state DEC_OUTPUT; end else if (cur_len MAX_BITS) begin dec_state DEC_ERROR; end else begin dec_state DEC_READ_BIT; end end endcase end这个状态机每个符号平均要跑 8 到 9 个时钟周期平均码长吞吐量不算高但逻辑简单、时序容易收敛。如果你的像素时钟是 100MHz解码一个 1080p 的 PNG 大概需要几毫秒对于非实时场景完全够用。3.3 查表法的加速方案要提速就得上查表法。核心思想是一次读入MAX_BITS位用这 15 位作为地址去查一张预建的表表里存了这个前缀对应的符号和实际码长。查表法每个符号只要 1 到 2 个时钟周期速度提升 5 倍以上。代价是表的大小。15 位地址意味着 32768 个表项每个表项存符号8 位加码长4 位一共 12 位总共约 48KB 的 BRAM。对于大 FPGA 不算什么对于小 FPGA 就有点吃力。折中方案是用分级查表先查 9 位如果没命中再查剩下的这样表大小降到 512 项加一个小的二级表。我在高性能版工程里用的就是分级查表实测在 Artix-7 上 BRAM 占用约 8 个 36K block可以接受。3.4 动态与静态 Huffman 的切换DEFLATE 有两种块类型固定 HuffmanBTYPE01和动态 HuffmanBTYPE10。固定 Huffman 的码表是预定义的直接硬编码即可动态 Huffman 需要先解码码长表再建表。硬件实现时我建议把两种模式做成两个分支用一个block_type信号选择。固定 Huffman 的码表直接写成常量查找省去建表时间。动态 Huffman 走完整的建表流程。切换逻辑不复杂但要注意码长表的解码本身也是 Huffman 解码用的是固定的码长字母表这里容易绕晕建议单独画个状态图理清楚。4. LZ77 滑动窗口的硬件实现4.1 32KB 窗口怎么放LZ77 回溯的距离最大 32768所以滑动窗口至少要 32KB。这个大小用 BRAM 实现最合适。我用一个双口 BRAM写口负责把解压出的字节写入窗口读口负责回溯时读取历史数据。窗口的地址管理是个细节。因为是循环使用的写指针wr_ptr和读指针rd_ptr都是 15 位自然回绕。回溯时读地址 wr_ptr - distance用补码运算自动处理回绕。// 滑动窗口读写 always (posedge clk) begin if (wr_en) begin window_bram[wr_ptr] wr_data; wr_ptr wr_ptr 1; end if (rd_en) begin rd_data window_bram[rd_ptr]; end end // 回溯地址计算 assign rd_ptr wr_ptr - distance;这里有个容易忽略的点回溯时写入和读取可能同时发生。比如复制一段重叠的数据distance 小于 length读指针会追上写指针。BRAM 的双口特性正好能处理这种情况但要注意读延迟。我一般让读操作提前一个周期发起保证数据及时到位。4.2 回溯复制的状态机回溯复制的逻辑是给定 distance 和 length从窗口里读 length 个字节每个字节既输出到解压流又写回窗口。这个过程中写指针和读指针同步前进。// 回溯复制状态机 always (posedge clk) begin case (copy_state) COPY_IDLE: begin if (copy_start) begin copy_cnt length; copy_state COPY_RUN; end end COPY_RUN: begin if (copy_cnt 0) begin copy_state COPY_IDLE; end else begin // 读窗口、输出、写回 copy_cnt copy_cnt - 1; end end endcase end注意 length 的范围是 3 到 258distance 是 1 到 32768。这两个参数在解码时是从码表里查出来的长度和距离都有额外的扩展位别漏了。4.3 窗口与 Huffman 解码的协同Huffman 解码和 LZ77 回溯是交替进行的解码出一个字面量就直接输出解码出一个长度/距离对就触发回溯。这两个模块之间需要一个小的协调逻辑判断当前符号是字面量还是长度码。DEFLATE 的符号定义是0 到 255 是字面量256 是块结束257 到 285 是长度码。长度码还要查一个额外的表得到实际长度和扩展位数。这套映射关系建议做成常量表综合成查找逻辑。5. 反滤波与像素重建5.1 PNG 五种滤波器的原理PNG 在压缩前会对每一行做滤波目的是提高压缩率。滤波器有五种类型None0不滤波原样输出。Sub1当前像素减去左边像素。Up2当前像素减去上一行同位置像素。Average3当前像素减去左边和上边像素的平均值。Paeth4当前像素减去左边、上边、左上三个像素的 Paeth 预测值。反滤波就是把这些减法反过来做加法。硬件实现时每种滤波器对应一个组合逻辑用filter_type选择。5.2 行缓冲与上一行缓存反滤波需要访问上一行的数据所以至少要缓存一整行。对于 1080p 的 RGB 图像一行是 1920×3 5760 字节用 BRAM 缓存完全没问题。我的做法是用两个行缓冲交替一个存当前正在重建的行一个存上一行。重建完一行后交换角色。这样 Up、Average、Paeth 滤波器都能拿到上一行数据。// 行缓冲读写 always (posedge clk) begin if (line_wr_en) begin line_buf_cur[col_addr] reconstructed_byte; end prev_byte line_buf_prev[col_addr]; prev_left_byte line_buf_prev[col_addr - bpp]; end这里的bpp是每像素字节数灰度是 1RGB 是 3RGBA 是 4。Sub 和 Paeth 滤波器需要访问左边bpp个字节之前的数据所以行缓冲的读地址要做偏移。5.3 Paeth 预测器的硬件实现Paeth 滤波器是五种里最复杂的因为它要计算三个候选值的 Paeth 预测。Paeth 预测器的逻辑是给定左、上、左上三个值 a、b、c计算 p a b - c然后选离 p 最近的作为预测值。// Paeth 预测器 function [7:0] paeth_predict; input [7:0] a, b, c; reg [9:0] p; reg [9:0] pa, pb, pc; begin p a b - c; pa (p a) ? (p - a) : (a - p); pb (p b) ? (p - b) : (b - p); pc (p c) ? (p - c) : (c - p); if (pa pb pa pc) paeth_predict a; else if (pb pc) paeth_predict b; else paeth_predict c; end endfunction这个函数综合出来是一堆比较器和加法器逻辑不深但面积不小。如果资源紧张可以考虑用流水线拆开但一般没必要。5.4 位深与颜色类型的处理PNG 支持 1、2、4、8、16 位深以及灰度、RGB、调色板、灰度Alpha、RGBA 五种颜色类型。纯 Verilog 方案我建议先支持 8 位深的灰度和 RGB/RGBA这覆盖了绝大多数实际应用。对于 16 位深需要额外处理高低字节对于调色板需要额外读 PLTE 块并做查表。这些在进阶工程里有实现但代码复杂度会上升不少。我的建议是先把 8 位 RGB 跑通再逐步扩展不要一上来就追求全支持。6. 10 套工程源码的定位与选型6.1 基础版与进阶版的差异10 套工程不是简单的复制粘贴而是按功能复杂度和目标平台分了层次。基础版工程 1-3只支持 8 位 RGB、非隔行、固定 Huffman代码量小、易理解适合入门学习。进阶版工程 4-7加上动态 Huffman、灰度、RGBA 支持。高性能版工程 8-10用查表法加速、支持多像素并行输出、针对特定开发板优化。工程编号核心特性目标场景资源占用参考1-38位RGB固定Huffman入门学习约 2000 LUT4-5加动态Huffman通用解码约 3500 LUT6-7加灰度/RGBA多格式支持约 4500 LUT8-9查表法加速高吞吐约 6000 LUT 8 BRAM10多像素并行实时显示约 9000 LUT 12 BRAM资源占用数据基于 Artix-7 综合结果不同平台会有差异仅供参考。6.2 怎么选适合你的那一套选工程的原则很简单从能满足你需求的最低版本开始。如果你只是学习 PNG 解码原理工程 1 就够了如果你要做实际产品先确认你的 PNG 是什么格式再选对应版本。我见过太多人一上来就想要最强版本结果代码看不懂、调不通最后放弃。硬件开发和软件开发不一样代码量大到一定程度调试难度是指数上升的。循序渐进才是正道。6.3 移植到不同 FPGA 平台的注意事项纯 Verilog 的好处是可移植但不同平台还是有差异。主要注意三点BRAM 原语的例化方式、时钟管理单元PLL/MMCM的配置、以及 IO 标准。我的工程里 BRAM 用推断方式写不直接例化原语这样综合器会自动映射到目标平台的 BRAM移植性最好。时钟部分我留了一个通用的 PLL 包装模块你只需要改参数就能适配不同平台的时钟资源。IO 标准一般在约束文件里改不影响 RTL 代码。7. 调试过程中那些文档不会写的事7.1 位序问题第一个大坑PNG 的位流是 MSB-first而 Verilog 的移位习惯是 LSB-first。我第一次做的时候Huffman 解码死活不对查了两天才发现是位序反了。具体来说从字节流里取位时要先取最高位// 正确的 MSB-first 取位 next_bit byte_buf[7]; byte_buf byte_buf 1;而不是byte_buf[0]。这个细节在 RFC 里写得很清楚但很容易看漏。建议在代码里加注释强调避免以后自己都忘了。7.2 Huffman 码表的边界情况动态 Huffman 的码长表里码长 0 表示该符号不存在。建表时要跳过这些符号否则会分配错误的码字。另外码长表本身可能用重复码压缩符号 16、17、18解码时要正确处理重复逻辑。这几个边界情况我都在工程里做了处理但第一次写的时候确实漏了导致某些 PNG 解出来是花屏。7.3 滑动窗口的回绕陷阱滑动窗口的地址回绕是个隐蔽的坑。当wr_ptr接近 32768 时wr_ptr - distance可能变成负数在 Verilog 里会回绕成很大的正数。用 15 位宽自然回绕是对的但如果你用了更宽的位宽就要手动处理。我建议统一用 15 位让回绕自动发生。还有一个坑是回溯时读写冲突。当 distance 很小时读指针可能追上写指针读到刚写入的数据。BRAM 的双口特性可以处理但要注意读延迟——如果读操作和写操作在同一周期读出来的是旧数据。解决办法是让读地址提前一个周期计算。7.4 反滤波的行边界处理反滤波在每行的第一个像素有特殊处理Sub 和 Paeth 滤波器在第一个像素时左边的值视为 0。这个边界条件如果处理错整行像素都会偏移。我在代码里用col_addr 0判断把左值强制为 0。另外Average 滤波器在第一个像素时上边的值也可能不存在第一行这时上值也视为 0。这些边界条件建议单独写测试用例验证。7.5 仿真与上板验证的差异仿真通过不代表上板能跑。我遇到过仿真完全正确、上板花屏的情况最后发现是 BRAM 的读延迟在仿真模型和实际硬件里不一致。解决办法是在关键路径上加寄存器打拍保证时序一致。还有一个常见问题是复位释放时机。如果复位释放时输入数据已经在流动状态机可能进入错误状态。我一般让复位保持到输入流稳定后再释放或者在入口加一个数据有效标志。8. 性能优化与资源权衡8.1 吞吐量的瓶颈在哪PNG 解码的吞吐量瓶颈通常在 Huffman 解码。逐位解码每个符号要 8 到 9 个周期这是最大的延迟来源。其次是 LZ77 回溯每个字节要一个周期读写。反滤波是流式的基本不构成瓶颈。优化优先级先优化 Huffman查表法再优化 LZ77预取最后考虑反滤波的流水线化。8.2 查表法的资源代价前面提过查表法用 BRAM 换速度。15 位全表要 48KB BRAM分级查表降到 8KB 左右。对于 Artix-7 这种有几十个 BRAM 的芯片8KB 完全可以接受。但如果你的 FPGA 很小可能就得回到逐位法。我的建议是先用逐位法跑通功能确认正确后再换查表法优化。不要一上来就上查表否则功能不对时你分不清是查表逻辑错了还是解码逻辑错了。8.3 多像素并行的可行性对于 4K 或者更高分辨率的实时显示单像素输出可能不够。多像素并行是个思路一次解码多个像素并行输出。但 PNG 的压缩是串行的Huffman 解码没法并行只能在反滤波和输出阶段并行。实际做法是Huffman 解码和 LZ77 还是串行但解压出的字节流先存入一个行缓冲反滤波和输出阶段一次处理多个像素。这样能把输出吞吐量提升 2 到 4 倍。工程 10 就是这个思路。9. 从工程源码到实际项目的落地建议9.1 接口设计要留余量工程源码里的接口我设计得比较通用输入是字节流加 valid 信号输出是像素加行列坐标。实际项目里你可能需要加 AXI-Stream 接口、加帧同步信号、加中断输出。这些都可以在顶层包装不用改核心解码逻辑。我建议在顶层留一个简单的寄存器配置接口比如通过 SPI 或 I2C用来配置图像宽高、颜色类型等参数。这样同一套代码能适配不同来源的 PNG。9.2 测试用例怎么准备测试用例要覆盖各种边界情况最小图像1×1、最大图像、各种滤波器组合、各种颜色类型、动态和固定 Huffman。我一般用 Python 的 PIL 库生成测试 PNG再用脚本转成字节流喂给仿真。仿真时建议把解码结果和软件解码结果逐字节对比这样能快速定位错误。我写了一个自动对比脚本每次改代码都跑一遍省了很多手工检查的时间。9.3 常见问题速查最后整理一个常见问题速查表方便你调试时快速定位现象可能原因排查方向完全花屏位序错误检查 MSB-first 取位部分区域花屏Huffman 码表错误检查动态建表逻辑图像偏移反滤波边界错误检查首像素处理颜色错乱通道顺序错误检查 RGB/BGR 映射偶尔花屏时序问题检查 BRAM 读延迟解码卡死状态机死锁检查错误处理分支这套 PNG 解码方案我从第一版到现在迭代了两年多中间推翻重写过两次。最大的体会是硬件解码的难点不在算法而在把串行的、带状态的算法映射到并行的、无状态的硬件结构上。Huffman 解码的逐位状态机、LZ77 的滑动窗口、反滤波的行依赖每一个都是这种映射的典型案例。把这三个啃下来你对硬件设计的理解会有质的飞跃。工程源码我做了详细注释关键状态机都画了状态转移图。建议你拿到代码后先跑仿真用一个小 PNG 走通全流程再逐步换大图、换格式。遇到问题时对照上面的速查表大部分坑我都踩过了。如果要做产品级应用记得在入口加数据校验在关键路径加时序约束这两点能省掉很多上板后的麻烦。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑