资讯动态

STM32 FFT频谱分析:从采样链路到幅值换算的完整实现

发布时间:2026/10/16 7:55:47 来源:尧图企业网站定制
简介面向STM32嵌入式开发者与数字信号处理入门者资料以STM32F1系列微控制器为平台围绕Cortex-M3内核给出快速傅里叶变换的完整工程实现。内容从时域采样讲到频域分析覆盖蝶形运算、位反转、内存缓冲、定时器中断触发、CMSIS-DSP库调用、浮点误差控制等关键环节可用于嵌入式环境下的实时频谱分析。压缩包共225个文件整体约4.12MB。工程以Keil项目形式组织包含56个C源码文件与60个头文件另有uvprojx工程配置、sct链接脚本、map映射表、axf/hex运行镜像及编译产生的crf、lst等中间文件便于研究完整构建流程同时附带定时器、ADC、Flash、TFTLCD等外设驱动可直接驱动硬件完成采集、计算与显示。此份资源已有10172人学习参考工程结构清晰。阅读者可学习FFT算法的具体落地参考标准外设库应用与低功耗设计并拓展到音频分析、振动检测等场景适合毕业设计、课程设计和嵌入式项目开发。1. 需求背景与选型为什么STM32单片机的FFT方案先从采样链路入手“FFT”三个字母在STM32单片机项目里被问得最多但做起来最容易翻车的不是算法本身而是从采样到频谱输出的整条链路。之前带过一个做音频频谱显示的同学把1024点FFT直接丢进主循环屏幕卡到没法看有人照抄DSP库例程算出来的频率却对不上输入信号。这套方案要解决的就是在STM32单片机上把“定时器触发采样 → DMA搬运 → 加窗 → FFT → 幅值换算 → 频谱输出”完整落地而不是只调一个数学库函数。适合做音频频谱分析、振动监测、谐波检测相关课程设计和毕业设计的同学参考也适合想快速上手FFT工程的从业者。2. 总体架构设计三层结构与四个模块的职责划分任何FFT工程都可以拆成四件事把信号变成数字量把数字量变成复数数组把复数数组变成频谱把频谱送到用户面前。我一般在动手前会先把这四件事画成一条数据流标清楚每个环节的输入输出和缓冲区归属后面写代码才不会绕来绕去。数据流的主干是信号 → 调理电路 → STM32内置ADC → DMA搬运 → 双缓冲RAM → FFT计算 → 幅值谱 → 显示或串口发送。2.1 系统模块划分采集、计算、显示、通信整套系统按职责分成四个模块边界越清楚调试时越好定位问题。信号调理与采集模块负责把外部信号调整到ADC量程内并产生稳定的采样触发。STM32内置ADC只能采03.3V交流信号要先加直流偏置抬高到1.65V附近幅度要控制在ADC量程之内否则FFT结果会直接出现满量程削顶产生的谐波。算法模块负责加窗、执行FFT、把复数结果换算成幅值谱并提取关注的峰值频率和幅值。这个模块不关心信号从哪里来只消费一块时域浮点数组。显示模块OLED或LCD绘制频谱柱状图要求主循环不能长时间阻塞所以FFT尽量放在中断置位后的主循环空闲段执行。通信模块串口或者USB把频谱数据上传到上位机方便调试和验收。毕设答辩时上位机截图比屏照片更有说服力。模块划分就这么做最关键的约定是缓冲区所有权必须明确。哪些数组归DMA写哪些数组归算法层读不能同一个数组两头用这是后面所有坑的根源。2.2 硬件选型与资源预算算清RAM再选芯片选型时先看三件事内核有没有FPU、ADC能不能由定时器触发、DMA通道够不够。带单精度FPU的Cortex-M4或M7内核跑FFT最省心比如STM32F4系列、G4系列没有FPU的M0/M3也能跑只是1024点FFT耗时明显变长实时性会差一些。资源预算要按“最坏情况”算不要按理想情况算。1024点FFT如果用复数方式实现输入要分配2×1024个float也就是8KB再加一份原始ADC缓冲区和窗函数表总共15KB左右。2048点则直接翻倍到30KB上下。数据点数复数输入缓冲窗函数表ADC原始缓冲RAM合计估算5124KB2KB1KB约7KB10248KB4KB2KB约14KB204816KB8KB4KB约28KB这个表格是粗略估算具体还要看你用的是标准库还是HAL库但结论很清楚做2048点FFT选型时RAM不能低于32KB否则后面玩不开。2.3 软件分层与接口约定算法层不碰硬件软件按驱动层、算法层、应用层三层拆。驱动层只负责定时器、ADC、DMA存原始ADC值算法层提供一个独立的频谱计算接口输入时域浮点数组输出幅值谱和峰值频率应用层调用算法层拿结果去显示。算法层不直接读寄存器这是为了将来把工程从F4迁移到G4或者换HAL版本时只改驱动层就行。接口我一般这样定义/* dsp_fft.h */ typedef struct { uint16_t fft_size; /* FFT点数 */ float32_t sample_rate; /* 实际采样率由驱动层传入 */ float32_t *time_buf; /* 时域浮点数组由应用层传入 */ float32_t *mag_buf; /* 幅值谱输出长度为fft_size/2 */ } dsp_fft_ctx_t; void dsp_fft_init(dsp_fft_ctx_t *ctx); void dsp_fft_run(dsp_fft_ctx_t *ctx); float32_t dsp_fft_find_peak_freq(dsp_fft_ctx_t *ctx, float32_t *amp_out);缓冲区指针由外层传入算法层不负责分配也不关心ADC怎么采、DMA怎么搬。这样做的好处是单元测试时可以直接往time_buf里填一组正弦波数据不需要真实硬件就能验证FFT逻辑对不对答辩时这也是一个加分点。3. 采样链路设计定时器触发、ADC采集与DMA搬运采样链路决定了FFT结果可信不可信。这块有个血泪经验不要在主循环里轮询ADC。轮询方式采样间隔会跟着主循环负载抖动抖出来的采样时钟误差会直接变成频谱上的杂散看起来像信号有问题其实是采样时序不行。正确的做法是让定时器以固定频率触发ADC再用DMA把结果搬走CPU只在缓冲区就绪时介入一次。3.1 采样参数采样率、点数与频率分辨率FFT参数不是随便填的。三个核心参数互为约束采样率fs、FFT点数N、频率分辨率Δf fs / N。最高可分析频率是fs/2也就是奈奎斯特频率。以采样率10240Hz、N1024为例分辨率是10Hz频率轴从0Hz到5120Hz共512条有效谱线。为什么很多方案选10240Hz而不是10000Hz10240正好是2的整数倍相关的数值10×1024频率分辨率是10Hz谱线正中整数频率如果采样率是10000Hz分辨率变成9.766Hz做1kHz信号测试时峰值会落在两条谱线之间让老师误以为频率算错了。这个细节答辩时特别好讲。采样率和点数的选择顺序应该是先确定要分析的最高频率再按“信号最高频率小于fs/2”反推采样率最后按需要分辨的最小频率差确定N。如果只需要看05kHz的音频频谱10.24kHz采样率、1024点就行如果要做工频谐波分析50Hz的奇次谐波间隔100Hz分辨率至少要到10Hz但采样率不用太高8kHz足够覆盖40次谐波。3.2 定时器触发ADC采样率由硬件决定定时器触发ADC的核心思路是配置一个通用定时器让它产生更新事件ADC配置为外部触发模式收到触发信号后启动一次转换。这样采样间隔由定时器硬件保证跟主循环慢不慢没关系。/* 系统时钟84MHz配置定时器输出10kHz采样触发 */ void sam_timer_init(TIM_HandleTypeDef *htim) { TIM_MasterConfigTypeDef sMasterConfig {0}; htim-Instance TIM2; htim-Init.Prescaler 84 - 1; /* 84MHz / 84 1MHz */ htim-Init.Period 100 - 1; /* 1MHz / 100 10kHz */ htim-Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim-Init.CounterMode TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(htim); /* 选择更新事件作为TRGO输出接到ADC外部触发 */ sMasterConfig.MasterOutputTrigger TIM_TRGO_UPDATE; HAL_TIMEx_MasterConfig_Synchronization(htim, sMasterConfig); HAL_TIM_Base_Start(htim); }ADC那边只需要把触发源选成对应的定时器TRGO并启动规则通道的DMA采集。注意这里的采样率不是ADC时钟频率而是“每秒钟启动多少次转换”每个定时器周期触发一次。改采样率只需要改Prescaler和Period不要动ADC时钟分频ADC时钟是决定转换精度的一般配置在12MHz到36MHz之间。3.3 DMA搬运与半帧拷贝算着算着数据被覆盖是最常见的翻车点DMA把ADC结果搬到内存后一个新的问题出现了FFT计算要花几毫秒这段时间DMA还在继续采如果只有一个缓冲区等FFT读前一半数据时后一半可能已经被新数据覆写了。最简单的保护做法是“单缓冲半帧拷贝”。#define ADC_BUF_LEN 1024 static volatile uint16_t adc_buf[ADC_BUF_LEN]; static uint16_t work_buf[ADC_BUF_LEN]; static volatile uint8_t fft_ready 0; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { /* 前半段已采满立刻拷贝到工作区 */ memcpy(work_buf, (uint16_t *)adc_buf, (ADC_BUF_LEN / 2) * sizeof(uint16_t)); } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { /* 后半段采满整帧数据完整通知主循环启动FFT */ memcpy(work_buf ADC_BUF_LEN / 2, (uint16_t *)adc_buf ADC_BUF_LEN / 2, (ADC_BUF_LEN / 2) * sizeof(uint16_t)); fft_ready 1; }主循环里检测到fft_ready标志后把work_buf转成浮点数组再交给算法层。两个memcpy的开销在Cortex-M4上可以忽略换来的确定性非常值。如果想要真正的乒乓双缓冲可以准备两个完整的缓冲区DMA写A时FFT读B传输完成中断后交换角色逻辑更绕但省一次整体拷贝。课设和毕设阶段半帧拷贝法更直观排查也更容易。4. FFT算法实现从DSP库调用到幅值谱与频率轴FFT算法实现这里最重要的是想清楚“用库还是自己写”。很多同学上来就打算手写基2算法写到一半发现位反转排序、旋转因子表、溢出控制全是坑。我的建议是如果课程设计的核心是“实现FFT算法”手写算法作为原理展示是对的但最终演示系统还是用库更稳如果核心是“信号采集与分析系统”直接用库把精力花在采样和幅值校正上。4.1 方案对比手写FFT、复数库函数与实数库函数实现方式代表函数优点缺点手写基2/基4自研函数能讲清楚算法原理位反转、溢出、耗时调优都要自己处理CMSIS-DSP复数FFTarm_cfft_f32接口直观输出索引确定比实数FFT多算一倍CMSIS-DSP实数FFTarm_rfft_fast_f32性能最好输出排列特殊缓冲区要求严格对于课设和毕设我通常推荐先用arm_cfft_f32做通整条链路因为它把一个实信号放进复数数组虚部填0输出就是标准的2N个浮点偶数下标是实部、奇数下标是虚部索引一目了然排查起来不会玄学。等系统跑通了再优化成arm_rfft_fast_f32。#include arm_math.h #define FFT_SIZE 1024 static arm_cfft_instance_f32 cfft_inst; static float32_t fft_in[2 * FFT_SIZE]; /* 复数缓冲实部虚部交替 */ static float32_t mag_out[FFT_SIZE]; /* 幅值输出 */ void dsp_fft_init(void) { arm_cfft_init_f32(cfft_inst, FFT_SIZE); } void dsp_fft_run(float32_t *time_buf) { /* 时域数据放实部虚部清零 */ for (int i 0; i FFT_SIZE; i) { fft_in[2 * i] time_buf[i]; fft_in[2 * i 1] 0.0f; } /* 第三个参数0表示正变换第四个参数1表示做位反转 */ arm_cfft_f32(cfft_inst, fft_in, 0, 1); /* 库函数直接算复数模长省去手动sqrt */ arm_cmplx_mag_f32(fft_in, mag_out, FFT_SIZE); }这段代码把FFT和求模长都交给CMSIS-DSP库输出mag_out数组下标k对应的频率是k * fs / N。单从性能看复数FFT比实数FFT多了近一半计算量但1024点在带FPU的Cortex-M4上也就是几毫秒级别对显示刷新和串口上传来说完全跟得上。4.2 加窗不加窗会看到频谱泄漏加错窗同样翻车FFT是对一段有限长信号做计算等价于把无限长信号乘了一个矩形窗矩形窗的频谱旁瓣很高会让单频信号的谱线向两边漏出去看起来像信号被调频了。解决方法是先给时域数据乘一个窗函数压低旁瓣。汉宁窗是音频和振动分析里最常用的选择旁瓣衰减和主瓣宽度比较均衡。static float32_t hann_buf[FFT_SIZE]; void dsp_window_init(void) { for (int i 0; i FFT_SIZE; i) { hann_buf[i] 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); } } /* 在dsp_fft_run里先逐点乘窗再填入复数缓冲 */ void dsp_fft_run(float32_t *time_buf) { for (int i 0; i FFT_SIZE; i) { fft_in[2 * i] time_buf[i] * hann_buf[i]; fft_in[2 * i 1] 0.0f; } /* 后续FFT调用不变 */ }加窗千万要放在FFT之前而且是对时域数据逐点相乘。有些同学把窗函数加到频域结果上那是完全错的做法结果会面目全非。加窗还会带来一个副作用信号总能量被压缩了单频信号的幅度会衰减一半所以后面对幅值要做补偿否则测出来的电压幅值系统性偏小。4.3 幅值换算除N、乘2、再补偿窗函数FFT输出的模长不是信号的物理幅值。对于一个N点实数信号单频正弦会在正负频率各产生一个峰每边幅度是信号幅值的一半。要把mag_out转成真实的幅度谱需要除以N再乘2。#define FFT_SIZE 1024 #define VREF 3.3f #define ADC_BITS 12 static float32_t amp_buf[FFT_SIZE / 2]; void dsp_convert_spectrum(float32_t *mag_in, uint16_t n, float32_t fs, float32_t *amp_out) { /* 直流分量只除N不乘2 */ amp_out[0] mag_in[0] / n; for (uint16_t k 1; k n / 2; k) { amp_out[k] 2.0f * mag_in[k] / n; /* 加汉宁窗后的幅度补偿窗增益约0.5再乘2 */ amp_out[k] * 2.0f; /* 频率轴freq k * fs / n单独算不混在幅值里 */ } }这里每算一个k实际频率就是k乘以fs/N。如果ADC采的是12位原始值在04095之间换算成电压幅度还要先除以4096乘VREF。我一般把“原始ADC值 → 电压 → 幅度谱”分成两步不要混在一个公式里答辩时每步都能讲出物理意义。4.4 栅栏效应与简单插值让峰值频率更准谱线是离散的真实峰值往往落在两条谱线之间直接取最大值所在的下标会带来最多半个频率分辨率的误差。对于单频信号可以用抛物线插值修正峰位置/* k为最大幅度谱线下标amp为幅度谱数组 */ float32_t correction_parabolic(float32_t *amp, uint16_t k) { float32_t a amp[k - 1]; float32_t b amp[k]; float32_t c amp[k 1]; float32_t delta (a - c) / (2.0f * (a - 2.0f * b c)); return (float32_t)k delta; }修正后的k再乘fs/N就是更精确的频率。这个插值只适合单频或间隔较大的多频信号宽带噪声环境下意义不大但做信号源校准和验收测试时非常实用。5. 常见问题排查STM32 FFT项目最容易翻车的四个场景FFT项目的坑不在算法本身而在采样链路、缓冲区和单位换算上。下面四个场景是我见过最高发的按“现象 → 原因 → 解决”整理排查时可以直接对照。5.1 现象频谱峰值频率和输入信号对不上误差固定偏移现象是输入一个1000Hz正弦波频谱最大峰值出现在约1012Hz或980Hz误差稳定存在不是随机抖动。原因通常是实际采样率和配置值不一致常见来源是系统时钟主频和定时器分频算错或者外部晶振精度不够。另一个隐蔽原因是信号源本身有偏差我遇到过自测时频谱一直偏2%折腾半天发现是信号发生器没校准。解决方法是先验证采样率把定时器触发频率降到1kHz用示波器或逻辑分析仪测GPIO翻转周期确认实际采样率后反推修正Prescaler和Period。也可以在代码里留一个采样率校准常量把实际值填进去让频率轴跟着走。5.2 现象FFT结果全是毛刺看不出明显峰像噪声现象是输入标准正弦波幅值谱却像噪声底峰被淹没。原因通常有三类输入信号没有直流偏置负半周被ADC钳位导致波形畸变信号幅度太大或太小超出ADC量程或低于量化噪声底还有一类是加窗后又做了奇怪的归一化把信号幅度压没了。解决顺序是先用串口把原始ADC采样值导出一帧画出来看时域波形是否正常。时域不对不要碰频域。确认波形是干净的再检查ADC分辨率换算最后才怀疑FFT代码。这条排查顺序永远是第一位的。5.3 现象FFT运行过程中数据被覆盖结果时好时坏现象是频谱结果偶尔正确偶尔乱跳尤其在采样率较高或主循环有其他耗时操作时特别明显。原因是DMA一直在写缓冲区FFT正在读同一块内存读写竞争导致数据半旧半新。解决方法是强制采用前面说的半帧拷贝法或乒乓缓冲确保算法层只读work_bufDMA只写adc_buf。另外要注意中断优先级的配置DMA传输完成中断优先级应该高于普通外设中断但低于系统滴答中断避免在拷贝work_buf时被打断导致数据不完整。5.4 现象加窗之后幅值明显变小和真实电压对不上现象是未加窗时幅值勉强正确加汉宁窗后所有峰值掉到原来的一半。原因十分明确汉宁窗平均增益是0.5任何窗函数都会改变信号能量加窗后不做补偿等于把信号衰减了一半。补偿系数一般写2.0但严格来说不同窗函数有不同补偿系数矩形窗是1.0汉宁窗是2.0布莱克曼窗接近2.38。解决方法是把“除N、乘2、窗补偿”三步拆开写别揉在一起。先做不含窗的FFT验证幅值正确再引入窗函数和补偿系数这样每一步都能用信号源验证不用把FFT当黑匣子瞎猜。6. 验收与答辩频谱精度怎么验证、结果怎么展示验收的核心是用已知信号闭环测试而不是看着屏幕说“好像有波形”。通常的做法是准备一个频率和幅度都确定的信号源输入到调理电路然后检查FFT输出的频率、幅值和杂散底线。测试项输入条件通过标准频率准确度1kHz正弦波采样率10.24kHz插值后峰值频率在1000Hz±10Hz内幅值准确度1kHz正弦波峰峰值2V校正后幅值误差小于5%杂散抑制1kHz正弦波除峰值外没有明显杂散谱线计算耗时1024点FFT单次FFT耗时小于10ms答辩展示时把时域波形和频谱图并排放用串口把原始数据和频谱数据传上来在上位机画成图。峰值频率用鼠标点出来旁边标注理论值和实测值让老师一眼就能看到误差在可接受范围。答辩问答环节容易被追问三个方向一是为什么选1024点不选256点答案是从频率分辨率反推——10Hz分辨率需要10kHz采样率配1024点二是为什么加汉宁窗答案是降低频谱泄漏同时牺牲一点点主瓣宽度三是幅值为什么要除N乘2再补偿窗函数这就是本章第4节的换算链路拆开讲清楚每一步的物理含义比背公式可信得多。我之前做自测时频谱峰值偏了2%改采样率、换窗函数折腾了一晚上最后发现是信号源没接地。从那以后我每次做FFT自测都强制先跑一条已知信号校准记下频率和幅值再去碰算法。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