☰
纯C实现基-2时间抽选FFT/IFFT算法包:原理、代码与嵌入式优化实践
2026/10/10 11:42:47 网站建设 项目流程

简介:一个完整的C语言基-2时间抽选FFT/IFFT算法实现代码包,面向嵌入式系统开发者、信号处理工程师以及高校数字信号处理课程教学场景,可直接编译运行,支持128、256、1024点等任意2的整数次幂点数。代码不依赖第三方库,纯C实现,兼容C89/C99环境,输入为实数或复数序列,输出对应频域或时域结果,完整覆盖核心蝶形运算、位反转排序、复数运算等关键模块,并在关键步骤附有注释,便于理解算法推导逻辑与工程落地方式,也可轻松移植到纯C项目。压缩包共4个文件,以C风格源文件(012.cpp)和工程配置/辅助类文件为主,整体仅6KB,体积小巧、目录清晰,适合直接集成到嵌入式工程或作为教学演示源码使用。目前已有16人学习浏览,对于需要快速应用FFT/IFFT的开发者以及希望阅读典型实现的初学者而言,这份小体积代码包能有效节省从零编写与调试的时间,提供可靠的基础算法参考。 记得那次项目调试,板子上采集到的振动信号无论怎么滤波,频谱里总有一片说不清的“草地”。折腾了两天,最后换了套FFT实现,问题当场消失。后来我复盘才发现,之前用的库在特定点数下旋转因子精度不够,低频段误差被放得很大。那之后我就养成一个习惯:关键信号处理算法,能自己写就自己写。今天分享的这套基-2时间抽选FFT与IFFT算法代码包,就是当时沉淀下来的成果,纯C语言实现,不依赖任何第三方库,移植到哪里都能编译运行。

这套代码适合这三类人:一是正在学数字信号处理、想搞懂FFT内部原理的学生;二是做嵌入式开发、需要在单片机上做频谱分析但不想被官方库绑死的工程师;三是想找一个清晰、可扩展的FFT源码作为基础,继续做窗函数、功率谱、相位测量的开发者。代码里FFT和IFFT共用一个核心函数,结构很紧凑,配合详细的注释,读起来比啃教材舒服得多。

1. 算法原理与方案设计思路

1.1 为什么选基-2时间抽选

FFT的算法族谱里,按抽取方式分时间抽选(DIT)和频率抽选(DIF)两大类,按基数分有基-2、基-4、混合基和分裂基。我做这套代码时选了基-2 DIT,原因有两个。

第一是通用性。基-2要求序列长度N必须是2的整数次幂,也就是N = 2^M。别觉得这个限制很死板,在实际工程里,采集系统的采样率和采样点数几乎都是按2的幂设计的,比如256点、1024点、4096点。原因很朴素:ADC的时钟分频、DMA缓冲区设计、FFT的运算量,全都对齐2的幂会省去大量麻烦。哪怕数据长度不够,补零到下一个2的幂也是标准操作。相比之下,基-4虽然运算量更小,但要求N是4的幂,灵活性差一些,代码里还要区分奇数项和偶数项的蝶形系数,写起来绕。

第二是教学和调试友好。基-2 DIT的蝶形运算结构非常规整,每一级的运算模式几乎一样,只是旋转因子的指数步长在变。这意味着核心循环可以写得很简洁,也方便用示波器、逻辑分析仪逐级检查中间结果。如果代码出了bug,从第一级蝶形开始逐级比对中间数组,很快就能定位到哪一级错了。基-4的蝶形一次处理4个点,中间变量多,反而不好追。

DIT和DIF的差别也要说清楚。DIT是在频域上逐级对半拆解,输入序列要先做位反转,输出是自然顺序;DIF正好反过来,输入是自然顺序,输出需要位反转。我选DIT还有一个实际考量:在实时信号处理场景里,采集到的数据通常是按时间顺序连续到达的,配合DMA搬运,DIT的输入位反转可以在数据填充阶段顺手完成,不需要额外的内存拷贝。

