简介:FFT(快速傅里叶变换)是数字信号处理、图像分析和数据挖掘中极常用的基础算法,它能把时域信号高效映射到频域,是频谱分析、滤波与信号调制等技术的重要支撑。这份资源以 DOCX 文档形式提供 FFT 算法的 C++ 实现代码,面向需要编写频域转换程序的开发者、学生或算法移植人员,重点解决原理到代码实现的衔接问题。文档先阐述 Danielson-Lanczos 基本原理,再给出可运行的 FFT 函数,包含数据重排、蝶形运算、虚实部存储方式、正/逆变换开关等关键细节,适合按步骤理解并复用到实际项目中。资源包内仅 1 个文件,格式为 DOCX,压缩后大小约 8KB,轻量简洁,下载即可阅读。目前已有 1567 人学习或下载,适合具有一定 C++ 基础知识、需要参考 FFT 可运行代码或希望深入理解蝶形运算细节的读者使用。
1. 快速傅里叶变换(FFT)算法C++实现代码:从一份代码文档到可用的实时频谱工具
“快速傅里叶变换(FFT)算法C++实现代码”这个标题看着像一份代码附件,但真正落到工程里,它对应的是一整套选择:递归还是迭代、位反转怎么做、旋转因子怎么存、输出结果怎么解释。我见过A同学在音频频谱Demo里直接用三层循环却漏了位反转,频域数据乱成一团,调了一整晚才发现是次序问题。其实FFT的C++实现不玄学,把数据布局和蝶形运算顺序搞对,剩下就是参数和边界问题。这篇文按我平时做信号处理项目的习惯,给出一套能直接复现的迭代FFT代码、逆变换、窗函数以及几个典型踩坑点,适合正在写频谱分析仪、语音降噪或振动监测模块的开发者。新手能照着抄,熟手可以直接看参数选择和坑位。
2. 从DFT到FFT:为什么C++实现要用迭代和预计算
2.1 O(N^2)算不动:FFT的分治思想
离散傅里叶变换的标准公式是:
X[k] = sum_{n=0}^{N-1} x[n] * exp(-i2πkn/N)
直接按这个公式算,每个k都需要N次复数乘加,全部算完是N^2次乘法。N=1024时,接近一百万次复数运算;N=8192时,直接变成六千多万次。放在实时音频处理里,一块中等性能的MCU每秒算不了几次,延迟根本压不住。FFT的核心思路是把一个N点DFT拆成两个N/2点DFT,旋转因子W_N^{kn}本身有周期性和对称性,所以每一级都能复用上一级算过的结果。整个变换的复杂度降为O(N log2N),N=1024时大约一万次运算,和直接DFT差了百倍以上。这种差距在嵌入式裸跑环境里会被无限放大,所以标题点明“C++实现”而不是“Python调库”,通常意味着固定硬件上要跑稳定延迟,不能依赖运行时解释器。
旋转因子的周期性体现在W_N^{k+N/2} = -W_N^k,对称性体现在实数部分相同、虚数部分相反。这两个性质让蝶形运算能够直接用加减法复用乘法结果。换句话说,FFT不是另一个变换,而是用更少的重复计算重排DFT。C++在这里的优势是内存布局可控、乘加顺序可控,编译器能做循环展开和向量化;如果要进一步追求极致,还能改成定点数学放在DSP上跑。这也是我一般不用Javascript原型做最终方案的原因,原型能证明算法,但证明不了实时性。
2.2 递归写起来简单,迭代才是工程归宿
递归实现看起来最接近FFT的数学定义:把序列按奇偶下标分成两组,分别递归调用自身,再把结果按蝶形合并。
void fft_recursive(cplx* x, int n) { if (n <= 1) return; cplx* even = new cplx[n / 2]; cplx* odd = new cplx[n / 2]; for (int i = 0; i < n / 2; ++i) { even[i] = x[2 * i]; odd[i] = x[2 * i + 1]; } fft_recursive(even, n / 2); fft_recursive(odd, n / 2); for (int k = 0; k < n / 2; ++k) { cplx w = exp(cplx(0, -2.0 * M_PI * k / n)); cplx t = w * odd[k]; x[k] = even[k] + t; x[k + n / 2] = even[k] - t; } delete[] even; delete[] odd; }这个版本逻辑清晰,用来验证算法正确性很方便,但工程问题很大。每次递归都要new两块数组,N=4096时共有log2N层,每层都发生频繁的堆分配和拷贝,缓存局部性差。系统实时线程里如果发生堆分配,还可能出现不可控的调度抖动。递归调用的栈深度虽然只有log2N,但每次递归的临时数组使得内存峰值接近2N,这让嵌入式工程师非常不爽。迭代式Cooley-Tukey用三层循环完成同样的分解,所有数据都在原数组内更新,不需要额外缓冲,也没有调用栈,是更常见的工程实现。我习惯先用递归版本做逻辑基准,再写迭代版本,两个版本输出完全一致,说明没有写错。
2.3 复数数据结构与结果布局
使用C++标准库的std::complex当然可以,但要注意默认的complex乘法和比较运算有一定开销,尤其在某些交叉编译器上未必能内联干净。如果目标是嵌入式或者希望确保内存紧凑,我一般自定一个结构体:
typedef struct { double r; double i; } cplx; static inline cplx cpx_add(cplx a, cplx b) { cplx r = {a.r + b.r, a.i + b.i}; return r; } static inline cplx cpx_sub(cplx a, cplx b) { cplx r = {a.r - b.r, a.i - b.i}; return r; } static inline cplx cpx_mul(cplx a, cplx b) { cplx r = {a.r * b.r - a.i * b.i, a.r * b.i + a.i * b.r}; return r; }结构体两个double,对齐到16字节后,编译器有机会做SIMD优化;以后改造成float或Q15定点也只需要改这一处。FFT的输入输出是同一个数组原地更新,这叫in-place变换。输出结果的下标k对应频率k*Fs/N,其中Fs是采样率,N是参与变换的点数。也就是说,输出数组不是从低频到高频依次排列,而是下标0对应直流,下标1对应基频,下标N/2对应奈奎斯特频率,后半部分是共轭镜像。搞清这个布局比写对蝶形运算还重要,很多人在“输出结果怎么用”这一步卡住。
3. 迭代FFT的C++实现:核心代码与参数说明
3.1 位反转:第一个容易翻车的环节
迭代FFT的第一步是把输入序列按二进制位反转后的顺序重新排列。原因是分治到最后,奇偶分组等价于按二进制位反转分组。直接给一个可用的位反转实现,并预计算一张交换表,避免每次都重算:
#include <vector> #include <algorithm> int ilog2(int n) { int k = 0; while ((1 << k) < n) ++k; return k; } std::vector<int> make_bitrev_table(int n) { int bits = ilog2(n); std::vector<int> rev(n); for (int i = 0; i < n; ++i) { rev[i] = (rev[i >> 1] >> 1) | ((i & 1) << (bits - 1)); } return rev; } void bit_reverse_permute(cplx* a, const std::vector<int>& rev) { int n = (int)rev.size(); for (int i = 0; i < n; ++i) { int j = rev[i]; if (j > i) { cplx tmp = a[i]; a[i] = a[j]; a[j] = tmp; } } }这里的make_bitrev_table是一个递推写法:rev[i]由rev[i>>1]右移一位再根据最低位决定最高位得到。bits是log2(N)。之所以保存交换表,是因为FFT通常会被多次调用,每次调用都重新算位反转是一种浪费。参数n必须严格是2的幂次,否则rev表长度和位宽不匹配,交换会出现越界。
位反转这个环节的错误最难排查。现象是频谱看起来也有峰,但位置全部不对,比如单频正弦波输入后峰出现在错误的频点,或者谱线杂乱无章。原因是交换顺序不对,导致时域序列没有按正确次序进入蝶形运算。解决办法很简单:先对N=8做一次手工推演。原始序列0,1,2,3,4,5,6,7,位反转后应该是0,4,2,6,1,5,3,7,把这一组值打印出来和理论值对照,能立刻定位问题。
3.2 蝶形运算核心:三层循环
下面是完整的迭代FFT函数。假定cplx结构和运算已经在前面定义好。
#include <cmath> #include <vector> const double PI = 3.14159265358979323846; void fft(cplx* a, int n, int invert) { std::vector<int> rev = make_bitrev_table(n); bit_reverse_permute(a, rev); for (int len = 2; len <= n; len <<= 1) { double ang = 2.0 * PI / len; if (invert) ang = -ang; cplx wlen = {cos(ang), sin(ang)}; for (int i = 0; i < n; i += len) { cplx w = {1.0, 0.0}; int half = len >> 1; for (int k = 0; k < half; ++k) { cplx u = a[i + k]; cplx v = cpx_mul(a[i + k + half], w); a[i + k] = cpx_add(u, v); a[i + k + half] = cpx_sub(u, v); w = cpx_mul(w, wlen); } } } if (invert) { for (int i = 0; i < n; ++i) { a[i].r /= n; a[i].i /= n; } } }逻辑说明:最外层len从2开始,每次乘2,代表当前蝶形分组的跨度。内层i按len步进,把数组切成N/len个独立组。最内层k从0到len/2-1,每一次完成一个标准蝶形:u是上半部分,v是下半部分乘上旋转因子w。w从1开始,每处理一个k就乘以wlen,相当于连续旋转。invert参数控制逆变换方向,逆变换时旋转因子指数取相反符号,最后整体缩放1/N。
参数说明:n必须等于数组长度,且为2的幂次。invert=0表示正变换,invert=1表示逆变换。调用这个函数后,a的内容被覆盖为频域结果。如果正变换后不修改频域数据直接做逆变换,最后得到的是原始信号乘N;这个缩放关系是FFT约定之一,要保证逆变换正确,必须把1/N留在最后。这里没有采用每级都除以2的做法,因为那样会引入更多的浮点运算,而且会让中间结果精度略降。
这个实现里旋转因子wlen每次调用fft都会计算一次三角函数。对于离线处理无所谓,但如果在一个循环里反复对同一长度数据做FFT,最好把wlen表提前算好。3.3节会给出预计算版本。
3.3 逆变换与就地更新
逆变换的代码和正变换几乎一样,只是旋转因子符号改变,以及最后多一步归一化。可以这样封装:
void ifft(cplx* a, int n) { fft(a, n, 1); }使用方式如下:
cplx x[1024]; // 填充时域信号到x fft(x, 1024, 0); // 对频域结果做滤波或修改 ifft(x, 1024); // 此时x恢复为处理后的时域信号一个常见误解是认为fft会把数组清零或者需要单独分配输出数组。实际上FFT函数处理的是原数组,正变换和逆变换都是就地操作。这里有个工程习惯:如果同一个buffer还要用于下一帧数据,建议在逆变换后立即把数据拷走,或者用memmove覆盖,避免后续逻辑误读残留的频域值。
在逆变换后,可以做一个自检:用一组已知信号做正变换,再立刻做逆变换,计算误差。误差来源主要是浮点舍入,N=1024时误差幅度通常在1e-12量级(double)。如果误差明显偏大,比如0.1甚至1,基本可以断定归一化或者旋转因子符号出了问题。这个自检会写进第6章的验证步骤里。
4. 参数选择与调用细节:N、窗函数、性能
4.1 N的选择:为什么必须是2的幂次,零填充怎么用
迭代FFT要求输入长度是2的幂次,这不是一个风格偏好,而是算法结构决定的。蝶形分组长度len从2开始每次乘2,如果N不是2的幂次,循环到len接近N时会出现无法完整配对的情况,有些元素永远没有参与蝶形运算,输出结果自然错误。我见过某开发者直接把4096个采样点补到8192再送进FFT,省得改代码,这其实是个合理操作,但要注意补零会改变频率分辨率。
频率分辨率Δf = Fs / N。Fs固定时,N越大,Δf越小,频谱上的两条线越容易被分开。但补零不是增加真实信息,只是把频谱采样变得更密,相当于在原始频谱上做插值,不能突破DFT本身的分辨率极限。举例:Fs=44100,数据长度2048,Δf约为21.5Hz。如果想把50Hz和60Hz两个谐波分开,靠补零到8192只会让频点间隔变成5.4Hz,看起来能分辨,但严格讲,真正的分辨率受数据窗口时长限制,窗口只有2048个采样,主瓣宽度依然对应约21.5Hz。补零更像是个“后悔药”,后悔没采更多数据时用,但不能替代增加采样时长。
所以在工程上,我一般先确定能容忍的最小频率间隔,再用Fmin = N / Fs反推需要的样本数,然后向上取到2的幂次。如果取不到,就补零;如果补零比例太高,比如原始数据只有128点却补到1024,那得到的光滑曲线只是在原有低分辨率上做了插值,分析时别把“看起来有峰”当成真实频率成分。
4.2 窗函数:频谱泄漏的补救
做FFT前,默认信号是周期的且正好截取了整数个周期。现实中不可能保证这一点,非整周期截断会让频谱出现泄漏,原本一个尖锐的谱线变得扩散到很多相邻频点上。解决办法是在时域乘窗函数,让信号在首尾逐渐衰减到接近零。汉宁窗是最常用的简单选择:
void apply_hanning(cplx* a, int n) { for (int i = 0; i < n; ++i) { double w = 0.5 * (1.0 - cos(2.0 * PI * i / (n - 1))); a[i].r *= w; a[i].i *= w; } }调用时机是在fft之前。汉宁窗主瓣宽度较宽,频率分辨率会有损失,但旁瓣抑制好,适合分析宽带信号。如果更看重幅度精度,可以用平顶窗;如果只求最尖锐的谱线,矩形窗不衰减但泄漏大。参数上要注意,汉宁窗用n-1做分母,确保第一个点和最后一个点都接近0,这也是一种标准实现。
加窗会改变信号总能量,所以幅值谱需要做相应修正。恢复幅度时,通常根据窗函数的相干增益系数去乘,汉宁窗的幅度恢复系数约为2.0。实际操作中,先对原始信号做加窗FFT,取频域幅值,然后乘上恢复系数。这个系数在文档里经常会出现在“注意”里,但代码里不一定给,这就是为什么有的频谱数据看起来整体偏小。
4.3 性能优化:预计算旋转因子与固定尺寸特化
3.2节的代码每次调用都调用cos和sin计算wlen,对于N=4096来说有12次,确实不算多,但如果音频帧很小、一秒钟跑上百帧,三角函数的累计开销也不能忽略。更稳妥的做法是预计算一张旋转因子表,把wlen按不同len预先存好:
class FFTEngine { public: FFTEngine(int n) : n_(n), rev_(make_bitrev_table(n)) { rot_.resize(n / 2); for (int len = 2; len <= n; len <<= 1) { double ang = 2.0 * PI / len; int half = len >> 1; for (int k = 0; k < half; ++k) { double a = ang * k; rot_[k + half_offset(len)] = {cos(a), sin(a)}; } } } // ... private: int half_offset(int len) const { return len / 2; } std::vector<int> rev_; std::vector<cplx> rot_; };这张表的大小是N/2,每个旋转因子用cplx存储,内存占用只有8N字节,N=4096时需要32KB,对普通MCU来说可以接受。使用时,内层蝶形直接查表,不用每级重新算w。另一个优化点是位反转表已经预计算,整个过程只剩加法和乘法,编译器可以把内部循环向量化。
性能对比上,非预计算版本每次FFT多出约log2N次三角函数调用,N=1024时多10次,对现代CPU可能看不出差别;但在没有硬件浮点单元或数学库很弱的芯片上,查表和计算是两个数量级的差距。所以如果项目是固定N且实时采集,我一般会写一个专门针对N=1024或2048的特化版本,把循环全展开,再把旋转因子表放到内存连续区,这样缓存命中率更高。
5. FFT实现避坑指南:5个典型踩坑记录
5.1 现象:频谱顺序完全乱掉,峰值出现在错误的频点
原因:位反转没有做,或者位反转表生成逻辑错误。很多人在网上抄了蝶形运算代码,但漏了前面那一段位反转,因为蝶形循环本身不会报错,输出结果看起来也有峰,频率却对不上。解决:用N=8打印输出索引确认位反转表,把原始序列0..7重排成0,4,2,6,1,5,3,7。另外,如果预计算rev表用于多个N,记得在FFTEngine构造函数里重新生成,不能复用一个不同N的表。
5.2 现象:正弦波幅度谱峰值只有真实幅度的四分之一
原因:忘记加窗幅度恢复系数,或者把单边谱和双边谱搞混。很多人取频域绝对值后除以N,得到的单频分量只有原来的一半,因为负频率部分的能量被扔掉了。解决:分析单边频谱时,直流分量之外的幅值用2/N乘以频域绝对值;直流分量本身用1/N。如果加了汉宁窗,再乘2.0恢复系数。这个系数组合经常让人算错,最好在测试函数里用一个已知幅度的正弦波校准。
5.3 现象:逆变换后波形数据比原始输入放大了几百倍
原因:逆变换最后没有除以N。这是最典型的翻车点。正变换不归一化,逆变换必须把1/N放在最后,否则幅值变成原来的N倍。解决:检查fft函数的invert分支,确保循环里a[i].r /= n和a[i].i /= n执行了。我习惯在ifft函数中打印一两个点的数值,对比输入信号,误差在1e-6以内才算正常。如果输出是输入信号的N倍,说明归一化被放在了正变换里或根本没做。
5.4 现象:数据长度为非2幂次时,程序不报错但结果全是异常值
原因:数组长度可能不是2的幂次,比如N=1000。蝶形循环的len从2翻倍到512后,下一次是1024,超出1000,导致后半部分索引越界,未定义行为。有的编译器优化后可能“碰巧”没崩溃,但结果是错的。解决:在fft入口处加断言assert((n & (n - 1)) == 0);如果业务上无法保证2的幂次,就先补零到下一个幂次。注意补齐后,时域信号周期性和频率分辨率都会改变,不能把结果和原始长度直接对应。
5.5 现象:浮点误差累积,长时间运行后结果漂移
原因:使用了float单精度,旋转因子表精度不够,或者逆变换时1/N计算放在每级蝶形里,导致中间结果多次舍入。解决:尽可能用double;如果芯片只支持float,旋转因子表至少用double生成,再强转float存储。另外,归一化只做一次,不要每级都除。长时间循环处理时,建议每处理完一帧做一个Parseval能量检查,发现误差异常增大就及时暴露,而不是等系统输出明显错误才回头查。
6. 进阶:用验证代码让FFT实现“一次写对”
写完FFT后,别急着上真实信号,先用三个固定测试,能在十分钟内确认代码没毛病。第一步是单位脉冲测试:x[0]=1,其余全为0,做正变换,频域所有值都应为1。如果输出不是全1,说明位反转或蝶形运算有错。第二步是单频正弦波测试:生成幅值为A、频率为f0的sin信号,加汉宁窗后做FFT,在f0对应的频点找幅值峰值,幅值应在A的附近(允许窗函数修正误差),同时观察对称频点是否相等。第三步是Parseval定理检查:正变换前后,时域能量和频域能量应满足sum|x|^2 = (1/N)*sum|X|^2。
void validate_fft() { const int N = 1024; cplx x[N]; for (int i = 0; i < N; ++i) { x[i].r = (i == 0) ? 1.0 : 0.0; x[i].i = 0.0; } fft(x, N, 0); double max_err = 0.0; for (int i = 0; i < N; ++i) { double err = std::abs(x[i].r - 1.0) + std::abs(x[i].i); if (err > max_err) max_err = err; } // 期望max_err小于1e-9 }这套验证的精妙之处在于,它不依赖任何外部引用库,也没有复杂信号,出错了能直接定位到是位反转还是蝶形循环。我现在的习惯是每次改动FFT核心后都跑一遍这三个测试,哪怕只是改了预计算表。某开发者之前为了性能把旋转因子从double改成float,导致单位脉冲测试误差从1e-9涨到1e-4,单看时域波形根本没感觉,但已经影响到谐波分析的精度了。养成验证习惯,血泪经验会明显少很多。希望帮到你。
本文还有配套的精品资源,点击获取