☰
手写16点基-2 FFT的Verilog实现:从蝶形网络到RTL全流程
2026/10/6 13:26:32 网站建设 项目流程

写Verilog不是会写个计数器、状态机就算入门。想把FFT这种在FPGA里真正高频出现的DSP算法跑通,需要在算法理解和RTL实现之间反复横跳。16点的基-2 FFT就是极好的切入点——点数不大,但蝶形网络、旋转因子、位反转、定点化、乒乓存储、状态机控制一样不少,把这一套结构吃透,后面做OFDM解调、雷达脉冲压缩、音频频谱分析,都能直接迁移思路。

这篇文章是我手写16点基-2 FFT的经验记录,从算法推导到Verilog代码,再到仿真验证和踩坑,全流程讲一遍。适合已经有Verilog基础、正在学数字信号处理或者准备做FPGA信号处理的同学参考。如果你只会调IP核,想弄明白FFT内部到底发生了什么,这篇也值得读完。

1. 项目概述与整体设计思路

1.1 16点基-2 FFT到底在算什么

FFT不是玄学,它只是DFT的快速算法。16点DFT的定义式是:

X(k) = Σ x(n) · W_16^(nk),n从0到15,k也是0到15,其中W_16 = e^(-j2π/16)。

直接按这个公式算,16×16=256次复数乘法。基-2 FFT利用了旋转因子的对称性和周期性,把计算变成4级蝶形网络,每级8个蝶形,总共32个蝶形运算,每个蝶形只做1次复数乘法,计算量直接降到32次。16点规模虽然不算大,但省下的这个8倍差距,到1024点就会变成几百倍的差距,这就是FFT存在的意义。

我们这次要做的,就是把这4级蝶形网络用Verilog搭出来。输入16个复数样本,经过运算后输出16个频域复数结果。在FPGA里,复数用实部加虚部表示,每个部分一个16位有符号数,占32位。整个设计规模不大,非常适合手写学习和验证算法正确性。

1.2 串行蝶形方案为什么适合入门

FPGA上实现FFT,常见有三条路:直接用厂商IP核、用HLS高级综合、手写RTL。IP核效率最高,但内部是黑盒,FIFO深度、块浮点、缩放策略这些参数不调过几次根本理解不了;HLS写起来像C,但生成的逻辑难控制也难调试。手写RTL这条路上,又有全并行和串行两种典型架构。

全并行方案是把32个蝶形单元全部铺开,4级流水线,数据像流水线一样涌进去,吞吐率很高,但代价是消耗大量DSP乘法器,16点可能就要上百个,一般入门开发板扛不住,代码也不好看。

我选的是单蝶形复用方案,核心就一个蝶形运算单元:先把输入数据存进一组寄存器,然后依次取出需要运算的A、B两点数据和对应的旋转因子,算完写回另一组缓冲区。一个蝶形算完再算下一个,4级网络顺序完成。这样做逻辑层次非常清楚,方便对照算法一步步看波形、验证中间结果,而且资源占用极低,普通板子轻松跑。

1.3 数据流与存储结构规划

整体数据流是这样设计的:输入端16个复数样本按顺序进来,先做位反转重排,写入buffer0;4级蝶形运算逐级处理,每完成一级,读写buffer交换一次,最终结果放在最后写入的那个buffer里。因为整个过程只有两个buffer轮流读写,所以叫乒乓结构,后面代码里会详细展开。

存储这块我用寄存器组实现而不是RAM原语,原因很简单:16点规模太小,用RAM反而浪费,寄存器组读写逻辑直观,也便于仿真时直接查看每个点的中间值。真实工程里点数如果到1024以上,这里就要换Block RAM了,这个我们在最后扩展部分会说。

1.4 数据格式与定点化考虑

FPGA内部没有浮点单元,所以输入数据和旋转因子都要定点化。输入实部虚部各16位有符号数,取值区间大致在[-32768, 32767],可以理解为Q1.15格式,整数位1位,小数位15位。旋转因子的实部虚部绝对值都不超过1,直接用16位有符号数表示,相当于一个小数点在符号位后的定点数,范围约[-1, 1)。