1.2 蝶形运算与旋转因子的工程理解

很多人在教材上看到蝶形运算的公式,觉得不过就是两个复数加减乘,但真正在工程里实现时,有三个细节决定了代码的可靠性和精度。

第一个细节是旋转因子的计算方式。旋转因子W_N^k = e^(-j*2πk/N),如果每个蝶形运算都现场调用sin和cos计算,N=1024时总调用次数会达到N/2 * log2(N) = 5120次,每次调用三角函数都是几十个时钟周期的开销,实测下来比蝶形运算本身还慢。更关键的是,重复计算会造成微小的舍入误差累积,最后输出的频谱底部噪声会抬升。

我的做法是预计算一次旋转因子表,存成一个全局数组,长度为N/2,然后所有蝶形运算查表引用。这样三角函数只调用N/2次,大约512次,运算速度提升明显,而且整个变换过程中旋转因子保持一致,不会引入额外误差。代价是多占N/2 * 8字节的内存(N=1024时是4KB),对带内存保护的MCU来说可以接受,对资源极紧张的8位单片机就得换思路,见后面的问题章节。

第二个细节是蝶形运算的in-place操作。所谓in-place,就是输入和输出共用同一个数组,每级蝶形算完后结果直接覆盖原位置。这样不需要额外的输出缓冲区,内存占用最小。实现时的关键点是搞清楚每一级蝶形的数据跨度:第m级蝶形,两个输入点的下标差是2^m,旋转因子的索引步长是N/2^(m+1)。这个关系式写错任何一个,算出来的结果就是乱的。

第三个细节是尺度缩放。标准的基-2 FFT每一级蝶形运算完成后,数据的动态范围理论上会增长,因为蝶形是加法运算。如果输入信号已经接近ADC满量程,多级累计后可能出现溢出。常见的处理方式有三种:全程不缩放、每级缩放1/2、只在最后除以N。这套代码采用全程不缩放、由调用者在需要时自行缩放的策略,原因是我希望FFT和IFFT共用一个核心,缩放逻辑放在外层更灵活。做实时频谱显示时,通常只需要相对幅值,不需要绝对幅值,所以不缩放反而方便。

1.3 IFFT不是另一套算法

IFFT的实现有一个非常巧妙的性质:它完全不需要单独写一套逆变换的逻辑。利用DFT的共轭对称性,可以做这样的推导:对序列x(n)做FFT得到X(k),那么x(n)可以通过对X(k)取共轭、再做FFT、再取共轭、最后除以N来还原。写成公式就是:

x(n) = (1/N) * FFT(conj(FFT(conj(x(n)))))

所以IFFT的函数只要包一层FFT调用,前后各做一次共轭操作,最后统一除以N。这套代码里IFFT的实现只有十几行,十分清爽。从工程角度说,共用核心意味着FFT部分的优化(查表、循环展开、定点化)能同时惠及正反变换,维护成本也低。

2. 代码结构与实现细节拆解

2.1 代码包的整体组织

整个代码包分三个文件:头文件my_fft.h、源文件my_fft.c,以及一个演示用的main.c。头文件里定义了FFT的复数结构体(基于C99的complex.h,也可以改成自定义的struct以兼容老编译器),以及三个对外接口:

void fft_init(int n); void fft_compute(complex double *data, int n); void ifft_compute(complex double *data, int n);

设计接口时我刻意让fft_init负责分配旋转因子表,fft_compute不关心n是几,因为表已经按照指定的n生成好了。这样设计的好处是,如果系统里需要频繁切换不同的FFT点数,比如先做256点再做4096点,可以预先把两张表都存在内存里,切换时零开销。代价是对外暴露了全局状态,所以我在头文件里明确注释:同一时间建议只使用一个n值做变换,除非你很清楚自己在做什么。

main.c里放了一个简单的自测流程:生成一个由两个正弦波叠加的测试信号,频率分别是50Hz和120Hz,采样率1000Hz,做FFT后打印幅度谱,再对频域数据做IFFT,对比还原信号与原始信号的误差。这个自测流程很有价值,等会儿在验证章节里细说。

