拿到STM32G431这颗料,很多人第一反应都是数字电源、电机控制,毕竟片上CORDIC和FMAC就是为这些场景准备的。但我今天不讲FOC,讲另一条同样高频的路线:在这颗主频170MHz的Cortex-M4F上跑DSP库,把手头ADC采到的波形数据做FFT频谱分析。FFT这件事听起来不复杂,网上一搜一摞教程,但等你真正从CubeMX生成工程、添加CMSIS-DSP、把第一帧频谱打上串口,中间踩的坑可能比预想的多好几倍。
这篇文章从零开始,完整走一遍STM32G431的DSP库集成与FFT落地流程。内容覆盖两件事:一是DSP库到底怎么正确集成,二是一次真实FFT实战从采样链路到频谱输出的全过程。我会把踩过的坑、排查思路、参数计算方式都摊开讲,适合刚接触STM32G4系列、想用片上DSP能力做信号分析的朋友,也适合被各种“找不到头文件”“频谱一片乱”问题折磨的同学直接抄作业。
1. 为什么选STM32G431做FFT?先把家底盘清楚
1.1 这颗芯片的DSP底子到底强在哪
STM32G431是ARM Cortex-M4F内核,带单精度硬件浮点单元FPU,主频最高170MHz。M4内核本身就有DSP指令集扩展,支持单周期乘法累加MAC指令,再配合FPU,跑CMSIS-DSP库里的浮点FFT计算是有硬基础的。这跟老一代M0/M3芯片软浮点硬算,完全是两个概念。
更关键的是,G4系列相比传统F4系列多了几个专为信号处理设计的硬件模块:CORDIC协处理器和FMAC滤波数学加速器。CORDIC可以硬件算正弦、余弦、反正切、模长,这对FFT做完之后的幅值和相位计算有很大的帮助;FMAC则适合FIR滤波器的乘加流水线操作。虽然在跑FFT蝶形运算时,主要靠还是CPU核加FPU,但整个信号链路在G431上是成体系的:高精度ADC采样、运放调理、硬件三角函数加速、浮点FFT处理,可以全片搞定。
另外,G431的ADC资源也很能打。它有5Msps采样率,支持差分模式,采样保持时间可调,还有过采样硬件平均。这对FFT很重要,因为频谱分析的效果上限由ADC的信噪比和采样精度决定。你用5Msps的ADC去采音频、振动、电源纹波信号,完全够用。
相比F4系列,G431的功耗和价格也没有劣势,市场上Nucleo-G431RB、G431核心板都很便宜,适合拿来折腾。很多DIY示波器项目,包括DSO138这类经典套件的玩家自制FFT固件,底层芯片用的就是STM32G431这一代的M4F芯片,也是看中了它的ADC速度和硬件浮点能力。
1.2 从零到一前,先把工具链和硬件准备妥当
动手之前,先把家当列清楚。硬件部分最基础的三样:一块STM32G431开发板,一个ST-Link调试器(板载一般都有),以及一个信号源。信号源不一定非要函数发生器,用另一块单片机产生正弦波,或者直接用G431的DAC输出扫频信号,都能做FFT验证。没有信号源的话,摸一下ADC引脚引入50Hz工频噪声都能看到频谱峰,只是不好确认幅值准不准。
软件部分就三件套:STM32CubeMX用于图形化配置时钟和ADC/DMA,IDE用STM32CubeIDE或者Keil MDK都可以,再加上CMSIS-DSP库。这里有个我反复强调的点:工程配置第一步就要确认FPU是否打开。CubeMX生成的工程默认在SystemInit里会开启FPU访问权,编译参数也会自动带上硬浮点选项;但如果你之后用STM32CubeMX重新生成代码,覆盖了启动文件或者手动移植到别的工程模板,FPU没有打开,一跑到浮点运算就会HardFault。这个坑后面章节会细说。
动手顺序上,我强烈建议不要一上来就写FFT代码。先把LED点亮,再用串口打印一个变量,然后让ADC连续采集并通过DMA搬运数据,最后再来做FFT。每一步都验过,最后合在一起时排查范围会小很多。我见过太多人一次性把整个工程写完,然后分不清是DMA没配好还是FFT用法有问题。
2. DSP库集成:卡住90%新手的第一个坎
2.1 两种集成方式如何选
DSP库的集成方式网上说法五花八门,但归根到底就两条路:CubeMX图形化集成,或者手动把CMSIS-DSP源码和头文件放进工程。
先说CubeMX方式。在STM32CubeMX里选择芯片型号后,左侧中间件栏有一个DSP库选项,勾上之后,生成的工程会自动包含CMSIS-DSP的库路径,同时还配置好相应的预定义宏。用STM32CubeIDE打开工程时,头文件路径、库路径都已经给你配齐了。这种方式最适合用IDE内置工具链的朋友,省心。
再说手动方式。CMSIS-DSP的源码和预编译库可以从ARM官方GitHub仓库下载,也可以从Keil的Pack目录下找到。常见路径是CMSIS/DSP/Include放头文件,CMSIS/DSP/Source放源码,CMSIS/DSP/Lib/ARM放预编译好.lib库。手动方式需要自己做三件事:把Include路径加入编译搜索路径,把库文件(或需要的Source下的.c文件)加入工程,然后在编译选项或源码里定义相应的CMSIS-DSP宏。好处是可控性强,坏处是路径和宏容易配错,许多莫名其妙的编译错误都是在这层出的。
我的建议是:如果你刚开始接触,直接走CubeMX方式;如果后续需要对DSP库源码做改动、裁剪或调试,再切换到手动方式。实际上,CubeMX方式背后也是把官方库放进了工程,只是它把路径和宏替你处理好了。
2.2 集成必做的三个关键设置
不管用哪种方式集成,有几个设置不做好,后面必出问题。
第一个是FPU编译选项。在Keil MDK中打开Options for Target,在Target标签页的Floating Point Hardware下拉框里选择Single Precision。在STM32CubeIDE中,代码生成时会自动加上-mfpu=fpv4-sp-d16 -mfloat-abi=hard参数,但如果你换了编译器或手动建工程,就要检查这两项。FPU没开或者开了不匹配,DSP库里的浮点FFT函数跑起来会出现异常甚至HardFault。
第二个是CMSIS-DSP宏定义。打开arm_math.h本质上是根据芯片内核选择不同的实现路径,Cortex-M4需要定义ARM_MATH_CM4,同时如果你的工程启用了硬件浮点,建议一并定义ARM_MATH_CM4和ARM_MATH_ROUNDING。在Keil的C/C++选项卡里,可以直接把这些宏加到Define字段;在STM32CubeIDE中,可以在工程属性的预处理器设置里添加。宏没定义或者定义成M3、M7的型号,轻则编译报"函数未定义",重则因为条件编译走错了分支导致代码量暴涨或行为异常。
第三个是库文件或源码的加入。Keil用户可以去Manage Run-Time Environment里勾选CMSIS-DSP组件,选择Source选项而不是Library选项,这样工程会自动加入需要的源文件。这么做的好处是,不会出现AC5编译器和AC6编译器对.lib库兼容性的问题。STM32CubeIDE用户如果走CubeMX集成,一般已经自动加好了libarm_cortexM4lf_math.a,不需要手动处理。如果你坚持使用预编译库,注意Cortex-M4小端硬件浮点的库文件名一般长这样:arm_cortexM4lf_math.lib,文件名里有f代表FPU,l代表小端,选错了也一样会出链接错误。
2.3 验证库是否干活:跑通首个FFT测试
库集成完,先别接ADC,写一段纯数学的FFT测试函数。目的很简单:验证DSP库能不能正确编译链接,FFT函数能不能跑出正确的频谱。
我习惯生成一个已知频率的正弦波测试数据,比如8000Hz采样率下,生成1000Hz和3000Hz两个频率叠加的正弦信号,然后做1024点复数FFT,把频谱幅值通过串口打印出来。如果DSP库没问题,在bin索引128(1000Hz/8000Hz1024)和384(3000Hz/8000Hz1024)附近应该能看到两个明显的峰。
下面是一段可直接用的验证代码:
#include "arm_math.h" #include "stdio.h" #define FFT_SIZE 1024 #define SAMPLE_RATE 8000.0f // 复数FFT的输入输出缓冲区,实部虚部交织存储 float32_t testInput[FFT_SIZE * 2]; float32_t testMag[FFT_SIZE]; arm_cfft_instance_f32 fftInst; void initTestSignal(void) { for (int i = 0; i < FFT_SIZE; i++) { float32_t t = (float32_t)i / SAMPLE_RATE; testInput[2 * i] = 0.8f * arm_sin_f32(2.0f * PI * 1000.0f * t) + 0.5f * arm_sin_f32(2.0f * PI * 3000.0f * t); testInput[2 * i + 1] = 0.0f; // 虚部置零 } } void runFFTTest(void) { // 初始化FFT实例 arm_status status = arm_cfft_init_f32(&fftInst, FFT_SIZE); if (status != ARM_MATH_SUCCESS) { // 初始化失败,检查头文件和库版本 while (1); } initTestSignal(); // 第一个参数是实例指针,第二个是数据缓冲区, // 第三个ifftFlag填0表示正变换,第四个bitReverseFlag填1要求位反转输出 arm_cfft_f32(&fftInst, testInput, 0, 1); // 计算幅值谱,输入是复数交织数据,输出为对应频点幅值 arm_cmplx_mag_f32(testInput, testMag, FFT_SIZE); // 打印前64个频点,验证1000Hz对应bin=128,3000Hz对应bin=384 for (int i = 0; i < 64; i++) { printf("bin %03d: %.2f\r\n", i, testMag[i]); } }这段代码有两个细节值得说明。第一,CMSIS-DSP的浮点FFT输入要求复数按“实部、虚部、实部、虚部”交织存储,所以缓冲区长度是FFT_SIZE * 2。纯实数信号也得把虚部填0,不能偷懒。第二,arm_cfft_f32第三个参数ifftFlag为0时做正变换,第四个参数bitReverseFlag位反转必须置1,否则频谱输出顺序是乱的。很多人第一次跑出来结果不对,就是在这里填反了。
纯数学验证跑通之后,再往工程里接ADC采样数据,你已经确认了DSP库本身没问题,后续可以把注意力全部集中在采样链路上。
3. FFT实战落地:从AD采样到频谱显示全流程
3.1 采样链路设计:定时的艺术
FFT最核心的假设是等间隔采样。如果采样点之间的时间间隔抖动,频谱上会出现大量的杂散分量。很多人的FFT结果“脏”得没法看,根本原因不是FFT函数用错了,而是ADC采样时序就不靠谱。
最稳的采样方式不是用一个定时器查询+读ADC,而是用定时器硬件触发ADC转换,再由DMA把结果自动搬运到内存。这样CPU不干预采样节奏,采样间隔严格由定时器决定,抖动小到可以忽略。这套链路里,定时器频率就是采样率。
具体配置思路如下:
- 用TIM2产生触发事件,周期对应目标采样率;
- ADC1选择定时器触发作为启动源,配置规则通道;
- DMA配置为循环模式,把ADC数据持续搬运到内存数组;
- 数组大小等于FFT点数。
假设目标是采样率Fs = 40960Hz。STM32G431的APB1定时器时钟通常是170MHz,那么定时器参数就是(PSC+1) * (ARR+1) = 170000000 / 40960,大致等于4150。取PSC = 49,ARR = 82,正好是50 * 83 = 4150,触发频率就是40960Hz。这种参数精度在FFT里够了。注意,谱线分辨率是Fs / N,如果后面发现分辨率不够,优先调整的是采样率,而不是盲目增加点数。
DMA部分,我建议配置成循环模式,并且使用双缓冲(double buffer)。所谓双缓冲,就是在内存中开两个FFT点数的缓冲区,ADC先把数据填满缓冲区A,DMA自动切到缓冲区B继续填,同时CPU处理缓冲区A里的数据;等CPU处理完,DMA可能也快填满B了,再等DMA传输完成中断切换回来。这样采样的数据不会丢,FFT的计算时间也被隐藏在了下一帧的采样时间里。
3.2 FFT数据处理:频率分辨率与窗函数选择
数据从DMA缓冲区里出来是原始的ADC码值,比如12位精度就是0到4095的整数。做FFT之前要把这些整数转换为浮点电压值,并去掉直流分量。
直流分量是FFT结果里巨大的一坨。假如信号本身是单极性ADC采样,有效值围绕1.65V跳动,如果不减掉这个偏置,0频附近会有一个直径很大的峰,把其他小信号全“淹没”了。处理办法很简单:先算整帧原始数据的平均值,然后每个点减去这个平均值。平均值其实就是该帧的直流分量,对稳态信号来说,这也是这段时间内的信号中位值。
接着是窗函数。CMSIS-DSP库本身不提供窗函数,需要自己用循环乘上去。窗函数的用处是减少频谱泄漏。FFT假设你分析的整段信号恰好是周期的整数倍,但实际上往往不是,截断处会产生不连续,能量就会从主峰漏到旁边的频点。加一个汉宁窗可以把信号两端平滑到接近0,让截断处连续起来,泄漏就少了。
汉宁窗的代码实现很直接:
float32_t window[FFT_SIZE]; for (int i = 0; i < FFT_SIZE; i++) { window[i] = 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); } // 然后在帧处理时: for (int i = 0; i < FFT_SIZE; i++) { fftInput[i] = (adcFloat[i] - dcOffset) * window[i]; }加窗的代价是幅值精度下降,但换来了更干净的主瓣和更小的旁瓣。如果做的是稳态信号幅值测量,可以选平顶窗;如果主要看谐波频率位置,汉宁窗是通用首选。
3.3 性能估算:170MHz能跑到什么程度
很多人关心G431上做FFT到底能跑多快、能不能实时。换算一下心里就有数了。
CMSIS-DSP的浮点FFT是基于汇编级的蝶形运算优化过的,Cortex-M4F内核单周期乘加,170MHz主频。以1024点为例,一次复数FFT大概在0.5ms到1ms之间,具体跟编译器优化等级和是否使用硬件浮点有关。4096点大约3到5ms,256点大约0.1ms。如果配合ADC 40960Hz采样,采集1024点需要25ms,这个时间里做一次1024点FFT绰绰有余,甚至还能做点别的。
我列一个我实测压测的参考表:
| FFT点数 | 浮点FFT耗时(约) | 单片RAM占用(float32复数缓冲) | 频率分辨率(Fs=40960Hz) |
|---|---|---|---|
| 256 | 约0.10ms | 2KB | 160Hz |
| 1024 | 约0.63ms | 8KB | 40Hz |
| 4096 | 约3.20ms | 32KB | 10Hz |
注意,4096点的时候RAM占用已经非常紧张了。STM32G431内部RAM是32KB,光FFT复数输入缓冲就吃掉32KB,几乎没空间放其他东西。这种场景就要考虑分段处理、压缩数据类型或者换芯片。对绝大多数频谱分析应用,1024点是一个性价比非常高的选择。
如果觉得浮点FFT内存压力大,CMSIS-DSP还提供Q15和Q31定点FFT,存储占用是浮点的一半,速度也更快。但定点FFT的动态范围有限,数据需要归一化到[-1, 1],否则会溢出。对于12位ADC的原始数据,Q15定点一般够用。我的建议是:仿真和原型验证阶段用浮点,方便排查逻辑;到了资源受限的产品阶段再考虑定点化。
4. 避坑指南:我把踩过的坑都给你标出来了
4.1 链接与编译期的坑
这个阶段最大的坑就是编译通过但链接报错,最常见的字样是undefined symbol: arm_cfft_f32或者arm_math.h: No such file。
arm_math.h找不到基本是头文件路径问题。手动集成时最容易犯的错是只把CMSIS/DSP/Include加进了搜索路径,却忘了CMSIS的Device头文件路径。但更隐蔽的是宏定义没加ARM_MATH_CM4,导致arm_math.h里的条件编译跳到了错误的分支,同样会报找不到内部头文件或函数声明不一致。
链接阶段报undefined symbol,就要检查库文件是否有加入工程。CubeMX方式一般不会犯这个错;手动方式最容易踩的是下载了库源码但只加了头文件,源文件没有参与编译。另外,预编译库的选型要和编译器匹配。用ARM Compiler 5,选arm_CortexM4lf_math.lib通常没问题;用ARM Compiler 6,最好直接用源码方式编译,AC6对新库的静态库兼容性不如源码方式稳。
还有一个我遇到过的冷门问题:库路径里不能出现中文或者带空格的目录。有些IDE在绝对路径含中文时会解析失败,报一些看起来跟DSP库完全无关的编译错误,非常坑。把工程放到纯英文路径下能省掉很多事。
4.2 运行期结果不对的坑
编译链接都过了,跑起来也进入main了,可FFT结果就是不对劲。常见症状很多,我这里挑几个典型的。
第一个症状是频谱图里全是接近满幅度的乱码。这种情况十有八九是输入缓冲区里的数据一开始就是不正常的。我踩过最蠢的一个坑:ADC是12位数据,初始值全为0,却忘了等待DMA填满缓冲区再做FFT,结果是对着一堆0做变换,自然什么都解不出来。正确做法是等DMA传输完成标志置位后再处理。
第二个症状是主峰位置差了半格或几格。这通常是信号不是整周期截断导致的频谱泄漏,主峰向旁边泄漏。解决方式是加窗,或者调整采样率让信号频率正好落在某个bin上。还有一种情况是索引算错,bin索引对应的频率是k * Fs / N,从0开始,很多人把第1个bin当成1Hz,实际它对应的可能是40Hz。
第三个症状是DC分量巨大。刚才已经说过,ADC单极性信号如果不去直流,0频那个峰能大到几百。去直流之后,1000Hz正弦波会老老实实出现在bin=25(采样率40960Hz,1000Hz对应1000 / 40 = 25)上。
第四个症状是幅值看起来很小或很大,怎么都对不上输入信号。这里要理解CMSIS浮点FFT输出幅值的物理含义。对一个正弦波,频域峰的模长大约是时域幅值的N/2倍。换句话说,时域幅值1V的正弦波,1024点FFT后对应的谱线幅值大约是512。所以换算回去:signalAmplitude = mag / (FFT_SIZE / 2)。如果你输入数据是ADC原始码值,而不是归一化电压,那幅值就更没有意义了,必须先转换成实际物理单位。
4.3 性能与存储的平衡策略
G431内部RAM就32KB,1024点浮点FFT的输入缓冲是8KB,输出幅值4KB,再加上ADC DMA缓冲、显示缓冲、虚拟示波器里的波形缓冲,内存就会变得很紧张。这里有几个我总结下来的经验。
第一个经验是能用arm_rfft_fast_f32就不用arm_cfft_f32。实数信号用实数FFT,输入只有N个值,而复数FFT要N个实部加N个虚部,内存直接翻倍。CMSIS-DSP专门为实信号提供了arm_rfft_fast_f32,性能约提升40%。不过要注意它的输出格式不是整齐的N/2个复数,前两个点是直流和奈奎斯特频率分量,后面才是正频率部分的实部虚部交错。实际取幅值时,可以单独把直流和奈奎斯特抽出来,也可以用arm_cmplx_mag_f32从输出数组第二个位置开始计算。
第二个经验是ADC采集缓冲区的格式。DMA搬运过来的数据是uint16_t数组,直接参与FFT不行,要转成float32_t。转换本身不便宜,尽量不用浮点除法逐点算。我用过一个取巧方式:先把ADC码值减去中心值,再乘以一个缩放系数。中心值是4096/2=2048,缩放系数是3.3V / 4096的浮点值。整个转换循环里只有一次乘法,避免频繁的浮点除法。
第三个经验是大数组的放置位置。G431的RAM没有分多个bank,但DMA缓冲区、FFT缓冲区如果都放在大数组里,要小心链接器的内存分配。一旦RAM溢出,链接器会报错。真到临界情况下,可以把DMA缓冲区和FFT输入缓冲共用一块内存,反正ADC数据转换完就没用了,可以原地覆盖。
还有一点关于优化等级。正式编译时建议打开-O2或-O3。同一个FFT在O0和O3下,耗时能差出两三倍。调试时用O0方便单步,跑性能测试的时候记得切到O3再测。
5. 常见问题排查速查表与方案选型对比
5.1 问题排查速查表
把各种症状和排查方向汇成一张表,照着查最快:
| 症状 | 先查什么 | 关键词 |
|---|---|---|
| 编译报 arm_math.h 找不到 | 头文件搜索路径、宏定义ARM_MATH_CM4 | 头文件、路径 |
| 链接报 undefined symbol: arm_cfft_f32 | DSP库源码或预编译库是否加入工程 | 链接、库 |
| 跑起来直接HardFault | FPU是否启用,浮点数组是否4字节对齐 | FPU、HardFault |
| 频谱一片乱没有明显峰 | ADC/DMA是否真正填满了缓冲区,先确认采样波形 | 采样、DMA |
| 输出峰在0频附近特别大 | 输入数据是否去掉了直流分量 | 直流、偏置 |
| 峰位置对不上 | bin索引换算是否用Fs/N,是否加窗 | 分辨率 |
| 采样率越高越乱 | 定时器触发频率是否算错,ADC时钟是否超规格 | 混叠 |
| 多条谱线都长一样 | 输入信号本身是否正常,可能信号源饱和或ADC采样时间过短 | 信号源 |
| FLASH或RAM溢出 | FFT点数是否过大,是否同时开了多个大缓冲 | 资源 |
5.2 MCU做FFT vs FPGA做FFT:什么时候不用折腾
写了这么多STM32上做FFT的内容,最后聊一个经常被问到的话题:为什么不用FPGA做FFT?网上到处是Vivado里的FFT IP核,有人折腾FPGA FFT做高速频谱分析。甚至有人遇到“FFT IP核无法设置小数时钟输入”这种头痛问题。FPGA方案确实适用于需求极高并行的场景,比如连续流式FFT、上千通道的频谱分析,那是MCU做不到的。但反过来看,FPGA开发周期长、调试成本高、外围电路复杂,对大部分几十kHz级别的信号分析,比如音频频谱、电机振动诊断、电源纹波测量,STM32G431的DSP库方案已经绰绰有余,而且片上自带了ADC、运放、DAC,一块芯片就能闭环。
选择时可以按这三点判断:
- 采样率小于几百kHz,FFT点数不超过4096,优先MCU方案;
- 需要严格实时连续流水处理,吞吐量要求极高,再考虑FPGA;
- 纯粹学习FFT和信号处理,MCU方案成本低、上手快,远好过先啃一堆IP核时序约束。
单片机方案还有一个额外的优势:它有完整的CMSIS生态和现成的DSP库函数,外设驱动、调试工具链都成熟。等到真的上了FPGA,你会发现多出来的是时序约束、IP核版本兼容、AXI接口这些新问题,而不是信号处理本身的问题。
我个人在实际操作中最大的体会是:FFT本身只是工具箱里的一个函数,真正决定结果好坏的是采样链路和数据处理习惯。把ADC触发时序搞稳、把直流去掉、把窗加上、把bin换算做对,这四件事做好了,频谱自然清晰。最后再分享一个小技巧:Debug阶段一定要准备一个已知频率和幅度的正弦信号源,比如用G431的DAC直接输出一个1kHz正弦波,送到ADC引脚,整个FFT链路对不对,一眼就能从频谱峰的位置和高度判断出来。这个办法帮我排查过无数次“感觉哪错了但不知道错在哪”的疑难杂症。后续如果你想做得更深入,可以把CORDIC硬件加速用起来,用片上DAC输出扫频信号,再配合FMAC做前端滤波,那就是一套完整的信号分析前端了。