ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HLS实现二维FFT图像处理:从算法设计到Zynq上板全流程

HLS实现二维FFT图像处理:从算法设计到Zynq上板全流程 每年暑假的Xilinx暑期学校都是集中肝项目的好时候。我当时抽到的项目二是“通过HLS实现二维傅里叶变换(2D FFT)及图像数据读入读出”听名字很学院派实际做完才发现它几乎把HLS开发最常见的痛点是挨个打了一遍算法怎么写、数组怎么分配、数据怎么从PS送到PL、算完怎么拿回来、中间结果为什么颜色不对。这篇文章就把整个实现过程掰开了说包括最后的坑和排查思路给后面要碰类似题目的人一个参考。1. 项目整体设计与思路拆解1.1 为什么是HLS而不是直接写RTL二维FFT这个题目放到RTL里做光控制逻辑就能写掉一大半时间。蝶形运算是天然并行且规律性很强的结构但每一级旋转因子、每一级位宽对齐、中间数据的存储切换都要靠状态机逐拍管理。对暑期学校这种短周期项目来说RTL版本很容易卡在仿真验证环节。HLS最大的优势是你先用C/C描述算法验证通过后再用pragma去约束硬件行为比如#pragma HLS PIPELINE、#pragma HLS ARRAY_PARTITION。C仿真的速度比RTL仿真快几个数量级可以快速确认算法正确性后面的硬件问题再逐个击破。还有一点很实际做2D FFT时中间有一大块转置存储的逻辑。而HLS里转置就是两个嵌套for循环换个数组下标抽象层次高很多不容易写错。真拿到FPGA上不达标时再针对具体的memory带宽和资源瓶颈做优化。这个项目的目标很明确先跑通再评估性能而不是开局就纠结每一拍的时序。1.2 2D FFT的可分离性到底怎么用二维FFT的标准做法不是直接做一个二维蝶形网而是利用可分离性把M×N的变换拆成两步先沿每一行做N点一维FFT再把中间结果按列做M点一维FFT。对图像来说通常M和N相等比如256×256、512×512。这样做的计算量从O(N²logN)降到O(NlogN)硬件上最大的影响是中间结果必须在内存里完成一次“行变列”的重排也就是转置。转置是性能和资源消耗的分水岭。行方向处理完的数据写入时是一行一行连续写但下一次列方向读取时得按列读也就是跨行长距离跳读。如果直接挂在DDR上这种访问模式会带来严重的带宽浪费。所以我们当时的方案是在HLS IP内部用BRAM把整块中间结果缓存下来再按列读出来。BRAM的容量决定了能处理的图像上限这一步在架构设计时就要先算清楚。1.3 三种实现路径的取舍关于“HLS实现2D FFT”网上能搜到几种不同路子它们的差异不小方案优点缺点适用场景全自研C代码调HLS综合可定制程度高能深入理解FFT开发量大优化靠经验学习、算法定制调用Vivado的FFT IP核时序性能有保障参数配置灵活需要了解IP配置中间逻辑不好改造工程落地快调用HLS的xFFT库接口规范配合Vitis很方便黑盒程度高出了问题不好定位快速原型验证我最后选了全自研C代码这条路。一是暑期学校项目的基础要求是理解FFT实现二是如果把IP核一调核心算法就变成配置参数了即便结果对了你对FFT结构本身的感知还是不够三是为了后续做图像复用时能自由控制中间数据格式。如果你的目标只是快速出结果可能会想用IP核但后面做性能优化时会发现自定义的灵活性很难替代。2. HLS实现2D FFT的核心细节与实操要点2.1 数据类型的选择float还是ap_fixed初版代码全部用float图省事。但综合到FPGA上之后DSP48的资源消耗很夸张而且综合时间明显变长。原因是浮点加法器和乘法器会占用更多逻辑资源对FFT这种乘加密集型的算法来说这个开销会被放大。改用ap_fixed16, 7这类定点数之后资源占用可以下降非常多。选位宽时主要看两个边界一是输入图像数据位宽一般灰度图是8bit范围0到255二是旋转因子一般用16bit定点够用所以中间乘法结果用ap_fixed32, 16做累加比较稳妥。FFT中间级如果保留太多位BRAM容量直接翻倍如果太少输出图像会看到明显的噪声条纹。我当时调了一个晚上最后固定下来的是输入8bit内部24bit。这个选择对不同图像尺寸都有效。2.2 一维FFT的C代码框架先写一维FFT我用的是经典的迭代基2算法Cooley-Tukey避免递归调用在综合时产生不确定的栈开销。核心结构就是三层for循环外层是每一级迭代中间是蝶形分组内层是单个蝶形运算。#include fft2d.h void fft_1d(ap_fixed16,7 *real, ap_fixed16,7 *imag, int n) { int m log2(n); // bit reversal for (int i 0; i n; i) { int j 0; for (int k 0; k m; k) { if ((i k) 1) j | (1 (m - 1 - k)); } if (i j) { ap_fixed16,7 t_r real[i]; real[i] real[j]; real[j] t_r; ap_fixed16,7 t_i imag[i]; imag[i] imag[j]; imag[j] t_i; } } // butterfly for (int len 2; len n; len 1) { ap_fixed20,10 w_r 1, w_i 0; // 旋转因子可以查表可以实时算 int steps n / len; for (int i 0; i len / 2; i) { for (int j 0; j steps; j) { int idx i j * len; ap_fixed20,10 tw_r cos_table[i * n / len]; ap_fixed20,10 tw_i sin_table[i * n / len]; ap_fixed20,10 x_r real[idx len / 2]; ap_fixed20,10 x_i imag[idx len / 2]; real[idx len / 2] (ap_fixed20,10)real[idx] - (x_r * tw_r - x_i * tw_i); imag[idx len / 2] (ap_fixed20,10)imag[idx] - (x_r * tw_i x_i * tw_r); real[idx] (x_r * tw_r - x_i * tw_i); imag[idx] (x_r * tw_i x_i * tw_r); } } } }注意旋转因子建议用查表因为三角函数在硬件上不是免费的。位宽不一致时会引发精度损失所以要提前定义好不同宽度的中间变量。虽然编译不报错但数值上会和C仿真对不齐所以后来我把旋转因子表统一用ap_fixed20,10存乘完再截断回ap_fixed16,7。2.3 二维FFT与转置优化策略二维做法按行读、按行FFT、转置、再按列FFT。用C写就是void fft2d(ap_fixed16,7 img_in[ROWS][COLS], ap_fixed16,7 img_out[ROWS][COLS]) { #pragma HLS ARRAY_PARTITION variableimg_in cyclic factor4 dim2 #pragma HLS ARRAY_PARTITION variableimg_out cyclic factor4 dim2 ap_fixed16,7 real[ROWS][COLS]; ap_fixed16,7 imag[ROWS][COLS]; #pragma HLS ARRAY_PARTITION variablereal cyclic factor4 dim2 #pragma HLS ARRAY_PARTITION variableimag cyclic factor4 dim2 // 行FFT for (int r 0; r ROWS; r) { #pragma HLS LOOP_TRIPCOUNT min256 max1024 for (int c 0; c COLS; c) { real[r][c] img_in[r][c]; imag[r][c] 0; } fft_1d(real[r], imag[r], COLS); } // 转置 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { real_t[r][c] real[c][r]; imag_t[r][c] imag[c][r]; } } // 列FFT for (int c 0; c COLS; c) { fft_1d(real_t[c], imag_t[c], ROWS); } // 输出取模 for (int r 0; r ROWS; r) { for (int c 0; c COLS; c) { img_out[r][c] sqrt((ap_fixed16,7)real_t[r][c] * real_t[r][c] (ap_fixed16,7)imag_t[r][c] * imag_t[r][c]); } } }哈希符号的主题是#pragma HLS DATAFLOW但转置这一步有数据依赖所以不能直接对整个函数做dataflow。我们实际的做法是把行FFT、转置、列FFT拆成三个子函数接口用hls::stream来串然后对三个子函数分别pipeline。转置部分本质上是一个数据流重排序用stream实现之后HLS可以把它综合成BRAM缓冲加读写地址控制逻辑比自己手动管理地址可靠得多。这里有个很多人踩过的坑如果转置直接操作二维数组HLS综合后会默认把所有数据放BRAM而列方向的读写冲突会限制性能。最直接的解决办法是用#pragma HLS ARRAY_PARTITION cyclic factor4把每个数组按列方向切成4个bank这样一次能并行读4个数据写也分散到4个bank带宽翻4倍。代价是BRAM数量上升所以最后选了factor4作为平衡点。2.4 HLS Top-Level接口设计Top-level函数尽量简单我用了AXI4-Stream接口和AXI4-Lite接口结合的方式。输入输出图像数据走axis寄存器配置如图像行列数、使能标志走s_axilite。void fft2d_top(axis_data *input, axis_data *output, int rows, int cols) { #pragma HLS INTERFACE axis portinput #pragma HLS INTERFACE axis portoutput #pragma HLS INTERFACE s_axilite portrows #pragma HLS INTERFACE s_axilite portcols #pragma HLS INTERFACE ap_ctrl_none portreturn // 这里把stream转成数组再调用fft2d }axis_data是一个结构体里面包含一个ap_uint32的数据成员和TLAST等信号。实际使用时把一个像素放一个axis_data里可以同时打包实部和虚部比如低16位放实数、高16位放虚数这样少一半的事务数量吞吐量直接翻倍。这一点在带宽紧张的时候特别重要。3. 图像数据读入读出方法详解3.1 图像格式选择与预处理项目要求图像数据读入读出我们试了两条路。一个是用BMP图像BMP头文件要解析54字节的文件头再找到像素数据的偏移另一个是直接用RAW格式也就是把BMP转成纯灰度阵列。RAW格式省去了文件头解析读入读出最方便特别适合在FPGA上做验证。如果你用Python做预处理只需要这样转换from PIL import Image import numpy as np img Image.open(input.bmp).convert(L) # 转灰度 img img.resize((256, 256)) data np.array(img, dtypenp.uint8) data.tofile(input.raw) # 无头部的纯像素读入的时候PS端把RAW文件从SD卡读到DDR再通过DMA送到HLS IP。读出的时候同理HLS IP返回的数据通过DMA写回DDRPS端把数据保存成RAW再在PC端用脚本显示成图像。3.2 通过AXI DMA搬运数据的完整流程在Zynq平台上最常用的链路是PS - AXI DMA - HLS IP - AXI DMA - PS。搬运流程可以分成几个固定步骤PS端把像素数据放到DDR的某个地址告诉DMA源地址、目标地址和传输长度。DMA通过AXI4-Stream把数据流式发给HLS IP。HLS IP处理完后把结果作为AXI4-Stream输出DMA接收后写回DDR的另一块地址。PS端等DMA中断或轮询done标志之后读取DDR结果。DMA的配置我用了XDma Drivers的simple模式不用弄描述符链表。核心编程接口大概长这样#define DMA_IN_BASE 0x40400000 #define DMA_OUT_BASE 0x40400030 int dma_transfer(u32 *dma_in, u32 *dma_out, int len) { Xil_Out32(DMA_IN_BASE 0x04, (u32)(uchar *)dma_in); // source address Xil_Out32(DMA_IN_BASE 0x08, (u32)(uchar *)dma_out); // dest address Xil_Out32(DMA_IN_BASE 0x0C, len); // len bytes Xil_Out32(DMA_IN_BASE 0x00, 0x1); // start bit while ((Xil_In32(DMA_IN_BASE 0x10) 0x1) 0); // wait done return 0; }上面的寄存器地址是AXI DMA IP在地址空间的映射具体要以Block Design里实际的基地址为准。实际使用时要注意每次开始DMA前必须把上次的status寄存器清掉否则容易误判完成。很多莫名其妙“DMA卡死”的问题都是因为没清状态位。3.3 数据封装与像素映射HLS IP内部的计算维度是复数数组但从DMA传进来的只是无符号整数的数据流。所以需要在PS端和HLS端约定一个打包协议。我们用的协议是这样的每个AXI事务传32bit低16bit是当前像素的实数输入高16bit先填0HLS IP内部把实部取出、虚部置0送入FFT计算输出侧HLS IP把结果的实部放低16bit虚部放高16bit组合成32bit再送出去PS拿到结果后拆开用实部虚部算幅度或者直接只取实部做验证。这样做的理由很简单FFT输出必然是复数如果只传模值后面做频域滤波、相位分析都不够用。打包成32bit多占不了多少带宽但保留了完整的计算信息。3.4 结果回传与图像重建注意事项从DMA拿回的数据还需要做一些处理才能变成能看的图像。直接看FFT的实部通常啥也看不出来需要做幅度谱。我们在PC端用Python做的import numpy as np raw np.fromfile(output.raw, dtypenp.uint32) real (raw 0xFFFF).astype(np.float32) imag ((raw 16) 0xFFFF).astype(np.float32) mag np.sqrt(real**2 imag**2) mag np.fft.fftshift(mag) # 中心化低频放中间 mag_log np.log1p(mag) img_out (mag_log / mag_log.max() * 255).astype(np.uint8) img_out.tofile(output_python.raw)有两个细节值得记下来。一是FFT中心化的位置如果图是偶数尺寸np.fft.fftshift是正确的如果是奇数尺寸需要自己小心交换像素。二是幅度谱动态范围很大直接线性显示会变成一片白要先取对数再用最大值归一化。不然还会以为FFT结果算错了。4. 常见问题与排查技巧实录4.1 输出图像出现条纹状噪点是位宽还是数据错位第一次上板跑出结果时输出图像是一片黑白相间的仰条纹完全看不出频谱形状。第一反应是数据错位比如读入时一行数据的边界错了。但用Python重新解析同一份RAW把行数、列数都对上之后发现还是花纹。后来把中间FFT结果的实部虚部打印出来和纯C对比才意识到是定点数位宽截断导致精度不足。具体来说FFT的中间累加值增长很快输入是8bit经过几级蝶形之后乘加结果可能超过32bit的整数部分而我用了ap_fixed16,7存中间结果整数位只有7bit最大值只有127左右对一个256×256的图来说行FFT完的结果动不动就超过这个范围全部被截断和溢出出来的谱自然就是乱码。解决办法是把内部累加位宽提到ap_fixed32,16在每一级蝶形累加完成之后再做一次移位把数据缩回ap_fixed16,7。移位的位数量得和log2(FFT点数)处理好否则幅度整体偏小或偏大。4.2 DMA传输中途挂起另一个高频问题是DMA传着传着就停了连握手信号都看不到。后来排查发现不是DMA本身的问题而是HLS IP的AXI-S接口没有正确接收完数据导致反压backpressureDMA的FIFO被堵住传输就停住了。检查HLS综合报告里的接口时序发现我的axis接口配置的是ap_ctrl_none但内部没有设置好TREADY信号的行为导致TREADY在复位后一直是低。解决方式是在top函数里把axis的TREADY逻辑明确交给HLS综合器处理不要手动拉低它。还有一个经验是建议用#pragma HLS INTERFACE modeaxis register这样HLS会自动加入寄存器打拍逻辑时序收敛会容易很多。4.3 BRAM资源不够的大图处理思路512×512的灰度图做实数转复数存储实数虚数各按24bit算中间数据需要512×512×24bit×212Mbit的BRAM。Zynq的BRAM总量一般是几Mbit到十几Mbit这样一张图就吃掉一大半。要做到1024×1024几乎不可能在不做进一步优化的情况下直接用片内BRAM存下整张图。我们的方案是分块处理把大图按行切成多个横条每个横条的行数足够小能装进BRAM。行方向FFT在每个横条内完成列方向FFT则需要跨横条这时候有两种选择一是把所有横条的中间结果通过AXI总线写回DDR再按列读出来处理二是用overlap-save方法配合流式处理避免一次性把所有列结果堆积在片内。第二种方法更高级但周期长暑期学校版本先用了片内缓冲加DDR回写的折中方案实测下来256×256没问题512×512也还能跑1024×1024就得等很长的处理时间。4.4 C/RTL协同仿真的调试建议调HLS的bug最有用的是C/RTL协同仿真。它能告诉你的不仅是结果对不对还有位宽、时序、流行为是否正确。使用方法是先在C仿真里用典型图像验证算法再跑协同仿真对比C仿真和RTL仿真的结果。注意协同仿真和真实上板的数据路径稍有差异所以它通过之后上板纵然失败也基本能定位为接口或存储映射问题而不是算法问题。有一个我当时忽略了的坑协同仿真默认的输入是函数参数如果你在top-level直接读取了一个文件路径那么协同仿真不会去文件系统里找这个文件而是把它当作一个普通字符串。所以文件读取一定要放在PS端HLS IP不要干文件IO这回事。4.5 上板调试时如何快速定位故障范围上板之后如果图像不对我会按照这个顺序缩小范围先看DMA回传的数据是不是全0或全FF如果是基本是DMA没配好或HLS IP没输出。再看回传数据里能不能找到输入图像的轮廓能找到轮廓但看不清楚说明处理有效果问题多半在定标或显示映射。接着对比HLS协同仿真的输出和上板输出两者一致而图像不对就是算法逻辑的问题有两偏差则是位宽或数据排列差异。最后再查存不存在跨时钟域没同步的问题比如PL端和PS端的DMA时钟不同可能偶发性丢数据。这个流程帮我节省了大量时间也推荐给项目组里其他做图像加速的同学。5. 避坑清单与工程化建议5.1 一定要先做纯C参考模型写HLS之前先把你的FFT算法在纯C/纯Python里跑通生成一份标准输出。无论是HLS C仿真还是上板结果都要和这份标准输出对比。这个参考模型不光是验证工具还是后续排查问题时的“标准答案”。我们项目里两个同学同时做2D FFT其中一位先做好了参考模型最后联调时定位数据异常的速度快很多。5.2 合理使用pragma来“问”工具能做什么HLS的pragma非常多不要全都堆上去。刚开始图省事我把PIPELINE、ARRAY_PARTITION、DATAFLOW、UNROLL全写在一起结果综合时间翻了三倍资源利用率也很夸张。后来逐个验证发现对FFT这种规律算法先做ARRAY_PARTITION提升带宽再做PIPELINE提升吞吐最后局部UNROLL收益是最明显的。DATAFLOW用于模块间流水有奇效但必须在有stream接口的情况下用。5.3 输出幅度的定标经验FFT结果的幅度范围很不好预估尤其是输入图像动态范围大时。如果你只是要显示频谱最简单的定标方式是在PS端做一次全局最大值归一化。如果你想把FFT结果传给下一个算法模块建议不要在HLS内部做归一化而是把缩放系数作为寄存器参数传进去这样软件可以在线调整不用重新综合硬件。5.4 对时序收敛寄存器打拍比什么都有效HLS综合后的时序如果不过不要急着改算法。先在top-level接口加上register选项再检查各循环的IIinitial interval是否太高。很多情况下II不达标是因为循环依赖即当前迭代的写入与下一次迭代的读取冲突。FFT蝶形计算在行方向不存在跨行依赖完全可以II1真正受限的是转置模块因为它有大量数据搬移II很难压到1。这种情况下把转置拆成单拍读写BRAM的有限状态机反而比硬要流水线更简单。5.5 建立一套自己的错误码机制当我们跑通第一个版本后我又在HLS IP里加了一个错误状态寄存器比如0表示无错误1表示输入数据长度异常2表示内部FIFO溢出。这样在PS端一旦发现异常可以通过查看寄存器快速定位问题。这个做法在RTL设计里很常见但用HLS时大家经常忽略。加了之后联调效率提升非常明显。6. 项目最终效果与后续扩展方向最终版本的HLS IP在Zynq平台上完成了256×256灰度图像的2D FFT单帧处理时间大约在几十毫秒量级绝对性能不算特别亮眼但整个数据通路打通了PS读入RAW图通过AXI DMA送入HLS做完FFT再传回PC端重建频谱图。整个过程跑通后再回头去看这个项目的价值已经超越了2D FFT本身——它是一个典型的FPGA图像处理加速框架把数据搬运和算法计算解耦得很干净。后来我想在这个框架上继续做频域滤波只需要在行FFT之后、列FFT之前插入一个点乘模块对频谱做基准上的频率分量加权。HLS这边的工作量很小说明当初用C描述算法的选择是对的。如果你也想在Zynq上做类似的图像处理算法实验强烈建议照这个思路先搭一套读写通路再往里面填算法。通路稳定了算法再怎么换平台部分都不用动。
返回列表