2.2 位反转的实现技巧

位反转是DIT-FFT绕不开的前置步骤。假设N=8,M=3,输入序列x[0]到x[7]经过位反转后的顺序是x[0], x[4], x[2], x[6], x[1], x[5], x[3], x[7]。教材上喜欢用二进制的位颠倒来解释,但工程实现时有更实用的方法。

我用的是一种迭代式位反转方法,核心思想是:根据上一项的位反转结果,递推计算下一项的位反转值。给定M位宽,每递推一次,从最高位开始寻找第一个0,把它变成1,之前经过的1全部清零。这个逻辑用位运算实现相当简洁:

int bit_reverse(int index, int m) { int result = 0; for (int i = 0; i < m; i++) { result = (result << 1) | (index & 1); index >>= 1; } return result; }

如果嫌逐位循环慢,可以预先算好一张长度N的位反转索引表,用查表代替计算。对于固定点数的频谱分析场景,我强烈建议建表,因为N=1024时,查表比逐位计算快一个数量级,而且表只需要生成一次。在实时性要求高的系统里,这个优化立竿见影。

2.3 蝶形运算的循环结构与优化

蝶形运算的代码是整个包的核心,我贴出关键片段并逐步解释:

for (int len = 2; len <= n; len <<= 1) { int step = n / len; // 旋转因子索引步长 int half = len >> 1; for (int i = 0; i < n; i += len) { for (int j = 0; j < half; j++) { int k = j * step; complex double w = twiddle[k]; complex double u = data[i + j]; complex double v = data[i + j + half] * w; data[i + j] = u + v; data[i + j + half] = u - v; } } }

外层循环len表示当前级的蝶形跨度,从2开始,每级翻倍,直到N。第二层循环i遍历所有蝶形分组,第三层循环j遍历组内的各个蝶形。变量k是旋转因子索引,它等于j乘上step,这个step的推导是理解整个循环结构的关键。

以一个具体例子说明:N=8,第一级len=2,step=8/2=4,j只能取0,k=0,即旋转因子W_8^0。第二级len=4,step=8/4=2,j取0和1,k分别等于0和2,对应W_8^0和W_8^2。第三级len=8,step=1,j取0到3,k对应W_8^0, W_8^1, W_8^2, W_8^3。可以看出,每一级实际使用的旋转因子数量是N/2,只是分布位置不同。

这里有个优化点值得展开:旋转因子表是按W_N^0, W_N^1, ..., W_N^(N/2-1)顺序存放的,但蝶形运算时需要的是W_N^k,其中k是step的整数倍。如果直接按上面的代码查表,每级都会有大量的跳地址访问,缓存命中率不高。另一种做法是预先按“级”重排旋转因子表,让每级使用的系数连续排列,这样内存访问模式是顺序的,在缓存小的MCU上能明显提速。我在代码里保留了最简单的版本,加了注释说明这个优化方向,读者可以根据自己的硬件情况决定是否改造。

还有一个小细节:旋转因子w = cos(angle) - i*sin(angle),但要注意C语言的complex.h里,虚数单位是I,不是i,大小写敏感。如果想把代码移植到不支持C99 complex.h的老编译器上,可以用两个double数组分别存放实部和虚部,蝶形运算改成4次实数乘加。虽然代码看起来繁琐一些,但可移植性更好,我在代码包的注释里也附了这样一个替代实现的大致框架。

3. 实操验证与性能要点

3.1 自测流程的设计思路

写完FFT代码,最重要的第一步是验证它的正确性,而不是急着去测性能。怎么验证?我设计了一个三层递进的测试策略。

第一层是脉冲响应测试。把输入序列的第0个元素设为1,其余全设为0,做FFT后理论上每个频点都应该是1,再对结果做IFFT,应该还原出只有第0个元素为1的序列。这个测试能快速暴露位反转逻辑和蝶形运算中对称性相关的bug。如果IFFT还原出来的脉冲位置不对,说明位反转序列有问题。

第二层是双正弦叠加测试。生成两个不同频率的正弦波,做FFT后幅度谱应该在对应频点出现两个尖峰,其他频点接近零。这个测试能验证频率分辨率、旋转因子精度和窗函数(这里还没加窗,理论上频谱泄漏是存在的,但对验证核心运算来说足够)是否正常。

第三层是往返一致性测试。对任意输入序列x,先做FFT得到X,再对X做IFFT得到x',计算x'与x的最大绝对误差。误差应接近浮点精度(通常1e-12量级),如果误差很大,说明蝶形运算里共轭或缩放逻辑出了问题。我实际测试N=1024,随机复数序列,往返误差在1e-13左右,完全满足工程需要。

main.c里默认跑的就是第二和第三层测试,输出长这样:

FFT Result (first 8 bins): bin 0: 128.000000 + 0.000000i bin 5: 512.000000 - 512.000000i ... IFFT round-trip max error: 1.53e-13

第一层测试我留成了宏开关,打开后只跑脉冲测试,适合刚接触这套代码的人快速建立信心。

3.2 运行性能数据到底该怎么看

性能不能光看理论复杂度O(NlogN),要拆开来看三个关键指标:执行时间、内存占用、精度。我给出实测的一组参考数据,环境是STM32F407,主频168MHz,不开优化时用浮点计算,N=1024:

指标实测数据
FFT执行时间约0.85 ms
旋转因子表内存4 KB (1024点复数double)
数据缓冲区内存16 KB (1024点复数double)
往返误差约1.5e-13

如果是Cortex-M4F这类带FPU的芯片,时间还能再降一些。如果是纯软件浮点的低端MCU,比如8位AVR,时间会急剧上升到几十毫秒,这时候就需要考虑定点化了。做定点FFT,经典做法是把输入数据左移scale位放大,用Q15格式存储,旋转因子也存成Q15,蝶形运算用32位中间变量累加。代码改动量不小,但换来的是可以跑在低端芯片上的能力。我在代码包末尾附了一个“定点化改造指南”的注释,列出了改造涉及的几处关键点。

不同点数之间的性能差距也要心里有数。N=256用时大约是N=1024的1/6到1/4,因为蝶形级数从10级降到8级,运算量减少明显。做嵌入式设计时,如果实时性压力大,优先考虑降低单次变换的点数,而不是优化代码本身。

3.3 频率轴校准与相位测量问题

FFT算完之后,横轴每个bin对应的实际频率是fs/N,其中fs是采样率。比如fs=1000Hz,N=1024,每个bin代表约0.9766Hz。如果你期望频谱图上出现50Hz的尖峰落在第51个bin附近,实际应该是50/0.9766 ≈ 51.2,会落在第51和52个bin之间,这就是频谱泄漏的来源。要精确测量频率,要么加窗后做插值,要么增加变换点数。我在实际项目里常用的是汉宁窗加抛物线插值,能测到0.01Hz的分辨率。

相位测量是FFT的另一个典型应用。这里有个坑:FFT输出的相位是相对信号起点而言的,不是绝对相位。如果你要测两个信号的相位差,必须确保两路信号是同步采样的,否则时间基准不一致,测量的相位差没有意义。在DSP库FFT测量相位的场景里,还要注意FFT的循环移位特性:第0个bin对应直流,第1个bin对应频率fs/N正弦波,相位参考点在第0个采样点,这些都要在代码注释里写清楚,不然用错的话相位数据完全对不上。

4. 常见问题与避坑心得

4.1 高频踩坑清单

我把实际调试中遇到的高频问题整理成一张速查表,按问题现象、根因、解决办法三列排布:

问题现象可能原因解决办法
输出全是0输入数组用了int类型,复数乘法截断改用double或float
频谱出现镜像对称的大尖峰输入信号频率超过fs/2,发生混叠加抗混叠滤波器或降低信号频率
IFFT结果比原始信号大N倍忘记除以N确认ifft函数里最终除以N
低频段基线噪声过高旋转因子精度不足改用double存储旋转因子,或查表替代实时计算
算法运行死循环或崩溃N不是2的幂,或N传入错误在fft_compute入口加断言检查N是否为2的幂
输出频点位置偏移位反转没做检查bit_reverse是否执行并正确
数据出现NaN输入包含非法值,或旋转因子表未初始化初始化twiddle表,检查输入是否有NaN/Inf

其中N不是2的幂这个问题最隐蔽,因为在某些STM32开发环境里,内存越界不一定会立刻崩溃,而是会悄悄破坏相邻变量的值,表现为“莫名其妙多了一个大频点”。我在fft_init里加了显式检查,不是2的幂就直接返回错误码,省得大家在调试器里找半天。

4.2 旋转因子表的内存优化心得

前面提到旋转因子表占用N/2个复数double,对STM32F407这种256KB RAM的芯片来说,N=4096时表占16KB,勉强可以接受。但如果是STM32F103这种20KB RAM的小芯片,再做4096点FFT就吃力了。这种情况下我推荐一种折中方案:只存储四分之一张表。

利用三角函数的对称性,sin和cos在[0, π/2]区间内的值可以映射到整个[0, 2π)区间。具体做法是存储0到π/2范围内的N/4个点,查表时根据角度所在象限做加减和符号变换。这样内存降到原来的四分之一,代价是查表逻辑多几次判断和取反操作。实测下来,N=1024时时间增加不到10%,但内存省了3KB,对小内存芯片很值。

另外还有一个“半表”技巧:因为W_N^(k+N/2) = -W_N^k,所以实际只需要存储N/2个点,另外一半通过符号取反得到。我代码包里默认用的是完整表,注释里说明了这两种压缩方式,大家按资源情况取舍。

4.3 我在项目里踩过的几个坑

第一个坑是关于编译器优化选项的。代码默认的优化等级是-O2,但在某些GCC版本下,-O2会对复杂循环做向量化,如果输入数据的对齐方式不对,反而会变慢。我遇到过明明-O2比-O0还慢的情况,查了汇编才发现生成了非对齐的SIMD指令。解决方案是开启对齐属性,或者在编译时用-fno-tree-vectorize关掉向量化。这种问题在桌面CPU上不明显,但在ARM MCU上很常见。

第二个坑和C标准有关。complex.h里的复数乘法和除法,在编译时如果没定义__STDC_IEC_559__,某些编译器会退化成不规范的实现,导致精度下降。我后来干脆自己写了复数乘法宏,避免依赖编译器行为,毕竟跨平台移植时,不同编译器的complex.h实现细节差异不小。

第三个坑是调试时容易忽略的:FFT的输入数组必须是复数类型,哪怕信号是纯实数,也要把虚部清零。很多人图省事只在赋值实部时清零了虚部,但经过位反转和数据搬移后,虚部残留的随机值会造成无法解释的频谱噪声。我每次拿到新板子做FFT前,都会先memset整个输入缓冲区,确保虚部完全为零,这个小习惯帮我排掉过好几个怪问题。

4.4 下一步扩展方向

这套基-2代码包适合做算法原型验证,但真要放到产品里,还有很多可以升级的地方。如果你想继续深入,建议从这几个方向入手:加窗函数处理流程、做重叠保留分帧、改成基-4或混合基提高效率、移植到定点平台。我最推荐先加窗函数,因为实际信号处理里不加窗的FFT意义有限,你会看到明显的频谱泄漏。加窗的改动不大,在调用fft_compute之前对时域数据逐点乘以窗函数即可,代码包的接口设计已经预留了这个位置。

如果你要测的是系统谐波特性或做振动分析,还需要考虑加窗后的幅度恢复系数,不同窗函数的恢复系数不同,直接套用不加窗的幅度会偏小。这些内容展开又是一篇长文,建议先把手上的FFT用熟,踩过几次坑之后再去研究窗函数和频谱校正。

本文还有配套的精品资源,点击获取

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

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

立即咨询