简介:这份文档面向嵌入式开发初学者与信号处理入门者,围绕STM32平台讲解快速傅里叶变换的落地实现,帮助读者理解如何借助官方DSP库完成正弦信号的频谱分析。内容涵盖原始数据生成、FFT变换调用与输出结果处理三个环节,涉及采样频率与频率分辨率的关系、复数数组实部虚部的存储方式、频率与幅值的换算公式,并对比了官方库与非官方算法在1024点下的精度与耗时差异,可作为通信、音频、振动分析等场景的入门参考。资源包共1个docx文件,约244KB,以图文说明形式组织,便于对照阅读与复现。目前已有2701人学习下载,适合希望快速掌握STM32 FFT基本流程、为后续信号处理打基础的开发者查阅。
1. 从一块跑 FFT 的单片机说起:这项目到底能干什么
手头有一块 STM32 的板子,ADC 采回来一串电压值,你想知道这串信号里到底藏着哪些频率成分——是 21Hz 的主信号,还是混进了 15Hz、25Hz 的杂波?这时候就得把时域波形搬到频域去看,而搬家的工具就是 FFT。这个项目干的事很具体:在 STM32 上用官方 DSP 库的汇编函数cr4_fft_1024_stm32对 1024 点采样数据做快速傅里叶变换,再把每个频点的真实频率和幅值算出来,通过串口打印到电脑上。它同时给了一份非官方 C 实现的 FFT2.c 做对照,还配了 TIM6 定时器测两种算法的耗时。适合谁?正在做嵌入式信号采集、想把频谱分析塞进单片机的开发者,尤其是刚接触 STM32 DSP 库、不清楚定点数高 16 位实部低 16 位虚部怎么摆的新手。读完你至少能把这套流程在自己板子上跑通,并且知道官方库为什么快、非官方实现慢在哪。
2. 工程结构与数据流:从 ADC 采样到串口频谱输出
2.1 三个汇编库文件与主流程的分工
工程里最核心的是三个汇编文件:cr4_fft_64_stm32.s、cr4_fft_256_stm32.s、cr4_fft_1024_stm32.s,分别对应 64、256、1024 点。它们不是 C 源码,是 STM32 官方 DSP 库提供的 Cortex-M4 定点 FFT 汇编实现,直接操作寄存器做蝶形运算,所以快。主程序负责三件事:造数据、调 FFT、算幅值频率。数据流是这样的——lBUFIN[NPT]存输入,每个 32 位长整型里高 16 位放实部、低 16 位放虚部;调完cr4_fft_1024_stm32(lBUFOUT, lBUFIN, NPT)后结果进lBUFOUT[NPT],格式一样;再遍历前 NPT/2 个点算模值和真实频率,存进lBUFMAG[],最后printf出去。
这里有个容易忽略的点:为什么只算一半的点?因为实信号的 FFT 频谱关于奈奎斯特频率对称,后半段是前半段的镜像,算全了也是重复信息。所以循环写的是i<NPT/2,省一半计算量。
2.2 目录里各文件该放哪、编译时注意什么
按常见做法,工程目录大致这样组织:
Project/ ├── Core/ │ ├── Src/ │ │ ├── main.c // 主流程:造数据、调FFT、打印 │ │ ├── fft2.c // 非官方C实现,做对照 │ │ └── stm32f4xx_it.c // TIM6中断等 │ └── Inc/ │ └── fft2.h ├── DSP_Lib/ │ └── Source/ │ └── TransformFunctions/ │ ├── cr4_fft_64_stm32.s │ ├── cr4_fft_256_stm32.s │ └── cr4_fft_1024_stm32.s └── MDK-ARM/ 或 STM32CubeIDE/汇编文件必须加进编译列表,否则链接时会报undefined symbol cr4_fft_1024_stm32。另外这些.s文件对编译器有要求,Keil 下用 ARMCC/ARMCLANG 都能编,但要注意汇编语法是 ARM 汇编,别当成 GNU 语法塞进 GCC 工程里直接编,会报一堆语法错。我一般会先单独建一个最小工程只放这三个.s加一个空的调用,确认能链接通过,再往里加业务代码,省得后面排查是库的问题还是自己代码的问题。
2.3 输入数组的构造:实部虚部怎么摆
输入数据的构造是整条链路的第一道坎。看这段:
#define NPT 1024 #define Fs 102.4f #define PI2 6.28318530718f s32 lBUFIN[NPT]; s32 lBUFOUT[NPT]; u32 lBUFMAG[NPT]; for (i = 0; i < NPT; i++) { // 主分量21Hz振幅2048,叠加15Hz、25Hz和随机噪声 float fx = 2048 + 2048 * sinf(PI2 * i * 21 / Fs + 20) + 1000 * sinf(PI2 * i * 15 / Fs) + 100 * sinf(PI2 * i * 25 / Fs) + rand() % 100; // 高16位实部,低16位虚部;实信号虚部为0 lBUFIN[i] = ((s32)fx) << 16; }fx是合成的时域采样值,直流偏置 2048 让信号落在 0~4096 的 12 位 ADC 量程内,主频 21Hz 振幅 2048,另外叠了两个小分量和噪声,方便验证 FFT 能不能把它们分开。关键在最后一行:((s32)fx) << 16把实部推到高 16 位,低 16 位自然为 0,也就是虚部为 0——实信号本来就没有虚部。这个移位操作如果写反成(s32)fx,FFT 会把低 16 位当虚部、高 16 位当实部,结果全乱。参数上,Fs=102.4Hz、NPT=1024,频率分辨率就是102.4/1024=0.1Hz,正好能分辨 0.1Hz 的间隔,这也是正文里反复强调的对应关系。
3. 核心算法拆解:FFT 调用、频率映射与幅值还原
3.1 调用官方 FFT 函数与 NPT 宏的联动
FFT 调用本身只有一行:
// lBUFOUT 输出,lBUFIN 输入,NPT 点数 cr4_fft_1024_stm32(lBUFOUT, lBUFIN, NPT);但这一行背后有两个约束。第一,函数名里的1024和传入的NPT必须一致,你调cr4_fft_1024_stm32却传NPT=256,汇编内部按 1024 点做蝶形,越界读写跑不掉。第二,换点数不是改个宏就完事,得同时换库文件:64 点用cr4_fft_64_stm32.s配cr4_fft_64_stm32(),256 点同理。我一般会在头文件里用条件编译把这三组绑死:
#if NPT == 64 #define FFT_CALL(o, i) cr4_fft_64_stm32(o, i, NPT) #elif NPT == 256 #define FFT_CALL(o, i) cr4_fft_256_stm32(o, i, NPT) #elif NPT == 1024 #define FFT_CALL(o, i) cr4_fft_1024_stm32(o, i, NPT) #endif这样改NPT时编译器会帮你选对函数,少一次翻车机会。调用完,lBUFOUT里就是 N 点复数,第 0 点是直流,第 N/2 点是奈奎斯特频率,中间依次递增。
3.2 频率轴计算:第 i 点对应多少 Hz
频率映射的公式是Fn = i * Fs / NPT。代码里写成:
for (i = 0; i < NPT / 2; i++) { // i 从 0 开始,直接乘即可,无需减 1 float Fn = (float)i * Fs / NPT; printf("%4d, %.2f, %10u\n", i, Fn, lBUFMAG[i]); }这里i从 0 起,所以不用像某些教材那样减 1。代入Fs=102.4、NPT=1024,第 210 点对应210*0.1=21Hz,正好是主分量位置。要注意Fs和NPT的类型:Fs用浮点、NPT是整型宏,i*Fs/NPT里i会先转浮点再除,不会丢精度;如果写成i*(Fs/NPT)且Fs/NPT被当整数算,分辨率直接变 0,这是很隐蔽的坑。
3.3 幅值还原:为什么先除 32768 再乘 65536
幅值计算是整篇里最绕的一段,原文给了两版写法,本质一样。看这段:
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 = sqrtf(X * X + Y * Y) / NPT; lBUFMAG[i] = (u32)(Mag * 65536); } } lBUFMAG[0] = lBUFMAG[0] / 2; // 直流分量不乘2,补除回来 }逐层拆。lX取低 16 位是虚部,lY取高 16 位是实部,这是 Q15 定点格式,数值范围 -32768~32767 对应 -1~1。先NPT * lX / 32768把定点还原成浮点并乘上点数,再sqrt(X²+Y²)/NPT得到模值,最后*65536转回定点存进u32。为什么绕这一圈?因为汇编 FFT 输出的是 Q15 定点,直接sqrt(lX²+lY²)*2/NPT在整数域算会丢精度,先转浮点再转回来,中间精度保住了。lBUFMAG[0]/2是因为直流分量模值本来就是 N 倍,非直流是 N/2 倍,统一按乘 2 算之后直流多乘了一次,得除回来。这个/2漏了,0Hz 处幅值会翻倍,频谱图上第一根柱子明显偏高,是新手最常见的现象之一。
3.4 非官方 FFT2.c 的四个入口函数
非官方实现放在FFT2.c,对外四个函数:dsp_g2_init()造原始数据,dsp_g2_test()跑 FFT,printf_dsp_g2_Mag()打印各频点幅值,timetest_g2FFT()用定时器测耗时。它和官方库的差别在实现方式:官方是汇编手写蝶形、针对 Cortex-M4 指令集优化;非官方是纯 C 的通用实现,没做指令级优化,所以同样 1024 点,官方 9ms、非官方 411ms,差了四十多倍。这个对照很有价值——它告诉你 FFT 快不快,算法复杂度是一回事,底层实现用不用 DSP 指令、有没有手写汇编是另一回事。移植到别的内核(比如 M0 没有 DSP 指令)时,官方库用不了,这时候非官方 C 版就是退路,只是得接受这个耗时量级。
4. 跑起来与避坑:编译、采样与结果核对
4.1 最小可跑步骤
先把工程骨架搭起来,按顺序走:
- 新建 STM32 工程,芯片选带 FPU 的 Cortex-M4(官方 DSP 库的 FFT 汇编针对 M4 优化)。
- 把三个
cr4_fft_*.s加进工程,确认编译无报错、链接无 undefined symbol。 - 在
main.c里定义lBUFIN、lBUFOUT、lBUFMAG三个数组,长度都是NPT。 - 按 2.3 的循环造数据,调
cr4_fft_1024_stm32,再调dsp_asm_powerMag()。 - 配好串口(常见 115200),把
printf重定向到 UART,循环打印i, Fn, lBUFMAG[i]。 - 上电,串口助手里看输出,21Hz 附近应出现明显峰值。
第 5 步的重定向很多人卡住,常见做法是重写fputc:
int fputc(int ch, FILE *f) { while (!(USART1->SR & USART_SR_TXE)); // 等发送寄存器空 USART1->DR = (ch & 0xFF); return ch; }Keil 下还要勾选 Use MicroLIB,否则printf走标准库会链接失败。这一步不通,后面频谱再对也看不到。
4.2 避坑:五个真实会踩的坑
现象一:串口打印全是 0 或者乱码。原因多半是printf没重定向,或者波特率对不上。解决:先确认fputc重写生效、MicroLIB 勾上,再用示波器或逻辑分析仪量一下 UART 引脚波特率,别只信代码里的配置。
现象二:21Hz 峰值位置对,但幅值差一倍。原因就是 3.3 里说的lBUFMAG[0]/2漏了,或者非直流点忘了乘 2。解决:对照公式sqrt(lX²+lY²)*2/NPT逐项核,直流单独处理。
现象三:频谱整体偏移,峰值出现在错误的频点。原因通常是Fs和NPT的对应关系错了,比如实际采样率是 100Hz 却按 102.4 算频率轴。解决:频率轴公式里的Fs必须和造数据/ADC 实际采样率一致,改一处就得改另一处。
现象四:调用 FFT 后程序 HardFault。原因多是数组长度和函数点数不匹配,比如lBUFIN只开了 256 却调 1024 点函数,汇编越界写。解决:数组长度、宏NPT、函数名三者绑死,用 3.1 的条件编译宏兜底。
现象五:非官方 FFT 结果和官方对不上。原因可能是两边输入数据的定点格式约定不同,官方是 Q15 高实低虚,非官方 C 版可能用 float 或不同缩放。解决:先拿同一组lBUFIN喂两边,只比幅值不比绝对数值,确认缩放系数一致后再比精度。
4.3 用 TIM6 测耗时:9ms 与 411ms 怎么来的
耗时测试用 TIM6,单位 ms,测 10 次取平均,包含幅值计算时间。做法是 FFT 前后读定时器计数:
// TIM6 已配置为 1ms 计数 u32 t0 = TIM6->CNT; cr4_fft_1024_stm32(lBUFOUT, lBUFIN, NPT); dsp_asm_powerMag(); u32 dt = TIM6->CNT - t0; // 单次耗时,ms循环 10 次累加再除 10。官方 9ms、非官方 411ms 这个差距,落到工程决策上就是:实时性要求高的场景(比如每 10ms 出一帧频谱)只能用官方库;非官方 C 版适合做算法验证或移植到无 DSP 指令的内核。测的时候注意 TIM6 计数溢出,1024 点非官方要 411ms,如果定时器只配了 16 位、计数周期短,会回绕导致负数,常见做法是把定时器预分频调大或直接读溢出次数。
5. 二次开发:换点数、接真实 ADC 与精度验证
5.1 换点数与换采样率的联动改法
想把 1024 点换成 256 点,不是改个宏就完。要同步动三处:NPT宏改 256、库文件换成cr4_fft_256_stm32.s、调用换成cr4_fft_256_stm32()。频率分辨率随之变成Fs/256,如果Fs还是 102.4Hz,分辨率变 0.4Hz,原来能分开的 21Hz 和 25Hz 可能就糊在一起了。所以换点数前先算清楚:你要分辨的最小频率间隔是多少,反推NPT ≥ Fs/Δf。这是二次开发里最该先算的一笔账,算完再动手,比改完发现分不开再回头强。
5.2 把造数据换成真实 ADC 采样
例程用sinf造数据是为了验证算法链路,真上项目得换成 ADC。常见做法是开 DMA + 定时器触发,按Fs的节奏采NPT个点进缓冲区,采满一帧再调 FFT。注意 ADC 是 12 位、0~4095,而 FFT 输入要 Q15 定点,得先做电平搬移:减去直流偏置 2048 再左移 16 位,或者按实际量程缩放。搬移没做对,0Hz 处会顶出一根巨大的直流柱子,把真实信号压得看不见。我一般会先只采直流、看 0Hz 幅值是否接近预期,确认搬移对了再接交流信号。
5.3 精度验证:拿已知信号反推
验证 FFT 结果对不对,最稳的办法是喂已知信号反推。造一个振幅 2048、频率 21Hz 的纯正弦,跑完看 21Hz 处lBUFMAG是否接近 2048(考虑 Q15 缩放和定点误差,允许几个百分点偏差),再看其他频点是否接近噪声底。如果 21Hz 处明显偏低,查幅值公式的/NPT和*2;如果峰值位置偏一两个点,查Fs和NPT的比值。这套反推我每次移植 FFT 到新板子都会走一遍,比盲看频谱图靠谱得多。
5.4 一个具体技巧:用双缓冲避开 FFT 期间的采样断流
实时场景里,FFT 跑 9ms 这段时间如果还在同一个缓冲区采样,数据会被覆盖。常见做法是双缓冲:DMA 采 buffer A 时,FFT 处理 buffer B,采满一帧交换。切换点放在 DMA 半满/全满中断里,用标志位控制,别在 FFT 函数里做切换。这个结构搭好后,采样和计算并行,帧率能提上去。代价是内存多占一个NPT的缓冲区,1024 点 32 位也就 4KB,多数 M4 扛得住。
有个教训我记到现在:早期做频谱显示,图省事直接在 ADC 中断里调 FFT,结果中断里跑 9ms,主循环全卡死,串口都发不出去。从那以后我每次把 FFT 放进工程,都强制先确认它跑在主循环或低优先级任务里,中断只负责搬数据、置标志。这个习惯帮我省了不止一次通宵排查。希望帮到你。
本文还有配套的精品资源,点击获取