FFT这东西,搞嵌入式和信号处理的兄弟应该都不陌生。STM32F4上做频谱分析、Vivado里调FFT IP核、Matlab里做算法验证,背后全是它。我之前也是拿过来就用,直到有一天调试一个音频频谱显示的项目,怎么调都觉得数据不对,才硬着头皮把FFT的推导从头到尾啃了一遍。啃完才发现,之前很多“经验性调整”都是在瞎猜,理解了原理之后,哪里该改、哪里不该动,心里门儿清。
这篇东西就是我当时啃下来的完整笔记。从DFT的原始公式出发,一步步推到工程上最常用的基2 FFT,把蝶形运算、旋转因子、位反转这些概念彻底讲透。文章里不光有数学推导,还有C语言的实现参考、常见坑点,以及结合STM32和FPGA的实际落地经验。想真正搞懂FFT的底层逻辑,而不是停留在调用库函数的层面,这篇文章应该能帮到你。
1. 从DFT到FFT:到底在优化什么
1.1 原始DFT的数学长相与计算代价
一切FFT的起点,都是离散傅里叶变换(DFT)的公式:
X(k) = Σ[n=0 to N-1] x(n) * W_N^(nk),其中 W_N = e^(-j2π/N)
这个式子表达了什么?本质就是把N个时域采样点x(n),通过N个不同频率的复指数基函数,分解到N个频域点上。这里W_N被称为旋转因子(twiddle factor),它就是单位圆上的复指数。如果你之前没接触过复数运算,可以把e^(-jθ)理解为cos(θ) - j*sin(θ),是一个既有大小又有方向的量。
问题出在计算量上。直接按公式计算一个X(k)需要N次复数乘法和(N-1)次复数加法,算完所有N个频点就需要N²次复数乘法和N(N-1)次复数加法。这是什么概念?取N=1024,那就是超过100万次复数乘法。在实时信号处理场景下,单片机和FPGA的乘法资源都是极其宝贵的,这个计算量几乎不可接受。
1.2 旋转因子的三个“隐藏”性质
FFT之所以能大幅降低计算量,核心在于发现了旋转因子W_N的三个性质。我想让矩阵运算,把这三个性质单独讲一下,因为后文所有推导都依赖它们。
周期性:W_N^(k+N) = W_N^k。也就是说,旋转因子的指数部分加一个N,值不变,因为e^(-j2π(k+N)/N) = e^(-j2πk/N) * e^(-j2π),而e^(-j2π)等于1。
对称性:W_N^(k+N/2) = -W_N^k。指数加N/2,相当于在单位圆上转了半圈,从某个角度旋转到对面方向,所以结果就是取负。
可约性:W_N^(nk) = W_(N/2)^(nk/2),或者更常用的形式是W_N^(2nk) = W_(N/2)^(nk)。这个性质说明,把旋转因子的参数同时改变,可以缩小变换的规模。正是这个性质让“分治”成为可能。
这三个性质看上去简单,但它们构成了FFT整个算法的数学基础。记住它们,后面所有推导都会迎刃而解。
2. 按时间抽取(DIT)基2 FFT推导
2.1 第一层分解:奇偶拆分
基2 FFT要求N为2的整数次幂,比如8、16、1024。这样分治才能一直分到2点DFT为止。下面按照N=8来推导,因为8点FFT能够体现所有关键步骤,又不至于被公式淹没。
第一步,把DFT公式中的x(n)按照n的奇偶性拆成两部分。偶数项记为n=2r,奇数项记为n=2r+1,其中r从0取到N/2-1。
X(k) = Σ[r=0 to N/2-1] x(2r) * W_N^(2rk) + Σ[r=0 to N/2-1] x(2r+1) * W_N^((2r+1)k)
第一项里,利用可约性W_N^(2rk) = W_(N/2)^(rk);第二项先提一个公共因子W_N^k出来,剩下的W_N^(2rk)同样化为W_(N/2)^(rk)。于是整个式子变成:
X(k) = Σ[r=0 to N/2-1] x(2r) * W_(N/2)^(rk) + W_N^k * Σ[r=0 to N/2-1] x(2r+1) * W_(N/2)^(rk)
请注意看这两个求和式,它们本质上都是N/2点的DFT,只是输入序列不同。一个是对偶数项x(0), x(2), x(4), x(6)做N/2点DFT,记为G(k);一个是对奇数项x(1), x(3), x(5), x(7)做N/2点DFT,记为H(k)。这样一来:
X(k) = G(k) + W_N^k * H(k)
这就是核心的合成公式。但这里有个隐晦的细节:G(k)和H(k)原本是N/2点DFT,所以它们的周期是N/2,也就是说G(k+N/2) = G(k),H(k+N/2) = H(k)。而在算X(k)时,k是需要取0到N-1的。因此,当k ≥ N/2时,需要使用G(k-N/2)和H(k-N/2)来替代,同时注意到W_N^(k+N/2) = -W_N^k。
综合起来,8点FFT的第一层分解结果可以写成:
当k从0到3时,X(k) = G(k) + W_N^k * H(k)。 当k从4到7时,X(k) = G(k-4) - W_N^(k-4) * H(k-4)。
这就是经典的两个“蝶形”算式,加法和减法分支。一个N点DFT被拆成了两个N/2点DFT加N/2个蝶形运算。
2.2 递归分解到2点蝶形
拆完第一次后,G(k)本身还是4点DFT,继续按同样的规则拆。把g(r)的奇偶项再分开,G(k)同样变成一个两半的合成。这样4点的计算进一步化简为两个2点DFT加上4个蝶形运算。
一直到2点DFT时,就没什么可拆的了。2点DFT的公式直接展开:X(0) = x(0) + x(1),X(1) = x(0) - x(1)。这本身就是一个蝶形运算,而且旋转因子W_2^0 = 1,连复数乘法都不需要。
把整个过程画成信号流图,就是经典的FFT蝶形图。N=8时,总共有log2(8)=3级运算:第一级是4个2点DFT,第二级是2个4点DFT,第三级是1个8点DFT。每一级都有N/2=4个蝶形算子,每个蝶形算子有1次复数乘法和2次复数加法。总计算量就是N/2 * log2(N)次复数乘法,即8/2 * 3 = 12次复数乘法。对比直接DFT的64次复数乘法,节省相当可观。N越大,这个差距越悬殊,比如N=1024时,一个是5120次乘法,另一个是超过100万次乘法,差了整整两个数量级。
2.3 位反转排序的由来与实现
现在来看蝶形图最左侧的输入顺序。你会发现在标准蝶形流图中,输入的x序列不是自然顺序0到7,而是0, 4, 2, 6, 1, 5, 3, 7。这个顺序就是位反转顺序。
为什么需要位反转?因为在逐级分治的过程中,奇偶拆分不断改变数据的位置。第一次把偶数项放前面、奇数项放后面;第二次在偶数子序列中又按奇偶拆;第三次继续拆。整个过程在二进制视角下,就等价于把索引的二进制位高低颠倒。用8点FFT验证:输入索引1的二进制是001,位反转后变成100,对应十进制4,于是1被排到第4个位置。输入索引3的二进制是011,反转为110,对应6。这样就能解释蝶形图输入顺序的排列规则了。
实现位反转排序有几种做法。最简单的思路是对于每一个索引i,计算它的位反转值j,如果i < j就交换x[i]与x[j],避免重复交换。硬件上,FPGA里常用比特交换线网直接重排地址,零开销;DSP和MCU上则多用查表法,提前把位反转索引表算好存下来。
3. 蝶形运算的工程实现:从数学到代码
3.1 旋转因子的产生:查表还是实时计算
到了编程实现这一步,第一个绕不开的问题是旋转因子W_N^k怎么来。按照定义算,就是cos(2πk/N) - j*sin(2πk/N)。问题在于每次蝶形运算都要用三角函数的实时计算,开销极大,在FPGA上更是不可接受。
工程上最常用的做法是查表。因为在实际实现中,每一级的旋转因子取值是有限的。第m级(从1开始计数)用到的旋转因子个数是2^(m-1)个,而且这些值恰好可以共用。如果你仔细观察蝶形图,第三级四个蝶形用到的旋转因子分别是W_8^0、W_8^1、W_8^2、W_8^3,第二级是两个W_4^0和两个W_4^1。最高级的旋转因子步长最小,覆盖最全。所以实际应用里,只需要存一张长度为N/2的余弦、正弦查找表,就能覆盖整个FFT的旋转因子需求。
还有一种做法叫增量式迭代,利用递推公式W_N^(k+1) = W_N^k * W_N来逐步生成,每次只需要一次复数乘法。这样做省存储但误差会累积,定点平台上要非常小心。我的经验是:现在MCU的Flash空间普遍足够大,直接用查表最稳、最快,不值得在这省空间。
3.2 标准蝶形运算模块的代码写法
一个DIT蝶形运算的标准形式是:
temp = x[k + N/2] * W。 x[k + N/2] = x[k] - temp。 x[k] = x[k] + temp。
这里所有变量都是复数,temp是复数乘法的结果。注意操作顺序,必须先保存temp再更新两个输出臂,否则第一次赋值会破坏原始x[k]的值。这个细节新手很容易踩坑。
一段完整的8点浮点FFT参考代码如下(用C语言写清楚逻辑,便于移植到任何平台):
#include <math.h> #include <stdint.h> typedef struct { float real; float imag; } complex_t; // 位反转重排 void bit_reverse(complex_t* x, int n) { int i, j, k; for (i = 1, j = 0; i < n; i++) { int bit = n >> 1; while (j & bit) { j ^= bit; bit >>= 1; } j ^= bit; if (i < j) { complex_t tmp = x[i]; x[i] = x[j]; x[j] = tmp; } } } void fft(complex_t* x, int n) { bit_reverse(x, n); // len是当前蝶形运算的两个输入点之间的距离 // 第一次是1,第二次是2,第三次是4,以此类推 for (int len = 1; len < n; len <<= 1) { // step是转置因子的角频率步长 float step = -2.0f * 3.14159265358979f / (len << 1); for (int i = 0; i < n; i += (len << 1)) { for (int j = 0; j < len; j++) { float angle = step * j; complex_t w = { cosf(angle), sinf(angle) }; // 蝶形运算核心 complex_t temp; complex_t* p1 = &x[i + j]; // 上臂 complex_t* p2 = &x[i + j + len]; // 下臂 // 复数乘法 temp = p2 * w temp.real = p2->real * w.real - p2->imag * w.imag; temp.imag = p2->real * w.imag + p2->imag * w.real; // 完成蝶形加减 p2->real = p1->real - temp.real; p2->imag = p1->imag - temp.imag; p1->real = p1->real + temp.real; p1->imag = p1->imag + temp.imag; } } } }这段代码里每次蝶形运算都把W当场算一遍,虽然教学上够直观,但效率上有优化空间。实际工程中应当提前建好cos和sin查找表,内层循环改成查表访问,省掉cosf和sinf的大开销。上面代码我用的是浮点运算,不考虑定点定标,性能一般,但是逻辑清晰。
3.3 就地运算与数据缓存策略
FFT还有一个值得专门说明的特点:蝶形运算完全遵循“就地”原则。每一级运算中,每个蝶形的两个输出只和本蝶形有关,不串扰到其他蝶形。同一个数据只在当前级被读写,级间不存在跨运算的依赖。这个特性使得我们根本不需要额外开一块和输入等大的缓冲区,直接用原始数组覆盖更新即可。
在FPGA实现里,这个性质被利用得更充分。Vivado的FFT IP核内部结构通常采用流水线架构或基4突发架构,数据在内部RAM和蝶形运算单元之间流动,存储器资源复用度高。理解就地运算这个特性,你才能看懂为什么IP核的配置界面里有“数据顺序”和“自然顺序”的选项——因为不同实现策略下,输出顺序可能混乱,需要Reorder Buffer来恢复到自然顺序。
如果是在DSP或MCU上做较大点数的FFT,比如4096点甚至16384点,内存紧张时需要把数据放在外部SRAM或SDRAM中。此时建议将FFT运算相关的数据段放在紧耦合内存或Cache里,因为FFT的数据访问模式是跳变的,位反转访问对Cache并不友好,容易产生大量Cache Miss。这是实际工程中的性能瓶颈,很多人没注意到。
4. 按频率抽取(DIF)与工程选型
4.1 DIF推导思路
除了按时间抽取(DIT),另一种常见的FFT结构是按频率抽取(DIF)。DIF的思路反过来了:对输出序列X(k)按k的奇偶性拆分,而不是对输入x(n)拆分。
以8点为例,把n从0到7分成前半段和后半段:
X(k) = Σ[n=0 to 3] x(n) * W_8^(nk) + Σ[n=4 to 7] x(n) * W_8^(nk)
对后半段进行变量代换,令m = n - 4,则后半段可整理为W_8^(4k) * Σ[m=0 to 3] x(m+4) * W_8^(mk)。进一步利用W_8^4 = -1,当k为奇数时和前半段相减,当k为偶数时相加。提取公因子后就能把偶频点和奇频点分离出来。
DIF的蝶形图和DIT长得不一样。DIT是先做复乘再加减,DIF是先加减再复乘。DIF的输入是自然顺序的,输出是位反转顺序;DIT则相反,输入需要位反转,输出才是自然顺序。
4.2 两种结构在硬件和软件中的取舍
理解这个差别对实际工程很重要。用C语言在MCU上软解时,由于输入数据通常按照采样顺序自然存放,DIT结构需要额外做一次位反转重排,DIF结构可以省掉输入重排,但输出会变乱。如果你后续只需要做功率谱分析、观察频谱形状,输出顺序乱一点问题不大;但如果你要做频域滤波、IFFT变换回去,就必须把顺序理清。
FPGA上,Xilinx的FFT IP核往往采用基4算法或混合基算法,这是因为在硬件上,基4蝶形一次处理4个点,复乘次数比基2更少。基4 FFT的推导思路与基2完全一致,只是每次分治拆成4个子序列。从硬件资源角度讲,基4的实现更省DSP Slice,代价是控制和布线复杂度上升。Vivado FFT IP核里配置成Radix-4或者Mixed Radix之后,原理上依然是分治,理解基2的推导后也能看懂这些模式的时序与资源报告。
在实际项目选型时,我的建议是:软件平台上,点数不大(小于1024)直接用DIT,方便省心;点数较大或者追求极致性能,就考虑DIF加后期重排。硬件平台上,直接用IP核配置,不用自己折腾,但必须理解它内部的流水线延迟和帧时序,尤其是连续数据流和突发数据流两种模式的差异。
5. 嵌入式与FPGA落地实战要点
5.1 STM32F4平台的FFT实现路线
STM32F4上做FFT频谱分析是很多人的入门项目,我当年也踩了不少坑。主流路线有两条:一条是调用ARM官方CMSIS-DSP库里的arm_cfft_f32,另一条是自己写或移植第三方代码。CMSIS-DSP库经过高度优化,内部使用了M4内核的FPU和SIMD指令,速度远比自己写的循环要快,所以强烈建议直接用库。
使用CMSIS-DSP库的标准流程是:
#include "arm_math.h" #define FFT_SIZE 1024 float32_t input[FFT_SIZE * 2]; // 交错存储,偶数下标为实部,奇数下标为虚部 float32_t output[FFT_SIZE]; arm_cfft_instance_f32 fft_instance; arm_cfft_init_f32(&fft_instance, FFT_SIZE); arm_cfft_f32(&fft_instance, input, 0, 1); arm_cmplx_mag_f32(input, output, FFT_SIZE);这里有几个关键点。第一,CMSIS-DSP库要求输入数据按实部和虚部交错排列,实部在偶数下标、虚部在奇数下标。做实数信号FFT时,要把虚部全部置0,这在初始化数组时顺手就能完成。第二,arm_cfft_f32最后一个参数是位反转开关,如果设置为1,函数内部会自动完成位反转,不需要你提前重排。第三,arm_cmplx_mag_f32计算出的是复数模值,用于画频谱图的话这个值直接就是幅度。
从性能上说,1024点浮点FFT在168MHz的STM32F4上大约需要几十微秒到百来微秒级别的耗时,完全足够做实时音频频谱显示了。如果额外用了DMA双缓冲交替采样,数据采样和FFT计算可以完全流水线化,整个系统的吞吐量可以得到最大化。
5.2 Vivado FFT IP核的配置与使用
FPGA端最常用的是Vivado里的FFT IP核。创建IP核时的关键配置项包括:变换长度、采样时间、数据格式、架构选择、输出排序方式。
数据格式方面,定点数时选Q格式(比如Q1.15或者Q16.16),需要确定整数位和小数位的宽度。这个宽度选择直接影响SNR和资源消耗。配置界面里有“Scaling Options”选项,可选“Scaled”和“Unscaled”。Scaled模式下IP核会自动根据级数对中间结果进行右移定标,防止溢出;Unscaled模式下不做缩放,精度高但需要你确保输入数据幅值不会导致溢出。工程上默认选Scaled就够用,除非你有特殊精度要求。
架构选择最关键。配置里通常有Pipelined Streaming I/O和Radix-4 Burst I/O等选项。Pipelined Streaming适合连续数据流输入,每个时钟周期都能接受新数据,吞吐率高,占用资源也大;Burst模式适合处理帧数据,数据到达是突发的,资源占用更少。做实时频谱分析优先选Pipelined Streaming,做离线批量处理可以选Burst模式降低资源。
输出排序建议选Natural Order。虽然会额外消耗Reorder Buffer资源,但能大幅降低后续模块的处理复杂度。如果你的后续处理链和FFT IP核协同流水化工作,这个资源是多花得值的。
5.3 窗函数选择与幅值修正
做FFT频谱分析时,采样信号往往不是整周期截断的,这会导致频谱泄漏。解决办法就是加窗函数。常用窗函数有三种:矩形窗频率分辨率最高但旁瓣泄漏大;汉宁窗(Hanning)最常用,兼顾主瓣宽度和旁瓣抑制;海明窗(Hamming)与汉宁窗类似,但旁瓣衰减略差、主瓣更窄。如果你的分析对象是连续周期信号,优先用汉宁窗;如果是瞬态信号或冲击信号,矩形窗反而更合适,因为加窗会改变瞬态信号的形状。
还有一个容易忽略的点是幅值修正。FFT输出的是N点复数序列的模,如果直接拿来画频谱,幅值是偏大的。对于单频正弦信号,FFT峰值谱线的幅值应该乘以2再除以N,才能得到真实的信号幅值。例如一个幅度为1的1kHz正弦信号,用1024点FFT计算后,对应频率点的模值大约为512,此时需要用2/N因子修正,才能还原出幅度1。如果加了汉宁窗,还需要再乘以一个系数(约为1.63)进行窗函数幅度恢复。这个修正系数很多教程都没讲清楚,搞得很多人画出来的频谱幅度对不上信号实际幅值。
5.4 频率分辨率与采样率的配合
频率分辨率的公式是df = Fs / N,其中Fs是采样率,N是FFT点数。这个公式决定了FFT频谱中相邻两条谱线之间的频率间隔。比如采样率Fs = 48000Hz,FFT点数N = 1024,那么频率分辨率约为46.875Hz。这意味着两个频率相差小于46.875Hz的信号,在频谱图上会混在一起无法分辨。
提高频率分辨率的办法无非两条:加大N,或者降低Fs。加大N意味着增加FFT计算量和内存,在资源受限的MCU上不能无脑加大;降低Fs则受奈奎斯特采样定理约束(Fs必须大于信号最高频率的两倍),不能随意降低。这是频谱分析中一对核心矛盾,工程上需要根据实际信号特征来权衡。
如果要同时满足宽频覆盖和高频率分辨率,可以采用分段FFT加时间平均(Welch方法)来提高估计稳定性,或者用Zoom FFT只对感兴趣频段做精细分析。这些高级方法都建立在理解FFT基础公式之上,根基还是要先打牢。
6. 常见问题与排查经验
6.1 频谱整体镜像怎么办
判断是不是FFT输出顺序问题。FFT输出的k=0对应直流,k从1到N/2-1对应正频率,k=N/2对应奈奎斯特频率,k从N/2+1到N-1对应负频率。大部分频谱显示只需要画前N/2个点。如果发现正负频率对称地出现在频谱两端,这是正常现象,只需要截取前半段。如果频谱出现一个中心对称的镜像翻转,那可能是位反转没做,数据搞乱了,检查一下输入输出排序。
6.2 频率偏了几个bin怎么修
频率偏移通常由两个原因造成:其一,采样率不准,采样钟存在偏差会整体平移频谱;其二,信号频率不是FFT bin中心频率的整数倍,导致峰值落在了两个bin之间,出现频谱泄漏。对后者,单点峰值并不能准确反映真实频率,可以通过三点插值法(对峰值以及左右两个谱线进行抛物线拟合)提高频率估计精度。这个技巧在实际的测频类项目里非常实用,比单纯加大FFT点数节省资源得多。
6.3 FFT之后幅值忽大忽小怎么排查
先看输入数据有没有做归一化处理。如果信号本身出现过载削顶,FFT结果必然失真。其次检查定标因子。CMSIS-DSP库的浮点版本输出是归一化的,但某些定点库或IP核输出带有固定倍率的放大,需要对照数据手册搞清楚输出幅值与输入幅值的比例关系。还有一种较低级的问题:输入数组越界,读到了相邻内存区域的垃圾数据,导致频谱上出现无规律的随机毛刺。调试时先把输入固定为已知单频信号,如果输出频谱不是干净的单一尖峰,大概率是数据处理流程的问题,往下追查即可。
6.4 常见问题速查表
| 表现 | 可能原因 | 解决办法 |
|---|---|---|
| 频谱两边对称 | 没有只显示单边谱 | 只画0到N/2-1的谱线 |
| 低频有异常大的分量 | 信号混入了直流偏置 | 先做去直流处理(减均值)或加高通滤波器 |
| 峰值频率偏了软件估计值 | 泄漏或分辨率不够 | 加合适窗函数或用插值算法 |
| 结果全是乱码或NaN | 数据未初始化或位反转未执行 | 检查虚部是否清零,确认位反转步骤 |
| 频谱有周期性的毛刺 | 采样时钟抖动或工频干扰 | 改善采样时钟源,必要时加屏蔽和滤波 |
| 输出幅值和真实幅值不符 | 未做幅值修正 | 乘上窗函数修正系数和2/N因子 |
7. 更进一步:FFT的实用扩展
FFT原理弄明白之后,很多衍生技术就顺手多了。IFFT就是FFT的逆变换,只需在FFT前对输入取共轭、完成后取共轭再除以N。这个特性在做频域滤波(比如EQ均衡)时很有用:把信号变到频域,修改某些频段的系数,再IFFT回去,就能实现比时域卷积高效得多的处理。
另外,实数信号的FFT还可以用单次N点复FFT同时计算两路实信号,或者用一次N/2点复FFT计算N点实FFT。这类技巧在资源紧张时能节省一半的运算量,理解原理后可以自行推导。
还有一个有意思的方向是稀疏FFT(Sparse FFT),针对频谱具有稀疏性的信号,可以只计算少数重要频点,计算量进一步降低。这是目前学术界和工业界都在关注的方向,但核心思想仍然是基于FFT的分治框架。
我对FFT最大的体会是:这是一个典型地“看起来复杂,拆开就清爽”的算法。它的每一步推导都有直觉支撑,分治、蝶形、位反转,都是在和旋转因子的规律共舞。把这篇推导走一遍,比死记十篇库函数调用手册都有用。回头再遇到频谱不对、性能不够、精度不行这类问题,你能直接定位到根源,而不是靠运气调参数。