☰
基2时间抽取FFT详解:蝶形运算、码位倒序与C语言实现
2026/10/14 1:28:22 网站建设 项目流程

前面几篇笔记把DFT的定义、频谱的物理含义都捋了一遍,但说实话,工程里真正让我“敢用”频域滤波的,还是从掌握FFT那一刻开始。直接拿DFT去做频域处理,算到怀疑人生:1024点数据,一个频点就要做1024次复数乘法,全部算完超过一百万次,跑实时系统基本是做梦。基2时间抽取FFT(Decimation-In-Time FFT,简称DIT-FFT)就是来解决这个问题的:把百万次计算压到万次级别,差了整整两个数量级。

这篇笔记适合正在学数字信号处理、或者做嵌入式音频/振动分析时躲不开频谱计算的朋友。我不会只堆公式,会把“为什么要这么拆”“蝶形运算到底在干嘛”“代码怎么写才对”这些东西用实操视角讲清楚,最后附上完整可跑的C语言实现和几个我真实踩过的坑。

1. 从频域滤波说起:为什么非要FFT不可

1.1 DFT直接算:N平方的代价到底有多大

频域滤波的基本流程不复杂:采集时域信号,做变换到频域,对频谱做修正(比如滤掉某段不需要的频率),再变换回时域。这里面的核心计算环节就是正变换和逆变换。如果你直接用DFT定义去算,设数据长度是N,那么:

X[k] = Σ x[n] · W_N^(nk),n从0到N-1

每一个频点k都要做N次复数乘法,N个频点下来就是N²次。这在小点数的时候还能忍,N=64是4096次,N=256是65536次,到了N=1024直接变成1048576次,N=4096就是1677万次。你要是做音频处理,一秒钟44100个采样点,分帧后每帧1024点,一秒光变换就要跑43帧,每帧一百万次乘法,CPU直接冒烟。

FFT的复杂度是N·log₂N。同样N=1024,只需要512×10=5120次复数乘法;N=4096,2048×12=24576次。和DFT相比,节约了约68倍到680倍的计算量,而且N越大优势越明显。频谱分析、频域滤波、卷积加速,凡是能用FFT的地方,它的意义就是把“理论上可行”变成“工程上可用”。

1.2 旋转因子W_N^k:三个性质撑起整个快速算法

要理解FFT,先要把那个看起来很吓人的旋转因子W_N^k搞透。W_N^k = e^(-j2πk/N),在复平面上它就是单位圆上均分的第k个点。你可以把它理解成一个“相位旋转器”:信号每经过一个W,相位就偏转2πk/N。

这个旋转因子有三个基本性质,整个FFT的分解逻辑全靠它们:

  • 周期性:W_N^(k+N) = W_N^k。转完一整圈回到原点,所以k取模N即可。
  • 对称性:W_N^(k+N/2) = -W_N^k。转半圈就是取负号,这是后面蝶形运算里“加减成对”的根源。
  • 可约性:W_N^(2rk) = W_(N/2)^(rk)。点数可以约分,大点数的旋转可以换算成小点数的旋转。

这三个性质不是数学游戏。周期性告诉你旋转表可以只存N个值;对称性让两条支路的计算可以共用乘法结果;可约性保证了递归分解时每一级都能用同样的结构套下去。我当时就是把这三个性质抄在纸上,反复对照推导过程,才真正看懂了FFT。

2. 基2时间抽取的核心思想:一步步拆

2.1 第一层分解:把n按奇偶分开

基2时间抽取的思路一句话就能概括:把N点DFT拆成两个N/2点DFT,再拆成四个N/4点DFT,一直拆到2点为止。关键在于怎么拆最划算——按输入下标n的奇偶来拆。

把x[n]分成偶数下标序列x[2r]和奇数下标序列x[2r+1],其中r=0,1,...,N/2-1:

X[k] = Σ x[2r]·W_N^(2rk) + Σ x[2r+1]·W_N^((2r+1)k)

用可约性简化第一项:

W_N^(2rk) = W_(N/2)^(rk)

第二项里W_N^((2r+1)k) = W_N^k · W_N^(2rk),也换成N/2点的旋转因子:

X[k] = Σ x[2r]·W_(N/2)^(rk) + W_N^k · Σ x[2r+1]·W_(N/2)^(rk)

看明白了吗?右边第一个求和正好是偶数序列的N/2点DFT,记为G[k];第二个求和是奇数序列的N/2点DFT,记为H[k]。于是:

X[k] = G[k] + W_N^k · H[k],k = 0, 1, ..., N/2-1

