简介:这是一套基于VHDL实现的基4 FFT硬件工程,面向数字信号处理与FPGA开发者,可在硬件中高效完成离散傅里叶变换。压缩包共53个文件,以34个vhd源码文件为核心,覆盖蝶形运算、复数乘法、RAM/ROM存储、控制与地址生成等模块;另含综合报告(rpt)、仿真脚本(qpf/qsf)和结果文本,可辅助理解综合与验证流程。整包仅41KB,结构紧凑,适合作为学习或移植参考。已有161人学习下载。资源的价值在于:通过位翻转、基4蝶形运算和递归拆分等关键步骤,读者可掌握从DFT原理到VHDL编码的完整映射,同时了解并行处理、资源优化与时序约束等FPGA实现要点,可用于快速搭建或调试自己的FFT信号处理模块。
1. 用VHDL写基4 FFT:为什么在FFT IP核之外还要自己动手
解压一个“FFT.rar”压缩包,里面是几个VHDL文件,命名上混着 fft、基4、fftDfF,大概率是某版FPGA上基4 FFT的实现。很多工程师第一反应是直接调Vivado或Quartus的FFT IP核,但自己用VHDL写基4 FFT这件事并没有过时。基4蝶形单元一次处理4个点,复数乘法次数比基2少约25%,在DSP48资源紧张的板卡上优势明显。代价是要自己搞定基4倒位序、旋转因子ROM和流水线时序,这三个模块恰恰是网上常见VHDL代码里最容易出错的部分。这篇文章从数学分解讲到RTL实现,再给出仿真和ILA在线验证方法,适合刚从IP核转向自研算法的FPGA工程师。
2. 基4 FFT算法拆解:从DFT到蝶形单元的VHDL映射
2.1 DFT公式与基4分解的数学基础
离散傅里叶变换(DFT)的公式是 X(k)=Σ_{n=0}^{N-1} x(n)·W_N^{nk},其中 W_N=e^{-j2π/N}。当 N 是 4 的整数幂(N=4^m)时,可以把 n 和 k 同时按四进制展开:n = n0 + 4·n1 + 4²·n2 + ...,k 也做同样展开。代入DFT公式后,原来单一的求和会分解成若干次小的DFT,并在每级之间插入旋转因子。这个过程中,快速傅里叶变换fft算法把计算量从 O(N²) 降到 O(NlogN),基4属于其中一种按时间抽取(DIT)的变体。
基4 FFT每一级处理4个子序列,所以蝶形单元是4输入4输出。一次蝶形运算需要3个一般复数乘法和若干加减法,而基2 FFT一次蝶形只需1个复数乘法。表面上看基4的乘法器数量似乎是基2的三倍,但因为每一级处理的点数变成原来的四分之一,整体级数只有基2的一半,所以最终复数乘法总数反而更少。以 N=1024 为例,基2需要5120次复数乘法,基4只需要3840次,节省约25%。在FPGA中用DSP48实现这些复数乘法时,这个差距直接反映在乘法器占用上。
在VHDL里做基4 FFT,一般不直接按数学公式推导到最后落成一大片组合逻辑,而是把每一级蝶形单元抽象成一个可复用的组件。每一级做完一次蝶形运算,数据要写回同一块RAM,供下一级使用。这样整个FFT核心的数据通路是:输入缓存 → 第0级蝶形 → 第1级蝶形 → ... → 输出。关键参数有数据位宽、旋转因子位宽、FFT点数 N 和流水线级数,这些都会影响最终资源与Fmax。
2.2 基4蝶形运算单元的复数运算结构
一个基4蝶形单元有4个输入 x0、x1、x2、x3,输出 y0、y1、y2、y3。在不考虑旋转因子时,计算公式为:
y0 = x0 + x1 + x2 + x3 y1 = x0 - j·x1 - x2 + j·x3 y2 = x0 - x1 + x2 - x3 y3 = x0 + j·x1 - x2 - j·x3这里的 j 是单位虚部。实际每一级蝶形运算前,x1、x2、x3需要分别乘上相应的旋转因子 W_N^k、W_N^{2k}、W_N^{3k}。由于 W_N^k 是一个复数,所以 VHDL 中需要实现复数乘法器。
下面是一个最基本的流水线复数乘法器代码,输入 ar/ai 和 br/bi,输出 rr/ri:
library ieee; use ieee.std_logic_1164.all; use ieee.numeric_std.all; entity complex_mult is generic ( WIDTH : integer := 16 ); port ( clk : in std_logic; ar, ai : in signed(WIDTH-1 downto 0); -- 第一个复数实部/虚部 br, bi : in signed(WIDTH-1 downto 0); -- 第二个复数实部/虚部 rr, ri : out signed(WIDTH+WIDTH-1 downto 0) ); end entity; architecture rtl of complex_mult is signal p_ac, p_bd : signed(WIDTH+WIDTH-1 downto 0); signal p_ad, p_bc : signed(WIDTH+WIDTH-1 downto 0); begin process(clk) begin if rising_edge(clk) then p_ac <= ar * br; -- 中间乘积全部寄存 p_bd <= ai * bi; p_ad <= ar * bi; p_bc <= ai * br; end if; end process; rr <= p_ac - p_bd; -- 实部 = ac - bd ri <= p_ad + p_bc; -- 虚部 = ad + bc end architecture;这里用signed类型做有符号乘法,乘法结果位宽自动扩展为两操作数位宽之和,所以输出位宽是WIDTH+WIDTH-1 downto 0并不够,实际应该是2*WIDTH-1 downto 0。代码例子里为了方便展示写成WIDTH+WIDTH-1,它等于2*WIDTH-1,是正确的。rr和ri的组合赋值在寄存器更新后还要再等一个周期才稳定,所以这个复数乘法器有1个周期的输出延迟。如果整个蝶形单元是多级流水线,这个延迟会被额外补偿,否则会导致同一组四路输入在不同拍到达加法树。
2.3 定点数格式选择与溢出处理
在VHDL中实现FFT,数据通常用定点数而不是浮点数。最常见的格式是Q1.15:1位符号位,15位小数位,整体16位有符号数。旋量因子也用同样格式存储。用Q格式时,乘法结果位宽变成32位,直接截断会让频谱出现很大杂散,所以必须在截断前保留足够的小数位。
基4 FFT的蝶形单元一次最多做6次加法,数据范围可能扩大4倍。如果每级运算后不处理,没过几级就会溢出。常见做法是每级蝶形输出后做算术右移。右移位数可以固定,也可以根据输入统计自动调整。固定右移最简单,但要防止信号过弱导致小信号被移位后丢失。
下面是一组常用位宽分配参考表,适用于N=1024、输入16位有符号数。这个表是RTL设计时的通用经验值,具体还要经过仿真验证。
| 处理阶段 | 数据位宽 | 右移策略 | 说明 |
|---|---|---|---|
| 输入缓存 | 16位 | 不右移 | 原始ADC采样 |
| 第0级蝶形输出 | 18位 | 右移2位 | 防溢出并保留1位余量 |
| 第1级蝶形输出 | 20位 | 右移2位 | 逐级扩展位宽 |
| 第2级及以后 | 20位 | 每级右移2位 | 保持信号幅度一致 |
| 旋转因子ROM | 16位 | 不右移 | 用补码表示cos/sin |
旋转因子需要在VHDL中定义为常量数组或通过ROM初始化文件加载。vhdl中常量的定义一般写成constant TWIDDLE : array(0 to N/4-1) of signed(15 downto 0) := (...)。如果数组太长,更适合用.coe文件初始化Block ROM。旋转因子只有N/4个值,因为 W_N^{0}、W_N^{1}、W_N^{2}... 中前N/4个就足够推出其余象限的值,但直接存N/4个余弦和正弦值最省事。量化旋转因子时,将cos(2πi/N)乘上32767取整即可,误差会影响最终SNR,16位量化通常能保证60dB左右的信噪比。
3. VHDL实现基4 FFT核心:地址生成、蝶形运算与旋转因子ROM
3.1 输入缓存与基4倒位序地址生成
基4 FFT的输入顺序不是自然序,而是按四进制倒位序排列。假设N=16,原始序号0~15对应的倒位序地址为0,4,8,12,1,5,9,13,2,6,10,14,3,7,11,15。这个序列可以通过下面的函数生成:
-- 将自然序地址按4进制逆序,m = log4(N) function bit4_reverse(x : integer; m : integer) return integer is variable n : integer := 0; variable v : integer := x; begin for i in 0 to m-1 loop n := n * 4 + (v mod 4); v := v / 4; end loop; return n; end function;参数m表示4的幂次,等于log4(N)。这个函数在综合时会被展开成常量运算,不占用硬件逻辑,只是帮助生成ROM初始化地址或RAM写入地址。实际硬件中,输入数据通常按自然序写入一个双口RAM,然后用一个计数器从0开始递增,读地址取bit4_reverse(counter, m)得到的地址,把数据喂给第一级蝶形单元。
更常见的是把输入RAM和中间结果RAM合并为同一个双口RAM,FFT开始前先把时域样本写入RAM,然后用高电平启动FFT。之后每一级算完的结果直接写回原地址,下一级再按新的步长读取。这样整个FFT只占用一块数据量相同的RAM,对N=1024、16位复数数据来说,需要1024×32bit存储空间,在FPGA中通常用2块或4块Block RAM拼接。
地址生成器还需要产生旋转因子的ROM地址。每一级蝶形单元使用的旋转因子步长与级数相关:第0级使用 W_{N}^{k},第1级使用 W_{N}^{2k},第2级使用 W_{N}^{4k}……实际上随着级数增加,步长翻倍。实现时可以用一个stage信号,ROM地址 =(group_index * step) mod (N/4)。这个模运算可以用一个简单的比较器配合减法完成,避免使用除法器。
3.2 蝶形运算单元VHDL代码:复数乘法与加法树
前面已经给出了复数乘法器,现在把它应用到基4蝶形单元中。下面是一个基4蝶形单元的核心加法树代码,输入四路复数信号,输出四路结果。这里省略了旋转因子乘法细节,只展示加法树的寄存器复制与加/减法结构。
-- 基4蝶形运算核心,X和Y均为signed向量,宽度由外部参数决定 -- 这里只给出组合逻辑部分,实际使用时需在时钟沿打一拍 process(x0i, x0q, x1i, x1q, x2i, x2q, x3i, x3q) begin -- y0 = x0 + x1 + x2 + x3 y0i <= (x0i + x1i) + (x2i + x3i); y0q <= (x0q + x1q) + (x2q + x3q); -- y2 = x0 - x1 + x2 - x3 y2i <= (x0i - x1i) + (x2i - x3i); y2q <= (x0q - x1q) + (x2q - x3q); -- y1 与 y3 需要先乘旋转因子后再加减, -- 完整实现时需要插入中间信号:m1i = x1i*w1i + x1q*w1q 等 -- 本例只展示无乘法的部分,旋转因子乘法调用 complex_mult 模块 end process;逻辑说明:y0是四路输入的累加,y2是带减号的累加,对应蝶形运算中不乘旋转因子的两条路径。y1和y3在真实代码中要先把 x1、x3 和对应的旋转因子复数乘法结果送入加法树,再用y1i <= x0i + m1i_ri + ...这样的形式组合。代码中y0i是输出信号,直接赋值会形成组合逻辑多级加法,Fmax可能不高,所以工程上会在加法树中间插入寄存器,把一级蝶形运算拆成2拍。
这里有个参数需要注意:x0i、x1i等数据位宽要与旋转因子乘法器输出位宽匹配。如果输入是16位,旋转因子也是16位,复数乘法器输出是32位,那么加法树要使用32位加法器。数据位宽不匹配时,综合工具会自动补位,但会消耗额外加法器资源,而且行为可能与仿真不一致。
3.3 旋转因子ROM的设计与量化误差
旋转因子ROM可以直接用Vivado的Block Memory Generator,也可以先用外部脚本生成.coe文件再导入。下面的Python脚本生成16位定点旋转因子.coe文件:
import math N = 64 WIDTH = 16 max_val = (1 << (WIDTH-1)) - 1 with open('twiddle.coe', 'w') as f: f.write("memory_initialization_radix=10;\n") f.write("memory_initialization_vector=\n") for i in range(N//4): angle = 2 * math.pi * i / N cos_val = int(round(math.cos(angle) * max_val)) sin_val = int(round(-math.sin(angle) * max_val)) f.write(f"{cos_val},{sin_val},\n")脚本参数N是FFT点数,WIDTH是旋转因子位宽。这里只生成N/4个复数,即余弦和正弦各一个序列。基4蝶形单元需要 W_N^k、W_N^{2k}、W_N^{3k} 三个旋转因子,其中 W_N^{2k} 和 W_N^{3k} 可以通过对 W_N^k 的实部和虚部进行符号变换得到,也可以用ROM地址直接读取。符号变换虽然省存储,但会引入额外的多路选择器和补码计算,实际设计中习惯把三个系数都做成查找表。
量化误差是旋转因子ROM无法避免的问题。对于16位ROM,最大的量化误差约为2^-15,体现在FFT输出上就是噪底抬升。要降低误差,可以把旋转因子ROM位宽增加到18位或20位,但ROM面积会相应翻倍。对于一般频谱分析应用,16位已足够;如果要观测60dB以上动态范围,可以改成18位。
4. 基4 FFT的仿真与FPGA调试:从Modelsim波形到Vivado FFT核对比
4.1 用testbench产生正弦波激励并验证FFT结果
testbench是验证基4 FFT的第一步。下面代码在VHDL测试平台中生成一个数字正弦波,频率为Fs/64,幅值1000,作为FFT输入。
signal clk : std_logic := '0'; signal reset : std_logic := '0'; signal test_in : signed(15 downto 0); process variable phase : integer := 0; begin clk <= '0'; wait for 5 ns; clk <= '1'; wait for 5 ns; end process; process(clk) variable phase : integer := 0; begin if rising_edge(clk) then if reset = '1' then phase := 0; test_in <= (others => '0'); else test_in <= to_signed(integer(1000.0 * sin(2.0 * MATH_PI * phase / 64.0)), 16); phase := (phase + 1) mod 64; end if; end if; end process;参数说明:to_signed把实数计算结果转为16位有符号数,MATH_PI是math_real库中的常量。这个testbench每64个时钟周期输出一个完整正弦波,正好适合N=64的FFT。实际验证N=1024时,把除法里的64改成1024即可。仿真时可以用Modelsim或Vivado Simulator观察输出频谱,但光看波形不够,需要把输出数据写入文件,再与Matlab计算结果对比。
推荐做法是在testbench中增加一个文件写过程:当FFT完成信号有效时,把输出实部虚部写到文本文件,然后用Matlab读取该文件,调用fft函数对比频率峰值位置。如果两者一致,说明VHDL实现的FFT算法正确。如果峰值出现在镜像频率上,说明输入倒位序或输出级序有误;如果峰值频率偏移,说明旋转因子ROM的地址索引计算错误。
4.2 资源占用与性能:和Vivado FFT核对比
自研基4 FFT和Vivado FFT核之间没有绝对优劣,但资源形态有显著差异。下面是一组典型参考值,基于Artix-7、N=1024、输入16位定点、流水线结构。这些数字来自同类型设计的综合报告常见量级,不同FPGA型号会有较大浮动。
| 实现方式 | LUT | FF | DSP48 | 并行度与特点 |
|---|---|---|---|---|
| 自研基4全流水线 | 约1500 | 约2200 | 约48 | 每级连续计算,吞吐高,控制信号复杂 |
| 自研基4折叠结构 | 约700 | 约900 | 约16 | 单个蝶形单元循环使用,资源少但延迟大 |
| Vivado FFT核(基4 burst) | 约1300 | 约1800 | 约12 | 自动优化,带AXI-Stream接口,存算调度已封装 |
| Vivado FFT核(流水流) | 约2400 | 约3200 | 约24 | 连续流模式,吞吐最高,占用也最高 |
从表中能看出,Vivado FFT核用DSP48明显更少,封装了旋转因子ROM与地址调度。自研基4 FFT的价值在于可以在不遵循AXI-Stream协议时嵌入自定义数据通路。比如某些多通道采集系统要求FFT数据直接以指定间隔输出到后端,用IP核反而要加额外FIFO适配层。另外在FPGA教学或芯片验证场景中,自研RTL可以自由改变蝶形单元结构和位宽,便于做量化误差分析。
如果追求低DSP消耗,自研代码可以只保留一个复数乘法器,在状态机控制下分时完成三个旋转因子乘法。这样DSP48用到3个,但延迟会增加。实际项目中要根据吞吐要求选择折叠度,而不是一上来就写全流水线。
4.3 常见时序问题:复位时序、流水线延迟与约束
自研VHDL FFT调试中最常见的坑有三个。第一个是复位信号处理不严谨。很多代码只在复位时清零状态机,但数据通路里的寄存器和RAM使能没有复位,导致上电后前几拍数据是随机值。建议用同步复位,且复位只作用于状态机信号,数据通路上寄存器的初始值不重要,只要在状态机使能后再开始接收数据即可。
第二个是旋转因子ROM输出与蝶形输入错位。ROM读取有延迟,数据RAM读取也有延迟,两个延迟不一定相等。解决方法是检查ROM的clka和 RAM的clka是否接到同一个时钟,并且把ROM输出打一拍,确保与RAM读出的四路数据在同一个组合逻辑分支上同时稳定。
第三个是算术右移方向错误。VHDL中的shift_right对有符号数只是机械右移,不保证补零到最高位。对于signed类型,正确的算术右移应该直接使用resize(signed_value sra 2)或std_logic_shift_right的算术版本。如果写错,负数的小数部分会被截断成向负无穷大偏移,导致频谱出现直流漂移。
5. 在线验证技巧:用Vivado ILA抓取内部数据,定位基4 FFT计算错误
仿真通过不代表FPGA跑起来没问题。基4 FFT的地址生成和ROM时序在硬件上很脆弱,信号打一拍后可能完全错位。建议在RTL里插入ILA调试核,直接观测蝶形单元的输入输出。下面是一个ILA例化的最小代码片段:
-- 假设fft_top中已有: clk, fft_start, din_re, dout_re, butter_y0_re ila_0 u_ila ( .clk(clk), .probe0(fft_start), -- 触发条件,上升沿表示FFT开始 .probe1(din_re), -- 时域输入实部 .probe2(din_im), -- 时域输入虚部 .probe3(butter_y0_re) -- 第一级蝶形输出实部 );在Vivado中配置ILA的触发条件为probe0 == 1’b1,采样深度设为4096,可以完整抓取一次N=1024 FFT的前若干帧。将ILA抓到的数据导出为CSV后,与testbench仿真中的同一时刻数据比较。如果仿真波形和ILA波形在复位解除后前几拍一致,说明硬件启动正常;如果第1级蝶形输出有错,通常是旋转因子地址错位,重点检查ROM的addra和控制状态机的group_index是否同步变化。
一个很实用的验证技巧是利用FFT的对称性:输入一个实正弦波时,FFT输出频谱应只有一个正频率峰值和一个负频率镜像峰。如果ILA抓到的FFT结果出现两个正频率峰值,说明蝶形单元中的加减号搞错了,比如y2的符号反了;如果峰值位置比预期扩大一倍,说明基4倒位序地址没有除以步长,导致同一组数据被读了两次。这类错误用ILA观测四个输入信号的索引会非常明显。
实际调试时还可以给ILA同时接上ROM地址和RAM地址,与蝶形数据一起保存。这样分析哪一拍的地址出错比单纯看数据更快。如果ILA存储深度有限,可以只抓前两级蝶形数据,因为基4 FFT的地址和控制逻辑在每一级很相似,修复前两级后,后续级数通常不会引入新问题。
本文还有配套的精品资源,点击获取