ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FFT从推导到工程落地:蝶形运算、位反转与实现要点全解析

FFT从推导到工程落地:蝶形运算、位反转与实现要点全解析 FFT这东西搞嵌入式和信号处理的兄弟应该都不陌生。STM32F4上做频谱分析、Vivado里调FFT IP核、Matlab里做算法验证背后全是它。我之前也是拿过来就用直到有一天调试一个音频频谱显示的项目怎么调都觉得数据不对才硬着头皮把FFT的推导从头到尾啃了一遍。啃完才发现之前很多“经验性调整”都是在瞎猜理解了原理之后哪里该改、哪里不该动心里门儿清。这篇东西就是我当时啃下来的完整笔记。从DFT的原始公式出发一步步推到工程上最常用的基2 FFT把蝶形运算、旋转因子、位反转这些概念彻底讲透。文章里不光有数学推导还有C语言的实现参考、常见坑点以及结合STM32和FPGA的实际落地经验。想真正搞懂FFT的底层逻辑而不是停留在调用库函数的层面这篇文章应该能帮到你。1. 从DFT到FFT到底在优化什么1.1 原始DFT的数学长相与计算代价一切FFT的起点都是离散傅里叶变换DFT的公式X(k) Σ[n0 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)次复数加法。这是什么概念取N1024那就是超过100万次复数乘法。在实时信号处理场景下单片机和FPGA的乘法资源都是极其宝贵的这个计算量几乎不可接受。1.2 旋转因子的三个“隐藏”性质FFT之所以能大幅降低计算量核心在于发现了旋转因子W_N的三个性质。我想让矩阵运算把这三个性质单独讲一下因为后文所有推导都依赖它们。周期性W_N^(kN) W_N^k。也就是说旋转因子的指数部分加一个N值不变因为e^(-j2π(kN)/N) e^(-j2πk/N) * e^(-j2π)而e^(-j2π)等于1。对称性W_N^(kN/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为止。下面按照N8来推导因为8点FFT能够体现所有关键步骤又不至于被公式淹没。第一步把DFT公式中的x(n)按照n的奇偶性拆成两部分。偶数项记为n2r奇数项记为n2r1其中r从0取到N/2-1。X(k) Σ[r0 to N/2-1] x(2r) * W_N^(2rk) Σ[r0 to N/2-1] x(2r1) * W_N^((2r1)k)第一项里利用可约性W_N^(2rk) W_(N/2)^(rk)第二项先提一个公共因子W_N^k出来剩下的W_N^(2rk)同样化为W_(N/2)^(rk)。于是整个式子变成X(k) Σ[r0 to N/2-1] x(2r) * W_(N/2)^(rk) W_N^k * Σ[r0 to N/2-1] x(2r1) * 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(kN/2) G(k)H(kN/2) H(k)。而在算X(k)时k是需要取0到N-1的。因此当k ≥ N/2时需要使用G(k-N/2)和H(k-N/2)来替代同时注意到W_N^(kN/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蝶形图。N8时总共有log2(8)3级运算第一级是4个2点DFT第二级是2个4点DFT第三级是1个8点DFT。每一级都有N/24个蝶形算子每个蝶形算子有1次复数乘法和2次复数加法。总计算量就是N/2 * log2(N)次复数乘法即8/2 * 3 12次复数乘法。对比直接DFT的64次复数乘法节省相当可观。N越大这个差距越悬殊比如N1024时一个是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^(k1) 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) Σ[n0 to 3] x(n) * W_8^(nk) Σ[n4 to 7] x(n) * W_8^(nk)对后半段进行变量代换令m n - 4则后半段可整理为W_8^(4k) * Σ[m0 to 3] x(m4) * 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 48000HzFFT点数N 1024那么频率分辨率约为46.875Hz。这意味着两个频率相差小于46.875Hz的信号在频谱图上会混在一起无法分辨。提高频率分辨率的办法无非两条加大N或者降低Fs。加大N意味着增加FFT计算量和内存在资源受限的MCU上不能无脑加大降低Fs则受奈奎斯特采样定理约束Fs必须大于信号最高频率的两倍不能随意降低。这是频谱分析中一对核心矛盾工程上需要根据实际信号特征来权衡。如果要同时满足宽频覆盖和高频率分辨率可以采用分段FFT加时间平均Welch方法来提高估计稳定性或者用Zoom FFT只对感兴趣频段做精细分析。这些高级方法都建立在理解FFT基础公式之上根基还是要先打牢。6. 常见问题与排查经验6.1 频谱整体镜像怎么办判断是不是FFT输出顺序问题。FFT输出的k0对应直流k从1到N/2-1对应正频率kN/2对应奈奎斯特频率k从N/21到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。这类技巧在资源紧张时能节省一半的运算量理解原理后可以自行推导。还有一个有意思的方向是稀疏FFTSparse FFT针对频谱具有稀疏性的信号可以只计算少数重要频点计算量进一步降低。这是目前学术界和工业界都在关注的方向但核心思想仍然是基于FFT的分治框架。我对FFT最大的体会是这是一个典型地“看起来复杂拆开就清爽”的算法。它的每一步推导都有直觉支撑分治、蝶形、位反转都是在和旋转因子的规律共舞。把这篇推导走一遍比死记十篇库函数调用手册都有用。回头再遇到频谱不对、性能不够、精度不行这类问题你能直接定位到根源而不是靠运气调参数。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进