到这里只算了一半频点。另一半X[k+N/2]呢?别慌,利用G和H的周期性——它们作为N/2点DFT,满足G[k+N/2]=G[k]、H[k+N/2]=H[k]。再结合对称性W_N^(k+N/2) = -W_N^k,直接得到:

X[k+N/2] = G[k] - W_N^k · H[k],k = 0, 1, ..., N/2-1

所以只要算一次G[k]和H[k],前半段和后半段都出来了,一个乘法喂给两个频点用。这就是分而治之的精髓。

2.2 蝶形运算是什么,为什么它叫蝶形

上面那对公式就是蝶形运算的原型:两个输入G[k]和H[k],先用旋转因子W_N^k乘H[k],然后做一次加法和一次减法,得到两个输出,分别对应X[k]和X[k+N/2]。画成信号流图就像展开的蝴蝶翅膀,所以叫“蝶形”。

蝶形运算可以用一张表说清楚:

输入操作输出
G[k]直接通过X[k] = G[k] + W·H[k]
H[k]先乘W_N^kX[k+N/2] = G[k] - W·H[k]

每个蝶形只做一次复数乘法(W·H)和两次复数加减法。而且注意,加减法是一对共轭运算:上面的加法结果和下面的减法结果共用同一个乘积W·H。这意味着计算量被压缩得很死,没有任何冗余。

我之前总觉得蝶形这是“优化的巧合”,后来才意识到,它背后就是旋转因子的对称性。因为后半段频点和前半段只差一个负号,所以天然可以成对计算。理解了这一点,你就不需要死记蝶形图,随时能自己推出来。

2.3 递归到2点:整个算法的最底层

N/2点DFT可以继续按奇偶拆分,变成两个N/4点DFT,每个N/4点又拆成两个N/8点。一直拆到2点DFT为止,因为2点DFT是不能再拆的最小单元:

当N=2时,W_2^(0)=1,W_2^(1)=-1,于是:

X[0] = x[0] + x[1] X[1] = x[0] - x[1]

一个2点蝶形不过就是一次加一次减,连复数乘法都没有。这个基元是递归的终点,也是整个算法的最底层“积木”。

以N=8为例,完整结构是:最底层4个2点蝶形 → 中间层2个4点蝶形(每个里面含2个2点结构) → 最上层1个8点蝶形(含4个2点结构)。一共三级,每级N/2=4个蝶形,总计12个蝶形。而DFT需要8×8=64次复数乘法,FFT只需要12次乘法加24次加减法。数据量再翻倍,优势就越拉越大。

很多教材直接给“N点FFT需要(N/2)log₂N次复数乘法”,你现在应该能理解了:共log₂N级,每级N/2个蝶形,每个蝶形里1次复数乘法。记住这个公式,后面估算运行时间特别有用。

3. 工程实现躲不开的三个细节

3.1 码位倒序:输入为什么要“乱序”

这是初学者最容易栽的地方。你高高兴兴写完了蝶形循环,输入x[0]到x[7]原封不动丢进去,结果出来的频谱杂乱无章,高频低频像被折叠过一样。原因在于:时间抽取法按输入下标奇偶不断拆分数据,这种拆法天然改变了数据的排列顺序。

具体来说,第一级按最低位(LSB)是0还是1把下标分成偶奇两组;第二级再按次低位分组;第三级按更高位分组。整个过程等价于把下标的二进制位从左到右反向排列。N=8时的映射关系如下:

原始下标二进制位反转新下标
00000000
10011004
20100102
30111106
41000011
51011015
61100113
71111117

所以输入序列得先按“0,4,2,6,1,5,3,7”这个新顺序重排,FFT的输出才是自然顺序。这个操作就叫码位倒序(bit reversal)。

代码里实现位反转有很多技巧,常见做法是维护一个变量j,从0开始,对i从1到N-1不断按“翻转进位”的方式更新。具体逻辑是:从最高位往低位找第一个0,把它置1,前面遇到的1全部清零。写成循环就是j ^= bit反复处理。这段代码我第一次看觉得像天书,后来在纸上走了一遍N=8的整个过程才明白:它是在模拟一个反向计数器。理解了原理,再背代码就很快。

3.2 原位计算:一个数组搞定所有中间结果

FFT能节省内存的秘诀在于原位计算(in-place)。每个蝶形的两个输出都写回两个输入的位置:上支路G[k]被X[k]覆盖,下支路H[k]被X[k+N/2]覆盖。因为蝶形的输入一旦用完就不会再被其他蝶形引用,覆盖是安全的。

