简介:面向嵌入式开发者,STM32实现4096点FFT的完整工程资源,基于Keil MDK开发环境,适合音频分析、通信解调与频谱检测等场景。工程覆盖从ADC采样、数据预处理到FFT运算、幅相计算及UART输出的完整链路,并选用CMSIS-DSP库进行浮点运算,可直接在STM32F4等带FPU的型号上移植运行。资源包共275个文件、4.49MB,以.h/.c源码和.s启动文件为主,同时包含.o/.axf/.hex等编译产物及.uvproj工程配置,方便直接打开查看构建结果。已有3347人学习。整套工程还保留了map、lst、crf等中间文件,便于分析编译链接细节;通过对照源码与生成文件,可深入理解FFT位反转、蝶形运算以及Cortex-M内核上的优化方式,对学习数字信号处理与嵌入式编程都很有帮助。 4096点FFT在STM32上,看起来就是调一个arm_cfft_f32的问题,但真正动手之后你会发现,这串代码背后绑着采样率、RAM、浮点性能、实时刷新率和一堆工程取舍。我最初接手一个音频频谱显示项目时,第一版直接在STM32F407上把FFT跑通了,示波器看波形也算正常,一进频谱界面就发现刷新率上不去,后来才搞清楚问题根本不在FFT本身,而在采样链路的吞吐。后来把定时器触发ADC、DMA双缓冲和FFT处理串成流水线,刷新率才稳定下来。这篇文章就把从零实现4096点FFT的算账过程、工程配置、调用细节和踩坑记录完整放出来,给准备在STM32上做FFT的同行一个参考,不管你是刚入门还是已经在做产品验证,里面都应该有能直接抄作业的部分。
1. 先算账:4096点FFT在STM32上的实际代价
1.1 频率分辨率:到底为了什么选4096
FFT点数并不是拍脑袋定的。频率分辨率的计算公式是Δf = fs / N,其中fs是采样率,N是FFT点数。这个分辨率决定了你能区分多近的两条谱线,也决定了每个频点对应的宽度。
以音频频谱显示为例子,采样率设为40kHz,N=4096,算下来Δf = 40000 / 4096 ≈ 9.77Hz。也就是说,屏幕上每个频点间隔约9.77Hz,20kHz可分析带宽被分成2048个有效频点,低频段一个100Hz的信号会落在第10个频点附近。这个精度做音频显示非常够用,但如果要分析电网谐波、振动特征频率这种低频场景,9.77Hz可能就不够细致了,到时候要考虑提高点数或者降低采样率。
这里最容易犯的错是“点数越大越专业”。4096点确实比1024点分辨率高4倍,但代价是全链路都要跟着升级。做之前先想清楚:你需要的频率分辨率是多少?显示刷新率要求多高?单片机是不是带浮点单元?把这三个问题串起来,4096点到底值不值得上,答案其实很清晰。
1.2 时间账:采样时间和刷新率的隐形成本
很多人算时间账只盯着FFT计算耗时,忽略了采样时间,我在项目里吃过这个亏。采4096个点需要的时间是N / fs,以40kHz采样率计算,一轮采样要102.4ms。这意味着就算FFT计算部分只要3ms,整帧数据的最快刷新周期也被锁死在102.4ms,理论最大刷新率约9.8fps。
这里给几组参考数据:
| 采样率 | 采集4096点耗时 | 理论最大刷新率 |
|---|---|---|
| 20kHz | 204.8ms | 约4.9fps |
| 40kHz | 102.4ms | 约9.8fps |
| 100kHz | 40.96ms | 约24.4fps |
如果产品要求屏幕刷新到30fps以上,4096点在这个采样率下很难满足,除非降低采样率或者接受更低的刷新率。音频显示行业常见的折中方案是1024点或2048点,不是没道理的。回到4096点本身,它更合适的应用场景是:对频率细节有明确需求、但刷新率要求没那么极端的项目,比如振动分析、电力谐波检测、高精度音频频响测量。
2. 硬件底牌:RAM、FPU和DSP库的适配
2.1 4096点数据量对内存并不友好
先算一笔内存账。4096点FFT如果用float32_t存复数数组,长度是2 × 4096,每个4字节,共32KB。这还没算窗函数数组16KB、幅值输出数组16KB,以及ADC原始缓冲区。随便一加就是64KB以上。
STM32F103系列标准型号只有20KB SRAM,浮点复数数组本身就放不下,基本不具备跑4096点浮点FFT的条件。STM32F405/F407系列的192KB SRAM就从容得多,放完这些数组还能剩100多KB给显示缓冲和协议栈。所以如果你手里只有F103,又确实要上4096点,可以走arm_cfft_q15定点路线:每个采样点实部虚部各占2字节,复数数组16KB,幅值数组8KB,加上ADC缓冲勉强能塞进20KB,但运行时会非常紧张,实际性能也不乐观。
内存对齐也要注意。DMAC和FFT处理都涉及32位访问,像这种大数组建议用__attribute__((aligned(4)))强制4字节对齐,否则DMA搬运到一半出现总线错误,排查起来非常头疼。
2.2 F1与F4的本质区别:浮点、定点与DSP扩展
F103属于Cortex-M3内核,没有FPU也没有DSP扩展指令,跑float运算全靠编译器调用软浮点函数,一条乘法要拆成好几条整数指令,性能天然吃亏。F407属于Cortex-M4F,带单精度FPU和DSP扩展指令,CMSIS-DSP库里的arm_cfft_f32能充分利用这些硬件特性,性能差距能达到一个数量级。
实测下来,在F407 @168MHz上跑4096点arm_cfft_f32大约2~3ms级别,在F103上跑同样的浮点版本恐怕要到几百毫秒。所以F103上如果必须做FFT,工程实践通常用Q15定点库,计算速度快很多,但动态范围不如浮点,需要小心溢出和精度损失。
如果项目还没定型,我的建议很直接:要用STM32做4096点实时FFT,优先选F4、F7或者H7系列的M4F/M7内核芯片,别在F1上硬扛。
2.3 工程配置中的三个关键开关
工程配置有两个容易漏的开关,第一个是DSP库。用STM32CubeMX生成的工程,可以在Middleware/Software Packs里勾选DSP库,或者手动把libarm_cortexM4lf_math.lib加进链接选项。不加这个库,头文件里的arm_cfft_f32就是空声明,链接必然报错。
第二个是全局宏定义。F4平台需要在C/C++编译器预处理器里加上ARM_MATH_CM4,让CMSIS-DSP知道当前内核类型;如果用了带FPU的芯片,还要确保Keil的Target选项里FPU选成Single Precision,否则浮点性能同样起不来,而且常见现象是能编译能运行,但就是慢得离谱。
第三个是优化等级。Debug模式下默认-O0,FFT耗时会被放大好几倍,我见过有人用Debug模式测出十几毫秒的数据,以为F4不行,其实换成-O2马上掉到3ms以内。做性能评估务必用Release或至少-O2。
3. 采样链路设计:定时器触发ADC与DMA乒乓缓冲
3.1 为什么while循环读ADC是错误示范
刚接触FFT时容易踩一个坑:在while(1)里循环调用ADC读取函数,读够4096个点后开始FFT。这么做在低速采样下勉强能玩,但问题在于采样间隔抖动严重。单片机里中断、屏幕刷新、通信协议处理都会让读取时间出现随机延迟,40kHz采样时周期是25us,哪怕抖动2us,在FFT眼里都是不该存在的相位噪声,频谱会莫名多出杂散。
更致命的是,如果主循环里某个任务卡了几十ms,ADC采样时机会完全错乱,FFT结果直接不可用。所以要做FFT,正确姿势是让ADC由硬件定时器精确触发,再用DMA把转换结果自动搬运到内存,全程不占CPU。
3.2 定时器TRGO触发ADC,DMA乒乓缓冲
F4系列的标准做法是用定时器更新事件触发ADC,关键代码大概是这样的:
TIM_HandleTypeDef htim2 = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 0; htim2.Init.Period = 4199; // 168MHz / 4200 = 40kHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim2); TIM_MasterConfigTypeDef sMasterConfig = {0}; sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig); // ADC外部触发源选定时器2的TRGO hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; HAL_ADC_Init(&hadc1);每个定时器更新事件触发一次ADC转换,采样率就等于定时器的触发频率,连续而且均匀。ADC转换完成由DMA自动搬运到内存,不需要CPU介入。为了保证“边采集边处理”,我用了两个缓冲区,一个给DMA往里写,另一个给CPU做FFT,这就是乒乓缓冲:
volatile uint8_t dma_buf_idx = 0; uint16_t adc_buf[2][4096]; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { dma_buf_idx ^= 1; // 立即启动下一轮DMA采集,写到另一个缓冲区 HAL_ADC_Start_DMA(hadc, (uint32_t *)adc_buf[dma_buf_idx], 4096); fft_ready = 1; // 通知主循环处理刚采完的那一块 } }当DMA采满adc_buf[0]后触发完成中断,回调里启动采集adc_buf[1],CPU同时可以处理adc_buf[0]里的数据。用普通DMA模式时,这个“重新启动DMA”的动作必须在中断里尽量快地完成,否则两次采集之间会出现间隙。更稳的方案是配DMA循环模式,配合半传输/全传输中断,DMA本身不停止,只是告诉你这块满了处理器块,我在后面的坑里会细讲。
3.3 从ADC整型数据到复数输入数组
ADC原始数据是uint16_t,FFT输入需要float32复数数组,这一步必须转换。我习惯在转换的同时把去直流和加窗一起做了,避免重复遍历4096个点:
for (uint16_t i = 0; i < 4096; i++) { // 12位ADC右对齐,先归一化到-1.0~1.0范围 float32_t x = ((float32_t)adc_buf[proc_idx][i] / 4095.0f - 0.5f) * 2.0f; // 去掉直流分量 x -= dc_offset; // 加汉宁窗 x *= hanning_window[i]; // 实部存输入,虚部填0 fft_input[2 * i] = x; fft_input[2 * i + 1] = 0.0f; }dc_offset可以预先统计或实时计算一个均值,最简单的方式是先扫一整帧算平均再减掉,不然直流偏置会变成0频处一根巨大的谱线,把低频段细节全盖住。窗函数数组可以提前算好,4096个float占16KB RAM:
for (uint16_t i = 0; i < 4096; i++) { hanning_window[i] = 0.5f * (1.0f - cosf(2.0f * PI * i / 4095.0f)); }这个循环在启动时跑一次就行。注意FFT输入数组是“实部、虚部、实部、虚部”交替排列的,这是CMSIS-DSP库的固定格式,踩过一次之后我专门在代码里加了注释提醒自己。
4. DSP库调用细节:4096点FFT的正变换与幅值谱
4.1 旋转因子实例与正变换调用
CMSIS-DSP库对CFFT(复数FFT)封装得很好,4096点可以直接用官方预定义好的旋转因子实例,不需要手动建表:
arm_cfft_f32(&arm_cfft_sR_f32_len4096, fft_input, 0, 1);参数里第一个是旋转因子实例,第三个0表示正变换(不是逆变换),第四个1表示做位反转。如果后面要用IFFT,再把第三个参数改成1。
这里有个细节:arm_cfft_sR_f32_len4096这个全局符号会占用比较大的Flash,因为它包含4096点到所有子级的旋转因子表。如果Flash紧张,可以考虑用arm_cfft_init_f32手动初始化一个实例,但工程实践里直接用预定义实例是最省事的,也减少出错概率。
4.2 从复数结果到可显示的幅值谱
FFT之后得到的还是复数数组,要变成能显示的幅值谱,可以用arm_cmplx_mag_f32直接求模:
float32_t fft_mag[4096]; arm_cmplx_mag_f32(fft_input, fft_mag, 4096);这个函数输出的是每个频点的sqrt(re²+im²),但还没做归一化。要让幅值代表真实的信号幅度,第k个频点的换算关系是:
- k=0是直流分量,幅度等于fft_mag[0] / N;
- k≥1的频点,单频信号幅度约等于fft_mag[k] × 2 / N,前提是信号频率刚好落在频点上且没有频谱泄漏。
做显示时一般只画前N/2个点,也就是0到fs/2范围;后半段是镜像,没有额外信息。显示成dB谱可以这样:
for (uint16_t k = 0; k < 2048; k++) { float32_t amp; if (k == 0) { amp = fft_mag[0] / 4096.0f; } else { amp = fft_mag[k] * 2.0f / 4096.0f; } dB_display[k] = 20.0f * log10f(amp + 1e-12f); }加窗之后幅度会比真实值低,因为窗函数本身有衰减系数,汉宁窗的相干增益是0.5,如果要做定量幅值测量,还需要在系数里把这个修正回来;只做相对频谱显示的话,不需要那么严格。
另外,如果目标是“用FFT测相位”,不要用幅值结果,直接
本文还有配套的精品资源,点击获取