☰
STM32上FFT信号解析从原理到落地:采样率、窗函数与幅值换算全攻略
2026/10/14 2:25:00 网站建设 项目流程

简介:在嵌入式开发中,对ADC采集的信号进行频域分析是振动监测、音频处理、电力谐波检测等场景的常见需求。离散傅里叶变换(DFT)虽然数学定义清晰,但计算量大,难以直接在MCU上实时运行。快速傅里叶变换(FFT)利用旋转因子的周期性和对称性,将复杂度从N²降至NlogN,使STM32等资源受限设备也能胜任频谱解析。工程实现时,采样率、FFT点数、频率分辨率三者相互制约,还需借助CMSIS-DSP库完成高效变换,并通过窗函数抑制频谱泄漏,最后对幅值进行归一化换算才能还原真实电压。本文从AD采样、硬件触发到FFT调用、结果校正,系统梳理了在STM32上落地频谱分析的关键步骤,并针对工程中常见的底噪抬高、幅值减半、镜像峰等问题给出了排查思路,适合正在调试信号频域特性的开发者参考。

1. 在STM32单片机上使用傅里叶解析信号,为什么很多人卡在“FFT结果不会看”

拿到一块STM32,ADC能稳定采到波形了,下一步做傅里叶解析,很多工程师在这里卡住:FFT结果画出来,主峰频率对不上、幅值只有真实值的一半、频谱底部还有一圈毛刺。在STM32单片机上使用傅里叶解析信号,核心不是把DFT公式背一遍,而是把采样率、采样点数、窗函数、幅值换算这几件事按顺序做对。我按自己实际调通的顺序来讲:先说明为什么用FFT而不是直接算DFT,再给一套基于CMSIS-DSP的完整落地流程,最后列出最容易翻车的细节,以及用信号发生器把整套链路验证通的方法。这篇文章适合正在调ADC信号分析、却对频域结果半信半疑的开发者。

2. FFT为什么能在单片机上跑起来:从DFT到FFT的复杂度账与参数选型

2.1 直接算DFT为什么不行:复杂度账先算清楚

离散傅里叶变换的定义式看起来很简单,对N个采样点求频谱,需要给每个频率点做N次复数乘法,一共N个频率点,总计算量是N²量级。拿常用的1024点来说,就是大约105万次复数乘法,而且旋转因子还要实时计算或用表查。在STM32这种级别的MCU上,用纯C做这件事,既慢又把Flash和RAM占掉一大片,基本不具备工程意义。

FFT能落地,靠的是旋转因子的周期性和对称性,把DFT拆成多级蝶形运算,计算量降到(N/2)·log₂N。同样1024点,只需要大约5120次复数乘法,比直接算省了差不多200倍。这个复杂度账决定了:STM32上做傅里叶解析,选型几乎只有一条路——用FFT,而且优先调用现成的CMSIS-DSP库,而不是自己写蝶形算法。自己实现FFT不是不行,但位反转、旋转因子表、定点溢出这三个坑足以让调试周期翻倍。

内存账同样要紧。1024点复数FFT,输入缓冲区要放1024个实部和1024个虚部,float32类型就是8KB;再加上旋转因子表、幅值缓冲区,这一套下来十几KB RAM就没了。STM32F103这种中等容量的芯片,一个FFT任务就用掉相当一部分内存,所以点数不是想开多大就开多大。工程上常见做法是N取256、512、1024三档,很少直接上4096,除非芯片内存和CPU都足够宽裕。

2.2 采样率、采样点数和频率分辨率:先算清楚再写代码

在STM32上做傅里叶解析,开写代码前必须把三个参数定下来:采样率fs、FFT点数N、频率分辨率Δf。它们的关系只有一个公式:Δf = fs / N。含义是一帧数据能区分的最小频率间隔,也是频谱图上相邻两条谱线的距离。分辨率越细,越能区分靠得近的频率成分。

采样率由被测信号的最高频率决定。奈奎斯特定理只要求fs ≥ 2·fmax,但那是理论底线,工程上我一般取最高频率的4到10倍,给信号链路的带宽留足余量,也为前端抗混叠滤波留过渡带空间。下面的表格是几组常用参数,可以直接照着算。

采样率fsFFT点数N频率分辨率Δf一帧数据时长
8000 Hz25631.25 Hz32 ms
8000 Hz10247.8125 Hz128 ms
16000 Hz102415.625 Hz64 ms
6400 Hz25625 Hz40 ms