这意味着不管你N是1024还是4096,全程只需要一个复数数组。递归版本的FFT看起来思路清晰,但每层都要分配新数组,内存开销大还容易出错。工程实现几乎都采用迭代就地版本。蝶形操作的索引规律也很简单:某级蝶形步长为len/2,每组起始位置是i,上支路索引是i+k,下支路索引是i+k+len/2,其中k从0到len/2-1。这组规律写清楚,循环就稳了。

3.3 旋转因子的计算与存储

旋转因子的来源是W_len^k = cos(2πk/len) - j·sin(2πk/len)。每次调用cos/sin现算当然可以,但三角函数调用开销不小,在实时系统里往往成为瓶颈。工程上两种常用方案:

  • 查表法:预先算好各级所需旋转因子,存成数组,运行时直接读。
  • 递推法:利用W_len^(k+1) = W_len^k · W_len,每级从W=1出发,循环内乘一次wlen即可,避免重复调用cos/sin。

递推法是代码里最常见的做法。需要注意:连续做几百次复数乘法会累积浮点误差,N比较大的时候建议分段重置。比如每算32个蝶形就把w重新从定义式算一次,误差可以控制得很好。查表法没有累积误差,但在内存受限的MCU上要权衡表大小。N=1024的旋转因子表存成单精度复数,大概是8KB,嵌入式场景一般能接受。

4. 完整代码与验证

4.1 C语言实现全套流程

这里给出一个完整可运行的基2时间抽取FFT实现。代码不含任何平台依赖,用标准C99的complex类型,方便阅读,也方便改成定点实现。

#include <stdio.h> #include <math.h> #include <complex.h> #define N 8 #define PI 3.14159265358979323846 // 码位倒序 void bit_reverse(double complex *x, int n) { for (int i = 1, j = 0; i < n; i++) { int bit = n >> 1; while (j & bit) { j ^= bit; bit >>= 1; } j |= bit; if (i < j) { double complex t = x[i]; x[i] = x[j]; x[j] = t; } } } // 基2时间抽取FFT,正变换 void fft_dit2(double complex *x, int n) { bit_reverse(x, n); for (int len = 2; len <= n; len <<= 1) { double complex wlen = cexp(-2.0 * I * PI / len); // W_len for (int i = 0; i < n; i += len) { double complex w = 1.0 + 0.0 * I; int half = len / 2; for (int k = 0; k < half; k++) { double complex u = x[i + k]; double complex v = x[i + k + half] * w; x[i + k] = u + v; // X[k] x[i + k + half] = u - v; // X[k+N/2] w *= wlen; } } } } int main() { double complex x[N]; // 输入:单位冲激 (只有x[0]=1) for (int i = 0; i < N; i++) x[i] = 0; x[0] = 1; fft_dit2(x, N); // 输出:应该全为1 for (int k = 0; k < N; k++) { printf("X[%d] = %.4f + j%.4f\n", k, creal(x[k]), cimag(x[k])); } return 0; }

核心循环只有三层:最外层len控制当前合并到几点DFT,中间层i按步长len分组,内层k遍历每组内的蝶形。每一轮内层循环,w从1开始一步步推进,就覆盖了W_len^0到W_len^(half-1)。这段代码最需要注意的是len的循环边界:len <= n,并且每次len <<= 1,保证正确覆盖全部log₂N级。

4.2 怎么验证实现是对的

FFT代码写完,先用几个能口算的信号验证,不要直接上真实数据。我的测试顺序向来是这样:

  • 单位冲激:x[0]=1,其余全0,FFT结果应该每个频点都是1。这个测试验证基本蝶形逻辑。
  • 直流信号:全部为1,FFT结果应该是X[0]=N,其余全0。此时所有校验加在k=0这一个点上。
  • 单频正弦:sin(2π·2n/N),N=16时,正常情况下频谱在k=2处出现峰值,k=14处出现镜像峰(因为是实信号)。

如果这三个都对,基本可以确定算法没问题。更严格的做法是在代码里写一个O(N²)的DFT参考函数,随机生成若干组数据,对比FFT和DFT输出,最大误差应接近浮点精度。我习惯在工程代码里保留这个测试开关,后期改动算法时随时能跑回归验证。

void dft_reference(double complex *x, double complex *X, int n) { for (int k = 0; k < n; k++) { X[k] = 0; for (int i = 0; i < n; i++) { double angle = -2.0 * PI * k * i / n; X[k] += x[i] * cexp(I * angle); } } }

注意这里DFT的旋转因子指数符号必须和FFT一致,都取负号才对应正变换。我曾经吃过一次亏:在某个平台移植时逆变换写的正号,正变换写的负号,结果做完频域滤波重建出来的时域信号总是反折的,排查了很久。

