资讯动态

STM32上FFT信号解析从原理到落地:采样率、窗函数与幅值换算全攻略

发布时间:2026/10/14 2:25:03 来源:尧图企业网站定制
简介在嵌入式开发中对ADC采集的信号进行频域分析是振动监测、音频处理、电力谐波检测等场景的常见需求。离散傅里叶变换DFT虽然数学定义清晰但计算量大难以直接在MCU上实时运行。快速傅里叶变换FFT利用旋转因子的周期性和对称性将复杂度从N²降至NlogN使STM32等资源受限设备也能胜任频谱解析。工程实现时采样率、FFT点数、频率分辨率三者相互制约还需借助CMSIS-DSP库完成高效变换并通过窗函数抑制频谱泄漏最后对幅值进行归一化换算才能还原真实电压。本文从AD采样、硬件触发到FFT调用、结果校正系统梳理了在STM32上落地频谱分析的关键步骤并针对工程中常见的底噪抬高、幅值减半、镜像峰等问题给出了排查思路适合正在调试信号频域特性的开发者参考。1. 在STM32单片机上使用傅里叶解析信号为什么很多人卡在“FFT结果不会看”拿到一块STM32ADC能稳定采到波形了下一步做傅里叶解析很多工程师在这里卡住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 ms8000 Hz10247.8125 Hz128 ms16000 Hz102415.625 Hz64 ms6400 Hz25625 Hz40 ms一帧数据时长T N / fs这决定了每次出频谱需要攒多久的数据。举个例子电力谐波分析里基波50Hz采样率取6400HzN取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是复数FFTarm_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 */ /* 定时器时钟72MHz72MHz / (PSC1) / (ARR1) 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触发ADCDMA循环搬运。定时器触发是硬件行为间隔由分频系数决定抖动极小。缓冲区采满一帧再由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数据采集全部交给DMAFFT只在新一帧数据就绪后执行一次。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链路正确与否最快的方法不是拿真实设备试而是用信号发生器灌一个已知信号。步骤固定下来之后每次改参数都能复用信号发生器输出1kHz正弦、1V幅值接入ADC通道先把幅值换成电压读数确认AD正确。8kHz采样率、1024点FFT、Δf约7.8125Hz找到最高峰所在bin应当接近128。读amp[128]的幅值应该和信号源的幅值一致误差在ADC量化精度范围内。叠加一个500Hz的低幅值信号确认两个峰都能分辨且幅值比例正确。最后关掉信号源把ADC输入接地观察底噪水平判断是否需要加窗去直流。这一套走完采样、FFT调用、幅值换算、加窗四个环节都被验证到位。我现在的习惯是每次动到采样率或FFT点数先把第1步做一遍确认峰值bin和幅值都对再继续加窗、插值这些进阶处理。第一次把幅值算错时我一度怀疑是信号源和硬件问题查了半天才发现是换算系数漏了一项自那以后单频验证就成了我调试FFT的默认起点。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