1. 项目缘起与整体设计思路
1.1 为什么要在FPGA里硬解PNG
做图像处理的朋友大概率都遇到过这个场景:上位机或者摄像头给过来一张PNG图片,想在FPGA内部直接参与后续的缩放、叠加、滤波等流水线处理,结果发现PNG是压缩格式,没法像BMP那样直接按像素读。常规做法是先在PC端把PNG转成RAW或者BMP再喂给FPGA,但这样一来整个系统就离不开上位机,产品化的时候很别扭。
PNG解码本身不是什么新课题,zlib、libpng这些库在软件端已经非常成熟。但把PNG解码搬到FPGA上,用纯Verilog从零实现,难点集中在三块:第一是DEFLATE解压,涉及Huffman变长码和LZ77滑动窗口回溯,天然带反馈依赖,跟FPGA擅长的流水线思路相冲;第二是滤波反演,PNG有五种滤波类型,逐像素依赖前一个像素,必须串行处理;第三是内存带宽,解码中间数据量比原始压缩数据大几十倍,怎么用有限的片上RAM配合外部DDR把数据流转起来,是决定方案能不能落地的关键。
这个项目的定位很明确:给需要把PNG解码集成进FPGA图像处理链路的工程师一套可直接复用的纯Verilog方案,不依赖任何软核、不调用厂商IP,从比特流解析到最终像素输出全部自己实现。适合有一定Verilog基础、做过图像采集或显示项目、想深入理解压缩格式硬件解码的开发者。哪怕你只是想搞明白Huffman解码在硬件里到底怎么落地,这套代码也值得啃一遍。
1.2 整体架构怎么切分
整个解码链路我按数据流方向切成四级流水,每一级职责单一,接口用简单的valid/ready握手,方便单独仿真和替换。
第一级是PNG文件解析与块提取。PNG文件由8字节固定头加若干数据块(chunk)组成,关键块有IHDR(图像头)、PLTE(调色板,索引色才有)、IDAT(压缩数据,可能多个)、IEND(结束)。这一级负责扫描块结构,把IHDR里的宽高、位深、颜色类型、隔行方式提取出来,把多个IDAT的数据拼成一条连续的压缩码流写进外部DDR的输入缓冲区。
第二级是DEFLATE解压。这是最重的一块,内部再分Huffman解码、LZ77回溯、输出组装三个子模块。输入是压缩码流,输出是滤波后的原始扫描行数据。这里我用的是双Huffman表(literal/length表和distance表)配合一个深度足够的滑动窗口缓存。
第三级是滤波反演。PNG每行数据在压缩前会做滤波,类型有None、Sub、Up、Average、Paeth五种。反演时必须按行顺序、按像素顺序串行做,因为Sub依赖左边像素、Up依赖上一行同位置像素、Paeth还依赖左上角像素。这一级把滤波后的数据还原成真实像素值。
第四级是像素格式转换与输出。根据颜色类型(灰度、真彩、索引、带Alpha等)和位深(1/2/4/8/16),把像素组装成统一的RGB888或RGBA8888格式,写入输出帧缓存,供后续模块读取。
四级之间用DDR做数据缓冲,片上只保留当前行和上一行的必要数据。这样做的原因是PNG解码的中间数据量远大于片上BRAM容量,比如一张1024x768的真彩图,一行原始数据就是3KB,加上滤波需要的上一行,再算上滑动窗口,片上根本放不下,必须借外部存储。
1.3 方案选型背后的取舍
有人会问,为什么不直接用Xilinx或Intel的Huffman IP?原因有两个:一是这类IP通常绑定特定器件系列,换平台就得重来;二是PNG的DEFLATE有它自己的细节,比如动态Huffman表的码长限制、距离码的额外位,通用IP不一定完全匹配。纯Verilog实现虽然工作量大,但可移植性拉满,从低端Artix到高端Kintex,甚至国产FPGA都能跑。
另一个取舍是滑动窗口放片上还是放DDR。放片上用BRAM实现,访问延迟低,但窗口大小受BRAM容量限制,PNG的LZ77窗口最大32KB,用BRAM实现32KB窗口在中等规模FPGA上可行,但会占用大量BRAM资源。放DDR则容量无忧,但每次回溯都要读DDR,延迟高、带宽压力大。我的做法是折中:窗口用片上BRAM实现,但只缓存最近若干KB,超出部分回退到DDR,通过地址映射统一管理。这样既保证了常见情况下的低延迟,又不会因为窗口太大把BRAM吃光。
2. 核心细节解析与实操要点
2.1 PNG块结构解析的硬件实现
PNG文件头是固定的8字节:89 50 4E 47 0D 0A 1A 0A。硬件里用一个8字节的移位寄存器逐字节比对,全部匹配才进入块解析状态。块的结构是:4字节长度(大端)+ 4字节类型 + 数据 + 4字节CRC。长度和类型都要做字节序转换,因为PNG是大端,而FPGA内部通常按小端处理。
解析状态机我设计成五个状态:IDLE、READ_LEN、READ_TYPE、READ_DATA、READ_CRC。每个块解析完根据类型分派:IHDR把宽高、位深、颜色类型、压缩方法、滤波方法、隔行方式锁存到配置寄存器;PLTE把调色板数据写入片上RAM;IDAT把数据流写入DDR输入缓冲,同时维护一个写指针;IEND触发解码完成标志。
这里有个容易踩的坑:IDAT可能有多个,它们的数据在逻辑上是连续的,必须按顺序拼接。我在实现时用一个连续的DDR地址空间,每收到一个IDAT就接着上次的写指针继续写,不重新对齐。另外CRC校验在硬件里做完整计算比较费资源,如果对数据完整性要求不是极致,可以只做长度和类型的合法性检查,CRC跳过或者只做抽样校验。我在工程里提供了带CRC和不带CRC两个版本,按需选用。
注意:IHDR必须是第一个块,且只能出现一次。解析时如果第一个块不是IHDR,直接报错退出,不要继续往下跑,否则后面全是垃圾数据。
2.2 Huffman解码的硬件落地
DEFLATE的Huffman解码是变长码,每个符号的码长不固定,硬件里没法像软件那样用查表循环。我的做法是构建一棵规范Huffman树,然后用逐位比较的方式解码。具体来说,先把码表按码长排序,生成每个码长对应的起始码值和符号索引,解码时从码流里逐位读入,每读一位就检查当前累积的码值是否落在某个码长的有效范围内。
为了提速,我用了一个两级查表结构:第一级用固定位数(比如9位)做快速查表,如果码长不超过9位,一次就能解出符号;如果超过9位,再走第二级慢速路径逐位比较。实测下来,大部分符号的码长都在9位以内,所以平均每个符号1到2个时钟周期就能解出,吞吐率足够支撑1080p级别的图像解码。
Huffman表本身在动态模式下是从码流里读出来的,需要先解析码长数组,再重建码表。码长数组本身也是Huffman编码的,用的是固定的码长码表。这一层嵌套容易绕晕,我的建议是先把固定Huffman表的解码跑通,再上动态表。固定表只有两张,码长固定,调试起来直观得多。
LZ77回溯部分,长度和距离都有额外位要读。长度码29到31对应的是带额外位的长匹配,距离码30到31也是。额外位的读取要在Huffman解码之后紧接着做,不能漏。我见过有人在这里漏读额外位,导致后续码流全部错位,解出来的图像花屏,排查了半天才发现是少读了几位。
2.3 滤波反演的串行处理技巧
滤波反演必须严格按行、按像素顺序做,因为每种滤波都依赖前一个像素或上一行像素。硬件里用一个行缓冲存上一行反演后的像素,当前行反演时同时读上一行对应位置和左边已反演的像素。
五种滤波的计算方式不同:None直接输出;Sub加左边像素;Up加上边像素;Average加左边和上边的平均;Paeth用左边、上边、左上三个像素做预测。Paeth的预测函数稍微复杂,但硬件里就是几个加法和比较,组合逻辑能搞定。
位深小于8的时候,一个字节里打包了多个像素,反演前要先拆包。比如4位灰度,一个字节两个像素,反演要按像素粒度做,做完再打包回去或者直接输出。这里容易出错的是行尾对齐:PNG每行数据按字节对齐,如果一行像素数乘以位深不是8的倍数,末尾会补位,反演时要跳过这些补位,不能当成有效像素。
实操心得:滤波反演的行缓冲建议用双口BRAM实现,一个口读上一行,一个口写当前行,读写可以同时进行。如果只用单口RAM,读写要分时,吞吐率会掉一半。
3. 实操过程与核心环节实现
3.1 工程目录结构与模块划分
十套工程源码我按平台和功能做了分类,目录结构统一如下:
png_decoder/ ├── rtl/ │ ├── png_parser.v # 块解析 │ ├── inflate_top.v # DEFLATE解压顶层 │ ├── huffman_dec.v # Huffman解码 │ ├── lz77_window.v # 滑动窗口 │ ├── filter_reverse.v # 滤波反演 │ ├── pixel_convert.v # 像素格式转换 │ └── ddr_ctrl.v # DDR读写控制 ├── sim/ │ ├── tb_png_parser.v │ ├── tb_inflate.v │ └── tb_top.v ├── con/ │ └── top.xdc # 引脚约束 └── doc/ └── register_map.md # 寄存器映射说明十套工程的区别主要在顶层封装和DDR控制器:有的用Xilinx MIG,有的用Intel UniPHY,有的用国产器件的DDR控制器,还有两套是纯片上BRAM版本,适合小图或者低端器件。每套工程的RTL核心是一样的,换平台只需要替换DDR控制器和约束文件。
3.2 关键参数计算与配置
DDR输入缓冲的大小要按最大压缩比估算。PNG的压缩比通常在2:1到10:1之间,极端情况可能更高。假设最大支持1920x1080真彩图,原始数据约6MB,按10:1压缩比算,压缩数据约600KB。输入缓冲分配1MB足够。输出帧缓存按原始数据算,1920x1080x4字节(RGBA)约8MB,分配16MB留余量。
滑动窗口大小我配置成32KB,这是DEFLATE规范允许的最大值。片上BRAM实现32KB窗口,在Xilinx 7系列上大约占16个36Kb BRAM,对中等规模器件可以接受。如果BRAM紧张,可以降到8KB,但压缩率会受影响,因为距离超过8KB的匹配就找不到了。
Huffman解码的查表深度我设成9位,对应512个表项。两级查表的第二级用逐位比较,最多支持15位码长(DEFLATE规范上限)。这个配置在Artix-7上综合后大约占2000个LUT,频率能跑到150MHz以上。
3.3 仿真验证流程
仿真我分三步走:先用小图(比如16x16)验证功能正确性,再用中等图(256x256)验证吞吐率,最后用大图(1920x1080)验证DDR带宽和整体时序。
测试激励的生成方法:用Python的PIL库把BMP转成PNG,同时导出原始像素数据作为参考。仿真时把PNG文件读进testbench,解码输出跟参考数据逐像素比对。Icarus Verilog和Vivado自带的仿真器我都试过,Icarus启动快、适合小规模调试,Vivado仿真器对时序和DDR模型支持更好。
# Icarus Verilog 编译仿真示例 iverilog -o tb_top tb_top.v ../rtl/*.v vvp tb_top比对脚本我用Python写,读仿真输出的像素文件和参考文件,逐字节比较,输出差异位置和数量。差异为0才算通过。
注意:仿真时DDR模型要加延迟,不能设成0延迟,否则综合后的时序可能对不上。我一般设读延迟20个周期、写延迟10个周期,接近实际DDR控制器的表现。
4. 常见问题与排查技巧实录
4.1 解码花屏的几种典型原因
花屏是最常见的现象,原因五花八门。我整理了一个排查表,按出现概率从高到低排列:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 整幅图花屏 | Huffman表解析错误 | 检查码长数组读取顺序 |
| 图像上半部分正常下半部分花 | 滑动窗口溢出 | 检查窗口地址回绕逻辑 |
| 图像有规律条纹 | 滤波反演行对齐错误 | 检查行尾补位处理 |
| 颜色错乱 | 像素格式转换错误 | 核对颜色类型和位深配置 |
| 图像偏移 | 隔行扫描未处理 | 检查隔行方式配置 |
Huffman表解析错误是最隐蔽的,因为码流本身没报错,只是解出来的符号不对。我的排查方法是把解出的符号序列打印出来,跟软件端zlib的解码结果比对,看从第几个符号开始分叉,分叉点往前就是问题所在。
4.2 DDR带宽不够导致的丢帧
大图解码时DDR带宽是瓶颈。输入压缩数据读、滑动窗口回溯读写、输出像素写,三股流量叠加。我实测1920x1080真彩图解码,DDR有效带宽需要约400MB/s才能实时。如果DDR控制器配置的位宽或频率不够,就会出现丢帧或者解码卡顿。
优化手段有几个:一是滑动窗口尽量放片上,减少DDR回溯;二是输入压缩数据用突发读,一次读一大块缓存到片上FIFO;三是输出像素用突发写,攒够一行再写DDR。这三招下来,DDR带宽需求能降一半左右。
4.3 时序不收敛的解决思路
Huffman解码的组合逻辑比较深,容易成为时序瓶颈。我的做法是把逐位比较拆成流水线,每级只处理一位,虽然延迟增加,但频率能上去。另外查表RAM用分布式RAM而不是BRAM,读延迟更小。
滤波反演的Paeth预测函数组合逻辑也不浅,我在中间插了一级寄存器,把三个像素的读取和预测计算分开,时序明显改善。综合时如果还差一点,可以给关键路径加max_delay约束,让工具重点优化。
实操心得:先保证功能正确,再优化时序。我见过有人一上来就追求高频,结果功能都没跑通,调时序纯属浪费时间。功能对了,时序慢慢磨总能收敛。
5. 十套工程的差异化说明与选用建议
5.1 按平台分类的工程清单
十套工程我按平台和存储方案做了区分,方便不同背景的开发者直接选用:
| 工程编号 | 平台 | DDR方案 | 适用场景 |
|---|---|---|---|
| 01 | Xilinx Artix-7 | MIG | 通用入门 |
| 02 | Xilinx Kintex-7 | MIG | 高性能 |
| 03 | Xilinx Zynq-7000 | PS DDR | 软硬协同 |
| 04 | Intel Cyclone IV | UniPHY | 低成本 |
| 05 | Intel Cyclone V | UniPHY | 中端 |
| 06 | 国产安路 | 自研控制器 | 国产化 |
| 07 | 纯片上BRAM | 无DDR | 小图低端 |
| 08 | 纯片上BRAM | 无DDR | 小图低端 |
| 09 | Xilinx Artix-7 | MIG | 带CRC校验 |
| 10 | Xilinx Kintex-7 | MIG | 带CRC校验 |
01到06是带DDR的完整版,支持大图。07和08是纯片上版本,只支持小图(比如256x256以内),适合BRAM资源少或者不想折腾DDR的场景。09和10在01和02基础上加了CRC校验,对数据完整性要求高的选这两套。
5.2 移植到新平台需要改什么
移植的核心工作是替换DDR控制器和约束文件。RTL部分跟平台无关,只要DDR控制器提供标准的读写接口(地址、数据、使能、应答),直接对接就行。我定义的DDR接口是简单的类SRAM接口,读写各一套,移植时写个适配层即可。
约束文件要改时钟频率、引脚分配、时序例外。如果新平台的BRAM结构不同,滑动窗口的RAM推断可能要调整,比如改用厂商提供的RAM IP或者调整读写模式。这些在doc目录的移植指南里有详细说明。
5.3 技术支持与代码获取
十套工程的源码我打包在一起,附带仿真脚本、测试图片和比对工具。代码注释我写得比较密,关键状态机和计算逻辑都有说明。遇到问题可以先看doc目录的FAQ,大部分常见问题都有记录。
如果FAQ解决不了,可以把仿真波形和日志发过来,我帮忙定位。技术支持的范围包括:编译报错、仿真不通过、时序不收敛、移植适配。不包含:帮你改需求、帮你写论文、帮你做非PNG格式的解码。
我在实际调试这套代码的过程中,最大的体会是:PNG解码的难点不在单个模块,而在模块之间的数据流配合。Huffman解出的符号怎么喂给LZ77,LZ77的输出怎么对齐到滤波反演的行边界,滤波后的数据怎么按像素格式打包,这些接口如果没定义清楚,单个模块仿真都过,连起来就花屏。我的建议是先把接口时序图画出清楚,再动手写代码,能省很多返工时间。另外,小图调通再上大图,功能调通再优化时序,这个顺序别乱,乱了就是给自己找麻烦。