4.3 计算量实测对比

我在一台普通PC上分别用DFT和FFT做了1024点变换,实测单次运行时间:DFT大概在几十毫秒量级,FFT在零点几毫秒量级。如果采样率是44.1kHz,每帧1024点,一秒钟约43帧,DFT完全没法跑实时,FFT则有余量处理滤波和逆变换。

再来算一个嵌入式场景:某MCU主频72MHz,64点FFT大约需要几十微秒到一两百微秒(取决于编译器优化和浮点能力)。做FFT之前先算一下(N/2)log₂N,64点是192次复数乘法,心里有个底,就不会把系统卡的莫名其欠。

5. 实测中踩过的坑

5.1 长度不是基2怎么办

基2算法要求N必须是2的整数次幂,你手上的数据往往不是。比如采集了1000个样本,直接传给FFT就完了。解决办法是补零到1024。但补零必须明确一点:不会增加任何频率分辨率,只是把频谱节点细化,像把一根栅栏的条数变多,但能看到的细节并不会变多。这个词后面还会细说。

如果你的尺寸刚好是3×512这样的组合,可以考虑混合基FFT。但一般工程场景下,数据先做补零处理到2的幂,然后开始FFT,是最省事的方案。

5.2 频谱里有假峰?先查混叠

有一次同事调一个音频频谱显示功能,明明输入只有高频信号,频谱低频段却出现一个很大的峰,怎么改代码都没用。最后发现是采样率不够:信号频率超过采样率一半,高频分量折叠到低频段形成了“假峰”。这是采样定理的锅,不是FFT的锅。

做频域滤波之前,一定先确认采样率满足奈奎斯特条件,或者在ADC前加模拟抗混叠滤波器。这个检查必须在数据进FFT之前完成,因为混叠一旦发生,折叠过来的频率和真实频率在频谱上看起来几乎一样,后面任何频域处理都救不回来。

5.3 补零能提高分辨率吗?不能

常见误解是:把数据补零到很大N,频谱看起来更细腻了,是不是就相当于分辨率提高了?不是。频率分辨率取决于信号的有效记录时长:Δf = 1/T,T是你真正采集数据的时间长度。补零只是插值,让频谱曲线更光滑,两个真实频率很接近的峰,并不会因为补零而分离。

举个例子:fs=1000Hz,采集1秒,两个正弦频率分别为100.5Hz和101Hz,相差0.5Hz,理论上分辨率恰好能分辨。如果你只采集了0.1秒,那Δf就是10Hz,补零到几万点也分不清这两个峰,只会看到一个宽包络。要想真正分辨它们,只能多采数据。

5.4 定点平台上的旋转因子误差

MCU或DSP上如果不用浮点,而是用Q格式定点数,旋转因子的量化误差会成为隐患。16位定点里cos/sin量化到Q15格式,单个误差大约在1e-5量级,但旋转因子在蝶形中反复传播,当N比较大时误差会累积到可见程度。如果做的是低频振动监测这类动态范围很大的场景,定点误差可能直接影响滤波效果。

我的经验是:定点平台上优先用查表法存旋转因子,表的精度用32位而不是16位;如果只能用递推法,定期重置w,避免误差滚雪球。条件允许的话,复数乘法用饱和处理而非直接截断,虽然增加几条指令,但能明显改善长时间运行后的频谱毛刺。

6. 写给自己的几条体会

整篇笔记最核心的一件事就是理解“分解”这两个字。FFT没有用任何高深数学,它只是利用旋转因子的周期、对称、可约三个性质,把大DFT逐级拆成小DFT,再用蝶形运算复用中间结果。

我建议每个想彻底掌握FFT的朋友都干一件笨事:拿纸笔完整手推一遍N=8的计算过程。

从码位倒序开始,把0,4,2,6,1,5,3,7这个顺序写下来,然后一级一级做蝶形,把每个中间值都算出来。第一次算大概需要半小时,但这半小时比你看十遍教材都有用。做完之后,三层循环的索引规律、旋转因子的推进方式、输出顺序对不对,全都刻在脑子里了。

还有一点关于后续:我在项目里经常要用到FFT做频域滤波,真正的难点往往不在FFT本身,而在滤波器的设计与相位处理——加窗函数时的频谱泄漏、线性相位怎么保证、逆变换之后边缘要不要重叠相加。这些我在后续笔记里会逐一展开。这一篇先把FFT这块地基夯实了,后面的内容才好继续。

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

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

立即咨询