ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 FFT实战:1024点9ms实现与0.1Hz分辨率解析

STM32 FFT实战:1024点9ms实现与0.1Hz分辨率解析

简介:这份文档面向嵌入式开发初学者与信号处理入门者,围绕STM32平台讲解FFT算法的落地实现,帮助读者理解如何在资源有限的单片机上完成信号频谱分析。内容以官方DSP库为主线,涵盖原始数据生成、FFT变换调用与输出结果处理三个环节,并给出频率与幅值的换算公式,同时对比了非官方FFT算法在精度与耗时上的差异,配有实测结果说明。资源包共1个docx文件,约244KB,篇幅紧凑,适合作为工程参考与学习笔记。目前已有2701人学习下载,说明其在嵌入式学习者中有一定认可度。读者可从中获得可直接对照的FFT实现思路、函数调用方式与结果验证方法,为通信、音频、振动分析等场景下的频率识别打下基础。

1. 拆开一个 STM32 FFT 工程:1024 点、9ms、0.1Hz 分辨率意味着什么

手头这份 STM32 FFT 算法实现说明,核心不是讲傅里叶变换的数学推导,而是给了一套能在片子上跑通的完整链路:用 1024 点采样、102.4Hz 采样率,把混了随机噪声的三路正弦信号送进官方 DSP 库的汇编 FFT,再从输出数组里把频率和真实振幅还原出来,最后用 TIM6 掐表对比官方库和非官方 C 实现的耗时。官方库跑完 1024 点加幅值计算只要 9ms,非官方版本要 411ms,差了四十多倍。这个数字对做嵌入式信号处理的从业者来说很直观——同样是 FFT,选错实现,实时性直接崩掉。适合手里有 STM32、需要做音频分析、振动检测、电源谐波监测,又不想从零推公式的人。下面按“数据怎么造、FFT 怎么调、结果怎么读、坑在哪”的顺序拆。

2. 造数据与定点格式:为什么输入数组要左移 16 位

2.1 采样参数怎么定:Fs、NPT 与频率分辨率的三角关系

这份工程里最容易被跳过、但后面所有结果都依赖的一步,是采样参数的选择。原文给的目标信号是 21Hz 主分量、15Hz 和 25Hz 两个次要分量,振幅分别是 2048、1000、100,再叠一个rand()%100的随机噪声。采样率 Fs 定在 102.4Hz,点数 NPT 取 1024,于是频率分辨率 = Fs/NPT = 0.1Hz。这个 0.1Hz 不是随便写的,它决定了 15Hz、21Hz、25Hz 三个峰在频谱上能不能被分开——相邻峰间隔 4Hz 和 6Hz,远大于 0.1Hz,所以不会糊在一起。

反过来说,如果 Fs 取 1024Hz、NPT 还是 1024,分辨率变成 1Hz,虽然三个峰还是分得开,但你想看 21Hz 附近更细的结构就没戏了。这里有个常被忽略的约束:采样率必须大于信号最高频率的两倍,25Hz 分量要求 Fs > 50Hz,102.4Hz 满足;同时 NPT 越大分辨率越高,但 RAM 和计算时间也涨。1024 点是官方库三个档位(64/256/1024)里最高的,选它是为了演示分辨率,实际项目里 256 点跑 9ms 以内更常见。

生成数据的循环长这样,注意PI2是 2π 的宏,Fs和NPT都是宏定义:

for(i = 0; i < NPT; i++) { // 主分量 21Hz 振幅 2048,叠加 15Hz/25Hz 和 0-100 随机噪声 Fx = 2048 + 2048 * sin(PI2 * i * 21 / Fs + 20) + 1000 * sin(PI2 * i * 15 / Fs) + 100 * sin(PI2 * i * 25 / Fs) + rand() % 100; lBUFIN[i] = ((s16)Fx) << 16; // 高 16 位实部,低 16 位虚部 }

逻辑说明:2048 + 2048*sin(...)把正弦抬到 0~4096 的 ADC 量程内,对应 12 位 ADC 的满幅;后面两项是干扰分量,用来验证 FFT 能不能同时分辨多个频率;rand()%100模拟量化噪声。关键在最后一行:lBUFIN是 32 位长整型数组,官方库约定高 16 位放实部、低 16 位放虚部,所以实数输入要左移 16 位,低 16 位补零。这一步写错,后面幅值全错,而且错得很隐蔽——频谱形状看着对,幅值差一个数量级。

参数上,(s16)Fx先强转成有符号 16 位再左移,是因为Fx算出来是浮点或 32 位整型,直接左移可能溢出;s16范围 -32768~32767,而 Fx 最大约 4096+2048+1000+100 ≈ 7244,安全。如果你的信号振幅接近 32767,就得先缩放,否则强转截断。

2.2 官方库三个档位怎么选:64、256、1024 的取舍

工程里带了cr4_fft_64_stm32.s、cr4_fft_256_stm32.s、cr4_fft_1024_stm32.s三个汇编文件,对应 64、256、1024 点。它们不是随便给的,而是官方 DSP 库针对 Cortex-M4 的 SIMD 指令和单周期乘法器手写的定点 FFT,点数固定、不能运行时改。选哪个档位,本质是分辨率和实时性的权衡:

点数 NPT频率分辨率(Fs=102.4Hz)典型耗时量级适用场景
641.6Hz亚毫秒只看几个粗频段,RAM 紧张
2560.4Hz1~2ms音频基频、一般振动分析
10240.1Hz约 9ms(含幅值)需要细分辨率、离线分析

注意表里的耗时是这份工程实测的量级,不是数据手册值,换主频、换编译优化等级都会变。选 1024 的代价除了时间,还有 RAM:输入输出各 1024 个 32 位字,就是 8KB,加上幅值数组又是 4KB,小容量型号要算清楚。我一般会先按目标分辨率反推 NPT,再看 RAM 余量,最后才看时间——因为时间不够可以降点数或降采样率,RAM 不够只能换型号。

调用方式很直接,lBUFOUT和lBUFIN都是长度 NPT 的u32数组:

#define NPT 1024 u32 lBUFIN[NPT], lBUFOUT[NPT]; cr4_fft_1024_stm32(lBUFOUT, lBUFIN, NPT);

第一个参数是输出、第二个是输入,顺序别反,反了不会报错,只会得到一堆垃圾频谱。第三个参数 NPT 必须和函数名里的点数一致,传 256 给 1024 的函数,汇编内部按 1024 的蝶形算,越界读内存,现象是 HardFault 或者结果乱跳。

3. 从复数数组还原频率与振幅:除以 NPT 和那个 2 的来历

3.1 频率轴:第 i 个点对应 i*Fs/NPT

FFT 输出是 NPT 个复数点,第 0 点直流,第 NPT/2 点对应奈奎斯特频率 Fs/2,频谱关于它对称,所以只算前一半。第 i 点的真实频率就是i * Fs / NPT,原文特意强调“i 从 0 开始所以不用减 1”,这是针对某些教材把第 1 点当直流的写法,用错会整体偏一个 bin。

for(i = 0; i < NPT/2; i++) { Fn = i * Fs / NPT; // 该 bin 对应的真实频率 // 后续幅值计算与打印 }

i用整型、Fs和NPT是宏,乘法在 32 位内不溢出(1024*102.4 约 10 万),但如果你把 Fs 写成浮点,i*Fs/NPT会走浮点运算,在 M4 上慢不少。常见做法是整数算完再转浮点打印,或者干脆预先算好Fs/NPT这个系数。

3.2 幅值还原:为什么先除 32768 再乘 65536

这段是整份工程里最容易翻车的地方。官方库输出的是 Q15 定点复数,实部虚部各占 16 位,真实幅值要按sqrt(re²+im²)*2/NPT算,直流分量不乘 2。原文的dsp_asm_powerMag绕了一圈:

void dsp_asm_powerMag(void) { s16 lX, lY; u32 i; for(i = 0; i < NPT/2; i++) { lX = (lBUFOUT[i] << 16) >> 16; // 取低 16 位,虚部 lY = (lBUFOUT[i] >> 16); // 取高 16 位,实部 { float X = NPT * ((float)lX) / 32768; float Y = NPT * ((float)lY) / 32768; float Mag = sqrt(X*X + Y*Y) / NPT; lBUFMAG[i] = (u32)(Mag * 65536); } } lBUFMAG[0] = lBUFMAG[0] / 2; // 直流不乘 2,补除回来 }

逐行拆:(lBUFOUT[i] << 16) >> 16是取低 16 位并做符号扩展,得到有符号虚部;lBUFOUT[i] >> 16取高 16 位实部。然后NPT * lX / 32768把 Q15 定点还原成“点数倍”的浮点值,sqrt后除以 NPT 得到单边幅值,再乘 65536 存成 Q16 定点方便串口打印。直流那行除以 2,是因为前面统一按“乘 2”处理了双边谱,而直流分量在正负频率上不重复,必须补回来。

参数上,32768是 Q15 的满幅,65536是 Q16 的缩放,这两个数写死是因为库的定点格式固定。如果你换成浮点库(比如 arm_math 的arm_cfft_f32),这套缩放全不需要,直接sqrt(re²+im²)*2/NPT就行,但浮点库在 M4 上慢,且占 Flash。我一般定点库用于量产、浮点库用于调试对照,两边结果对不上时,先查 Q15 缩放,十有八九是这里。

打印用printf("%4d, %.2f%10d\n", i, (float)i*Fs/NPT, lBUFMAG[i]),注意lBUFMAG是 Q16,直接打印是放大 65536 倍的值,要除回去才是真实振幅。原文图里 21Hz 处幅值接近 2048、15Hz 接近 1000、25Hz 接近 100,说明还原链路是对的;如果三个峰都在但幅值统一差 2 倍或 NPT 倍,就是缩放系数错了。

4. 避坑与排查:定点 FFT 最常翻车的五个地方

4.1 现象:频谱有峰但幅值差整整 2 倍

原因:双边谱转单边谱时乘了 2,但直流分量也乘了,或者反过来漏乘。FFT 输出关于奈奎斯特对称,正频率幅值是单边谱的一半,所以非直流 bin 要乘 2;直流在正负频率上是同一个分量,不能乘。解决:按 3.2 的写法,循环里统一乘 2,循环外单独把lBUFMAG[0]除以 2。验证方法:造一个纯直流信号(常数),看第 0 点幅值是否等于该常数,不等就是这里错。

4.2 现象:输入数组左移后结果全乱,甚至 HardFault

原因:lBUFIN[i] = ((s16)Fx) << 16里Fx超出 s16 范围被截断,或者数组声明成s16而非u32,左移 16 位直接溢出。解决:确认lBUFIN/lBUFOUT是u32或long类型,Fx在强转前做范围检查;如果信号振幅可能超过 32767,先整体右移缩放,FFT 后再按比例还原。另外别用int代替u32,某些编译器int是 16 位。

4.3 现象:调用 FFT 后程序跑飞或结果随机

原因:传给cr4_fft_1024_stm32的 NPT 和函数名点数不一致,或者输入输出数组长度小于 NPT。汇编函数内部按固定点数做蝶形,越界读写不报错,直接踩内存。解决:用宏把点数和函数名绑死,比如#define FFT_POINTS 1024再拼函数名,或者至少加编译期断言sizeof(lBUFIN)/sizeof(u32) == NPT。换点数时三个地方一起改:数组长度、函数名、NPT 实参。

4.4 现象:频率轴整体偏移一个 bin,21Hz 峰出现在 21.1Hz

原因:把第 0 点当成了 1 点,频率公式写成(i-1)*Fs/NPT,或者打印时 i 从 1 开始。解决:统一约定 i 从 0 开始、第 0 点直流,公式就是i*Fs/NPT。排查时造一个已知频率的单音,看峰值落在哪个 bin,和理论值对不上就查索引起点。这个坑不影响幅值,只影响频率读数,容易被忽略。

4.5 现象:官方库和非官方库结果对不上,怀疑库有问题

原因:两个实现的定点格式、缩放系数、输入排列可能不同,非官方FFT2.c未必用同样的 Q15 高实低虚约定。解决:别直接比最终幅值,先比同一 bin 的实部虚部原始值,或者用同一个纯单音输入,看两边峰值 bin 是否一致、幅值比例是否固定。这份工程里两者幅值精度都高,差异在耗时(9ms vs 411ms),所以对比重点应放在时间上,而不是逐点数值。时间测量用 TIM6 掐 10 次取平均,注意把幅值计算也算进去,否则只测蝶形会低估非官方库的差距。

5. 耗时对比与选型收尾:9ms 和 411ms 之间怎么决策

把两种实现放在同一块板子上、同一组 1024 点数据上跑,官方汇编库 9ms、非官方 C 库 411ms,这个差距不是算法复杂度造成的——两者都是 O(N log N)——而是官方库用了 Cortex-M4 的 SIMD 和单周期 MAC,手写汇编把蝶形循环压到极致,非官方 C 实现受编译器优化和内存访问拖累。实测时用 TIM6 配成 1ms 中断或自由计数,包住“FFT + 幅值计算”整段,跑 10 次取平均,别只测一次,缓存和中断抖动会让单次结果偏。

选型上我的习惯是:实时性要求高(比如每 10ms 出一帧频谱)就锁官方库,点数按分辨率需求在 64/256/1024 里挑,别自己改点数;需要灵活点数或浮点精度,用 arm_math 的浮点 CFFT,但先确认主频够、Flash 够;非官方实现只拿来对照验证,不放进量产。还有一个容易忽略的点:官方库的 9ms 是在特定主频和优化等级下测的,换-O0编译或者降频,时间会涨,所以耗时数据要跟着工程配置一起记录,别把别人的 9ms 直接套到自己的板子上。

从那以后我每次移植定点 FFT,都强制先跑一遍“纯直流 + 单音 + 双音”三组自检:直流看第 0 点幅值、单音看峰值 bin 和幅值、双音看两个峰能不能分开,三组都对再上真实信号。这套自检花不了十分钟,但能挡住上面五个坑里的大部分。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进