这里有个新人比较容易忽略的点:FFT每一级蝶形运算都会让数据幅度最多翻倍,4级下来最大增长16倍。如果输入接近满幅,中间结果必定溢出。解决办法是在每级蝶形输出后做一次右移1位的缩放,让数据幅度稳定在合理范围。代价是最终输出整体缩小了16倍,对比算法参考结果时记得除回来。具体怎么在代码里处理,第三章会给出实现。

2. 算法原理与关键参数设计

2.1 蝶形运算的数学推导与符号约定

基-2 DIT蝶形运算的基本公式是:

Y_m = A + W × B
Y_{m+span} = A - W × B

A和B是输入的两点复数数据,W是相应的旋转因子,span是当前级数据跨距。一个蝶形做一次复数乘法和两次复数加减法。这里W的符号方向极其重要,我用的是工程上最常见的DFT符号约定:W = e^(-j2π/N),也就是旋转因子的虚部是负的。如果符号写反,输出频谱会左右颠倒,这个问题我见过的踩坑率极高。

复数乘法展开来是:

real(W·B) = B_re × W_re - B_im × W_im
imag(W·B) = B_re × W_im + B_im × W_re

在Verilog里实现,就是4个整数乘法器加2个加法器、2个减法器。16位乘16位得到32位结果,再通过右移15位截断回16位,完成一次定点复乘。

2.2 旋转因子的生成与定点化存储

16点基-2 FFT一共只需要8个不同的旋转因子,从W^0到W^7。它们的实际值如下表所示,我把16位定点近似值也一并列出来,方便直接抄进ROM。

索引三角函数实际值16位定点表示(实部,虚部)
01 + 0j0x7FFF, 0x0000
10.9239 - 0.3827j0x7641, 0xCF04
20.7071 - 0.7071j0x5A82, 0xA57E
30.3827 - 0.9239j0x30FC, 0x89BF
40 - 1j0x0000, 0x8000
5-0.3827 - 0.9239j0xCF04, 0x89BF
6-0.7071 - 0.7071j0xA57E, 0xA57E
7-0.9239 - 0.3827j0x89BF, 0xCF04

可以看到0x8000对应-1.0,0x7FFF对应约1.0,这就是Q1.15格式的全部秘密。实际设计里,用Verilog的case语句按索引输出对应的实部虚部值就是只读ROM,16点规模根本不需要真的调用Block RAM,一个组合逻辑就搞定了。

2.3 四级蝶形网络的地址规律分析

16点基-2 DIT总共4级,每一级的跨距span和使用的旋转因子索引都不同。这是整个RTL实现里最重要的一张表:

级数跨距span蝶形数量旋转因子索引
stage 018W^0
stage 128W^0, W^4
stage 248W^0, W^2, W^4, W^6
stage 388W^0, W^1, ..., W^7

对应的地址生成规律是:当前级共有N/(2×span)组,每组有span只蝶形,数据地址A为组号×2×span + 组内偏移,地址B为地址A加span。旋转因子索引为组内偏移左移(3 - 当前级号)位。这个规律在代码里可以直接写成位运算,很优雅,后面贴代码大家一看就懂。

2.4 位反转与输入重排

在按时间抽取的DIT算法中,输入序列需要按二进制序号反转后的地址重排,16点就是4位地址的bit0和bit3交换、bit1和bit2交换。比如输入第2个样本序号0001,位反转后是1000即地址8,所以要放到buffer的第8个位置。

位反转处理我建议放在数据加载阶段,一边写入一边重排,一劳永逸。否则在每一级运算时都要处理混乱的地址映射,代码会变得很难看。加载完成后,4级蝶形的地址计算就完全按照2.3节的规律顺序访问,非常干净。

3. Verilog核心实现与代码详解

3.1 顶层模块划分与端口定义

先看顶层模块的端口定义。我习惯把数据位宽做成参数,这样将来扩到24位或32位精度时只改一个地方。