一帧数据时长T = N / fs,这决定了每次出频谱需要攒多久的数据。举个例子:电力谐波分析里基波50Hz,采样率取6400Hz,N取256,分辨率25Hz,那么50Hz落在第2条谱线,100Hz落在第4条,150Hz在第六条,各次谐波正好各占一个bin,一帧40ms刷新一次,实时性也够。反过来,如果要做音频频率计,想分辨1Hz的差别,8kHz采样率下N就得取8192,这时RAM和计算时间的压力一下子全上来了。所以参数之间是联动的,N不是孤立选的。

2.3 用CMSIS-DSP还是自己移植:选型理由与取舍

CMSIS-DSP是ARM官方为Cortex-M系列提供的数学库,里面已经包含完整FFT实现,接口分成三类:arm_cfft_f32是复数FFT,arm_rfft_f32是实数FFT(输入是纯实数,省一半内存和运算),定点场景还有Q15和Q31版本。STM32工程里集成这个库非常方便,在集成开发环境的软件包管理器里勾选CMSIS-DSP即可,不需要自己维护旋转因子表,也不用处理位反转的细节。

选float32还是Q15,要看芯片有没有FPU。带FPU的Cortex-M4F/M7,直接用arm_cfft_f32最省事,精度也最高;老一代不带FPU的内核,float32运算靠软件模拟,慢得明显,这时候Q15定点FFT反而更实际。Q15的代价是输入要归一化到[-1, 1],小信号动态范围受限,这个我后面避坑章节会专门展开。我的习惯是:优先float32,只有芯片实在跑不动时才降级到Q15,并且会在采样前端把信号幅度调理到ADC量程的70%以上,尽量把有效位用满。

3. 把ADC采样变成频谱:基于CMSIS-DSP的FFT落地流程与幅值换算

3.1 定时器触发ADC加DMA:先把采样均匀这件事做对

傅里叶解析对采样时刻的均匀性非常敏感。采样间隔若是靠软件里while循环控制,中断一来就抖一下,抖动的能量会均匀散布到整个频谱上,表现为底噪抬高、主峰底部变宽。所以我的固定做法是:定时器产生触发信号,硬件触发ADC转换,ADC结果由DMA直接搬进内存,整个过程不占用CPU,采样间隔由定时器的硬件分频决定,几乎不抖动。

/* 定时器触发ADC,目标采样率 fs = 8000 Hz */ /* 定时器时钟72MHz:72MHz / (PSC+1) / (ARR+1) = 8000 */ #define ADC_SAMPLE_RATE 8000 #define FFT_SIZE 1024 #define ADC_BUF_LEN FFT_SIZE #define TIM_PSC 7 /* 72MHz / 8 = 9MHz */ #define TIM_ARR 1124 /* 9MHz / 1125 = 8kHz */ uint16_t adc_raw[ADC_BUF_LEN]; /* DMA直接写入 */ volatile uint8_t fft_ready = 0; /* 采完一帧的标志 */

TIM_PSC和TIM_ARR的数值是从定时器时钟倒推出来的:先分频到9MHz,再计1125个周期,正好得到8kHz的触发频率。这里的PSC和ARR要按你自己芯片的总线时钟重新算,不要照抄。

/* ADC配置要点:定时器触发、DMA循环模式 */ ADC_HandleTypeDef hadc1; hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.ExternalTrigConvEdge = ADC_EXTERNALTRIGCONVEDGE_RISING; HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_raw, ADC_BUF_LEN);

DMA设置为循环模式,每采满1024个点,HAL库会触发传输完成回调,我在回调里只做一件事:置一个标志位,绝不在中断里做FFT。主循环检测到标志后,把adc_raw里的原始值送入FFT流程。这样做的原因是采样与计算异步,采集不丢点,主循环也不会被长时间阻塞。

3.2 调用arm_cfft_f32的最小代码骨架

CMSIS-DSP的复数FFT接口,输入缓冲区要求按“实部、虚部、实部、虚部……”交替存放。ADC采出来的是纯实数序列,所以要把每个实数值填到偶数下标,虚部全部填0。初始化一次,之后每帧数据到来只执行变换和求模两步。

#include "arm_math.h" float32_t fft_input[FFT_SIZE * 2]; /* 复数组:实虚交替 */ float32_t fft_mag[FFT_SIZE]; /* 幅值结果 */ arm_cfft_instance_f32 fft_inst; /* 初始化FFT:只做一次 */ arm_cfft_init_f32(&fft_inst, FFT_SIZE); /* 每帧数据处理 */ void process_fft_frame(void) { /* 把ADC原始值转成电压并装入复数缓冲 */ for (uint16_t i = 0; i < FFT_SIZE; i++) { fft_input[2 * i] = (float32_t)adc_raw[i] / 4095.0f * 3.3f; fft_input[2 * i + 1] = 0.0f; } /* 正变换,需要位反转 */ arm_cfft_f32(&fft_inst, fft_input, 0, 1); /* 求模:输出N个幅值,只用前N/2个有效频点 */ arm_cmplx_mag_f32(fft_input, fft_mag, FFT_SIZE); }

