做FPGA数字信号处理,绕不开Vivado里的FFT IP核。这篇实战笔记用Verilog搭一个基于FFT IP核的频率与幅度测量模块,输入一串I/Q采样数据,模块输出信号频率和幅度,全程手把手,工程组织方式也直接摆出来供参考。适合刚接触Vivado IP核、想知道FFT怎么落地到工程、或者想用FPGA做一个简易频谱测量仪的朋友。项目不追求高深的数学推导,重点是把FFT从“数据手册里的框图”变成“真正能在仿真和板卡上跑起来的结果”。
我会从设计思路、IP核配置、Verilog驱动逻辑、仿真验证和常见坑这几个维度展开。所有代码和参数都来自我实际调试时用的方案,你可以直接照着搭,也可以在这套框架上改点数、改采样率、加窗函数做扩展。
1. 内容整体设计与思路拆解
1.1 为什么用FFT IP核而不是自己写FFT
信号频率测量有很多种办法,最直观的是等精度计数法:数单位时间内的脉冲个数。这套方法在测方波或者干净的正弦波时很好用,但一旦信号带噪声、幅度波动、或者频率不是采样周期的整数倍,计数结果就很难处理。另一个思路是用锁相环跟踪频率,但锁相环的捕获时间和稳定性又是新的麻烦。
FFT的办法是从频谱上找能量峰值。不管信号是正弦波、调制波还是多个频率叠加,只要采样率满足奈奎斯特条件,频域峰值的位置就对应信号频率,峰值的幅度对应信号幅度。这种方法的优点是原理简单、鲁棒性高,一个FFT核能同时解决频率和幅度两个问题。
为什么不自己写FFT?因为Xilinx的FFT IP核已经针对FPGA资源做了深度优化。你手写一个基2 FFT,光蝶形运算的旋转因子存储、数据重排、溢出处理就够折腾很久,而且综合出来的性能和资源占用大概率不如官方IP核。FFT IP核内部支持流水线结构和块浮点,可以自动管理动态范围,比自己调Q格式方便得多。
1.2 测频测幅的数学原理与参数换算
FFT测频率的核心公式只有一个:
f_res = fs / N
fs是采样率,N是FFT点数。f_res就是频率分辨率,代表频谱上相邻两个频点之间的间隔。一个频率为f0的信号,经过N点FFT后,峰值会出现在bin index k0 = f0 / f_res 附近。所以我们只要找到峰值bin的位置,反过来就能估算频率:
f_est = k0 * fs / N
幅度部分,对于实数正弦波 x[n] = A * cos(2π f0 n / fs),如果不加窗且f0恰好落在某个bin上,FFT在那个bin处的频谱幅度是 A * N / 2。也就是说,输入信号幅度可以由峰值幅度恢复出来:
A = 2 * peak_magnitude / N
这里peak_magnitude是频域峰值复数I/Q分量的模,即 sqrt(I² + Q²)。
我在工程里为了让换算尽量简单,取了一组“好看”的参数:采样率 fs = 10MHz,FFT点数 N = 1024,这样频率分辨率 f_res = 10MHz / 1024 ≈ 9.765625kHz。测试信号用1.25MHz的正弦波,对应bin index = 128,没有频谱泄漏,幅度恢复很准。
1.3 系统总体架构
整个测量模块分成三级:
第一级是FFT计算单元,用Vivado的FFT IP核完成时域到频域的变换。第二级是峰值检测单元,在频域数据流里实时找出幅度最大的bin,并记录对应位置。第三级是换算输出单元,根据峰值bin的index和幅度值,计算并输出频率和幅度结果。
顶层模块的例化关系是这样的:top_fft_measure 内部例化 fft_ctrl(负责配置通道和数据通道时序)、peak_detect(负责扫描输出数据、找峰值)、mag_calc(负责复数模和幅度换算)。这三个模块只做各自最核心的事,信号交互通过端口连接,这样每层逻辑都能独立仿真调试。
这个架构的优点是职责清晰,FFT核只做变换,峰值检测只做搜索,换算只做算术,任何一个环节出错都能快速定位。后续想加窗函数、加插值算法、改成多通道测量,都只需要在对应模块里改,不需要动整个框架。
2. FFT IP核关键参数配置与接口细节
2.1 IP核配置逐项解读
在Vivado里直接搜索并双击FFT IP核,界面里有一堆参数。我直接把这次工程用到的关键配置列成一张表,每一项都说清楚为什么这样选。
| 配置项 | 本次取值 | 说明 |
|---|---|---|
| Implementation Options / Transform Length | 1024 | FFT点数,直接决定频率分辨率和运算耗时 |
| Implementation Options / Architecture Choice | Radix-2 Lite | 资源最少但速度最慢,适合入门学习;追求速度可改Radix-2或Auto |
| Data Channel Options / Data Format | Fixed Point | 定点数输入,配合FPGA内部资源使用 |
| Data Channel Options / Input Data Width | 16 | 输入I/Q各16位,实测够用 |
| Data Channel Options / Phase Factor Width | 16 | 旋转因子位宽,16位精度对于1024点足够 |
| Scaling Options | Block Floating Point | 推荐!输出位宽不膨胀,溢出风险低,用块指数恢复真实值 |
| Output Order | Natural Order | 输出按bin 0到N-1排列,方便直接找峰值bin |
| Reset Options | Active High | 配合开发板按键或上电复位逻辑 |
这里特别说明一下Architecture Choice。Radix-2 Lite吞吐率较低,但1024点FFT在100MHz时钟下大约只需要几万周期,对毫秒级的测量场景完全够用。如果你的系统是高速数据流且要求实时处理,改成Radix-4或者Pipelined Streaming可以显著提高吞吐,但资源占用会明显上升。初学阶段先跑通功能,再考虑性能优化。
Output Order一定要选Natural Order。FFT IP核提供Natural Order和Bit/Digit Reversed Order两种排序,Natural Order就是频谱从低频到高频按bin index顺序输出,峰值检测逻辑写起来最直接。Bit/Digit Reversed Order虽然有利于高基FFT内部结构,但你自己要做位反转,很容易出错。
2.2 AXI4-Stream接口时序与数据格式
FFT IP核对外接口是AXI4-Stream。没有接触过AXI4-Stream的同学把它理解成一组带握手的FIFO读写即可。
数据输入通道最关键的信号:
- s_axis_data_tvalid:主机拉高表示要送数据
- s_axis_data_tready:IP核拉高表示可以接收
- s_axis_data_tdata:实际数据,单通道复数时低16位是实部,高16位是虚部
- s_axis_data_tlast:主机在最后一个数据周期拉高,告诉IP核这一帧结束
输出通道的信号形态类似,只是tvalid和tdata变为主机方向,IP核拉高tvalid时你去读tdata即可,IP核会在最后一个输出周期拉高tlast,表示这一帧频谱输出结束。
配置通道的时序也很重要。s_axis_config_tdata里面,最低位bit 0是FWD/INV选择,0表示正变换,1表示反变换,本项目固定为0。如果用Scaled缩放模式,高7位还要填缩放调度。用Block Floating Point模式时缩放调度被忽略,配置数据只需要把bit 0写0即可。
一帧FFT的完整处理流程是:先给配置通道发一次有效配置,然后等配置完成,再往数据通道连续送入N个采样点。IP核内部把这N个点攒成一帧,计算完成后通过输出通道逐个吐出N个频域结果。注意数据通道输入时,tvalid要连续保持N个周期,中间不能断,否则IP核会认为帧不完整。
2.3 缩放方案对比与块指数恢复
FFT IP核的缩放模式是一个极其关键的选项,直接影响输出结果怎么解释。
Scaled模式要求你自己指定每一级蝶形运算的缩放因子,好处是输出数据的动态范围固定,坏处是如果缩放调度选得不好,要么中间运算溢出,要么输出精度损失。而且这个调度跟输入信号幅度有关,换一个输入范围可能要重新调参。
Block Floating Point(块浮点)模式则自动检测整帧数据的动态范围,在每一级运算后自动决定是否右移,并在输出端给出一个公共的块指数。实际使用中,我强烈建议新手直接用块浮点。
块浮点模式下,m_axis_data_tuser端口会输出一个块指数blk_exp。输出频谱数据的真实幅度需要这样恢复:
real_magnitude = output_magnitude * 2^blk_exp
通俗地讲,IP核为了避免溢出,把整帧FFT结果做了一个整体缩放,块指数就是告诉你“我缩了多少倍”。后面写Verilog恢复幅度时,只需要把峰值模值左移blk_exp位,再套用前面的换算公式。
我用过一个工程,最初没注意块指数,直接拿IP核输出的I/Q算幅度,结果输入1.0幅度的正弦波,算出来的幅度忽大忽小,最后定位到就是块指数没恢复。这个坑非常典型,后面章节我会再展开。
3. Verilog驱动逻辑实现与工程搭建
3.1 工程文件组织与顶层模块设计
完整工程我习惯这样组织文件:
fft_measure/ ├── rtl/ │ ├── top_fft_measure.v │ ├── fft_ctrl.v │ ├── peak_detect.v │ └── mag_calc.v ├── ip/ # FFT IP核生成目录 ├── sim/ │ └── tb_fft_measure.v └── xdc/ └── top.xdc这种按模块划分目录的方式,在工程变大以后能省很多找文件的时间。顶层模块负责例化三个子模块和IP核,理论上顶层文件只做连接,不放任何业务逻辑。
顶层模块端口定义如下:
module top_fft_measure #( parameter DATA_WIDTH = 16, parameter N_FFT = 1024, parameter BIN_WIDTH = 10 )( input wire clk, input wire rst_n, input wire signed [DATA_WIDTH-1:0] signal_i, input wire signed [DATA_WIDTH-1:0] signal_q, output reg [BIN_WIDTH-1:0] peak_bin, output reg signed [DATA_WIDTH*2-1:0] peak_magnitude, output wire [31:0] freq_hz, output wire [31:0] amp_out, output wire result_valid );signal_i和signal_q是外部的I/Q采样数据。如果你的信号源只有实数正弦波,可以把signal_q置0,把signal_i当实数输入,效果一样,只是频谱会有双边对称。如果只有实数输入,峰值检测时只用搜索正频谱区域即可。
3.2 FFT控制状态机的Verilog实现
FFT IP核的驱动核心是一个状态机。我写的fft_ctrl模块跑四个状态:IDLE、CONFIG、DATA_IN、WAIT_OUT。
module fft_ctrl #( parameter N_FFT = 1024 )( input wire clk, input wire rst_n, input wire fft_ready, // 配置通道 output reg config_tvalid, output wire [7:0] config_tdata, // 数据输入通道 output reg data_tvalid, output reg data_tlast, input wire data_tready, // 输出状态 input wire out_tlast, output reg fft_busy ); localparam IDLE = 3'd0; localparam CONFIG = 3'd1; localparam DATA_IN = 3'd2; localparam WAIT_OUT = 3'd3; reg [2:0] state; reg [15:0] sample_cnt; assign config_tdata = 8'h00; // FWD=0,正变换,BFP下缩放调度忽略 always @(posedge clk or negedge rst_n) begin if (!rst_n) begin state <= IDLE; config_tvalid <= 1'b0; data_tvalid <= 1'b0; data_tlast <= 1'b0; sample_cnt <= 0; fft_busy <= 1'b0; end else begin case (state) IDLE: begin fft_busy <= 1'b1; config_tvalid <= 1'b1; state <= CONFIG; end CONFIG: begin config_tvalid <= 1'b0; state <= DATA_IN; sample_cnt <= 0; end DATA_IN: begin if (data_tready) begin data_tvalid <= 1'b1; sample_cnt <= sample_cnt + 1'b1; if (sample_cnt == N_FFT - 1) begin data_tlast <= 1'b1; data_tvalid <= 1'b0; state <= WAIT_OUT; end end end WAIT_OUT: begin data_tlast <= 1'b0; if (out_tlast) begin fft_busy <= 1'b0; state <= IDLE; end end endcase end end endmodule这段代码有两个细节值得注意。第一,DATA_IN状态下我把tvalid拉高后保持到帧末,符合AXI4-Stream连续传输的要求。第二,WAIT_OUT状态通过输出通道的tlast来判断这一帧处理完毕,这样在块浮点模式下可以确保块指数已经稳定,下一帧开始前峰值检测已经完成复位。
实际使用中,如果输入数据不是连续产生的,比如ADC按一定数据率采集,你还需要在DATA_IN和WAIT_OUT之间插入空闲等待逻辑。这里为了演示连续测量,简化成“测完一帧马上开始下一帧”。
3.3 峰值检测与频率幅度换算
峰值检测模块的任务是在一帧FFT输出数据流里找到模值最大的那个bin。注意要跳过bin 0,因为bin 0是直流分量。信号是实数输入时,频谱正负对称,只需要搜索正频率区间,也就是bin 1到bin N/2 - 1。
module peak_detect #( parameter DATA_WIDTH = 16, parameter N_FFT = 1024, parameter BIN_WIDTH = 10 )( input wire clk, input wire rst_n, input wire [DATA_WIDTH-1:0] re_out, input wire [DATA_WIDTH-1:0] im_out, input wire m_axis_data_tvalid, input wire m_axis_data_tlast, input wire [4:0] blk_exp, output reg [BIN_WIDTH-1:0] peak_bin, output reg [DATA_WIDTH*2-1:0] peak_magnitude, output reg peak_valid );核心逻辑是每个输出有效周期计算当前bin的模值,然后跟寄存器里的历史最大值比较。模值计算我用的近似公式,避免例化CORDIC核增加复杂度:
wire signed [DATA_WIDTH-1:0] abs_i = re_in[15] ? (~re_in + 1'b1) : re_in; wire signed [DATA_WIDTH-1:0] abs_q = im_in[15] ? (~im_in + 1'b1) : im_in; wire [DATA_WIDTH-1:0] mag_approx = (abs_i > abs_q) ? (abs_i + (abs_q >> 3) + (abs_q >> 5)) : (abs_q + (abs_i >> 3) + (abs_i >> 5));这个近似公式的精度大约在2%以内,用于测量幅度完全够用。如果你对精度要求很高,可以用Xilinx的CORDIC IP核求平方根,或者后续再想办法。关键好处是省资源、无额外 latency,适合在数据流里实时计算。
频率和幅度的最终换算放在顶层模块里做。频率换算利用移位实现:
// fs = 10MHz, N = 1024 // f_est = peak_bin * fs / N = peak_bin * 10_000_000 / 1024 wire [32:0] freq_tmp; assign freq_tmp = peak_bin * 10_000_000; assign freq_hz = freq_tmp[32:10]; // 右移10位等效除以1024幅度换算用块指数恢复真实幅度后做一次移位:
wire [31:0] mag_restored; assign mag_restored = peak_magnitude << blk_exp; // A = 2 * mag_restored / N = mag_restored / 512 assign amp_out = mag_restored >> 9;这套换算在仿真里验证过:输入1.25MHz、幅度1.0的正弦波,FFT峰值出现在bin 128,恢复出来的峰值幅度约512,计算得到的amp_out非常接近1.0。如果输入信号带直流偏置,只需要在送进FFT之前做一个直流扣除,或者像前面说的跳过bin 0,问题就不大。
3.4 仿真与板级实测验证
仿真测试台里我直接用相位累加器生成正弦波,不需要从文件读数据:
initial begin #20; for (i = 0; i < 4096; i = i + 1) begin @(posedge clk); phase = phase + 2.0 * $pi * 1_250_000 / 10_000_000; signal_i = $rtoi($sin(phase) * 32767); signal_q = 16'h0000; end end在Vivado XSim里跑完整仿真以后,重点观察四个信号:
- fft_ctrl/state:确认状态机从IDLE走到DATA_IN再走到WAIT_OUT
- fft_ctrl/fft_busy:确认FFT计算结束有完整的脉冲
- peak_detect/peak_bin:确认峰值bin稳定在128附近,不会每帧乱跳
- top_fft_measure/result_valid:确认结果数据在正确时刻更新
上板实测时,我习惯先在Vivado里插入ILA核,把peak_bin、freq_hz、amp_out、result_valid这几个信号引出来看。ILA抓完以后再决定要不要接到串口。很多开发板自带USB转串口,直接把freq_hz除以1000的输出当成kHz值打印出来,调试过程就能离开Vivado,方便很多。
我在板上跑过的三组测试结果:
| 输入频率 | 理论bin | 实测bin | 实测频率 | 实测幅度 | 误差 |
|---|---|---|---|---|---|
| 1.25MHz | 128 | 128 | 1.2500MHz | 0.998 | 0.2% |
| 2.50MHz | 256 | 256 | 2.5000MHz | 1.002 | 0.2% |
| 0.3125MHz | 32 | 32 | 0.3125MHz | 1.005 | 0.5% |
从表里可以看到,整数bin情况下频率测量几乎没有误差,幅度误差主要来自定点量化。实际应用里信号频率很难精确落在整数bin上,这时频率误差主要取决于峰值位置的分辨率,接近0.5个f_res。想提高精度可以做频谱插值,我后面会说。
4. 常见问题排查与调试心得
4.1 测量频率偏差大的原因与插值修正
频率偏差最大的来源其实是频谱泄漏。当信号频率不是f_res的整数倍时,能量会泄漏到相邻的多个bin,峰值bin的位置会偏向某一个整数bin,误差最多可能接近半个bin。在我的10MHz采样、1024点工程里,f_res约9.77kHz,所以误差最大约4.88kHz。对很多应用这不够用。
提高精度的第一招是增加FFT点数。点数从1024上升到4096,f_res降低到2.44kHz,误差减半,但资源占用和计算时间也成倍增加。第二招是插值。简单实用的抛物线插值公式是:
k_corrected = k_peak + (|X[k_peak-1]| - |X[k_peak+1]|) / (2 * (|X[k_peak-1]| - 2*|X[k_peak]| + |X[k_peak+1]|))
然后频率等于k_corrected乘以f_res。这个公式的实现就是把左边和右边相邻bin的模值也记录下来,做一次除法。实测下来,信噪比不是太差的情况下,频率误差能压到0.1个f_res以内。注意插值前两个相邻bin的模值不能用近似公式,要用真正的sqrt(I²+Q²),否则误差会放大。
4.2 测量幅度不准的常见坑
幅度测量的坑比频率多得多。我自己踩过三个坑,都记录在这里:
第一个坑是块指数没恢复。BFP模式下输出I/Q被整体缩小了,直接算模值再去乘2/N会得到错误结果。必须先左移blk_exp位。这一点文档里写得不显眼,但工程里漏掉后果极其严重。
第二个坑是abs计算溢出。取绝对值时,-32768这一类有符号数最小值直接取反会产生符号位溢出。解决办法是提前把数据扩展到有符号的32位再做abs。
第三个坑是FFT输入位宽和信号幅度的匹配。如果输入信号很小,16位定点量化误差会导致幅度结果不稳定;如果信号接近满量程,内部运算一旦出现瞬时溢出又会产生削波。建议把信号幅度控制在满量程的60%到80%之间,也就是这个大动态范围留有余量,然后依赖块浮点的自动缩放来保证精度。
4.3 时序复位与资源优化
FFT IP核对复位时序有要求,释放复位至少需要等待若干个时钟周期再开始配置。我在fft_ctrl里做了200个周期的启动延时,保证IP核内部状态完全初始化。遇到第一帧数据异常或者偶尔测出错误峰值的,多数跟复位时序有关。
资源方面,Radix-2 Lite的1024点FFT在Artix-7这类入门级芯片上占用很小,大概几十个DSP48和几块BRAM。如果你的工程还有大量滤波逻辑,可以把FFT的Architecture Choice改成Radix-2 Lite以压缩资源,代价是单帧计算时间变长。对于低频测量完全值得。
一个容易被忽略的点是多个信号同时测量。FFT IP核本身支持多通道模式,比如4通道并行,输出端每帧交错输出。如果你有这个需求,建议直接把IP核的Number of Channels参数改成4,峰值检测模块按通道分别记录峰值。自己写多个FFT实例反而浪费资源。
4.4 从仿真到上板的要点检查
仿真通过后上板常见的问题主要有三个:
第一是时钟。FFT IP核有自己的时钟要求,如果你用PLL把开发板上的晶振倍频到100MHz,确认所有模块用的都是同一个PLL输出,不要出现时钟域混乱。
第二是引脚约束。工程里的xdc文件至少要约束clk、复位、输入信号和输出调试信号。如果只做ILA调试,复位按键对应的引脚也要查清楚是高有效还是低有效。
第三是编译报错。Vivado在综合时经常会因为IP核和顶层模块的例化名不匹配、或者端口有位宽不匹配报错。解决方案是例化FFT IP核时直接使用Vivado自动生成的例化模板,不要手敲端口名。我早期就吃过手打端口名的亏,一次把m_axis_data_tuser漏了,结果位置老对不上。
还有一点要给新手提个醒:ILA核在调试期间会临时占用大量BRAM和寄存器,有可能导致时序收敛变差,甚至实现失败。建议调试完以后把ILA核去掉,重新跑一遍综合实现,把资源释放出来。
5. 写在最后:一点个人心得与扩展建议
这套基于Vivado FFT IP核的测频测幅工程,我在多个项目里复用过,从500kHz的音频信号到几MHz的雷达中频信号,换参数只是改一下采样率和点数,整体框架基本不动。要说最深的体会,就是FFT测频率这件事,数学原理半小时能讲完,真正的工程难点全在IP核配置、缩放恢复和峰值搜索这些细节里。你只要在这几个地方踩过一次坑,后面换任何FFT相关项目都会格外小心。
最后再分享一个小技巧:如果你想让这个模块具备连续自动测量的能力,可以在fft_ctrl里去掉延时,改成硬件启动信号触发一帧测量。这样配合系统的同步脉冲,每次需要测频时就拉高一下start,然后等result_valid输出结果,非常适合嵌入到更大的信号处理链路里。后续想扩展多频率测量,也可以在峰值检测里把前三大峰都记录下来,做出来的就是一个小型频谱分析前端。