简介:面向 STM32F1 系列嵌入式开发者,提供一套基于 Cortex-M3 内核的快速傅里叶变换(FFT)实现工程,解决单片机数字信号处理中时域到频域转换、算法落地与工程调试等实际问题。压缩包内共有 225 个文件,约 4.12MB,核心为 56 个 C 语言源文件与 60 个头文件,覆盖 ADC 信号采集、定时器中断、TFTLCD 显示、I2C 与 CAN 通信等常用外设驱动;同时保留 Keil 工程、编译中间文件、烧录文件、链接脚本与批处理工具,目录结构清晰,方便直接打开工程对照学习或进行二次开发。内容包含 FFT 蝶形运算与位反转实现、CMSIS-DSP 库调用、内存布局、中断触发采样、误差分析和性能优化等关键知识点,并配有标准外设库例程,能够让读者在真实硬件上完成信号采样、FFT 计算与频谱显示的完整流程。已有 10147 人浏览学习,适合希望入门数字信号处理或提升嵌入式算法能力的单片机开发者,也可作为课程设计、毕业设计与项目实践的参考资料。 最近带毕设的学弟问我:FFT能不能在单片机上跑?他手里是一块STM32F103,希望采样一段电压信号,不连电脑、不上位机,直接在板子上算出“信号里有哪些频率分量”。我说能,而且核心代码不超过十行——难的不是FFT本身,而是采样怎么采、参数怎么配、结果怎么解释。这也是这篇文章想讲清楚的事:从为什么优先用ARM官方DSP库,到采样率、FFT点数和频率分辨率的配合,再到代码级调用、串口传回PC验证结果,最后聊聊F1/F4不同芯片的实测表现与选型建议。
1. 在STM32上做FFT:先分清三条路线和一个核心约束
1.1 三条路线怎么选
想给STM32加频谱分析能力,无非三条路:
| 路线 | 实现方式 | 优点 | 难点 |
|---|---|---|---|
| 官方CMSIS-DSP库 | 直接调用arm_cfft_f32等API | 速度快、稳定性高、API统一 | 需要正确配置宏定义和库文件 |
| 手写FFT | 用C语言实现基2蝶形运算 | 对FFT原理理解最透彻 | 开发调试成本高,性能难保证 |
| 硬件FFT加速 | 利用部分MCU内置FFT外设或FPGA IP核 | 计算速度快 | STM32F1/F4等大多数型号没有,通用性差 |
我给学弟的建议很直接:工程上优先用CMSIS-DSP库。这不是说手写FFT没意义——想真正理解“蝶形运算”“旋转因子”“位反转”,自己写一遍是很好的学习路径。但做实际产品时,官方库经过ARM体系结构级别的汇编优化,比编译器自动优化出来的C代码快得多,而且它的API在F1、F4、F7、H7各系列上完全一致。
1.2 核心约束:RAM空间和CPU算力
很多人在电脑上运行FFT毫无压力,一移植到STM32就报内存不足,原因在于FFT的空间复杂度是O(N)不假,但它需要2倍点数的float数组来存放复数数据。
举个例子,1024点FFT的输入缓冲是float32_t fft_input[1024 * 2],一个float占4字节,光是这个数组就需要:
1024 * 2 * 4 = 8192字节
也就是8KB。做2048点就是16KB,4096点就是32KB。STM32F103C8T6的RAM只有20KB,如果是4096点浮点FFT,光输入缓冲就超了,再加上ADC缓冲、其他全局变量,基本跑不起来。因此选FFT点数之前,先看一眼芯片的RAM容量,这是一个很多新手容易忽略的硬约束。
2. 采样参数怎么定:采样率、点数与频率分辨率的黄金三角
2.1 两条公式决定一切
在单片机上做FFT,最终算出的频谱能“看清”什么,取决于采样率fs和点数N这两个参数,它们直接决定频率分辨率:
频率分辨率 Δf = fs / N
假设你设置采样率fs = 4096Hz,做1024点FFT,那么Δf = 4096 / 1024 = 4Hz。这意味着频谱上相邻两根谱线之间的频率间隔是4Hz,两个频率相差不到4Hz的信号,在频谱上会混成一个峰,分不开。如果你的信号是100Hz和103Hz两个正弦波叠加,用这个配置测出来就是“一个很宽的峰”,而不是两个清晰尖峰。
另一个铁律是采样定理:采样率必须大于信号最高频率的2倍,否则会发生频谱混叠。这里要特别强调“大于”而不是“等于”,工程上一般取信号最高频率的5到10倍。比如信号最高频率1kHz,采样率选5kHz到10kHz比较稳妥。
2.2 为什么必须做“等间隔采样”
FFT算法的前提是采样点之间的时间间隔完全一致。如果主循环里用软件延时去采ADC,中断一多、任务一乱,时间间隔就会抖动,频谱上会出现很多“莫名其妙的毛刺”。
正确的做法是定时器触发ADC + DMA搬运:
- 定时器产生固定频率的触发事件,送给ADC的触发输入。
- ADC每次触发完成一次转换,结果通过DMA自动搬运到内存。
- DMA搬运满N个点后触发中断,在中断里置一个标志,主循环检测到标志后开始做FFT。
CubeMX里的配置要点是:ADC的触发源选择定时器TRGO,而不是软件触发;DMA模式选正常模式(非循环),传输长度设为N,这样采完一轮后DMA会停止,避免主循环还在算FFT,DMA又把新数据写进来,把缓冲覆盖掉。
定时器的分频系数和自动重载值按目标采样率反推即可。假设定时器输入时钟是84MHz(F4的APB1定时器时钟),想得到比较接近4096Hz的触发频率,可以这样配:分频PSC = 83,计数频率 = 84MHz / 84 = 1MHz,自动重载ARR = 243,则触发频率 = 1MHz / 244 ≈ 4098Hz,和4096Hz差了不到0.05%,对频谱分析影响可以忽略。
2.3 别忘了ADC量程问题
STM32的ADC输入范围是0到VREF(通常3.3V)。如果你的被测信号是双极性的,比如-1.5V到+1.5V的正弦波,直接接进ADC引脚会把负半周削掉,频谱里会出现很多高次谐波。此时需要加一级偏置电路,把信号抬到0到3.3V范围内,同时保证幅度不超过ADC量程。这个环节在硬件调试时特别容易踩,我曾见过有人调了半天频谱不对,最后发现是运放供电电压不对导致偏置点漂移,信号削波了。
3. 用ARM DSP库跑通第一个FFT:代码级拆解
3.1 环境准备:CubeMX和Keil的配置
CMSIS-DSP库的使用方式有两种:
- 方式一:在STM32CubeMX的Software Packs列表中勾选ARM CMSIS-DSP,生成工程时会自动加入源码路径和宏定义。
- 方式二:手动在Keil工程里添加库文件。
方式二的手动配置需要三步:
- 在C/C++编译选项的Define里加宏定义。F1系列加
ARM_MATH_CM3,F4系列加ARM_MATH_CM4,如果芯片带FPU还要加ARM_MATH_MATRIX_CHECK可选的这类检查和__FPU_PRESENT=1。 - 把CMSIS-DSP的Include头文件路径添加到工程。
- 在工程中加库文件。F1用
libarm_cortexM3l_math.lib,F4带FPU用libarm_cortexM4lf_math.lib(末尾的f代表float单精度浮点)。
3.2 核心调用:arm_cfft_f32和arm_cmplx_mag_f32
DSP库做FFT的核心调用只有两个函数。下面以1024点为例给出完整逻辑:
#include "arm_math.h" #define FFT_SIZE 1024 #define PI 3.14159265358979f float32_t fft_input[FFT_SIZE * 2]; // 复数缓冲,偶数下标存实部,奇数下标存虚部 float32_t fft_mag[FFT_SIZE]; // 幅值谱 extern volatile uint16_t adc_buffer[FFT_SIZE]; extern volatile uint8_t adc_done_flag; void process_fft(void) { if (!adc_done_flag) { return; } adc_done_flag = 0; // 1. 把ADC采样值构造成DSP库要求的复数格式 for (int i = 0; i < FFT_SIZE; i++) { // 假设ADC为12位,0~4095对应0~3.3V,转成以1.65V为中心的双极性波形 float voltage = (float)adc_buffer[i] * 3.3f / 4095.0f - 1.65f; fft_input[2 * i] = voltage; fft_input[2 * i + 1] = 0.0f; } // 2. 执行FFT,第4个参数bitReverseFlag传1,库内部处理位反转 arm_cfft_f32(&arm_cfft_sR_f32_len1024, fft_input, 0, 1); // 3. 计算幅值谱 arm_cmplx_mag_f32(fft_input, fft_mag, FFT_SIZE); // 4. 找峰值对应的频率 uint32_t max_index = 0; float max_val = 0.0f; arm_max_f32(fft_mag, FFT_SIZE, &max_val, &max_index); float sample_rate = 4098.0f; // 由定时器配置决定 float peak_freq = (float)max_index * sample_rate / FFT_SIZE; // 这里可以用串口把peak_freq和谱线发送出去 }这段代码有几个关键点值得说明:
arm_cfft_f32的第3个参数是方向:0表示正变换(时域到频域),1表示反变换(频域到时域)。一般我们只用正变换。- 第4个参数
bitReverseFlag传1,意思是由库函数帮你做位反转。如果传0,输出顺序是错乱的,需要自己再处理。 arm_cmplx_mag_f32计算的是复数模值,也就是sqrt(实部^2 + 虚部^2),这是窄带信号的幅值谱。
3.3 从频域数据到“看得懂”的频率和幅值
FFT做完后,输出数组的第k个元素对应频率:
freq(k) = k * fs / N
比如前面例子中fs = 4098Hz、N = 1024,那么第25根谱线对应频率25 * 4098 / 1024 ≈ 100.05Hz。这是解释频谱最基本的换算关系。
幅值的校正要看k取值:
- 第0根谱线是直流分量,它的真实幅值 = fft_mag[0] / N。
- 第1到第N/2 - 1根谱线是正频率部分,真实幅值 = fft_mag[k] / (N/2)。
- 第N/2到第N-1根谱线是负频率镜像,工程上一般不看。
如果你对加窗有要求,比如测量真实信号时加了汉宁窗,交流分量的幅值还需要乘一个恢复系数,汉宁窗大约是2倍。这个细节直接关系到你测到的幅度准不准,但很多人只关注峰值位置对不对,忽略了幅值标定。
4. 频谱到手之后:怎么验证你的FFT结果是正确的
4.1 先用“干净的合成信号”验证算法本身
我调试FFT有个习惯:先不用真实ADC数据,只在代码里构造一个已知的测试信号,验证算法链路是否正常。
方法很简单,在fft_input里填充两个正弦波叠加,例如:
for (int i = 0; i < FFT_SIZE; i++) { float t = (float)i / 4098.0f; float sig = 1.0f * arm_sin_f32(2.0f * PI * 100.0f * t) + 0.5f * arm_sin_f32(2.0f * PI * 300.0f * t); fft_input[2 * i] = sig; fft_input[2 * i + 1] = 0.0f; }这里用了100Hz、1V幅值的正弦波叠加300Hz、0.5V幅值的正弦波。如果FFT正常,频谱上应该在100Hz处出现一个幅值约为1.0的峰,在300Hz处出现一个幅值约为0.5的峰。
为什么这步很有价值?因为如果算法代码有bug,合成信号就能直接暴露问题,而不需要去怀疑外围电路。我自己遇到过两种典型情况:一是第0根谱线直流分量特别大,其他峰都很矮,后来发现是忘了减均值;二是峰的位置偏了一两根谱线,检查后确认是采样率参数写错。用合成信号快速定位,能节省大量时间。
4.2 再走实际链路:用DAC生成信号,ADC采集后做FFT
如果条件允许,我推荐做一个“板级自测”方案:用STM32的DAC输出一个指定频率的正弦波,同时用ADC采回来做FFT。
这个方法需要芯片带DAC外设,比如STM32F103RET6、F401、F407等都有,但注意F103C8T6没有DAC。具体做法是用一个正弦查找表,通过DMA定时触发DAC输出,让它产生一个1kHz的正弦波。DAC输出接到ADC输入引脚,ADC按照前面说的定时器触发方式采样,再做FFT,理想情况下频谱主峰应该在1kHz附近。
这样做最大的好处是不依赖外部信号发生器,整条链路都是板级验证。如果DAC生成的幅值和FFT测出的幅值对不上,经验上先检查DAC的参考电压和ADC的参考电压是否一致,再检查是否有偏置误差导致小信号被量化噪声淹没。
4.3 串口输出频谱,到PC端画图核对
单片机上的串口调试信息输出也很重要。我一般会让STM32把谱线以文本形式打出来,格式简单即可:
printf("%d %.2f %.2f\r\n", (int)k, freq, fft_mag[k]);串口助手把这些数据保存成txt文件,再用Python或者MATLAB读进来画图,就能快速核对频谱形状。
需要注意:printf的浮点输出在无FPU的F103上比较慢,会占用不少CPU时间。如果FFT处理周期要求高,建议用microlib或者自己实现一个简单的浮点转字符串函数,或者直接只输出整数部分的幅值,减少格式化耗时。
5. 实测耗时、常见坑与工程选型建议
5.1 不同芯片和点数的耗时量级
很多读者最关心的问题:FFT在单片机上跑一次到底要多久?这个问题和主频、Flash等待周期、编译器优化等级、库版本都有关系。以下是我项目实测的经验量级,供参考:
| 芯片 | 主频 | 是否有FPU | 1024点浮点FFT耗时 |
|---|---|---|---|
| STM32F103C8T6 | 72MHz | 无 | 约5ms级别 |
| STM32F401CCU6 | 84MHz | 有 | 约0.5ms级别 |
| STM32F407VET6 | 168MHz | 有 | 约0.1ms级别 |
如果你用的是F103且对实时性要求比较高,可以考虑改用定点FFT,也就是arm_cfft_q15函数。Q15格式是把数据限制在-1到0.9999之间的定点数,计算速度比浮点快很多,在F103上1024点Q15定点FFT通常能压到0.5ms以内。代价是幅值处理逻辑要绕一点,因为Q15的定标关系需要额外换算。我的建议是:如果只是做一个低频信号的频谱分析,比如电力信号50Hz、振动信号几百Hz,浮点版完全够用;如果是音频或者需要跑到几kHz甚至几十kHz的信号,再考虑定点版。
5.2 新手最容易踩的五个坑
数组长度搞错。1024点FFT的输入数组长度必须是2048,很多人在代码里定义了
float fft_input[1024],结果越界写坏其他变量,程序跑飞。记住是2N不是N。忘了加DSP库的宏定义。Keil里如果没加
ARM_MATH_CM4这类宏,编译时会出现大量未定义标识符的报错,或者能编译过但链接时找不到库函数。ADC输入负压削波。前面提到过,双极性信号直接进ADC会削掉负半周,频谱里出现大量谐波,第一反应不一定能想到是电路问题。
采样间隔不均匀。用了软件延时循环采ADC,或者DMA缓冲和FFT缓冲共用导致数据被覆盖,结果就是频谱底部抬高,出现连续噪声地板。
只看峰值不看频率坐标换算。有时调试时看到频谱峰位置对,但始终差一点,多半是把fs和N的对应关系算错了。检查一下
freq = k * fs / N这条公式是不是用了正确的fs。
5.3 给工程选型的建议
做完这个项目后,我个人的体会是:STM32做FFT,难在“怎么把系统搭对”,而不是“怎么把FFT算出来”。先画一张参数关系表,明确你的信号最高频率、需要的频率分辨率,反推出采样率和点数,再回过来看芯片RAM能不能承受。算法验证阶段先用合成信号跑通FFT,再接真实ADC链路,最后再考虑优化实时性问题,这个顺序最省事。
如果你的项目需要的是功率谱密度而不是幅值谱,可以分两步走:STM32只管算出幅值谱,功率谱密度到PC端再用Python的scipy.signal.welch这类工具继续处理,没必要把复杂统计计算全部堆到单片机上。MCU的定位应该是可靠的数据采集和初步频谱计算,真正复杂的分析交给上位机,这个分工在实际工程里最顺手。
本文还有配套的精品资源,点击获取