arm_cfft_f32的第三个参数是变换方向,0代表正变换,1代表反变换;第四个参数bitReverseFlag固定为1,由库内部完成位反转。初始化函数arm_cfft_init_f32会生成旋转因子表并占用一部分内存,所以只调一次,不要放在每帧的循环里。arm_cmplx_mag_f32对输入的复数组逐点求模,输出存到fft_mag,这一步之后fft_input里的原始复数仍然保留,后面算相位还要再用。

3.3 从FFT结果到电压幅值:最容易被漏掉的两行换算

FFT输出的幅值不是直接用,必须做量纲换算。我见过不少新人用fft_mag直接当幅值画图,结果峰高和信号发生器设置对不上,然后开始怀疑硬件。问题出在两点:第一,FFT本身没有归一化,每个频率点的幅值比真实分量大N倍;第二,实数信号的频谱是双边对称的,正负频率各占一半能量,看单边谱必须把另一半并回来。

float32_t f_axis[FFT_SIZE / 2]; float32_t amp[FFT_SIZE / 2]; for (uint16_t i = 0; i < FFT_SIZE / 2; i++) { f_axis[i] = (float32_t)i * ADC_SAMPLE_RATE / FFT_SIZE; if (i == 0) { amp[i] = fft_mag[i] / FFT_SIZE; /* 直流分量 */ } else { amp[i] = fft_mag[i] * 2.0f / FFT_SIZE; /* 交流分量 */ } }

换算规则只有两条:直流分量(第0条谱线)除以N就得到真实直流电压;交流分量先除以N再乘2,得到该频率分量的幅值。乘2是因为把负频率那一半能量合并回来了。这里的幅值指的是正弦波的幅值,不是峰峰值,和信号发生器输出的电压有效值对应关系要自己换算。相位同理可以取出来:

float32_t phase_deg[FFT_SIZE / 2]; for (uint16_t i = 0; i < FFT_SIZE / 2; i++) { phase_deg[i] = atan2f(fft_input[2 * i + 1], fft_input[2 * i]) * 180.0f / PI; }

相位是复数实部和虚部的反正切,单位是度。注意相位与采样起点对齐,如果关注的是相对相位差,比如三相电的相间角度,这个计算就够用。

4. 频谱泄漏、加窗与去直流:让谐波和幅值更接近真实测量值

4.1 频谱泄漏是怎么来的:非整周期截断与栅栏效应

FFT处理的一帧数据相当于把一个无限长信号硬截成一段。如果信号频率不是频率分辨率的整数倍,这一帧的首尾波形不连续,截断产生的跳变会在整个频谱上散布能量,主峰旁边出现旁瓣,低频段和小幅值谐波容易被淹没。这就是频谱泄漏,它不是软件bug,而是有限长数据做傅里叶变换的固有代价。同时,由于频谱是离散的谱线,信号真实频率落在两条谱线之间时,只能看到附近若干根谱线的能量分布,这被称为栅栏效应。

窗函数主瓣宽度第一旁瓣衰减适用场景
矩形窗2Δf-13 dB整周期截断或瞬态测量
汉宁窗4Δf-31 dB大多数连续信号频率分析
海明窗4Δf-43 dB幅度精度要求较高时
布莱克曼窗6Δf-58 dB强旁瓣抑制、弱信号检测

矩形窗其实就是不加窗。当采样率能被信号频率整除且采样时长正好包含整数个周期时,矩形窗没问题;但实际中频率常常不是整周期,所以我默认给信号加汉宁窗,主瓣宽度只增大到4Δf,旁瓣从-13dB压到-31dB,代价是幅度测量时要多乘一个恢复系数。

4.2 汉宁窗在STM32上的具体写法

汉宁窗每个采样点的权重是0.5减去0.5乘以一个余弦。在STM32上可以直接调用CMSIS-DSP提供的arm_cos_f32,比标准数学库的cos更快,精度也够用。

#define PI 3.14159265358979f for (uint16_t i = 0; i < FFT_SIZE; i++) { float32_t w = 0.5f - 0.5f * arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1)); float32_t v = (float32_t)adc_raw[i] / 4095.0f * 3.3f; fft_input[2 * i] = v * w; fft_input[2 * i + 1] = 0.0f; }

