news 2026/10/4 7:04:05

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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_fixed<16, 7>这类定点数之后,资源占用可以下降非常多。选位宽时主要看两个边界:一是输入图像数据位宽,一般灰度图是8bit,范围0到255;二是旋转因子,一般用16bit定点够用,所以中间乘法结果用ap_fixed<32, 16>做累加比较稳妥。FFT中间级如果保留太多位,BRAM容量直接翻倍;如果太少,输出图像会看到明显的噪声条纹。我当时调了一个晚上,最后固定下来的是输入8bit,内部24bit。这个选择对不同图像尺寸都有效。

2.2 一维FFT的C代码框架

先写一维FFT,我用的是经典的迭代基2算法(Cooley-Tukey),避免递归调用在综合时产生不确定的栈开销。核心结构就是三层for循环:外层是每一级迭代,中间是蝶形分组,内层是单个蝶形运算。

#include "fft2d.h" void fft_1d(ap_fixed<16,7> *real, ap_fixed<16,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_fixed<16,7> t_r = real[i]; real[i] = real[j]; real[j] = t_r; ap_fixed<16,7> t_i = imag[i]; imag[i] = imag[j]; imag[j] = t_i; } } // butterfly for (int len = 2; len <= n; len <<= 1) { ap_fixed<20,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_fixed<20,10> tw_r = cos_table[i * n / len]; ap_fixed<20,10> tw_i = sin_table[i * n / len]; ap_fixed<20,10> x_r = real[idx + len / 2]; ap_fixed<20,10> x_i = imag[idx + len / 2]; real[idx + len / 2] = (ap_fixed<20,10>)real[idx] - (x_r * tw_r - x_i * tw_i); imag[idx + len / 2] = (ap_fixed<20,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_fixed<20,10>存,乘完再截断回ap_fixed<16,7>。

2.3 二维FFT与转置优化策略

二维做法按行读、按行FFT、转置、再按列FFT。用C写就是:

void fft2d(ap_fixed<16,7> img_in[ROWS][COLS], ap_fixed<16,7> img_out[ROWS][COLS]) { #pragma HLS ARRAY_PARTITION variable=img_in cyclic factor=4 dim=2 #pragma HLS ARRAY_PARTITION variable=img_out cyclic factor=4 dim=2 ap_fixed<16,7> real[ROWS][COLS]; ap_fixed<16,7> imag[ROWS][COLS]; #pragma HLS ARRAY_PARTITION variable=real cyclic factor=4 dim=2 #pragma HLS ARRAY_PARTITION variable=imag cyclic factor=4 dim=2 // 行FFT for (int r = 0; r < ROWS; r++) { #pragma HLS LOOP_TRIPCOUNT min=256 max=1024 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_fixed<16,7>)real_t[r][c] * real_t[r][c] + (ap_fixed<16,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 factor=4,把每个数组按列方向切成4个bank,这样一次能并行读4个数据,写也分散到4个bank,带宽翻4倍。代价是BRAM数量上升,所以最后选了factor=4作为平衡点。

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 port=input #pragma HLS INTERFACE axis port=output #pragma HLS INTERFACE s_axilite port=rows #pragma HLS INTERFACE s_axilite port=cols #pragma HLS INTERFACE ap_ctrl_none port=return // 这里把stream转成数组,再调用fft2d }

axis_data是一个结构体,里面包含一个ap_uint<32>的数据成员和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, dtype=np.uint8) data.tofile('input.raw') # 无头部的纯像素

读入的时候,PS端把RAW文件从SD卡读到DDR,再通过DMA送到HLS IP。读出的时候同理,HLS IP返回的数据通过DMA写回DDR,PS端把数据保存成RAW,再在PC端用脚本显示成图像。

3.2 通过AXI DMA搬运数据的完整流程

在Zynq平台上,最常用的链路是PS -> AXI DMA -> HLS IP -> AXI DMA -> PS。搬运流程可以分成几个固定步骤:

  1. PS端把像素数据放到DDR的某个地址,告诉DMA源地址、目标地址和传输长度。
  2. DMA通过AXI4-Stream把数据流式发给HLS IP。
  3. HLS IP处理完后,把结果作为AXI4-Stream输出,DMA接收后写回DDR的另一块地址。
  4. 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先填0;
  • HLS 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', dtype=np.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_fixed<16,7>存中间结果,整数位只有7bit,最大值只有127左右,对一个256×256的图来说,行FFT完的结果动不动就超过这个范围,全部被截断和溢出,出来的谱自然就是乱码。解决办法是把内部累加位宽提到ap_fixed<32,16>,在每一级蝶形累加完成之后再做一次移位,把数据缩回ap_fixed<16,7>。移位的位数量得和log2(FFT点数)处理好,否则幅度整体偏小或偏大。

