☰
STM32定点FFT实战:1024点9ms实现与幅值还原
2026/10/28 19:55:52 网站建设 项目流程

简介:这份文档面向嵌入式开发初学者与信号处理入门者,围绕STM32平台讲解FFT算法的落地实现,帮助读者理解如何在资源有限的单片机上完成信号频谱分析。内容以1024点FFT为主线,涵盖原始正弦信号与随机噪声的构造、官方DSP库cr4_fft_1024_stm32函数的调用方式、频率与幅值的换算公式,并对比官方与非官方算法在精度和耗时上的差异,配有串口打印与频谱结果图示。资源包共1个docx文件,约244KB,以图文说明形式组织,便于对照代码理解每一步处理逻辑。目前已有2701人学习下载,适合希望快速上手STM32 FFT工程实践、为通信或振动分析等场景打基础的开发者参考。

1. 定点 FFT 的真相:为什么 1024 点变换只要 9ms

很多人第一次在单片机上跑 FFT,都会下意识觉得这东西是浮点运算的重灾区,能跑通就不错了,更别提实时。但真实情况恰恰相反:同样 1024 点变换,官方 DSP 库跑完只要 9ms,一份手写的通用实现却要 411ms,差了四十多倍。这个差距不是编译器优化能解释的,它来自一个更底层的设计选择——定点化与汇编级流水线。

嵌入式场景里做频谱分析,核心诉求从来不是「算得准到小数点后几位」,而是「在有限的 RAM 和主频下,稳定地把主频分量找出来」。这份 STM32 FFT 实现说明的价值,就在于它把这条链路完整摊开:从 ADC 采样的 12 位数据怎么塞进 32 位数组,到 FFT 输出的复数怎么还原成真实振幅,再到官方库和非官方库的耗时对比。搞懂它,你就明白定点 FFT 为什么能 work,以及那些看起来奇怪的移位和缩放到底在防什么。

2. 数据是怎么塞进 32 位数组的:实部虚部打包与定点缩放

2.1 输入缓冲区的位布局:高 16 位实部、低 16 位虚部

STM32 官方 DSP 库的 FFT 函数签名长这样:cr4_fft_1024_stm32(lBUFOUT, lBUFIN, NPT)。注意它的入参不是两个 float 数组,而是两个 32 位长整型数组。这是整个实现里第一个反直觉的地方——一个 32 位整数同时装着实部和虚部。

// 输入数据打包:高 16 位放实部,低 16 位放虚部 for (i = 0; i < NPT; i++) { Fx = 2048 + 2048 * sin(PI2 * i * 21 / Fs + 20) + 1000 * sin(PI2 * i * 15 / Fs) + 100 * sin(PI2 * i * 25 / Fs) + rand() % 100; // 叠加 0-100 的随机噪声 lBUFIN[i] = ((s16)Fx) << 16; // 实部左移 16 位,虚部此时为 0 }

这段代码里((s16)Fx) << 16是关键。Fx是实数采样值,虚部天然为 0,所以左移 16 位后低 16 位全零,正好符合「高实低虚」的约定。为什么库要这么设计?因为 Cortex-M4 的 SIMD 指令(比如SMLAD)能在一个周期内同时对两个 16 位半字做乘加,把实虚部打包进一个寄存器,等于一次运算干两件事。这是定点 DSP 库性能的根基,也是它不肯用 float 数组的原因。

这里有个容易翻车的细节:Fx的取值范围。12 位 ADC 输出 0-4095,叠加噪声后可能到 4195 左右,仍在s16(-32768~32767)范围内,所以强转安全。但如果你的信号经过放大后超过 32767,左移就会溢出成负数,频谱直接乱掉。常见的做法是在进 FFT 前做一次饱和判断,或者把增益压到 16 位动态范围的一半以内。

2.2 采样参数怎么定:频率分辨率与 NPT 的耦合

原文给的例子很典型:Fs = 102.4Hz,NPT = 1024,于是频率分辨率Fs/NPT = 0.1Hz。这个 0.1Hz 不是随便凑的,它由「要分辨的最小频率间隔」倒推出来——想分辨 0.1Hz,采样时间就得是 10 秒,1024 点除以 102.4Hz 正好 10 秒。