窗函数提前算好存成表也行,但1024个float32就是4KB,放在RAM里有点心疼;运行时用arm_cos_f32计算,代价是每次多几微秒。代码里的分母是FFT_SIZE - 1,这是汉宁窗的周期定义,不要错写成FFT_SIZE,否则窗形会整体偏掉。加窗之后,主瓣变宽的意义是:两个频率相差不到4Δf的信号会融合成一个峰,这在选择N的时候就要考虑到。

4.3 去直流与幅值修正:0Hz分量和窗函数恢复系数

直流分量在频谱里是第0条谱线,如果信号里含有一个较大的直流偏置,它会泄漏到很宽的低频范围,把低频成分盖住。对ADC信号来说,传感器往往叠加了工作点偏置,这个直流必须先去干净。常见做法是在加窗前先减均值:

float32_t mean = 0.0f; for (uint16_t i = 0; i < FFT_SIZE; i++) { mean += (float32_t)adc_raw[i] / 4095.0f * 3.3f; } mean /= FFT_SIZE; for (uint16_t i = 0; i < FFT_SIZE; i++) { float32_t v = (float32_t)adc_raw[i] / 4095.0f * 3.3f - mean; float32_t w = 0.5f - 0.5f * arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1)); fft_input[2 * i] = v * w; fft_input[2 * i + 1] = 0.0f; }

减均值必须放在加窗之前。窗函数本身有直流分量,如果先加窗再减均值,会把原始直流的一部分泄漏到低频谱线上。加窗之后幅值要修正:汉宁窗把主峰幅值压低到原来的一半,所以在前面那套单边谱换算公式基础上,还要再乘2。最终公式是:amp[k] = fft_mag[k] × 2 / FFT_SIZE × 2,两个乘2别合并错了,第一个是单边谱合并,第二个是窗函数恢复系数。

5. STM32傅里叶解析避坑清单:5个最容易翻车的细节

5.1 频谱底噪明显抬高、主峰底部变宽

现象:信号发生器灌一个干净正弦,理论上频谱应该只有一根细峰,实际却出现主峰两侧的裙边,底噪整体抬起来。

原因:采样时刻不均匀。用while循环里查询定时器标志、再调用ADC转换的方式,中断和代码执行的随机延迟都会让采样间隔抖动,抖动在频域就是宽带噪声。

解决:改用定时器TRGO触发ADC,DMA循环搬运。定时器触发是硬件行为,间隔由分频系数决定,抖动极小。缓冲区采满一帧再由DMA中断置标志,软件只在数据就绪后做FFT,这条路基本能把采样抖动问题根除。

5.2 幅值正好偏一半,频率却是对的

现象:1V幅值的正弦波,FFT峰值谱线高度显示0.5V,但峰值所在bin的频率和设定值完全一致。

原因:忘了做单边谱合并。实数信号的FFT结果是双子谱,正负频率各分一半能量,直接除以N只能得到0.5V。

解决:交流分量用amp[k] = fft_mag[k] * 2.0f / FFT_SIZE换算。这个系数很多人记不住,我的办法是把换算写成独立函数,每次改完代码都先用信号发生器验证一次单频幅值,确认无误后再往下做。

5.3 采样点数一加大,主循环明显卡顿

现象:把FFT点数从256加到1024,程序主循环节奏明显变慢,LED闪烁频率都能看出来变化。原因:ADC这一侧用了阻塞式读取,for循环里逐点等待转换完成,加上FFT又在主循环里同步执行,CPU长时间被占住。

解决:ADC数据采集全部交给DMA,FFT只在新一帧数据就绪后执行一次。1024点float32 FFT在不带FPU的旧款Cortex-M系列上确实要算好几毫秒,但这和采样是异步的,不会拖累采集。如果还要继续压实时性,就做流水线:DMA双缓冲,半传输和全程传输交替,交替处理两帧数据,等效于刷新率翻倍。

5.4 Q15定点FFT下小信号频谱被吞掉

现象:用Q15版本的FFT,信号幅度较大时频谱正常,把输入信号调小后,频谱上的峰消失或变形。

原因:Q15把输入映射到[-1, 1]范围,ADC的12位分辨率本身有效位不够,再经过Q15定点运算,小信号量化误差直接淹没真实频谱。定点FFT的动态范围和float32差距明显。

