在单片机上跑FFT/IFFT这事,听起来很唬人,尤其是看到一堆复数、蝶形运算、位反转概念的时候。但真正上手后你会发现,ST的STM32F407自带FPU,配合ARM官方CMSIS-DSP库,其实整个流程已经被封装得相当友好。这篇文章就围绕一个很实际的场景:用STM32F407采集或生成一段时域信号,通过FFT变换到频域做处理,再用IFFT还原回时域,验证信号还原的准确度。核心API是CMSIS-DSP里的arm_rfft_fast_f32,我会把环境搭建、库的坑、数据排列格式、完整代码和排查经验全部写出来。适合正在做音频分析、振动监测、通信解调或者电力谐波分析的朋友参考,尤其那些需要在MCU上既做频谱分析、又需要把处理后的信号还原成时域波形的场景。
1. 方案选型与整体设计思路
1.1 为什么选STM32F407跑FFT/IFFT
STM32F407这颗芯片在嵌入式圈子里算是一代经典了。Cortex-M4F内核,主频最高168MHz,带单精度硬件浮点单元FPU。FFT运算本质上就是大量乘加运算,FPU配合ARM官方优化的DSP库,性能比纯软件模拟浮点高出好几倍。实测下来,1024点实数FFT在这种配置下大概只需要零点几毫秒量级,完全够用。
有人可能会问:用F103行不行?F103没有FPU,跑浮点FFT会非常吃力,通常只能用Q15定点版本,精度和开发效率都不如浮点舒服。F407价格不高、资料多、外设丰富,做信号处理入门和产品原型都非常合适。如果项目里还要顺带做以太网、USB、CAN这些通信外设,F407的资源也更从容。
1.2 为什么用CMSIS-DSP的arm_rfft_fast_f32而不是自己造轮子
手写FFT是很多数字信号处理课程的作业,但放到工程里,我强烈建议直接用CMSIS-DSP。原因很简单:ARM针对Cortex-M系列做了指令级优化,基4和基8混合蝶形运算,还充分利用了FPU的流水线特性,性能和稳定性比自己写的版本好得多。CMSIS-DSP里提供了多套FFT接口,按数据类型分有f32、q31、q15,按输入信号类型分有复数FFT(arm_cfft_f32)和实数FFT(arm_rfft_fast_f32)。
实际工程中,ADC采样得到的信号绝大多数是实数序列。如果你用arm_cfft_f32,你得先把实数序列包装成虚部为零的复数数组,输出的N个复数点里有接近一半是冗余的镜像数据,内存浪费一半,运算量也白白增加。arm_rfft_fast_f32就是针对实数输入做了专门优化的版本,输入缓冲区长度只要N个float,内部自动按半复数格式处理,输出依然保持N个完整的复数点(实部虚部交错排列),既省内存又快。所以,实数信号场景我基本无脑选arm_rfft_fast_f32。
1.3 整个信号还原链路怎么设计
这个项目的核心链路可以概括成五步:
- 准备一段已知的时域信号,比如两个不同频率正弦波的叠加,再混入一些噪声;
- 调用arm_rfft_fast_f32做正向FFT,把时域信号变换到频域;
- 在频域做处理,本案例以低通滤波为例,滤掉高频分量;
- 调用arm_rfft_fast_f32做IFFT,把处理后的频域数据还原成时域序列;
- 对比还原后的波形与期望波形,计算误差,验证整条链路是否正确。
之所以先用已知信号做验证,是因为信号还原这类功能一旦出错,问题可能出在FFT本身、数据排列、滤波算法或IFFT缩放任意一个环节。用已知信号跑通全流程,每个环节都能对照验证。这个思路我建议所有做信号处理的朋友都养成习惯:先验证算法链路,再接入真实传感器数据。
2. 开发环境准备与CMSIS-DSP库接入
2.1 硬件平台和工程模板
我用的是最常见的F407开发板,芯片型号STM32F407VGT6,主频168MHz。这类板子网上非常多,正点原子、野火或者自己画的最小系统板都行。跑纯FFT算法对硬件外设没什么特殊要求,只要晶振正常、串口能打印日志就够了。
工程模板推荐用STM32CubeMX生成,选好芯片型号后,配置一下时钟树(主频拉到168MHz)、串口(用来打印结果)、调试口,生成MDK或IAR工程。CubeMX生成的工程已经默认开启了FPU编译选项,这点非常重要。如果你是自己手动搭的工程,务必检查编译选项里是否启用了FPU,否则后面跑浮点运算性能会非常难看,甚至直接hardfault。
2.2 添加CMSIS-DSP库的几种方式
CMSIS-DSP库的获取和添加方式有好几种,我按推荐程度排序说。
第一种,MDK的Run-Time Environment。在Keil里点击Manage Run-Time Environment,勾选CMSIS-DSP,Keil会自动把库文件和头文件路径配好。这种方式最省心,缺点是你得保持Keil版本比较新,Pack包也要装好。
第二种,STM32CubeMX直接配置。新版CubeMX在Software Packs或Middleware里其实不会自动加CMSIS-DSP,它是通过“Add Software Pack”或者让你手动添加库文件路径来集成。老实说,CubeMX对DSP库的集成不算无缝,我一般还是习惯手动添加。
第三种,手动添加源码或预编译库。从GitHub的ARM-software/CMSIS-DSP仓库下载源码包,放到工程里。你有两个选择:一是把Source目录下的TransformFunctions、CommonTables、SupportFunctions等子目录源码直接加入工程编译;二是使用预编译库,在keil目录下有arm_cortexM4lf_math.lib这样的文件,把它加入工程,然后配置好头文件路径。
注意:库文件名称有讲究。arm_cortexM4lf_math.lib里面的“M4”指Cortex-M4,“l”表示小端模式,“f”表示浮点单元版本。如果你的芯片是大端或者没有FPU,库名是不同的。F407是小端加FPU,所以用这个库名。如果你用GCC或IAR,对应的是arm_cortexM4lf_math.a或.a文件,别搞混。
2.3 必须配置的几个宏定义和头文件
库加入工程后,还有几个关键的编译宏必须定义,否则会编译报错或链接失败:
- ARM_MATH_CM4:告诉CMSIS-DSP当前芯片是Cortex-M4架构;
- __FPU_PRESENT=1:告诉库当前芯片带FPU;
- ARM_MATH_MATRIX_CHECK 和 ARM_MATH_ROUNDING:这两个是可选宏,前者使能矩阵运算的边界检查,后者控制某些函数的舍入方式,用不到可以不定义。
在MDK中,可以在C/C++选项卡的Define里填:ARM_MATH_CM4,__FPU_PRESENT=1。然后头文件包含路径里加上CMSIS-DSP的Include目录,代码里#include "arm_math.h"即可。
调试时可以在main函数开头调用arm_sqrt_f32(2.0f, &result)验证库是否生效,如果算出来是1.4142,说明环境OK。这个验证步骤虽然简单,但能帮你把“环境问题”和“业务问题”快速隔离。
3. arm_rfft_fast_f32核心细节全面拆解
3.1 函数原型到底长什么样
先看函数原型:
void arm_rfft_fast_f32( const arm_rfft_fast_instance_f32 * S, float32_t * p, float32_t * pOut, uint8_t ifftFlag );S是指向实例结构体的指针,使用前必须先调用初始化函数arm_rfft_fast_init_f32(&fft_instance, fft_len)填充旋转因子等参数。p是输入缓冲,pOut是输出缓冲,ifftFlag为0时做正向FFT,为1时做逆IFFT。
这个设计需要注意几点。第一,p和pOut长度不同:正向FFT时p是N个float的时域数组,pOut是2N个float的频域数组。第二,arm_rfft_fast_f32支持就地变换,也就是p和pOut可以指向同一个缓冲区,但前提是这个缓冲区长度要足够容纳2N个float,而且你确定不会用到原始数据。我习惯把输入输出缓冲区分开,这样时域数据和频域数据互不干扰,排查问题时也更清晰。
3.2 频域输出的数据排列格式
这是整个库最容易踩坑的地方,也是很多移植教程没讲透的地方。arm_rfft_fast_f32虽然输入是N个实数,但输出的频域数据是N个复数点,以float数组形式连续存放。具体排列是:
- freq[0] = 直流分量实部,freq[1] = 直流分量虚部(恒为0);
- freq[2] = 第1个频率点实部,freq[3] = 第1个频率点虚部;
- freq[4] = 第2个频率点实部,freq[5] = 第2个频率点虚部;
- 依此类推,freq[2k] = 第k个频率点实部,freq[2k+1] = 第k个频率点虚部。
输出数组总共2*N个float,包含N个完整复数点。对于实数输入,它的FFT结果天然具有共轭对称性:第k个频率点与第N-k个频率点互为共轭。也就是说,k从1到N/2-1是正频率分量,k从N/2+1到N-1是负频率镜像分量,这两部分是有关联的。
理解这个排列格式极其重要,尤其是你要在频域做修改再IFFT还原的时候。如果你只改了正频率部分而忘了同步修改负频率镜像部分,IFFT还原出来的信号就会不对,很可能出现虚部不为零或者波形幅度错乱。我最初做频域滤波时就在这里栽过跟头。
3.3 频率下标与物理频率的换算关系
用FFT做分析时,你得知道频域数组里第k个点对应多少Hz的物理频率。换算公式很简单:
f_k = k * fs / N
其中fs是采样率,N是FFT点数。举个例子,采样率设为2048Hz,FFT点数1024,那么频率分辨率是2048/1024 = 2Hz,第k个点代表2*k Hz的频率。如果输入信号有一个50Hz分量,它落在k=25的位置;300Hz分量落在k=150的位置。
设计测试信号时,尽量让信号频率是频率分辨率的整数倍,这样频谱泄漏最小,幅值最准确。如果频率不能整除,就要考虑加窗函数了。在我们这个做FFT后IFFT还原的场景里,加窗会破坏原始幅值比例,所以我建议先用整周期采样的方式避开窗函数这个变量,先把链路验证通过再说。
3.4 IFFT的缩放问题
FFT/IFFT最让人头疼的就是缩放系数。不同实现方式习惯不同,有的库正向不缩放、逆向除以N,有的库正向除以N、逆向不缩放,还有的库正逆都不缩放。用CMSIS-DSP的arm_rfft_fast_f32时,我的实践经验是:正向FFT输出的是未归一化的频谱,幅度比真实值放大了N倍;IFFT的输入输出比例在不同版本CMSIS-DSP中行为略有差异,尤其是早期版本和后来重构的版本,千万不能凭记忆写死。
所以我会在代码里做一次标定:输入一个已知幅值的正弦波,跑一次正向FFT再跑一次IFFT,对比输出波形的峰峰值和原始波形的峰峰值,确认缩放比例。这种做法虽然看起来啰嗦,但能在第一时间暴露出库版本差异带来的问题。实测中,我通常发现arm_rfft_fast_f32的IFFT输出已经自带了1/N的归一化,但这不代表你用其他版本或平台时也一定如此,养成标定习惯比相信某个具体行为更稳妥。
4. 信号还原完整工程实现与验证
4.1 设计测试信号与参数选择
先确定采样率和FFT点数。为了便于计算和验证,我选fs = 2048Hz,N = 1024点。这样频率分辨率是2Hz,采集一帧数据刚好0.5秒。测试信号由两个正弦波叠加:
- 50Hz正弦波,幅值1.5;
- 300Hz正弦波,幅值0.8。
两个频率都是2Hz的整数倍,满足整周期采样条件,FFT频谱会非常干净。我们的目标是:通过FFT观察频谱,在频域滤除300Hz分量,再IFFT还原,最终得到接近1.5幅值的50Hz正弦波。
信号生成代码:
#define FFT_LEN 1024u #define SAMPLE_RATE 2048.0f #define PI 3.14159265358979f float32_t input_signal[FFT_LEN]; float32_t freq_buffer[2 * FFT_LEN]; float32_t output_signal[FFT_LEN]; for (int i = 0; i < FFT_LEN; i++) { float32_t t = (float32_t)i / SAMPLE_RATE; input_signal[i] = 1.5f * sinf(2.0f * PI * 50.0f * t) + 0.8f * sinf(2.0f * PI * 300.0f * t); }4.2 初始化与正向FFT
初始化函数返回一个arm_status类型,正常返回ARM_MATH_SUCCESS,如果FFT长度不合法会返回错误。arm_rfft_fast_f32要求长度必须是2的整数次幂,且范围有上下限要求,最小一般是32,最大到4096或8192,具体看版本。
arm_rfft_fast_instance_f32 fft_instance; arm_status status; status = arm_rfft_fast_init_f32(&fft_instance, FFT_LEN); if (status != ARM_MATH_SUCCESS) { // 初始化失败,一般是FFT长度不合法 return -1; } arm_rfft_fast_f32(&fft_instance, input_signal, freq_buffer, 0);正向FFT之后,freq_buffer里存的就是1024个复数点。为了直观验证频谱是否正确,可以计算各个频率点的幅值。对于非直流分量,幅值计算公式是:
magnitude = sqrt(Re^2 + Im^2) * 2 / N
直流分量的幅值是sqrt(Re^2 + Im^2) / N。我在排查时写了一个简单的谱峰搜索函数,遍历freq_buffer前N/2个复数点,找出幅度最大的几根谱线并打印频率和幅值。实测应该能看到50Hz处幅度约1.5,300Hz处幅度约0.8。
4.3 频域低通滤波:镜像处理是核心
现在做低通滤波。我们想保留50Hz,滤掉300Hz及以上频率。50Hz对应k=25,300Hz对应k=150。所以正频率部分保留k=0到k=25附近,k>=150的部分清零。
但这里必须同时处理负频率镜像。整个1024点频谱中,k=0到k=511是0到1024Hz,k=512是奈奎斯特频率±1024Hz,k=513到k=1023对应负频率-1022Hz到-2Hz。300Hz的正频率分量在k=150,它的镜像在k=1024-150=874处。低通滤波时,这两个位置都要清零。
更严谨的做法是:定义截止频率对应的k值,把正频率k>cutoff以及负频率k<N-cutoff的部分全部置零。保留的频段是k <= cutoff以及k >= N-cutoff。
// 截止频率100Hz,对应k=50 int cutoff_index = 50; for (int k = 0; k < FFT_LEN; k++) { // 正频率部分保留 k <= cutoff_index // 负频率部分保留 k >= FFT_LEN - cutoff_index if (k > cutoff_index && k < (FFT_LEN - cutoff_index)) { freq_buffer[2 * k] = 0.0f; // 实部清零 freq_buffer[2 * k + 1] = 0.0f; // 虚部清零 } }这段代码的思想是:对每个复数点,如果它既不属于低频正频率区,也不属于低频负频率区,就直接从频域抹掉。因为是要做IFFT还原的滤波,实部虚部必须同时清零,不能只清一个。
4.4 执行IFFT并验证还原效果
滤波完成后,调用IFFT把频域数据还原成时域。ifftFlag传1。
arm_rfft_fast_f32(&fft_instance, freq_buffer, output_signal, 1);还原完成后,比较output_signal和原始的50Hz分量。理想的output_signal应该是幅值1.5、频率50Hz的正弦波。我写了一个误差统计函数,计算还原波形和理想波形的峰值误差和均方根误差:
float max_err = 0.0f; float sum_sq_err = 0.0f; for (int i = 0; i < FFT_LEN; i++) { float32_t t = (float32_t)i / SAMPLE_RATE; float32_t expect = 1.5f * sinf(2.0f * PI * 50.0f * t); float32_t diff = expect - output_signal[i]; if (fabsf(diff) > max_err) { max_err = fabsf(diff); } sum_sq_err += diff * diff; } float rms_err = sqrtf(sum_sq_err / FFT_LEN);正常情况下降误差应该在1e-3量级甚至更低,主要来自浮点精度累积。如果你看到还原波形幅值明显偏大或偏小、频率不对、波形畸变,基本可以断定是镜像处理或缩放系数出了问题。
4.5 用DWT计数器统计耗时
做实时信号处理,性能评估是绕不开的。Cortex-M内核有个免费的周期计数器DWT->CYCCNT,可以精确到CPU周期,比用定时器更简单。使用前需要解锁调试寄存器:
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0u; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;然后在FFT前后读取DWT->CYCCNT差值,除以主频168MHz得到耗时。我在F407上实测1024点实数FFT在几十微秒到一百多微秒之间,具体取决于编译器优化等级和是否启用FPU。IFFT耗时和FFT基本相当。整个“FFT+频域滤波+IFFT”链路跑一遍,一帧数据大概在几百微秒量级,对于常规音频采样率(16kHz-48kHz)来说实时性相当宽裕。
5. 常见问题与排查技巧实录
5.1 常见问题速查表
我整理了做FFT/IFFT项目中最常遇到的几个问题,直接对照排查。
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| IFFT还原波形幅值减半 | 频谱修改时只处理了正频率,镜像未同步修改 | 检查置零逻辑是否同时处理了k和N-k两个频率点 |
| IFFT还原波形幅值变大 | 库版本差异导致缩放行为不一致 | 用已知正弦波标定,确认是否需要额外除以N |
| 还原波形是乱的,像噪声 | 频域数据排列理解错误,实部虚部位置搞反 | 打印freq_buffer前几个值,对照格式说明检查 |
| 频谱峰值不在预期频率上 | 采样率或FFT点数与换算公式不匹配 | 用f=k*fs/N重新确认对应关系 |
| 程序运行后卡死或HardFault | 缓冲区长度错误,freq_buffer少于2*N | 检查数组定义,确保频域缓冲是2*N个float |
| FFT速度极慢 | FPU未启用或优化等级不够 | 检查编译选项FPU设置,优化等级改为-O2/O3 |
5.2 最典型:还原幅值不对怎么排查
幅值问题十有八九出在镜像处理。很多初学者做频域滤波,只把正频率部分置零,负频率镜像没动,IFFT之后波形幅值就只剩一半,或者波形明显不对。这是实数FFT的固有性质:正负频率共同决定实信号的幅度。处理频域时,要么保留完整的共轭对称结构,要么非常清楚自己在做什么,否则IFFT输出一定出问题。
排查手法其实很简单:先把滤波这一步去掉,直接FFT再IFFT,看还原波形是不是和原始波形一致。如果不一致,说明FFT/IFFT链路本身有问题,和滤波无关。如果一致,再逐步加入滤波逻辑,问题就定位到滤波代码了。这种“二分法”排查真的很好用。
5.3 调试工具链:没有示波器怎么观察波形
很多朋友手头没有示波器,看不到还原波形。我的办法是用开发板自带的USB虚拟串口,把output_signal数组的数据通过串口发到上位机,然后在PC端用Python的matplotlib画图。F407配虚拟串口其实很方便,CubeMX里就能配置USB CDC功能,把数据打包成字节流发出去,上位机按float解析即可。
如果你连USB也不想搞,还有一个更省事的办法:在代码里只打印几个关键特征值,比如波形峰峰值、RMS误差、第一个波峰出现的位置。如果这些特征值和理论值对得上,波形大概率没问题。调试阶段不需要追求完美可视化,先把数值验证清楚。
5.4 关于仿真和移植的提醒
我看到有人在问“Proteus里没有STM32F407怎么办”。说实话,Proteus对F407的支持一直不好,我不建议在仿真器里折腾这个项目。FFT/IFFT是纯运算逻辑,和具体外设关系不大,如果你想先在PC上熟悉算法,完全可以用Python或MATLAB把流程走一遍,CMSIS-DSP的算法逻辑是通用的。等你确认算法没问题,再往F407上移植,配合串口或USB打印结果验证,效率比仿真高得多。
另外还要提醒一个容易忽略的点:CMSIS-DSP库源码是开源的,不同版本之间API兼容性很好,但内部实现细节可能有微妙差异。团队多人协同时,一定要统一库版本,否则可能出现“你那边跑得好好的,我这边就出问题”的情况。
6. 这个项目还能怎么扩展
文章写到最后,我忍不住想聊聊FFT/IFFT在F407上的扩展玩法,也算是我自己做过的几个方向的总结。
第一种是实时频谱仪。ADC+DMA双缓冲采集音频信号,每采满一帧就做一次FFT,把各频段能量算出来,通过LCD或OLED显示成柱状图。这个项目里FFT是核心,但不涉及IFFT,难度相对低,适合入门。
第二种是频域滤波,也就是这篇文章的核心场景。除了低通,还可以做带通、带阻、陷波,甚至设计一个简单的均衡器。核心就是要牢记共轭对称规则,改频谱时保持对称性。
第三种是相关分析和特征提取。比如振动信号里的故障特征频率提取,FFT之后不用还原时域,直接把各特征频点的幅值作为故障判据。F407跑完FFT后还有充足资源做判断逻辑。我之前在做设备状态监测时就走过这个路线。
我自己现在做F407相关的项目,FFT+IFFT已经成了一个标准工具箱里的常备件。如果你后续要做数字下变频、快速卷积实现FIR滤波、频域插值这类更进阶的功能,IFFT的功底会非常有用。
最后分享一个我的个人习惯:凡是涉及FFT/IFFT的代码工程,我一定会保留一个“自测模式”,用一个已知正弦波做全链路验证,随时可以回归测试。这个习惯帮我省了无数排查时间。希望这篇文章能帮你少踩几个坑,把FFT/IFFT稳稳跑起来。