
PNG 解码这件事放在 PC 上就是一句stb_image调用的事但搬到 FPGA 里尤其是要求纯 Verilog、不借助任何软核和现成 IP 的时候它立刻变成一场关于状态机、存储带宽和时序收敛的硬仗。我最早接触这个方向是因为手头一个图像采集项目需要在板子上直接把摄像头抓到的画面存成 PNG 再通过串口吐出去当时想的是解码而已能有多难结果光是搞清楚 zlib 的位流怎么对齐就折腾了快一周。这篇就围绕FPGA 纯 Verilog 实现 PNG 图片解码这个主题把整个链路的原理、模块划分、关键坑点和工程组织方式讲透适合已经写过 Verilog 状态机、想往图像处理方向深入的开发者也适合手里有 10 套工程源码但还没完全吃透内部逻辑的朋友对照着看。1. 为什么要在 FPGA 里硬解 PNG1.1 PNG 解码放到硬件里到底图什么先说清楚动机不然很容易陷入用 FPGA 解 PNG 是不是吃饱了撑的这种自我怀疑。PNG 相比 BMP、RAW 这类格式最大的优势是无损压缩同样一张 1080P 的图RAW 要 6MB 左右PNG 往往只有几百 KB 到 1MB 出头。在带宽受限或者存储受限的场景里这个差距是决定性的。比如工业相机要把图片通过千兆网或者低速串口传出去传 RAW 根本来不及传 JPEG 又是有损的某些检测场景不接受压缩失真这时候 PNG 就是最优解。那为什么不用 CPU 或者软核来解因为解码本身是逐像素、逐位的密集运算一个 1080P 的 PNG 解码涉及几十万次 Huffman 查表和几十万次滤波运算用 MicroBlaze 或者 Nios 跑帧率会低到没法看。而 FPGA 的并行能力天生适合这种流水线式的位流处理把 Huffman 解码、反量化、滤波拆成多级流水理论上可以做到每个时钟周期出一个像素甚至多个像素。这就是硬解的核心价值用逻辑资源换吞吐率。还有一层现实考虑很多边缘设备的主控就是一颗 FPGA 或者 FPGAARM 的异构芯片ARM 那边跑着操作系统和业务逻辑没空也没资源去啃解码这种重活把解码卸载到 FPGA 逻辑侧ARM 只负责搬运结果整个系统的实时性会好很多。我在一个多路图像采集的项目里就是这么干的四路摄像头同时进来ARM 只做调度解码全部丢给 FPGACPU 占用率从 70% 直接降到 15% 以下。1.2 纯 Verilog 实现意味着放弃了什么标题里纯 Verilog这四个字很关键它意味着你不能用 HLS 写 C 再综合不能用厂商提供的图像处理 IP也不能在解码过程中调用任何软核。所有的状态机、所有的位操作、所有的存储管理都得自己用always块和assign写出来。这带来的直接后果是开发周期长、调试难度大但换来的是极致的可移植性和资源可控性。我见过太多人一开始用 HLS 写解码仿真跑通了综合出来资源爆炸时序还过不去最后推倒重来。纯 Verilog 虽然前期慢但每一拍的行为你都是清楚的资源占用可以精确到每一个 LUT时序约束也能针对性地优化。对于要上量的产品这个可控性是刚需。另外纯 Verilog 还有个隐性好处它不绑定任何厂商Altera 的工程改改约束就能挪到 Xilinx 上甚至国产 FPGA 也能跑这在供应链波动的时候是保命的能力。1.3 10 套工程源码的组织逻辑提供 10 套工程源码不是简单地把同一份代码复制十遍而是按照功能裁剪和平台适配两个维度来组织的。功能维度上有的工程只做解码核心输入是已经剥离了文件头的 PNG 数据流有的工程包含完整的文件解析能从 SD 卡或者 Flash 里直接读文件还有的工程带显示输出解码完直接驱动 HDMI 或者 RGB 屏。平台维度上覆盖了 Xilinx 7 系列、Altera Cyclone 系列以及部分国产器件每套工程的约束文件、时钟方案、存储接口都不一样。这样组织的目的是让不同起点的开发者都能找到合适的入口。如果你只是想验证解码算法拿最精简的那套仿真环境都搭好了如果你要做完整产品拿带文件系统和显示的改改接口就能用。我建议新手不要一上来就啃最复杂的那套先从纯解码核心开始把数据流跑通再逐步往上加模块这样出问题的时候排查范围小。2. PNG 格式里那些必须啃下来的硬骨头2.1 从文件头到 IDAT 的数据组织PNG 文件的结构是大端字节序的块chunk序列每个块由长度4 字节、类型4 字节、数据变长、CRC4 字节组成。第一个块必须是 IHDR里面藏着图像的宽、高、位深、颜色类型、压缩方法、滤波方法、隔行扫描方式这七个关键参数。解码器要做的第一件事就是把这七个参数抠出来因为它们决定了后面所有模块的配置。这里有个新手特别容易踩的坑长度字段是大端而 Verilog 里习惯的位拼接是高位在前如果你直接用{byte0, byte1, byte2, byte3}去拼得到的正好是大端值看起来没问题但如果你中间做了字节序转换就会错得莫名其妙。我在第一版代码里就因为这个宽度读出来是 0x00000438 变成了 0x38040000图像直接花屏查了两天才发现是字节序的问题。IHDR 之后是一堆辅助块比如 gAMA、cHRM、tEXt 这些解码器可以跳过但必须正确解析长度才能找到下一个块。真正要处理的是 IDAT 块图像数据就藏在里面可能是一个 IDAT也可能是多个 IDAT 拼接而成。多个 IDAT 的情况在硬件里处理起来要小心因为 zlib 流是跨块连续的你不能在每个 IDAT 边界重置解码状态必须把它们的 payload 当成一个连续的字节流来喂给解压模块。2.2 zlib 与 DEFLATE 的位流陷阱IDAT 里的数据是 zlib 格式结构是 2 字节头 DEFLATE 数据 4 字节 Adler-32 校验。DEFLATE 本身是位对齐而不是字节对齐的这是硬件实现里最恶心的地方。Huffman 编码的码字长度不固定可能 3 位也可能 15 位你读的时候必须维护一个位计数器跨字节边界去取位。我见过不少人图省事想先把整个 IDAT 缓存到 BRAM 里再慢慢解结果发现大图根本存不下。1080P 的 PNG 解压后是 6MB 以上片上 BRAM 撑死几 MB还得留给其他模块。所以正确的做法是流式解码来一个字节就处理一个字节位缓冲器维护一个 32 位或者 64 位的移位寄存器每次需要取 N 位就移出 N 位不够就从输入流补字节。这个位缓冲器的设计直接决定了解码器的吞吐率我一般用 64 位配合一个当前有效位数的计数器实测下来在 100MHz 时钟下能轻松跟上千兆网的输入速率。DEFLATE 的块类型有三种00 是存储块不压缩01 是固定 Huffman10 是动态 Huffman。存储块最好处理直接拷贝固定 Huffman 的码表是写死的查表逻辑简单动态 Huffman 最麻烦它会在块头里传一套自定义的码表你得先解析这套码表构建出查找表再去解数据。硬件里构建动态码表是个挑战因为码表长度可变我通常用一个双端口 BRAM 来存码表解析阶段写解码阶段读中间加一个状态切换。2.3 Huffman 查表的硬件实现策略Huffman 解码的本质是变长码到定长符号的映射。软件里用二叉树逐位下降硬件里这么干太慢一拍只能走一层。主流做法是查表法预先根据码表构建一张查找表表的地址是输入位流的前 N 位表的内容是符号值和实际消耗的位数。N 一般取 9 到 12N 越大表越大但一次命中的概率越高。这里有个权衡N 取小了长码字需要多次查表吞吐率下降N 取大了BRAM 消耗上去了。我实测下来对于典型的 PNG 图像N10 是个不错的平衡点一次命中率能到 95% 以上偶尔的长码字走一个慢速路径补查。查找表的内容要包含三部分符号值、码字长度、是否是有效码。无效码意味着输入位流有误或者表还没建好这时候要报错而不是继续解。动态 Huffman 的码表构建更考验设计。DEFLATE 规定码表用码长序列来传输先传每个符号的码长再用这些码长反推出规范 Huffman 码。硬件里反推的过程可以用一个排序网络或者用计数排序的思路统计每个码长的符号数量然后按码长从小到大分配码字。我倾向于用计数排序因为它的硬件结构规整就是几个累加器加一个分配状态机时序好收敛。3. 解码核心模块的 Verilog 架构拆解3.1 顶层模块划分与数据流走向一套干净的 PNG 解码器顶层应该只做数据分发和状态协调具体的解码逻辑下沉到子模块。我的典型划分是这样的png_top负责接收字节流、解析 chunk、提取 IHDR 参数、把 IDAT 数据转发给inflate_coreinflate_core做 zlib 头和 DEFLATE 解压输出的是滤波后的原始字节unfilter模块做反滤波把字节还原成像素pixel_pack把像素按颜色类型打包成 RGB 或者 RGBA 输出。数据流是单向的从输入到输出中间用 FIFO 或者简单的 valid/ready 握手连接。我强烈建议用AXI-Stream 风格的握手即使你的系统里没有 AXI自己定义一套 valid/ready 也比裸的使能信号可靠得多。原因是解码过程中各个模块的处理速率不一样Huffman 解码可能一拍出一个符号但反滤波要等一整行数据才能开始中间必须有缓冲。握手信号能自然地处理背压避免数据丢失。时钟域方面如果输入来自异步的接口比如串口或者以太网需要在入口做跨时钟域处理。我一般用一个异步 FIFO 把数据搬到解码时钟域解码全程单时钟这样时序约束简单也不用担心多时钟域带来的亚稳态问题。3.2 位缓冲器与 Huffman 解码器的联动位缓冲器和 Huffman 解码器是绑在一起设计的因为它们之间的交互非常频繁。位缓冲器的职责是维护一个位流窗口对外提供取 N 位和消耗 N 位两个操作。Huffman 解码器先 peek 前 10 位去查表查到码字长度 L 后再告诉位缓冲器消耗 L 位。这里有个细节peek 和 consume 必须分开。如果只有 consume那查表失败的时候你就没法回退。peek 是只读的consume 才真正移动读指针。我用一个 64 位的移位寄存器加一个 6 位的有效位计数器来实现peek 就是直接看寄存器的高位consume 就是把寄存器左移并减少有效位计数。当有效位少于 32 位时触发补字节逻辑从输入 FIFO 里取数据填进来。补字节逻辑要处理输入 FIFO 空的情况这时候整个解码流水线要暂停等数据来。暂停信号要一路往上传让上游别再发数据。这个背压链路如果设计不好很容易出现数据丢失或者死锁。我的经验是每一级都加一个小的 skid buffer深度不用大2 到 4 个就够用来吸收握手时的气泡这样背压传播更平滑。3.3 反滤波的四种模式与行缓存设计PNG 的滤波是在压缩之前对每一行做的目的是提高压缩率。滤波有五种类型None、Sub、Up、Average、Paeth。每个扫描行的第一个字节是滤波类型后面才是数据。反滤波的时候Sub 需要当前行左边的像素Up 需要上一行同位置的像素Average 需要左边和上边Paeth 需要左边、上边和左上。这意味着你必须缓存至少一整行的像素数据。对于 1080P 的 RGBA 图一行就是 7680 字节用 BRAM 存完全没问题。我的做法是用一个双端口 BRAM 做行缓存写端口写当前行读端口读上一行。反滤波模块同时读当前行的前一个像素和上一行的对应像素做加减运算。Paeth 滤波是最复杂的它要计算三个预测值然后选最接近的那个。硬件里实现 Paeth 预测器需要几个加法和比较不算难但要注意有符号和无符号的转换。PNG 的像素是无符号字节但预测的中间结果可能为负Verilog 里如果不小心用了无符号运算负数会变成很大的正数结果就全错了。我一般把中间结果扩展成有符号的 9 位或者 10 位算完再截断回 8 位。行缓存的地址管理也要小心。图像的宽度不一定是 2 的幂地址计算不能简单用位截断。我通常用一个计数器从 0 数到 width-1然后回绕。如果一行有多个字节每像素比如 RGBA 是 4 字节计数器要按字节数走反滤波的时候再按像素分组。4. 存储、带宽与时序工程落地的三道坎4.1 行缓存与输出缓存的资源估算资源估算是 FPGA 项目能不能落地的第一道关。PNG 解码器的主要存储消耗在两块行缓存和输出缓存。行缓存的大小是width * bytes_per_pixel对于 1080P RGBA就是 1920 * 4 7680 字节。用 BRAM 实现的话一块 36Kb 的 BRAM 能存 4KB 左右所以需要两块。如果支持 4K 分辨率那就是 3840 * 4 15360 字节需要四块 BRAM。输出缓存看你的下游接口。如果下游是 HDMI通常需要一整帧的缓存来做帧同步1080P RGBA 一帧是 8MB 以上片上 BRAM 根本存不下必须外挂 DDR。这时候解码器的输出就要通过 AXI 或者 Avalon 写进 DDR再由显示控制器读出来。DDR 的带宽要算清楚1080P 60 帧每帧 8MB那就是 480MB/s 的写带宽加上读带宽就是接近 1GB/sDDR3 勉强够DDR4 更稳。如果下游是低速接口比如串口或者 SPI那就不需要大缓存解码一个像素发一个像素用一个小 FIFO 平滑一下就行。这种情况下资源消耗很小一颗小 FPGA 就能装下。4.2 吞吐率瓶颈定位与流水线优化解码器的吞吐率瓶颈通常在两个地方Huffman 解码和反滤波。Huffman 解码如果一次查表不中就要走慢速路径吞吐率会掉。反滤波如果行缓存访问有冲突也会拖慢。定位瓶颈的方法很简单在仿真里统计每个模块的平均每像素周期数。理想情况是每个时钟出一个像素但实际往往做不到。我一般会在关键模块加性能计数器跑一段真实的 PNG 数据看看哪个模块的 stall 最多。如果是 Huffman就加大查找表如果是反滤波就优化 BRAM 的访问模式比如把读写分成两个端口或者用乒乓缓存。流水线优化有个原则把组合逻辑长的路径切开。比如 Huffman 查表 位消耗 符号输出如果全塞在一个 always 块里时序肯定过不去。我会把它拆成三级第一级 peek 和查表第二级判断码字有效性第三级消耗位并输出符号。每级之间加寄存器虽然增加了一拍延迟但时钟频率能上去总体吞吐率反而更高。4.3 时序收敛的常见手段纯 Verilog 解码器跑不到高频率十有八九是时序问题。最常见的原因是大位宽的组合逻辑比如一个 32 位的加法器直接接在 BRAM 输出上路径延迟太长。解决办法是插入流水寄存器把加法拆成两级。另一个常见问题是跨时钟域。如果输入数据来自异步接口而你没有正确处理综合工具会报一堆时序违例。我的做法是入口处用一个异步 FIFO把数据搬到解码时钟域之后全程单时钟。异步 FIFO 的读写指针用格雷码这个标准做法能避免亚稳态。还有BRAM 的输出寄存器。很多 FPGA 的 BRAM 自带输出寄存器如果你不用BRAM 到逻辑的路径会很长。在综合选项里把 BRAM 输出寄存器打开能显著改善时序。这个选项在 Xilinx 的 Vivado 里叫 Register Outputs在 Quartus 里也有类似的设置。5. 从仿真到上板的完整验证链路5.1 用 Icarus Verilog 搭建轻量仿真环境不是每个人都有商业仿真器Icarus Verilog 是个很好的免费选择。它的语法支持比较完整跑 PNG 解码这种规模的工程完全够用。我的仿真环境是这样的用 Python 生成测试激励把一张 PNG 文件读成字节流写成一个.mem文件Verilog 的 testbench 用$readmemh读进来逐字节喂给解码器。解码器的输出写到一个输出文件仿真结束后用 Python 读出来和原图对比。这个流程的关键是自动化对比。手动看图是不现实的必须写脚本逐像素比对。我一般用 Python 的 PIL 库读原图把解码输出也拼成图像然后算两个图像的差异。如果完全一致说明解码正确如果有差异就定位到第一个出错的像素反推是哪个模块的问题。Icarus Verilog 的一个小坑是它对 SystemVerilog 的支持不完整如果你用了logic、always_ff这些 SV 语法可能编译不过。我的建议是纯 Verilog 工程就用纯 Verilog 语法写 testbench别混用省得折腾。5.2 上板调试从 ILM 到实际图像输出仿真过了不代表上板能跑这是 FPGA 开发的铁律。上板调试的第一步是确认数据通路。我会先用一个简单的测试模式比如让解码器输出固定的颜色看看下游显示是否正常。确认通路没问题后再喂真实的 PNG 数据。调试手段上Xilinx 的 ILA 和 Altera 的 SignalTap 是神器。我会在关键节点插探针输入字节流、Huffman 解码输出的符号、反滤波后的像素、最终输出。通过观察这些信号的实时波形能快速定位问题。比如如果输入字节流正常但 Huffman 输出全是 0那问题就在解压模块如果 Huffman 输出正常但像素花屏那问题在反滤波或者像素打包。有个经验上板调试时先把时钟降下来。100MHz 跑不通就先跑 50MHz甚至 25MHz。低速下如果功能正常说明是时序问题如果低速下也不正常说明是逻辑问题。这个二分法能省很多时间。5.3 常见花屏、错位的根因排查表现象可能原因排查手段整屏花屏颜色随机字节序错误或位流对齐错误检查 IHDR 解析确认宽高正确图像错位像被平移行缓存地址计算错误检查行计数器回绕逻辑颜色偏色比如红色变蓝像素打包顺序错误检查 RGB 通道映射图像下半部分正常上半部分花行缓存乒乓切换错误检查行缓存读写指针图像有规律条纹反滤波类型判断错误检查每行首字节的滤波类型解析解码中途卡死背压死锁或 FIFO 溢出检查握手信号和 FIFO 深度这张表是我踩坑踩出来的基本上覆盖了 90% 的常见问题。遇到花屏先别急着改代码对着表逐项排查往往能快速定位。6. 10 套工程源码怎么选、怎么改、怎么用6.1 按需求匹配工程的决策路径10 套工程不是让你全看一遍而是按需取用。如果你的目标是学习解码算法选那套纯解码核心的输入是已经剥离好的 IDAT 数据输出是像素流仿真环境完整适合逐模块研究。如果你的目标是做产品原型选带文件系统和显示输出的它包含了从 SD 卡读文件、解码、HDMI 显示的完整链路改改分辨率就能用。如果你的板子不在支持列表里选一套平台最接近的重点改三个地方时钟约束、存储接口、引脚分配。时钟约束改.xdc或者.sdc文件存储接口如果是 DDR改 MIG 或者 UniPHY 的配置引脚分配按你的板子原理图来。这三块改完基本就能跑。我建议的决策路径是先明确你的输入是什么文件、网络、摄像头输出是什么显示、存储、传输然后找输入输出最匹配的那套中间的差异用胶合逻辑补。别想着找一套完全一样的那不存在。6.2 移植到不同 FPGA 平台的关键改动点移植的核心是存储和时钟。Xilinx 的 BRAM 和 Altera 的 M9K 在接口上不一样如果你用了厂商原语的 BRAM移植时要换。我的做法是尽量用推断式 BRAM就是写标准的always (posedge clk)块让综合工具自己推断这样跨平台不用改代码。只有在对 BRAM 的初始化或者双端口配置有特殊要求时才用原语。时钟方面Xilinx 用 MMCMAltera 用 PLL国产 FPGA 各有各的。移植时把时钟生成模块单独拎出来换平台只改这一个文件。解码核心本身不依赖具体的时钟资源只要给它一个稳定的时钟就行。还有一个容易忽略的点是复位极性。Xilinx 的复位通常是高有效Altera 很多是低有效。移植时如果忘了改整个系统起不来。我一般把复位统一成高有效在顶层做一次转换这样内部模块不用关心平台差异。6.3 二次开发加解密、缩放、格式转换的扩展思路解码器跑通之后往上加功能是很自然的。最常见的扩展是图像缩放。解码输出的是原始分辨率如果要显示到不同尺寸的屏幕上需要缩放。双线性插值是个不错的选择硬件实现也不复杂两个行缓存加几个乘法器就行。我一般把缩放模块接在反滤波之后像素打包之前。另一个扩展是格式转换。PNG 支持灰度、RGB、RGBA、调色板等多种颜色类型解码器内部统一按 RGBA 处理输出的时候再按需转换。比如输出到 RGB 屏就把 alpha 通道丢掉输出到灰度设备就做个加权平均。这些转换都是组合逻辑不消耗存储。还有加密的需求。有些场景下 PNG 数据是加密的解码前要先解密。这个可以在输入 FIFO 之后加一个解密模块AES 或者轻量级算法都行。解密模块和解码模块用握手连接解密完一个块就发给解码器流水线不中断。7. 几个只有踩过才知道的实操细节7.1 位缓冲器的边界情况处理位缓冲器最容易出问题的地方是输入流结束时的边界。DEFLATE 流的最后几个字节可能不足以填满位缓冲器这时候 peek 操作会读到无效位。如果不处理查表可能查到一个看似合法的码字解出错误的符号。我的做法是在位缓冲器里维护一个流结束标志当输入 FIFO 空且没有更多数据时置起这个标志peek 的时候如果有效位不足且流已结束就返回一个特殊的无效码让上层知道该停了。还有一个边界是跨块边界。多个 IDAT 块之间位流是连续的但字节流可能有间隙。位缓冲器在补字节的时候要能跨过 IDAT 的块头直接取下一个 IDAT 的 payload。这个逻辑我放在 chunk 解析模块里它负责把多个 IDAT 的 payload 拼成一个连续的字节流位缓冲器只管从这个流里取字节不用关心块的边界。7.2 动态 Huffman 码表的构建时序动态 Huffman 的码表构建是个串行过程必须先读完所有码长统计完才能开始解码数据。这中间有个天然的停顿如果处理不好吞吐率会掉。我的优化是预取在构建码表的同时把数据流的前几个字节先缓存起来等码表建好立刻就能开始解码不用等数据。码表构建本身的状态机要小心码长溢出。DEFLATE 规定码长最大 15 位但输入流如果有误可能读到更大的值。这时候要报错而不是继续。我在状态机里加了一个检查码长超过 15 就跳到错误状态输出错误标志让上层决定怎么处理。7.3 多 IDAT 拼接与 CRC 校验的取舍PNG 的每个 chunk 都有 CRC 校验理论上解码器应该校验每个 chunk 的 CRC发现错误就报错。但在硬件里做 CRC 校验要额外的逻辑和存储而且 CRC 计算是逐字节的会拖慢数据流。我的做法是默认不校验 CRC只在调试模式下打开。产品里如果对数据完整性要求高可以在上游做校验或者用外部的校验模块。多 IDAT 拼接的时候要注意最后一个 IDAT 的结束位置。zlib 流的结束是由 DEFLATE 的结束码标记的不是由 IDAT 的长度决定的。所以解码器不能简单地按 IDAT 长度来切分必须解析 DEFLATE 流本身遇到结束码才停。这个细节如果搞错多 IDAT 的图会解出多余的数据导致花屏。7.4 资源与频率的平衡经验最后聊聊资源和频率的平衡。纯 Verilog 解码器的资源消耗主要在 BRAM 和 DSP。BRAM 用于行缓存和查找表DSP 用于反滤波的加减和 Paeth 预测。如果资源紧张可以复用 DSP比如反滤波的加法和 Paeth 的减法分时复用同一个 DSP。但这样会增加控制逻辑的复杂度时序也可能变差。频率方面我的经验是不要盲目追求高频。100MHz 对于大多数应用足够了1080P 60 帧的像素率是 148.5MHz但那是显示时钟解码时钟可以低一些用 FIFO 做跨时钟域。把解码时钟定在 100MHz 到 150MHz 之间时序好收敛资源也不会因为过度流水而膨胀。如果非要跑到 200MHz 以上那每一级流水都要仔细设计开发成本会高很多。我在一个项目里为了省 BRAM把行缓存从存整行改成存半行用两遍扫描的方式解码结果控制逻辑复杂了一倍调试时间多了两周最后省下来的 BRAM 还没用上。这个教训告诉我资源够用就行别为了省而省开发效率也是成本。整套 PNG 解码器从零写到稳定运行我前后花了大概三个月其中一半时间在调试位流对齐和反滤波。现在回头看最难的不是某个模块的实现而是对整个数据流的把控——你得清楚每一个字节从哪里来、经过哪些变换、到哪里去。把这套逻辑理顺了代码只是表达。10 套工程源码的价值也在这里它们不是让你抄而是给你一个参照让你知道一个成熟的实现应该长什么样哪些地方可以简化哪些地方必须严谨。