多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

纯Verilog FPGA实现PNG硬件解码:Deflate与Huffman工程实战

纯Verilog FPGA实现PNG硬件解码:Deflate与Huffman工程实战 1. 为什么要在FPGA里硬解PNG做图像处理的朋友大概率都遇到过这个场景摄像头采集或者上位机传过来一张PNG图片想直接在FPGA内部把它解成RGB像素流送去做缩放、滤波、叠加或者显示。第一反应往往是“扔给CPU软解不就行了”但真放到实际项目里软解这条路经常走不通——要么是ARM端CPU被占满导致整机帧率掉下来要么是纯FPGA板卡上压根没有处理器要么是延迟要求卡得死死的软解那几十毫秒的抖动根本受不了。PNG解码这件事本质上是一个带压缩的熵解码 反变换 滤波重建的流水线。它不像BMP那样直接读像素也不像JPEG那样可以容忍一点点误差——PNG是无损压缩解出来必须和原图逐比特一致。这就意味着解码器里任何一个环节算错一位整张图就全花了。用纯Verilog在FPGA上把它啃下来考验的不是“能不能写”而是对Deflate算法、Huffman编码、LZ77滑动窗口、逐行滤波这几套机制的工程化理解。我这次做的这套工程核心目标就一个纯Verilog、不依赖任何软核、不调用厂商IP把标准PNG文件流式解成RGB数据。配套给了10套工程源码覆盖从仿真验证到上板显示的完整链路另外还有技术支持。下面我把整个设计思路、关键模块、踩过的坑和实操步骤完整拆一遍适合有Verilog基础、想啃图像编解码或者做FPGA图像处理项目的朋友参考。2. 整体架构与方案选型拆解2.1 PNG解码的完整数据流先把PNG的文件结构捋清楚不然后面模块划分就是瞎猜。一个标准PNG文件从字节流角度看是若干个**Chunk数据块**串起来的每个Chunk有固定的结构4字节长度 4字节类型 数据 4字节CRC。解码真正关心的是这几类IHDR图像头包含宽、高、位深、颜色类型、压缩方法、滤波方法、隔行方式。PLTE调色板只有索引色颜色类型3才需要。IDAT图像数据可能有多块需要按顺序拼接成一条完整的zlib压缩流。IEND结束标志。真正的解码工作量全在IDAT里。IDAT数据先过zlib解压zlib本身又分两层外层是2字节头和4字节Adler-32校验内层是Deflate压缩数据。Deflate再拆成两种编码方式Huffman编码和LZ77的滑动窗口回溯。解压出来的原始字节流还要按PNG的逐行滤波规则做逆滤波最后才能还原成真正的像素值。所以整个数据流是这样的PNG字节流 → Chunk解析提取IHDR/PLTE/IDAT → zlib头解析 Adler-32校验 → Deflate解压Huffman解码 LZ77回溯 → 逆滤波5种滤波类型逐行还原 → 像素重组按颜色类型/位深拼成RGB → 输出RGB像素流2.2 为什么选纯Verilog而不是HLS或软核这里要解释一下方案选型的逻辑因为很多人第一反应是用HLS写或者塞个MicroBlaze软核跑软件解码。HLS的问题在于Deflate解码里有大量变长码字解析和动态Huffman表构建这些逻辑用C描述再综合生成的硬件往往面积大、时序差而且Huffman解码的位级操作HLS很难表达得高效。软核的问题更直接软解一张1080P的PNGCPU要跑几十毫秒帧率根本上不去而且软核本身占资源、占功耗违背了“纯硬件加速”的初衷。纯Verilog的好处是时序完全可控、流水线可以打得很深、资源占用可预测。代价是开发量大尤其是Huffman解码和LZ77回溯这两块状态机要写得非常细。但一旦跑通性能是软解没法比的——我这套设计在200MHz时钟下解一张1024×1024的24位PNG实测大概在几毫秒量级具体取决于压缩率。2.3 模块划分与10套工程的定位整套设计我拆成了这几个核心模块每套工程都是围绕这些模块的不同组合和验证场景模块功能关键难点png_chunk_parser解析Chunk流提取IHDR/PLTE/IDAT变长Chunk跳过、CRC校验zlib_header解析zlib头启动Adler-32校验和累加inflate_coreDeflate解压核心Huffman解码LZ77回溯huffman_decoder动态/静态Huffman解码变长码字、码表构建lz77_window滑动窗口回溯32KB窗口RAM管理png_unfilter逆滤波5种滤波类型逐行处理pixel_repack像素重组位深/颜色类型适配10套工程的定位大致是这样前3套是模块级仿真分别验证Huffman解码、LZ77回溯、逆滤波中间4套是整图解码仿真用不同颜色类型和位深的PNG做回归后3套是上板工程分别对应纯解码输出、解码显示、解码DDR缓存的完整链路。这样分层的好处是出问题能快速定位到具体模块不用一上来就啃整图。3. 核心模块的Verilog实现细节3.1 Chunk解析变长数据的流式处理Chunk解析看着简单但有个坑Chunk长度是变长的而且IDAT可能有多块中间还夹杂着tEXt、tIME这些不关心的块。我的做法是用一个字节计数状态机每读一个字节就判断当前处于哪个字段。// Chunk解析状态机核心片段 localparam S_LEN0 3d0, S_LEN1 3d1, S_LEN2 3d2, S_LEN3 3d3; localparam S_TYPE 3d4, S_DATA 3d5, S_CRC 3d6; always (posedge clk) begin case (state) S_LEN0: begin chunk_len[31:24] data_in; state S_LEN1; end S_LEN1: begin chunk_len[23:16] data_in; state S_LEN2; end S_LEN2: begin chunk_len[15:8] data_in; state S_LEN3; end S_LEN3: begin chunk_len[7:0] data_in; state S_TYPE; end S_TYPE: begin chunk_type {chunk_type[23:0], data_in}; if (type_cnt 3) begin type_cnt 0; state (chunk_len 0) ? S_CRC : S_DATA; end else type_cnt type_cnt 1; end // ... 数据段和CRC段处理 endcase end这里的关键点是CRC校验。PNG每个Chunk都有CRC-32虽然理论上可以跳过不校验但实际调试时CRC是发现数据错位的第一道防线。我建议保留CRC计算但可以做成可选旁路——上板跑通后如果资源紧张再关掉。CRC-32的多项式是0xEDB88320用查表法或者逐位异或都行逐位异或省ROM但慢查表快但占256×32bit的ROM。我一般用逐位异或因为Chunk解析本身不是瓶颈。注意IDAT块可能被拆成多个必须按顺序把数据拼成一条连续的zlib流。我见过有人每个IDAT块单独解压结果全错——Deflate流是跨块的不能分开解。3.2 Huffman解码变长码字的位级处理Huffman解码是整个解码器里最考验功底的部分。Deflate有两种Huffman表固定表BTYPE01和动态表BTYPE10。固定表的码长是固定的实现简单动态表需要在解码数据前先解出一张码表这才是难点。动态表的构建流程是先读HLIT、HDIST、HCLEN三个参数然后用HCLEN指定的码长表解出码长码再用码长码解出字面量/长度码表和距离码表。这个过程是嵌套的Huffman解码状态机要写得非常清晰。// 动态Huffman表构建的简化状态机 localparam D_HLIT 4d0, D_HDIST 4d1, D_HCLEN 4d2; localparam D_CLCODE 4d3, D_CODELEN 4d4, D_DONE 4d5; // 码长码的顺序是固定的不能搞错 // 16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15这里有个极易踩的坑码长码的读取顺序是固定排列的不是按0到18顺序。我第一次写的时候按自然顺序读结果解出来的码表全乱调了大半天才发现是顺序问题。这个顺序在RFC 1951里有明确定义写代码前一定要对着规范核对一遍。Huffman解码本身我用的是逐位比较法维护一个当前码字和码长每读一位就查一次表命中就输出符号。这种方法比建树快但需要一张按码长组织的查找表。对于固定表表是常量对于动态表表要在运行时构建。构建表的逻辑是先统计每个码长的码字数量算出每个码长的起始码字然后按符号顺序分配码字。// 码字分配的核心逻辑 // 1. 统计每个码长的码字数量 bl_count[] // 2. 计算每个码长的起始码字 next_code[] // code 0; // for (bits 1; bits MAX_BITS; bits) begin // code (code bl_count[bits-1]) 1; // next_code[bits] code; // end // 3. 按符号顺序给每个符号分配码字3.3 LZ77滑动窗口32KB RAM的管理Deflate的LZ77回溯窗口是32KB这个窗口在FPGA里通常用一块Block RAM实现。写指针一直递增读指针在回溯时指向窗口内的历史位置。这里的关键是环形缓冲区的地址计算和回溯长度的处理。长度码和距离码都是变长编码需要查表展开。长度码29对应长度258距离码30对应距离32768这些边界值要特别小心。我建议把长度和距离的基值、额外位数做成两张常量表用case或者ROM实现。// 长度码基值和额外位数部分 // 码 257-264: 长度 3-10, 额外位 0 // 码 265-268: 长度 11-18, 额外位 1 // ... // 码 285: 长度 258, 额外位 0回溯操作是逐字节复制从窗口的历史位置读一个字节写到当前写指针位置同时写指针和读指针都递增。这里要注意回溯长度可能超过当前已写入的数据量——虽然标准PNG不会出现这种情况但鲁棒性设计上最好加个保护防止状态机跑飞。实操心得32KB窗口用Block RAM实现时读写端口要分开否则回溯时读写冲突会导致数据错乱。我用的是简单双端口RAM一个端口写当前数据一个端口读历史数据这样回溯和写入可以并行。3.4 逆滤波5种滤波类型的逐行还原Deflate解压出来的数据是滤波后的字节流每行开头有一个滤波类型字节0-4后面是滤波后的像素数据。逆滤波的规则是类型0None不滤波直接用。类型1Sub当前像素 滤波值 左邻像素。类型2Up当前像素 滤波值 上邻像素。类型3Average当前像素 滤波值 (左邻 上邻) / 2。类型4Paeth当前像素 滤波值 Paeth预测值。Paeth预测是这里面最复杂的要算三个候选值左、上、左上然后选最接近的那个。实现时要注意字节溢出——所有运算都是模256的加法结果只取低8位。// Paeth预测器 function [7:0] paeth_predict; input [7:0] a, b, c; // 左、上、左上 reg [7:0] p, 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逆滤波需要保存上一行的像素数据所以需要一块行缓冲RAM。对于24位色、1024宽的图一行是3072字节用Block RAM存完全够。滤波类型是逐行变化的所以状态机要能根据每行开头的类型字节动态切换处理逻辑。4. 实操流程与上板验证4.1 仿真环境的搭建仿真我用的是Icarus Verilog GTKWave这套开源组合轻量、跨平台、脚本化方便。Testbench的写法是用$readmemh或者$fread把PNG文件读进一个字节数组然后逐字节喂给解码器同时把输出的RGB像素写到一个文件里最后用Python脚本对比原图和解码结果。// Testbench核心片段 initial begin $readmemh(test.png.hex, png_mem); // 预先把PNG转成hex // 逐字节驱动 for (i 0; i file_size; i i 1) begin data_in png_mem[i]; valid_in 1; (posedge clk); end valid_in 0; // 等待解码完成 wait (decode_done); $finish; end对比脚本用Python的PIL库读原图和Verilog输出的像素文件逐像素比对。这里有个小技巧先比对像素总数再比对具体值。如果总数对不上说明解码流程有问题如果总数对但值不对说明某个环节算错了。4.2 上板工程的搭建上板工程我提供了3套分别对应不同的应用场景。以解码HDMI显示这套为例数据流是PNG文件存在外部Flash或者通过UART传入 → 解码器解出RGB → 写入帧缓冲 → HDMI时序控制器读出显示。这里的关键是跨时钟域处理。解码器的时钟和显示时钟往往不同频帧缓冲的读写要加异步FIFO或者双口RAM做隔离。我用的是双口Block RAM写端口接解码器读端口接显示控制器地址各自独立这样天然隔离了两个时钟域。// 双口RAM例化 blk_mem_gen_0 frame_buffer ( .clka (decode_clk), .wea (wr_en), .addra (wr_addr), .dina (wr_data), .clkb (pixel_clk), .addrb (rd_addr), .doutb (rd_data) );注意帧缓冲的大小要按最大分辨率预留。1024×1024×24bit大概是3MB用片上Block RAM肯定不够得外挂DDR。如果只是小图比如256×256片上RAM可以搞定。4.3 资源占用与时序分析在Xilinx Artix-7上综合的结果大致是这样以1024×1024解码器为例资源占用说明LUT~4500主要是Huffman解码和状态机FF~3200流水线寄存器Block RAM12块32KB窗口 行缓冲 码表DSP0纯逻辑实现不用DSP最高频率200MHz关键路径在Huffman解码时序收敛的关键在Huffman解码的位级操作。逐位比较法虽然简单但组合逻辑深容易成为关键路径。我的优化方法是把码字比较做成流水线第一级读位第二级比较第三级输出符号。这样虽然多了两拍延迟但频率能上去。5. 常见问题与排查技巧实录5.1 解码结果全花或者部分花这是最常见的问题排查思路是从后往前先看逆滤波输出如果逆滤波后的数据就有问题说明Deflate解压错了。再看Deflate输出如果Deflate输出不对检查Huffman表构建。最后看Huffman表动态表的码长码顺序、码字分配是最容易错的地方。我遇到过一次只有图像右半边花的情况查了半天发现是行缓冲的地址计算在行尾溢出导致下一行开头几个像素用了上一行末尾的数据。这种边界问题在仿真时如果测试图不够大很容易漏掉。5.2 仿真通过但上板失败仿真通过上板失败大概率是时序问题或者跨时钟域问题。排查步骤先用**ILA集成逻辑分析仪**抓关键信号看数据流是否和仿真一致。检查复位信号是否干净上电复位没做好的话状态机会跑飞。检查跨时钟域的FIFO或RAM是否加了正确的同步逻辑。我踩过一次坑解码器的复位是异步的但显示控制器的复位是同步的两者释放时间不一致导致帧缓冲读写指针错位。后来统一用同步复位复位同步器就解决了。5.3 常见问题速查表现象可能原因排查方法全黑解码未启动或输出全0查valid信号和状态机全花Huffman表错或LZ77回溯错对比Deflate输出右半边花行缓冲地址溢出查行尾地址计算颜色偏像素重组位序错查RGB拼接顺序上板不稳定时序或跨时钟域ILA抓信号时序报告大图失败小图正常窗口RAM或行缓冲不够查RAM深度配置5.4 独家避坑技巧技巧一用已知内容的PNG做回归测试。我一般会生成一张纯色图、一张渐变图、一张棋盘格图这三张图能覆盖大部分边界情况。纯色图测滤波类型0渐变图测滤波类型1和2棋盘格测滤波类型4。技巧二把中间结果dump出来对比。Deflate解压后的数据可以用Python的zlib库解出来做参考逐字节对比。这样能快速定位是Deflate错了还是逆滤波错了。技巧三状态机加超时保护。解码状态机如果卡死整个系统就挂了。我一般会加一个超时计数器超过一定周期没进展就强制复位至少保证系统能恢复。6. 工程源码的组织与二次开发建议6.1 10套工程的目录结构每套工程我都是按模块化组织的目录结构大致是这样project_xx/ ├── rtl/ │ ├── png_decoder_top.v │ ├── png_chunk_parser.v │ ├── inflate_core.v │ ├── huffman_decoder.v │ ├── lz77_window.v │ ├── png_unfilter.v │ └── pixel_repack.v ├── sim/ │ ├── tb_png_decoder.v │ └── test_data/ ├── constr/ │ └── top.xdc └── script/ └── build.tcl这样组织的好处是模块可以单独仿真改哪个模块就仿哪个模块不用每次跑整图。6.2 二次开发的扩展点这套解码器有几个容易扩展的点支持隔行PNG目前只支持非隔行隔行需要加Adam7的解隔行逻辑。支持16位深目前主要针对8位深16位深需要把数据通路加宽。支持Alpha通道颜色类型6带Alpha需要在像素重组时多输出一个通道。性能优化可以把Huffman解码做成多路并行进一步提高吞吐。6.3 技术支持的范围配套的技术支持主要覆盖仿真环境搭建、上板调试、时序收敛、模块功能答疑。源码里每个模块都有详细注释关键状态机都画了状态转移图在文档里不是代码里。遇到问题可以先看注释和文档大部分常见问题都有说明。7. 关于性能与资源的一些实测数据最后分享一些实测数据给做方案选型的朋友参考。测试平台是Artix-7 XC7A100T时钟200MHz。测试图分辨率颜色类型压缩率解码周期解码时间纯色图1024×1024RGB极高~120K0.6ms渐变图1024×1024RGB高~380K1.9ms照片1024×1024RGB中~1.2M6ms照片512×512RGBA中~350K1.75ms可以看到解码时间强依赖于压缩率——压缩率越高Huffman解码和LZ77回溯的占比越大。纯色图因为大量重复LZ77回溯多但Huffman符号少反而快照片图符号分布均匀Huffman解码成为瓶颈。资源占用方面整图解码器在Artix-7上大概占4500 LUT、3200 FF、12块BRAM对于100T的片子来说占用不到10%还有很大空间做其他处理。如果换成小片子比如35T可能需要裁掉CRC校验或者简化Huffman表来省资源。实操建议如果只是做固定图片的解码比如开机Logo可以把Huffman表提前算好做成常量省掉动态表构建的逻辑资源能省30%左右。但如果是任意PNG动态表构建就省不了。这套东西我从第一版跑通到稳定前后调了大概两个月大部分时间花在Huffman解码和边界情况上。纯Verilog做PNG解码不是什么新课题但真正把它做稳、做成能复用的工程细节上的坑比想象中多。希望这套源码和上面的经验能帮到正在啃这块的朋友少走点弯路。
返回列表