参数取值约束来源
采样频率 Fs102.4 Hz需大于信号最高频率的 2 倍(奈奎斯特)
采样点数 NPT1024库支持的固定档位:64 / 256 / 1024
频率分辨率0.1 HzFs / NPT
目标信号频率21 Hz主谐波分量

注意 NPT 不能随便取。官方库只提供 64、256、1024 三档汇编实现,因为基 4 蝶形运算要求点数是 4 的幂,而且每档的旋转因子表是预先算好烧在 ROM 里的。你写 1000 点它根本不认。这是嵌入式 FFT 和 PC 上numpy.fft最大的区别——点数是被硬件资源锁死的。

3. 蝶形运算与旋转因子:官方库为什么能快四十倍

3.1 基 4 分解:把 N 点 DFT 拆成四路子问题

FFT 相对 DFT 的加速,本质是分治。DFT 的复杂度是 O(N²),1024 点要做约一百万次复数乘法;FFT 把它拆成 log 层,每层 N 次蝶形,复杂度降到 O(N log N),1024 点约一万次量级。官方库用的是基 4 分解,每级把序列按模 4 拆成四组,蝶形单元一次处理四个点。

用文字描述这个数据流:输入序列先做位反转重排(bit-reversal),把自然顺序打散成蝶形运算需要的顺序;然后每一级里,四个点通过三次复数乘加合成四个新点,旋转因子W_N^k = e^(-j2πk/N)从预存表里查。基 4 相比基 2 的好处是乘法次数更少——同样 N 点,基 4 的复数乘法约为基 2 的 3/4,而且 Cortex-M4 的 SIMD 指令天然适合一次算两个 16 位分量。

旋转因子表是性能的另一个关键。cr4_fft_1024_stm32.s里那张表是编译期算好的定点值,运行时只查表不做三角函数。这也是为什么非官方实现慢——如果它在运行时用sin/cos现算旋转因子,光这一项就能吃掉几百毫秒。

3.2 汇编级流水线:为什么 9ms 不是玄学

9ms 这个数字放在 72MHz 的 Cortex-M3 上,意味着大约 65 万个时钟周期完成 1024 点 FFT 加幅值计算。摊到每个蝶形上只有几十个周期,这只有手写汇编能做到:循环展开、寄存器分配固定、乘加指令背靠背流水、避免任何函数调用开销。

我一般会这么理解官方库的定位:它不是给你「可读的算法」,而是给你「一个经过验证的时序黑匣子」。你不需要看懂.s文件里每一条LDR、SMULL,但你要知道它的输入输出契约——高实低虚、定点 Q15 格式、点数固定。契约对了,9ms 就是稳定的;契约错了,输出就是一堆噪声。

提示:官方库的定点格式是 Q15,即数值被放大了 32768 倍。这就是后面幅值计算里那个/32768的来源,不是随便写的魔数。

4. 从复数结果还原真实振幅:那些移位和缩放到底在干什么

4.1 幅值公式的推导:为什么是 2/NPT

FFT 输出第 i 个点的模值,和原始信号振幅 A 的关系是:除直流外,模值等于 A 的 N/2 倍。所以反推真实振幅就是A = 模值 * 2 / NPT。原文的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; // 反 Q15 缩放 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 位取出来并保持有符号性。如果直接& 0xFFFF,负数会变成大正数。这是定点代码里最常见的坑之一。

再看NPT * lX / 32768再/NPT的来回。表面上是先乘 NPT 又除 NPT,抵消了,但顺序不能换——先除 32768 是为了把 Q15 定点还原成真实数值,乘 NPT 是为了配合模值公式里的 N/2 关系,最后除 NPT 才是2/NPT里那个 NPT。浮点中间量保证精度不丢,最后*65536转回定点是为了串口打印和存储方便。

4.2 只算一半点的依据:频谱对称性

