嵌入式开发里有个挺有意思的现象:很多人做信号处理,第一反应是"得上个带FPU的高端片子"或者"直接换Linux板子跑Python",结果一个简单的频谱分析需求,硬是被搞成了系统级工程。实际上,像STM32G431这类带Cortex-M4内核、主频能跑到170MHz的芯片,配合官方DSP库,做几十点到上千点的FFT完全够用,成本还压得很低。这篇就聊聊我最近在一个小项目里,怎么从零把DSP库挂进CubeMX+Keil的工程里,并且跑通一个能看结果的FFT实验。
先说清楚这篇适合谁:如果你手上有STM32G431(或者同系列G4、F4的板子),会点CubeMX点灯、会用Keil编译下载,但一提到"加DSP库""做FFT"就有点发怵,那这篇基本是照着抄就能跑通的。我会把每一步为什么这么做讲清楚,包括那些官方文档里不会写、但实际配置时一定会卡住的地方。整个实验的目标很朴素:用ADC采一段正弦信号,做FFT,把幅频结果通过串口打出来,验证频谱峰值落在预期频点上。
1. 为什么G431做FFT是个划算的选择
1.1 Cortex-M4的DSP指令到底帮了多少忙
很多人对"单片机做FFT"的印象还停留在M0/M3时代——纯软件乘法累加,256点FFT跑下来要几毫秒甚至十几毫秒,实时性很勉强。G431用的是Cortex-M4F内核,这里有两个关键点:一是带单精度硬件浮点单元(FPU),二是支持SIMD风格的DSP指令集,比如单周期乘加(MAC)、饱和运算、位反转寻址辅助等。
FFT的运算主体是蝶形运算,每个蝶形核心就是一次复数乘加。以基2的256点FFT为例,总共需要 (N/2)·log2(N) = 128×8 = 1024 个蝶形,每个蝶形大约4次实数乘法和6次实数加减。纯软件跑,这些乘法都是多周期指令;而M4的MAC指令配合FPU,浮点乘加能压到很少的周期数。实测下来,用官方DSP库的浮点FFT,256点在170MHz下耗时在几十微秒量级,1024点也就百微秒出头,这个量级对于音频、工频谐波分析、简单振动检测这类场景完全够用。
这里要澄清一个常见误解:不是"必须用FPU才能做FFT"。DSP库同时提供定点(q15/q31)和浮点(f32)版本,定点版本在M3上也能跑,只是精度和动态范围要自己权衡。G431既然有FPU,用f32版本写起来最省心,不用操心定标和溢出,调试阶段尤其友好。
1.2 官方DSP库和"自己手写FFT"的取舍
我见过不少教程让人自己抄一份FFT代码进去,理由是"库太重"。这个说法在G431上其实站不住脚。CMSIS-DSP库是ARM官方维护的,针对Cortex-M做了大量汇编级优化,尤其是位反转和蝶形循环,手写C很难达到同等效率。而且库里的函数签名统一、文档齐全,arm_rfft_fast_f32这类实数FFT接口用起来非常直接。
自己手写的唯一优势是"可控、可裁剪",但代价是调试成本高——定点溢出、位反转索引写错、窗函数没对齐,这些坑足够耗掉一两天。我的建议很明确:除非你有极端的代码尺寸限制,否则直接用CMSIS-DSP。G431的Flash一般有128KB起步,DSP库按需链接后增加的空间完全可接受。
1.3 这个实验的边界:它能验证什么,不能验证什么
把预期先摆出来,避免跑通之后产生错误认知。这个实验能验证的是:DSP库正确挂载、FFT函数调用链路通、ADC采样数据能进算法、频谱峰值位置和输入信号频率对得上。它不能直接代表"产品级信号处理方案",因为真实场景还要考虑抗混叠滤波、窗函数选择、采样率与分辨率匹配、多帧平均等等。
换句话说,这是个"打通链路"的实验,不是"调优"的实验。我个人的习惯是先把链路跑通、看到峰值,再回头逐项加滤波、加窗、调采样参数,这样出问题时容易定位是哪一环引入的。
2. 工程搭建:CubeMX里那些容易点错的配置
2.1 时钟与调试接口的基线配置
新建工程选好G431芯片后,第一步是把时钟树配到位。G431最高170MHz,我一般直接用外部晶振(HSE)走PLL,配到170MHz。这里有个细节:CubeMX里PLL的M/N/P/Q分频要按板子实际晶振频率算,比如8MHz晶振,常见配法是M=2、N=85、P=2得到170MHz。算错了不会报错,但主频不对,后面FFT耗时估算全乱。
调试接口务必在SYS里把Debug设成Serial Wire,否则下载一次之后可能锁死,得用复位+连接的方式救回来,很折腾。这个坑我踩过不止一次,尤其是工程从别人那拷过来、CubeMX配置被覆盖的时候。
2.2 ADC采样配置的关键参数
实验里我用ADC1的某个通道采正弦信号,配置上有几个点必须想清楚:
- 采样时间:信号源内阻不明时,采样时间给长一点(比如设置成较长的周期数),避免采样保持电容充不满导致读数偏低。
- 连续转换模式:开启Continuous Conversion Mode,配合DMA搬运,这样CPU不用逐次读寄存器。
- DMA配置:ADC对应DMA通道设为循环模式(Circular),数据宽度Half Word,缓冲区大小设成FFT点数。这样DMA会自己把采样值填满数组,填满一轮触发一次中断或置标志。
采样率怎么定?这是整个实验里最容易被忽略、又最影响结果的一环。ADC的转换速率由ADC时钟和采样周期共同决定,但更稳妥的做法是用一个定时器(TIM)触发ADC采样,把采样率牢牢锁在定时器频率上。比如要10kHz采样率,就让TIM以10kHz触发。用软件延时或者靠转换时间"凑"采样率,频谱峰值位置会飘,这是新手最常见的困惑来源。
2.3 开启FPU与DSP库的工程级开关
CubeMX生成的工程默认可能没开FPU。在Keil的Target选项里,Floating Point Hardware要选"Single Precision"。同时在C/C++的预定义宏里加上ARM_MATH_CM4和__FPU_PRESENT=1(有些工程模板已经带了,检查一下别重复定义冲突)。
这里解释一下为什么需要ARM_MATH_CM4:CMSIS-DSP的头文件靠这个宏来决定包含哪套内核相关的实现,选错了会编译报错或者链接到不匹配的符号。G431是M4F,所以是CM4,不是CM7也不是CM3。
3. 把CMSIS-DSP真正挂进Keil工程
3.1 库文件的来源与目录组织
CMSIS-DSP有两种获取方式:一是通过Keil的Pack Installer安装ARM::CMSIS-DSP包,二是在ST的固件包里找。我倾向用Pack方式,版本管理清晰。安装后在工程里通过Manage Run-Time Environment勾选CMSIS→DSP,Keil会自动把需要的源文件或预编译库加进来。
如果你不想依赖RTE,也可以手动把DSP的Include路径和对应的.lib(或源码)加进工程。手动方式的好处是工程可移植、不依赖本机Pack状态;坏处是要自己维护路径。我一般项目里用手动方式,因为团队协作时别人机器上Pack版本不一致会出幺蛾子。
目录上建议这样组织:在工程根目录建一个Drivers/CMSIS-DSP文件夹,放Include和预编译库,然后在Keil的Include Paths里加上Drivers/CMSIS-DSP/Include。库文件按内核选arm_cortexM4lf_math.lib(lf表示little-endian + FPU),这个命名规则要记牢,选错链接阶段会报一堆未定义符号。
3.2 链接阶段的常见报错与定位思路
挂库时最常遇到两类错误。第一类是"undefined symbol arm_rfft_fast_init_f32",说明库没链上或者内核版本选错;第二类是"multiple definition",通常是同时引入了源码和预编译库,或者RTE和手动添加重复了。
排查顺序我总结成一条链:先看Include Paths里有没有DSP的Include目录,再看工程里是否真的加入了lib文件(在Target的File Groups里能看到),最后确认预定义宏ARM_MATH_CM4存在。这三步覆盖了九成的链接问题。还有个小细节:Keil的lib文件要放在"Library"类型的文件组里,或者至少在Linker里被引用,光拖进工程不配置有时不生效。
3.3 内存与栈的预留
FFT会用到几块缓冲区:输入实数数组、输出复数数组、FFT实例结构体。以1024点f32为例,输入4KB、输出8KB(复数实虚各4KB),加上实例结构体,十几KB的RAM就出去了。G431的RAM一般32KB起,跑1024点要留意别把栈挤爆。
我的做法是把大数组定义成全局静态变量(放.bss或指定段),不要放函数栈上。同时在Keil的Startup文件里把Stack Size适当调大(比如0x800),Heap按需。曾经有一次把1024点数组定义在函数内,编译没报错,运行直接HardFault,查了半天才发现是栈溢出——这种问题现场很难第一时间联想到栈。
4. FFT实验的代码骨架与调用逻辑
4.1 实数FFT的初始化与执行流程
CMSIS-DSP的实数FFT用arm_rfft_fast_f32系列,流程分三步:初始化实例、执行变换、取模。初始化用arm_rfft_fast_init_f32(&inst, fftLen),其中fftLen必须是2的幂。执行时传入输入数组和输出数组,最后一个参数0表示正变换、1表示逆变换。
这里有个容易搞混的点:arm_rfft_fast_f32的输出是打包格式的复数——输出数组的前半段是实部、后半段是虚部,不是交错存放。取模的时候要按这个布局来算,不能当成[re0, im0, re1, im1...]去读。我第一次用的时候就栽在这,频谱画出来完全对不上,后来翻头文件注释才反应过来。
取模可以用库里的arm_cmplx_mag_f32,但它期望的是交错格式。所以要么手动按打包格式算模,要么先把打包数据拆成交错再调库函数。点数不多时手动算更直观:
for (int i = 0; i < fftLen / 2; i++) { float re = out[i]; float im = out[i + fftLen / 2]; mag[i] = sqrtf(re * re + im * im); }注意只需要算前 N/2 个点,因为实数FFT的输出是对称的,后半段是前半段的镜像。
4.2 采样率、点数与频率分辨率的对应关系
这三个参数是绑死的:频率分辨率 = 采样率 / 点数。比如采样率10kHz、1024点,分辨率约9.77Hz。也就是说,两个频率相差小于约10Hz的成分,在这个配置下是分不开的。
做实验时我建议先反推:想看清1kHz正弦的峰值,采样率至少给到信号频率的2倍以上(奈奎斯特),实际工程里通常给5到10倍,取10kHz很合适。点数选1024,分辨率够用,RAM也扛得住。如果只是想快速验证链路,256点、采样率8kHz也能看到明显峰值,跑得更快。
还有个隐含条件:采样时长 = 点数 / 采样率。1024点@10kHz是102.4ms,意味着信号在这段时间内要相对稳定,否则频谱会糊。测稳态正弦没问题,测瞬态信号就得换思路了。
4.3 从ADC缓冲区到FFT输入的搬运
DMA填满ADC缓冲区后,通常用半满/全满中断来通知。我的做法是:DMA配成循环模式,缓冲区大小等于FFT点数,在全满回调里置一个标志,主循环检测到标志后把数据拷进FFT输入数组(或者直接对DMA缓冲区做变换,但要注意变换期间DMA可能正在写,得用双缓冲)。
双缓冲是更稳的方案:两个缓冲区交替,DMA写A的时候CPU处理B,处理完交换。G431的DMA支持这种模式,配置上稍微多几步,但能避免数据撕裂。实验阶段如果信号稳定,单缓冲也能凑合,但要知道这是有隐患的简化。
数据进FFT前还有一步常被忽略:去直流。ADC读数有偏置(比如1.65V对应中点),不去掉的话频谱在0Hz处会有一个巨大的峰,把其他成分压得看不见。简单做法是减去缓冲区均值,或者硬件上做隔直。软件减均值一行代码的事,但很多人第一次跑FFT看到0Hz大峰就懵了。
5. 实测结果与几个反直觉的细节
5.1 峰值位置对不上?先查这三处
跑通之后如果发现峰值频率和输入信号对不上,按这个顺序查:第一,采样率是不是真的等于你设定的值——用定时器触发的话,检查TIM的预分频和重装载值算对没有;第二,输入信号有没有超过奈奎斯特频率导致混叠,混叠后的峰值会出现在"折叠"后的位置,看起来毫无规律;第三,FFT点数和你实际填的数据长度是否一致,填了1024点但只采了512个有效值,剩下的补零会让峰值展宽、位置偏移。
我遇到过一次峰值偏了一倍的情况,最后发现是定时器触发频率算错,实际采样率是设定值的两倍。这种错误在代码里看不出来,只能靠示波器量触发信号或者用已知频率信号反推。
5.2 幅值不准是正常的,别急着怀疑库
FFT出来的幅值和时域幅值不是1:1的关系,受窗函数、点数、归一化方式影响。不加窗、整周期采样时,峰值幅值约等于时域幅值乘以N/2(单边谱)。所以看到峰值是输入幅值的几百倍不要慌,除一下就对上了。
如果要做精确幅值测量,得考虑加窗(汉宁窗常用)和幅值修正系数。实验阶段验证"峰值在不在正确频点"就够了,幅值精度是下一步的事。这个认知能省掉很多"是不是库用错了"的自我怀疑。
5.3 串口打印频谱的正确姿势
把1024个点的模值全打出来,串口会刷屏且难看清。实用做法是只打前N/2个点里的局部,或者做一个简单的峰值搜索:遍历找最大值及其索引,换算成频率打出来。想看形状的话,可以每隔几个点抽样打印,或者把模值转成简单的字符柱状图。
波特率别太低,115200打一千多个浮点数也要一会儿。调试阶段我一般先只打峰值频率和幅值,确认对了再扩展打印范围。
6. 几个能省下半天时间的实操习惯
第一个习惯:每次改CubeMX配置重新生成代码前,先把用户代码写在/* USER CODE BEGIN */和/* USER CODE END */之间。这个规矩老生常谈,但真有人图省事写在外面,一重新生成全没了,白干。
第二个习惯:FFT相关的大数组统一放一个单独的源文件里,用__attribute__((section(".ram")))或者干脆全局静态,集中管理内存占用。散落在各处的数组最容易在后期加功能时把RAM撑爆。
第三个习惯:先用仿真器在FFT输出数组上打个断点,看几个值是否合理(比如输入纯直流时,只有第0点明显非零),再上串口。断点看内存比串口打印快得多,也更能发现"数据根本没进来"这类问题。
第四个习惯:把采样率、点数、信号频率做成宏定义放在文件头,改参数只改一处。这个实验里参数联动关系强,散着写迟早改漏一个。
最后分享一个我自己的调试小技巧:如果怀疑DSP库没生效,可以先用一个极小的已知输入(比如8点、输入全是1)跑一次FFT,手算一下预期输出对比。8点FFT的直流分量应该是8(或归一化后的对应值),其他点为0。这个"最小可验证用例"能在几分钟内确认库调用链路是否正常,比直接上1024点瞎猜高效得多。