module fft16_top #( parameter DW = 16 )( input wire clk, input wire rst_n, input wire start, input wire signed [DW-1:0] din_re, input wire signed [DW-1:0] din_im, input wire din_valid, output reg busy, output reg done, output reg signed [DW-1:0] dout_re, output reg signed [DW-1:0] dout_im, output reg dout_valid );

端口不算多。start拉高后开始接收16点输入数据,din_valid每一拍的有效数据都要能采到。busy信号告诉外部当前正在运算,done信号表示FFT计算完毕,此时从dout_re和dout_im读取频点0的结果。我简化了一下,完整16点输出其实就存放在内部buffer里,按地址0到15依次读取即可。如果想做一个连续输出接口,加个计数器把16个点流水读出就行,逻辑不复杂。

3.2 双buffer的乒乓切换

双buffer在代码里就是两组16深度的寄存器数组,一组实部一组虚部,再加一个写buffer选择信号和一个读buffer选择信号。

reg signed [DW-1:0] buf_re [0:1][0:15]; reg signed [DW-1:0] buf_im [0:1][0:15]; reg rd_buf; // 当前读buffer reg wr_buf; // 当前写buffer

加载阶段写wr_buf为0的buffer,运算开始后从rd_buf为0的buffer读,计算完的结果写入wr_buf为1的buffer。这一级8个蝶形全部算完,将rd_buf和wr_buf都取反,下一级从Buffer1读、往Buffer0写。这样读写物理上分离,不会出现同一时刻读和写同一块存储的冲突问题。

这个乒乓结构在流式数据处理里非常常见,很多同学学到这里只记得“双buffer能提高速度”,其实它更本质的作用是彻底解决读写冲突,让每一级运算可以在确定的时间窗口内完成。

3.3 组合蝶形单元与复数乘法

蝶形单元我直接写成组合逻辑,先取数,再复乘,再做加减和右移,最后在时钟沿写回。单周期完成一只蝶形,对于16点规模的教学设计完全够用。在高时钟频率场景下,这段组合路径可能需要插入流水寄存器,后面第4章会给出优化提示。

// 数据读取 wire signed [DW-1:0] a_re = buf_re[rd_buf][addr_a]; wire signed [DW-1:0] a_im = buf_im[rd_buf][addr_a]; wire signed [DW-1:0] b_re = buf_re[rd_buf][addr_b]; wire signed [DW-1:0] b_im = buf_im[rd_buf][addr_b]; // 复乘 W*B wire signed [2*DW-1:0] b_w_re = b_re * w_re - b_im * w_im; wire signed [2*DW-1:0] b_w_im = b_re * w_im + b_im * w_re; // 截断回16位, 先偶数右移15位 wire signed [DW-1:0] b_w_re_t = b_w_re[2*DW-2:DW-1]; wire signed [DW-1:0] b_w_im_t = b_w_im[2*DW-2:DW-1]; // 蝶形输出并右移1位做防溢出缩放 wire signed [DW-1:0] y0_re = (a_re + b_w_re_t) >>> 1; wire signed [DW-1:0] y0_im = (a_im + b_w_im_t) >>> 1; wire signed [DW-1:0] y1_re = (a_re - b_w_re_t) >>> 1; wire signed [DW-1:0] y1_im = (a_im - b_w_im_t) >>> 1;

这里两个细节值得展开。第一,32位乘法结果取[30:15]这段,等价于算术右移15位,把Q1.15格式的旋转因子乘法结果缩回到Q1.15。第二,蝶形加减后我用无符号右移运算符>>>做算术右移1位,这是有符号数的正确右移方式,能保持符号位。很多初学者在这用>>,最终负数会变成很大的正数,频谱直接是错的。

3.4 旋转因子表:用case实现ROM

旋转因子表用组合逻辑case实现,这里需要特别小心的是虚部符号,我在注释里标清楚,防止以后自己看代码时被绕晕。