循环条件是i < NPT/2,因为实数输入的 FFT 输出关于奈奎斯特频率共轭对称,后半段是前半段的镜像,算了也是浪费。第 0 点是直流(0Hz),第 NPT/2 点对应 Fs/2。每个点的真实频率是Fn = i * Fs / NPT,i 从 0 开始所以不用减 1——原文特意强调这点,因为有些教材的公式是从 1 开始编号的,照抄会整体偏移一个 bin。

注意:直流分量lBUFMAG[0]要单独除以 2。因为通用公式按「乘 2」处理,而直流分量的模值本来就是 N 倍而非 N/2 倍,不修正的话直流幅值会翻倍,频谱图上 0Hz 处会冒出一根假峰。

5. 避坑与排查:定点 FFT 最容易翻车的五个地方

5.1 现象:频谱上找不到 21Hz 的峰,全是噪声

原因通常是输入数据没按「高实低虚」打包。有人直接把 ADC 值赋给lBUFIN[i],低 16 位被当成虚部,等于给每个采样点加了一个虚假的虚部分量,相位全乱。解决方法是严格用((s16)value) << 16,并在进 FFT 前打印几个lBUFIN的值确认低 16 位是 0。

5.2 现象:幅值算出来是真实值的两倍或一半

先查直流修正有没有做,再查2/NPT里的 NPT 是不是和实际点数一致。常见错误是宏定义改了 NPT 为 256,但幅值函数里还硬编码 1024,结果所有幅值缩水 4 倍。把 NPT 统一走宏,别在函数里写死数字。

5.3 现象:FFT 输出全是 0 或溢出成负值

多半是输入幅值超了 16 位动态范围。12 位 ADC 满量程 4095,叠加噪声和增益后如果超过 32767,左移 16 位就溢出。排查时在打包前加一句范围检查,或者把信号增益降到 16384 以内,留足 headroom。

5.4 现象:换用非官方 FFT2.c 后结果对但慢得没法用

411ms 对 1024 点,说明它大概率是浮点实现且旋转因子现算。这不是 bug,是设计取舍——它可读、可移植、点数任意,但牺牲了实时性。如果你的场景要求 10ms 级响应,就别在这条路上优化了,直接用官方汇编库;如果只是离线分析,慢点无所谓。

5.5 现象:频率分辨率达不到 0.1Hz,两个邻近峰糊在一起

检查采样时间是否真的够 10 秒。Fs/NPT只是理论分辨率,如果 ADC 采样有抖动、或者实际采样率偏离 102.4Hz,分辨率会退化。用定时器触发 ADC 采样而不是软件循环延时,能把采样间隔的抖动压下去,这是提升实际分辨率最有效的一步。

6. 定点与浮点的取舍:9ms 背后放弃了什么

把官方库和非官方实现放在一起看,其实是在看两种工程哲学的对照。官方库用汇编、定点、固定点数、预存旋转因子,换来了 9ms 的确定性延迟,代价是灵活性几乎为零——你不能改点数、不能改数据格式、调试时看不到中间变量。非官方实现用浮点、通用循环,411ms 换来的是可读、可改、任意点数,适合验证算法正确性。

维度官方 DSP 库非官方实现
1024 点耗时约 9ms约 411ms
数值格式Q15 定点浮点
点数支持64 / 256 / 1024任意
可调试性低(汇编黑匣子)高
适用场景实时频谱、在线监测离线分析、算法验证

这里真正的设计取舍是「确定性 vs 可演化性」。实时系统里,9ms 意味着你可以每 10ms 做一次频谱更新,配合 102.4Hz 采样刚好形成流水;411ms 则意味着你两秒才能更新五次,任何闭环控制都别想。但反过来,如果你要验证一个新的窗函数或者改采样策略,汇编库改不动,浮点版改一行就行。

我一般的做法是:先用非官方浮点版把算法和参数调对,确认 21Hz 峰位置、幅值量级、噪声底都对,再切到官方库做性能验证,两边结果对不上就回头查打包格式和 NPT 一致性。这个「先浮点验证、再定点上线」的流程,帮我省过好几次在汇编黑匣子里瞎猜的时间。从那以后我每次上定点 FFT,都强制先用浮点版跑一遍基准频谱做对照,再切库。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询