解决:优先选float32版本,前提是芯片有FPU或对耗时不敏感。如果必须用Q15,先把模拟前端放大,把信号幅度压到接近满量程,再用软件归一化到合适区间。不要指望定点FFT能从微弱信号里挖出主峰,动态范围是硬限制。

5.5 频谱里冒出一个不存在的镜像峰

现象:采样率8kHz,分析一个音频信号,却在接近4kHz的位置出现一个和真实主峰对称的峰,而且改变采样率时这个峰的位置也跟着变。

原因:频谱混叠。输入信号里含有超过fs/2的频率成分,ADC采样时这些高频成分被折叠回低频区,镜像峰是高频信号伪装的。滤波得在模拟端做,软件里没法挽回。

解决:给ADC前端加一级无源RC低通,截止频率设在fs/2附近或略低于fs/2。RC低通会带来阻抗和带宽问题,实际用的时候要测一下带内衰减,再在软件里做增益修正。若信号源阻抗较高,先加一个运算放大器做缓冲和放大,再接RC滤波到ADC引脚。

6. 进阶:滑窗FFT实时刷新与峰值频率插值校正

6.1 滑窗FFT:每次只挪一截,刷新率大幅提升

整帧采集再处理的方式,两次频谱之间的间隔等于一帧时长。频率分辨率要求高、N又大的时候,刷新率会很难看。滑窗FFT的思路是:旧数据大部分保留,只移走一截,再补一截新数据,这样每帧只等待一小段时间就能更新一次频谱。

/* 帧移取N/4,即每次滑动256个采样点 */ #define FRAME_SHIFT (FFT_SIZE / 4) memmove(fft_input, &fft_input[2 * FRAME_SHIFT], (FFT_SIZE - FRAME_SHIFT) * 2 * sizeof(float32_t)); for (uint16_t i = 0; i < FRAME_SHIFT; i++) { fft_input[2 * (FFT_SIZE - FRAME_SHIFT + i)] = new_sample[i]; fft_input[2 * (FFT_SIZE - FRAME_SHIFT + i) + 1] = 0.0f; }

帧移越小,频谱刷新越快,但连续两次FFT结果的重叠度越高,计算量也越大。N/4是我的默认值,N/8更平滑但CPU压力更大。注意memmove是按复数元素个数移动的,下标要乘2,这个细节错一次就白忙半天。

6.2 抛物线插值:把峰值频率校准到真实值

栅栏效应导致真实频率落在两条谱线之间时,峰值谱线只能指示最近的整数bin,误差最大可达半个Δf。用峰值点及其左右两个点的幅值做抛物线插值,可以把频率误差缩小到Δf的几十分之一。

/* k是峰值bin下标,mag来自fft_mag */ float32_t delta = 0.5f * (fft_mag[k - 1] - fft_mag[k + 1]) / (fft_mag[k - 1] - 2.0f * fft_mag[k] + fft_mag[k + 1]); float32_t freq_est = ((float32_t)k + delta) * ADC_SAMPLE_RATE / FFT_SIZE;

delta表示真实峰相对整数bin的偏移量,理论范围在[-0.5, 0.5]之间,算出来超出这个范围说明峰值选错了或旁瓣太强。这个方法对加窗后的频谱尤其有效,汉宁窗的抛物线形状和插值模型非常契合。我一般配合前面那套幅值公式一起用,频率和幅值同时输出。

6.3 用一个信号发生器把整套链路走通

验证FFT链路正确与否,最快的方法不是拿真实设备试,而是用信号发生器灌一个已知信号。步骤固定下来,之后每次改参数都能复用:

  1. 信号发生器输出1kHz正弦、1V幅值,接入ADC通道,先把幅值换成电压读数确认AD正确。
  2. 8kHz采样率、1024点FFT、Δf约7.8125Hz,找到最高峰所在bin,应当接近128。
  3. 读amp[128]的幅值,应该和信号源的幅值一致,误差在ADC量化精度范围内。
  4. 叠加一个500Hz的低幅值信号,确认两个峰都能分辨且幅值比例正确。
  5. 最后关掉信号源,把ADC输入接地,观察底噪水平,判断是否需要加窗去直流。

这一套走完,采样、FFT调用、幅值换算、加窗四个环节都被验证到位。我现在的习惯是:每次动到采样率或FFT点数,先把第1步做一遍,确认峰值bin和幅值都对,再继续加窗、插值这些进阶处理。第一次把幅值算错时,我一度怀疑是信号源和硬件问题,查了半天才发现是换算系数漏了一项;自那以后,单频验证就成了我调试FFT的默认起点。希望帮到你。

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

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

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

立即咨询