reg signed [DW-1:0] w_re; reg signed [DW-1:0] w_im; always @(*) begin case (w_addr) 3'd0: begin w_re = 16'sh7FFF; w_im = 16'sh0000; end // 1.0000 + 0.0000j 3'd1: begin w_re = 16'sh7641; w_im = 16'shCF04; end // 0.9239 - 0.3827j 3'd2: begin w_re = 16'sh5A82; w_im = 16'shA57E; end // 0.7071 - 0.7071j 3'd3: begin w_re = 16'sh30FC; w_im = 16'sh89BF; end // 0.3827 - 0.9239j 3'd4: begin w_re = 16'sh0000; w_im = 16'sh8000; end // 0.0000 - 1.0000j 3'd5: begin w_re = 16'shCF04; w_im = 16'sh89BF; end // -0.3827 - 0.9239j 3'd6: begin w_re = 16'shA57E; w_im = 16'shA57E; end // -0.7071 - 0.7071j 3'd7: begin w_re = 16'sh89BF; w_im = 16'shCF04; end // -0.9239 - 0.3827j default: begin w_re = 16'sh7FFF; w_im = 16'sh0000; end endcase end

注意负数在Verilog里用s符号标记,16'shCF04表示有符号数-0.3827的补码表示,直接参与有符号乘法时解释正确。如果忘了加s标记,后面对它做乘法时会被当作正数处理,结果就全乱了。

3.5 控制状态机的时序设计

状态机只有4个状态:IDLE、LOAD、PROC、DONE。加载16点数据需要16拍,运算32只蝶形需要32拍,总共不到60拍就能完成一轮FFT。

localparam IDLE = 3'd0; localparam LOAD = 3'd1; localparam PROC = 3'd2; localparam DONE = 3'd3; reg [2:0] state; reg [1:0] stage_cnt; // 0~3 reg [3:0] bf_cnt; // 当前蝶形编号0~7 reg [3:0] load_cnt; // 加载计数

LOAD阶段的工作是采样din_valid有效的数据,写入当前写buffer的位置,地址做位反转。加载完16点后自动跳到PROC,stage_cnt清零,bf_cnt清零。

PROC阶段每次处理一个蝶形:计算地址、取旋转因子、执行蝶形运算、写回目标buffer。bf_cnt到7表示本级8只蝶形全部完成,此时切换buffer、stage_cnt加1,如果已经是第3级则进入DONE状态。

这个状态机的核心设计思路是“浅状态+深计数”,状态本身只要4个,真正的复杂度都放在stage_cnt和bf_cnt这两个计数器的配合上,调试起来非常直观,用modelsim看波形时一目了然。

3.6 地址与旋转因子索引生成

地址生成和旋转因子索引是整个代码里最精华的部分,用位运算直接映射2.3节的规律。

wire [3:0] span = 4'd1 << stage_cnt; // 1, 2, 4, 8 wire [3:0] group_id = bf_cnt >> stage_cnt; // 组号 wire [3:0] offset = bf_cnt & (span - 4'd1); // 组内偏移 wire [3:0] addr_a = (group_id << (stage_cnt + 1'b1)) | offset; wire [3:0] addr_b = addr_a + span; wire [2:0] w_addr = offset << (3'd3 - stage_cnt);

拿stage 2验证一下,这时stage_cnt等于2,span=4,bf_cnt从0循环到7。bf_cnt为5时,group_id=5>>2=1,offset=5&3=1,所以addr_a=(1<<3)|1=9,addr_b=9+4=13,正好是第二组里第2号蝶形取地址9和13,正确。旋转因子索引w_addr=1<<(3-2)=2,查表得W^2,和2.3节舞台参数表一致。

这段组合逻辑我在仿真里反复对照过公式,确认在4级所有情况下都成立。扩展到大点数时,只需要把stage_cnt的位宽从2位改成log2(N)对应的位数,公式完全不用动,这也是这个写法比较高级的地方。

4. 仿真验证、问题排查与扩展建议

4.1 测试平台与参考数据对比

仿真我推荐用Icarus Verilog加GTKWave,轻量免费,适合学习调试。编译运行命令如下:

iverilog -o fft_sim.vvp fft16_top.v fft16_tb.v vvp fft_sim.vvp gtkwave fft_sim.vcd

测试向量我用一个最简单也最能说明问题的信号:单频余弦,x[n] = cos(2π·2n/16),n=0到15。这个信号理论上在频点2和频点14处各有一条谱线,幅度为N/2=8。用Python的numpy.fft.fft先算一遍参考结果,然后和FPGA仿真输出做比对。