4.2 DMA传输中途挂起

另一个高频问题是DMA传着传着就停了,连握手信号都看不到。后来排查发现不是DMA本身的问题,而是HLS IP的AXI-S接口没有正确接收完数据,导致反压backpressure,DMA的FIFO被堵住,传输就停住了。

检查HLS综合报告里的接口时序,发现我的axis接口配置的是ap_ctrl_none,但内部没有设置好TREADY信号的行为,导致TREADY在复位后一直是低。解决方式是在top函数里把axis的TREADY逻辑明确交给HLS综合器处理,不要手动拉低它。还有一个经验是建议用#pragma HLS INTERFACE mode=axis register,这样HLS会自动加入寄存器打拍逻辑,时序收敛会容易很多。

4.3 BRAM资源不够的大图处理思路

512×512的灰度图,做实数转复数存储,实数虚数各按24bit算,中间数据需要512×512×24bit×2=12Mbit的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 上板调试时如何快速定位故障范围

上板之后如果图像不对,我会按照这个顺序缩小范围:

  1. 先看DMA回传的数据是不是全0或全FF,如果是,基本是DMA没配好或HLS IP没输出。
  2. 再看回传数据里能不能找到输入图像的轮廓,能找到轮廓但看不清楚,说明处理有效果,问题多半在定标或显示映射。
  3. 接着对比HLS协同仿真的输出和上板输出,两者一致而图像不对,就是算法逻辑的问题;有两偏差,则是位宽或数据排列差异。
  4. 最后再查存不存在跨时钟域没同步的问题,比如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选项,再检查各循环的II(initial interval)是否太高。很多情况下,II不达标是因为循环依赖,即当前迭代的写入与下一次迭代的读取冲突。FFT蝶形计算在行方向不存在跨行依赖,完全可以II=1;真正受限的是转置模块,因为它有大量数据搬移,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上做类似的图像处理算法实验,强烈建议照这个思路先搭一套读写通路,再往里面填算法。通路稳定了,算法再怎么换,平台部分都不用动。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/4 7:03:45

老系统迁移无文档?从代码逆向提取PRD的实战指南

1. 接手一个没有文档的老系统&#xff0c;到底难在哪很多做企业级开发的朋友都遇到过这种局面&#xff1a;领导拍着你的肩膀说&#xff0c;这套系统跑了五六年了&#xff0c;现在要迁移到新架构&#xff0c;你先把需求文档整理出来。你打开代码仓库一看&#xff0c;别说需求文档…

作者头像 李华
网站建设 2026/10/4 7:03:11

偏振光栅衍射效率测量:斜入射条件下的公式修正与实操指南

做光栅衍射实验的人都知道&#xff0c;正入射条件只是理想化模型&#xff0c;真正装到系统里&#xff0c;入射角几乎不可能正好是零。这个项目之所以有意思&#xff0c;在于把"入射角不为零"和"偏振光栅"这两个变量叠在一起之后&#xff0c;原本在普通光栅…

作者头像 李华
网站建设 2026/10/4 7:03:02

滑动t检验的Matlab实现:气候水文突变检测与判读指南

做气候或水文序列突变检测的&#xff0c;十有八九开口就是Mann-Kendall检验。MK确实好用&#xff0c;但真到要判定具体哪一年发生突变的时候&#xff0c;它的UF/UB曲线经常给你画出一大片交叉区&#xff0c;反而让人犯难。相比之下&#xff0c;滑动t检验的思路朴素得多——把序…

作者头像 李华
网站建设 2026/10/4 6:59:55

ANSYS Workbench高斯热源仿真实战指南

1. 项目概述&#xff1a;为什么在Workbench里做增材制造高斯热源仿真不是“选做题”&#xff0c;而是“必答题”我在金属3D打印工艺开发组干了八年&#xff0c;从最早的SLM设备调参员一路做到现在带三个仿真小组的负责人。每天早上第一件事不是看生产报表&#xff0c;而是打开A…

作者头像 李华
网站建设 2026/10/4 6:57:47

Agent长对话失忆怎么办?Context Editing与Compaction实战

1. 从“第20轮失忆”说起&#xff1a;Agent上下文管理的真实痛点如果你正在做 Agent 开发&#xff0c;大概率遇到过这个场景&#xff1a;前几轮对话里&#xff0c;Agent 还能准确引用用户十分钟前提到的偏好、约束和中间结论&#xff0c;聊到第 15 到 20 轮之后&#xff0c;它开…

作者头像 李华