实际验证时,输入数据要定点化:把cos值乘以32767取整,作为16位有符号数喂给模块。FPGA输出由于每级右移1位,整体是参考结果的1/16,所以对比时把Python结果除以16再比较。我实测下来,频点2和14的幅度和参考值误差在1%以内,虚部是一个很小的残差,这就是定点化引入的量化噪声。

4.2 精度评估与误差控制

用16位定点做FFT,误差来源主要有三个。第一个是输入数据的量化误差,正弦值取整到整数,本身就有±0.5的量化噪声。第二个是旋转因子存储误差,我用的16位近似值和真实三角函数值有约零点几个百分点的偏差。第三个是每级右移1位时的舍入误差,直接截断会引入直流偏置,更精细的做法是加0.5后取整,也就是四舍五入。

我实测这个实现对比浮点FFT,信噪比大约能做到60到70dB,对绝大多数信号处理场景已经够用。如果你想要更高精度,把数据位宽从16位升到24位或者用块浮点策略,SNR会迅速改善;但如果只是想学习FFT体系,16位这个精度水平已经能完美暴露所有算法问题,是最合适的教学配置。

4.3 常见问题排查实录

我在调试过程中遇到的坑,这里整理成速查表,希望你们能少走弯路。

现象可能原因解决办法
输出频谱左右颠倒旋转因子虚部符号写反确认W = e^(-j2π/N),虚部为负
结果全是很大的正数有符号数用了逻辑右移>>用算术右移>>>
点数错乱、地址越界加载时没做位反转,或位反转写错检查4位地址bit0/bit3、bit1/bit2交换
中间结果溢出变成负大数级间没做缩放每级蝶形输出右移1位
状态机卡死在busy级结束标志bf_cnt判断时机不对检查bf_cnt在8只蝶形后的清零逻辑
结果整体偏小级间缩放太多确认每级只右移1位,总缩放为N

其中“输出频谱左右颠倒”这个坑我印象最深。第一次跑出结果时,频点2的能量跑到了频点14上,折腾半天以为是地址错乱,最后发现是旋转因子的虚部符号写反了。后来我把符号约定直接写进代码注释,再没犯过这个错。

4.4 性能评估与资源优化建议

以Cyclone IV这类入门级FPGA为例,单蝶形复用方案占用大约1000个左右的寄存器(主要就是两组16×16×2的寄存器数组),DSP乘法器只用4个,因为复乘只需要4个整数乘法器,而全并行方案需要128个。时序方面,16位乘16位加两个加法器的组合路径在50MHz下完全稳定,100MHz以上建议在蝶形单元中间插入流水寄存器。

如果追求吞吐率,建议改成流水线架构:每一级配一个独立的蝶形运算单元,级和级之间用寄存器打拍。这样数据可以连续不断地流进第一级,完成第一个点的时间是4拍左右,之后每个时钟周期出一个频点,吞吐率是单蝶形方案的8倍。代价是面积增加约4倍,这就是典型的面积换速度。

4.5 从16点到更大点数的扩展路径

把16点改成64点、256点,需要改动的部分其实非常少。第一,buffer深度从16改到N;第二,stage_cnt位宽从2位改成log2(N)对应的位数;第三,旋转因子表从8组扩展到N/2组,如果N很大就改用CORDIC算法实时计算。地址生成公式和状态机结构完全不用动,这也是当初选这个高度规整的基-2结构的原因。

我自己做完16点之后,顺手扩到了64点,改代码花了不到半小时,仿真一次通过。那种感觉就是之前花在理解地址规律、调试位反转上的时间,全部回本了。

最后再分享一个小习惯:调试FFT时我最常用的方法是把每一级处理完的中间buffer用$display打印出来,和Python里按级手算的结果逐级对比。这样一旦出错,能立刻定位到是哪一级的哪只蝶形出了问题,而不是在最终输出面前瞎猜。你如果刚写完这个模块,不妨也试试这个手段,会让你对整个蝶形网络的理解深一大截。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询