资讯动态

STM32频谱分析工程实践:从FFT到可信频谱的完整指南

发布时间:2026/10/14 1:03:49 来源:尧图企业网站定制
简介面向STM32开发者的信号解析工程包以在STM32单片机上实现快速傅里叶变换为主线展示如何将时域采样信号转换为频域表示适用于嵌入式信号处理、实时频谱分析和数据采集系统设计等场景尤其适合需要掌握CMSIS-DSP数字信号处理库用法的初中级嵌入式开发者。压缩包共999个文件容量约6.21MB其中C语言源文件多达559个、头文件246个并辅以汇编启动文件、链接配置文件、Keil集成开发环境工程配置以及编译输出文件涵盖从工程创建、外设配置到算法调用的完整开发链路。已有907人学习下载。包内包含CMSIS-DSP数学库、快速傅里叶变换初始化与计算源码、ARM Cortex-M3处理器静态库以及编译生成的axf与hex可执行文件。读者可对照理解模数转换器采样策略、内存资源管理、傅里叶变换复数运算、频域结果取模与功率谱分析等关键环节为后续音频分析、振动监测、通信解调等应用提供可直接改造的工程基础。1. 在STM32单片机上使用傅里叶解析信号跑通FFT很快跑出可信频谱很难在STM32单片机上使用傅里叶解析信号这事没有看起来那么轻松。振动监测、音频频谱显示、电机故障诊断这类场景里很多人第一反应是找FFT代码或直接套DSP库结果频谱图画得漂亮主峰却对不上物理量。我见过不止一个项目卡在采样率理解或数据错位上最后把问题归结为“玄学”。其实链路很清晰ADC采集精度、采样间隔、FFT库选择、窗函数处理和幅值换算每一环都有明确参数。这篇文章把这条完整链路拆开从数学前提、工程实现到参数调整和排错适合准备在自己项目里做真正信号解析的开发者。2. 傅里叶变换在单片机上的落地选型CMSIS-DSP为什么是默认答案在把代码写进工程之前先得把“在STM32上做傅里叶”到底在做什么理清楚。单片机拿到手的是ADC采样来的离散时域序列傅里叶解析就是把这段序列变换到频域得到各个频率成分的幅值、相位或功率信息。这个变换的数学形式是离散傅里叶变换运算量巨大而快速傅里叶变换则利用旋转因子的对称性做化简把复杂度从O(N²)降到了O(N log N)。这个降幅在STM32这类资源受限设备上不是性能优化而是能不能做得起的区别。以N1024为例直接按DFT公式算要做大约100万次复数乘加而FFT只需要大约1万次量级。用带硬件FPU的Cortex-M4内核1024点单精度复数FFT耗时通常在几百微秒到几毫秒换到没有FPU的Cortex-M3上做同尺寸浮点FFT耗时可能高出几十倍。所以选型第一步就是认清做实时频域解析带FPU的M4/M7/M33会比M0/M3省去大量麻烦这也是芯片选型的硬约束。2.1 FFT不自己写CMSIS-DSP库的函数族与精度选择在STM32的生态里CMSIS-DSP是事实上的数学库标准也包含了完整的FFT实现。它提供三类常见接口arm_cfft_f32用单精度浮点运算动态范围大、代码直观适合带FPU的内核arm_cfft_q15用16位定点内存占用小、速度快但要注意溢出和动态范围arm_cfft_q31用32位定点精度高于q15但速度和内存介于浮点和q15之间。我的判断标准很简单芯片带FPU就直接选f32不带FPU又要求实时性就选q15并在转换阶段做好定标。CMSIS-DSP的FFT输入要求复数按实部、虚部交替存放偶数下标放实部、奇数下标放虚部。ADC采集来的是实信号把实部填进偶数位置、虚部填0即可。函数库会原地处理数据变换完成后再调用幅值计算函数把交错复数转换成幅值数组。接口数据格式内存占用典型适用arm_cfft_f32float32最大带FPU的M4/M7/M33追求精度和速度平衡arm_cfft_q15int16最小无FPU内核内存吃紧实时性优先arm_cfft_q31int32中等无FPU但要求比q15更高精度我自己工程里优先f32哪怕Flash和RAM紧张也至少要保一个1024点f32缓冲。因为q15需要反复做定标和保护调试期一旦溢出问题反而更难定位更烧时间。这个选择属于“用内存换开发效率”的典型做法对多数中小型项目是划算的。2.2 采样定理约束奈奎斯特频率先于FFT任何离散采样都会遇到混叠问题。采样率fs必须大于信号最高频率的2倍这是傅里叶变换正确性的前提。很多开发者把注意力放在FFT算法上却忽略了ADC输入端的抗混叠。实际工程里我会留出至少1.5到2倍的裕量也就是说信号最高频率5kHz采样率至少选10kHz以上我通常做到20kHz左右前端再加一级RC低通截止频率设在信号带宽之上、采样率之下用来抑制高于奈奎斯特频率的噪声。RC滤波不能省略因为真实环境里高频干扰如果直接进ADC会被“折返”到低频区域出现在频谱里的假峰比真实信号还显眼。这个坑看起来是算法问题实际上是模拟前端问题碰过一次之后就再不敢省那两颗电阻电容了。采样率决定了傅里叶能解析的频率上限真正有用的频率信息都落在0到fs/2区间内FFT输出是两边对称的实际只关心前半段这个约束在后面代码里会反复出现。2.3 资源账内存占用、耗时和实时性怎么平衡傅里叶解析在STM32上绕不开资源约束。f32复数数组的大小是2×N×4字节N1024时占8KBN4096时直接到32KB再加上幅值数组、ADC缓冲和窗函数表内存很快就见底。所以选点数之前先算清楚剩余RAM。实时性方面采集一批N点数据需要的时间是N/fs比如fs20kHz、N1024时采集耗时约51ms而1024点f32 FFT在M4内核上通常是几百微秒到几毫秒量级处理时间远小于采集时间能够做到边采边算。但如果你把采样率提到200kHz采集1024点只要5ms处理时间就可能逼近采集时间这时得考虑优化或分帧处理。另一个常见做法是DMA双缓冲DMA采满半缓冲区就触发中断CPU处理前一半数据DMA同时填充后一半两件事重叠起来吞吐量能翻倍。这块资源账最好在方案设计阶段就算清楚而不是等代码写完再回头改。3. STM32采集链与FFT代码从定时器触发到基频识别的完整路径理论清楚了落地才有意义。这一章给出一条可以直接抄进工程的最小路径定时器硬件触发ADCDMA把结果搬进内存主循环里调用CMSIS-DSP做FFT最后从幅值谱里找到基频。这套链路里的每个环节都不是可有可无的定时器触发保证采样间隔精确可预测DMA搬运保证CPU不被中断拖死FFT放主循环而不是中断里处理避免影响实时采集。3.1 定时器触发ADC与DMA搬运采样间隔的硬件保障采样间隔抖动是傅里叶解析的大敌。用软件循环加延时的方式采集中断一多间隔就乱FFT对非均匀采样非常敏感结果会莫名出现一堆旁瓣。常见做法是利用定时器更新事件作为ADC外部触发源配合DMA将转换结果连续搬入内存整个过程CPU参与度极低。以主频168MHz的Cortex-M4内核芯片为例定时器预分频PSC设为83自动重载ARR设为99定时器时钟按168MHz算触发频率就是168MHz/(84×100)20kHz刚好适合最高5kHz以内的信号。这个PSC和ARR的组合不是死的采样频率变化时按同样公式调整即可。DMA配置部分要用循环模式缓冲区长度为FFT点数一次采满后触发完成中断在主循环里置一个标志位。回调里不要直接做FFT因为1024点浮点FFT耗时毫秒级放在中断里会拉长中断响应时间造成其他外设丢数据。/* 定时器触发频率fs T_CLK / ((PSC1) * (ARR1)) */ #define FFT_LEN 1024 #define ADC_BUF_SIZE FFT_LEN uint16_t adc_raw[ADC_BUF_SIZE]; volatile uint8_t adc_done 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { adc_done 1; /* 只置标志不在这里做FFT */ } }逻辑说明HAL库在DMA搬运完整组数据后调用这个回调。代码里只把adc_done置1主循环检测到该标志后才开始频域处理这样中断占用极短。参数说明FFT_LEN就是单次FFT的点数ADC缓冲区和它保持一致。adc_raw存放原始12位ADC码后续浮点转换从这里取数。3.2 FFT处理与幅值计算CMSIS-DSP最小代码拿到一组完整的adc_raw之后第一步不是直接调FFT而是把定点ADC码转成浮点电压。12位ADC满量程4095对应参考电压3.3V把原始值乘以3.3/4095得到电压。然后按复数交错格式填入fft_input偶数下标填实部奇数下标虚部清零。之后调用arm_cfft_f32做正变换再调用arm_cmplx_mag_f32算出每个频点的幅值。这段代码是整套流程的核心我一般封装成一个函数供主循环调用。#include arm_math.h static arm_cfft_instance_f32 fft_inst; static float fft_input[FFT_LEN * 2]; static float fft_mag[FFT_LEN]; void fft_init(void) { arm_cfft_init_f32(fft_inst, FFT_LEN); } void adc_to_spectrum(uint16_t *adc_raw) { for (uint32_t i 0; i FFT_LEN; i) { fft_input[2 * i] (float)adc_raw[i] * (3.3f / 4095.0f); fft_input[2 * i 1] 0.0f; } arm_cfft_f32(fft_inst, fft_input, 0, 1); arm_cmplx_mag_f32(fft_input, fft_mag, FFT_LEN); fft_mag[0] 0.0f; /* 丢弃直流分量避免干扰后续峰值搜索 */ }逻辑说明arm_cfft_f32的第三参数0表示正变换第四参数1表示按位反转标志已在变换过程中处理。arm_cmplx_mag_f32直接对交错复数求模值结果长度为FFT_LEN与频点一一对应。fft_mag[0]是0Hz分量也就是直流偏置对很多解析场景没有意义搜索峰值前先清零。参数说明FFT_LEN必须是2的幂常见取值256、512、1024、2048。fft_input长度必须为2×FFT_LEN因为每个复数占两个float。这里用3.3V参考是假设供电稳定参考源噪声大时幅值精度会受影响。提示如果ADC参考电压不是3.3V或者前端有运放增益电压换算公式里的满量程要按实际值替换。幅值再往后做归一化时这一处最容易埋错。3.3 基频识别从幅值谱到可用的频率值幅值谱算出来之后大多数场景的目标是找到最大峰值对应的频率。搜索范围限定在0到fs/2也就是前FFT_LEN/2个频点跳过直流。每次找到比当前最大值更大的频点就更新峰值索引最后把索引乘以fs再除以FFT_LEN换算成物理频率。这是最朴素的基频检测适合单峰主导的振动、转速信号如果是多峰场景就要改成峰值数组或做谱峰跟踪那是另一层需求。void find_fundamental(float *mag, uint32_t len, float fs, float *freq_out) { uint32_t peak_bin 1; for (uint32_t i 2; i len / 2; i) { if (mag[i] mag[peak_bin]) { peak_bin i; } } *freq_out (float)peak_bin * fs / (float)len; }逻辑说明这里从bin1开始bin1极低频往往带残余噪声bin0已经在上一节被清零。扫描到len/2之前即可后半段是镜像不包含额外信息。参数说明fs必须和定时器实际触发频率一致否则频标整体偏移这是第五章第一个坑的根源。len用FFT_LEN传入函数本身不修改任何全局数组方便单独测试。4. 采样率、FFT点数与窗函数频域精度由这三个参数决定FFT跑起来了但结果准不准取决于三个参数采样率、FFT点数和窗函数。这三个值不是孤立的采样率决定解析范围FFT点数在给定采样率下决定频率分辨率窗函数决定频谱泄漏的形态。三者一起决定了你能看到多细、多准的频域细节。4.1 采样率与FFT点数的组合频率分辨率怎么算频率分辨率Δf的公式是fs/N。采样率20kHz、FFT点数1024时Δf约为19.5Hz。这意味着两个频率相差不到19.5Hz的信号在频谱上会连成一个峰无法分辨。要分辨得更细只有两条路降低采样率或增大点数。降低采样率会压缩解析范围通常受信号本身约束不可行增大点数则意味着更多RAM和更长计算时间。N2048的f32复数输入数组占用16KBN4096则是32KB对许多STM32型号来说已经逼近内存上限。所以工程上常常不是“想用多大用多大”而是“在RAM预算内选最大的N”。采样率FFT点数频率分辨率复数数组RAM8kHz25631.25Hz2KB8kHz10247.8125Hz8KB20kHz102419.53Hz8KB20kHz40964.88Hz32KB我一般的取舍是先看信号最高频率决定fs再按需要分辨的最小频率间隔算出所需Δf最后反推N并在RAM剩余空间允许范围内取2的幂。比如振动分析需要分辨1Hz间隔fs8kHz时N至少8192复数数组加幅值数组总共约15KB多数M4型号能接受M0则很吃力。4.2 窗函数频谱泄漏怎么治FFT隐含了一个假设信号在采样窗口内是周期的。现实中这个假设几乎不成立信号频率不是频点整数倍时能量会泄漏到相邻频点主峰变宽、旁瓣升高。解决手段是加窗在时域上把采样数据逐点乘一个窗函数强制序列两端平滑过渡。矩形窗相当于没加窗主瓣最窄但旁瓣最高汉宁窗是工程里最常用的折中旁瓣衰减明显、主瓣宽度适中布莱克曼窗旁瓣更小但主瓣更宽适合强调幅度精度而非频率分辨的场景。加窗的代价是频域主瓣变宽两个很近的信号更难分辨所以窗函数的选择实际上是分辨率和泄漏的交换。窗函数主瓣宽度旁瓣衰减典型用途矩形窄-13dB频率恰好对齐频点、对幅值精度要求高的标定汉宁中等-31dB通用信号解析默认首选布莱克曼宽-58dB强调幅值测量精度可容忍频率分辨率损失实际代码里加窗就是在数组填入电压后、调用FFT前把fft_input的实部乘上窗系数虚部保持0。窗系数可以预计算成常量表避免运行时重复算cos。我一般用查表方式把1024个窗系数放在Flash里每次填充完直接乘进去。4.3 幅值读取与归一化别把频谱值当物理值arm_cmplx_mag_f32算出来的幅值并不是信号的物理幅值。FFT结果是复数模值除以N才是对应正弦分量的幅值又因为频谱正负对称真实幅值还要再乘以2。综合起来非直流频点的物理幅值等于2×fft_mag[k]/N。直流频点则只除以N即可。加窗之后窗函数会把信号能量压低还要再除以窗函数的平均增益。汉宁窗平均增益约0.5布莱克曼窗约0.42矩形窗约1.0。计算幅值的时候把这三个系数串起来结果才和示波器读到的值对得上。如果只关心频率不关心幅值这段换算可以跳过但做振动幅值或音频电平测量就必须算准。我习惯在头文件里定义好归一化常量N和窗类型一变宏跟着改避免散落各处改漏。5. STM32傅里叶解析信号避坑手记5个让频谱翻车的实际案例这一章把实际项目中碰到的典型问题整理成五条。每一条都按现象、原因、解决展开每一条都对应前面某一章里的某个参数或环节。把这块读透能省下大量调FFT的调试时间。5.1 主峰位置整体偏移采样率实际值和配置值对不上现象注入1kHz标准正弦波FFT主峰出现在比预期偏高的频点换算出来接近1.08kHz。原因定时器分频只按APB外设时钟估算没有考虑某些系列里APB1预分频不为1时定时器时钟自动翻倍的规则实际采样率比理论值高了约8%。频谱频率刻度按错误fs计算整体成比例偏移。解决不猜直接测。用GPIO在定时器更新中断里翻转电平接示波器数实际触发频率或者规律采样标准信号后反推真实fs。把测得的真实采样率写进代码用宏定义固定下来。这个坑最大的迷惑性在于频谱形状完全正常主峰尖锐且对称只有换算成频率才发现不对。我后来养成的习惯是每次搭建采样链时先测再写绝不用理论值直接上板。5.2 直流分量吞掉小信号频谱0Hz处尖峰巨大现象频谱图上0Hz处是一个极高的尖峰其他频点几乎贴地。原因信号经过运放后叠加了约1.65V直流偏置直流分量在FFT里集中在bin0其幅值远大于交流成分显示范围被压扁小信号看不见。解决在把ADC原始值转浮点之后先求整段数据的平均值再用每个采样值减平均值等效于去掉直流偏置。代码里在做实部填充前完成这步虚部依旧填0。这个方法简单有效对大多数只关心交流成分的场景都适用。如果信号本身是单极性的比如只测正值那就要重新考虑是保留直流还是去掉不要一刀切。5.3 频谱结果杂乱无章DMA多通道数据交错存储现象FFT输出类似白噪声找不到任何可解释的峰。原因ADC没配置成单通道而是多通道扫描模式。DMA按通道顺序交错存放N个通道的数据直接把整块缓冲当单通道序列送入FFT时域序列被错误重排频域自然完全失真。解决确认ADC只开一个通道或扫描模式下做通道抽取。抽取时按adc_raw[i*channelsch]取指定通道数据重排到新数组再交给FFT。我建议做FFT的场景一律单独分配一个单通道DMA缓冲区从源头避免交错。这类问题最容易发生在从其他外设复用ADC代码时配置改了但DMA逻辑没跟着改。5.4 浮点FFT慢得离谱FPU编译选项或缓存一致性问题现象同样的1024点FFT理论应该几毫秒算完实测却要几十毫秒。原因编译器没开浮点硬件调用浮点参数走软浮点ABI每条浮点指令被编译成函数调用速度自然上不去。另一类情况是带D-Cache的内核DMA写入的数据还留在外设侧CPU读到的缓存副本全是最旧值结果表现为“FFT结果偶尔对、经常错”。解决确认编译选项加了-mfloat-abihard和对应内核的-mfpu比如Cortex-M4用fpv4-sp-d16Cortex-M7用fpv5-sp-d16。带D-Cache的内核DMA缓冲区要么放在不缓存的SRAM区要么在处理前调用缓存失效函数让CPU从内存重新读取。这两个问题在调试器里都不容易直接看出来需要逐个选项排查。5.5 相位或幅值抖动采样触发抖动是隐形杀手现象FFT结果每次跑出来幅值都在变尤其是噪声底和峰值幅度不稳定。原因采集用了软件循环触发或中断里启动ADC中断优先级一抖动采样间隔就不等。频域对采样间隔的非均匀性很敏感表现为频谱底噪抬高、峰值展宽严重时出现假峰。解决回归定时器硬件触发ADC的方案DMA长度固定采集完成的标志位只在中断里置位不做其他处理。这个改动通常能明显压低噪声底也是我调试此类问题时第一个检查的点。血泪经验是如果你发现FFT结果“每次都不一样”先别怀疑FFT库先怀疑采样触发链。6. 用内置自检法验证频谱正确性十分钟校准一条信号链路最后一章分享一个每个项目我都会跑的自检方案。它不依赖信号发生器纯靠代码构造已知信号十分钟内就能验证FFT库、幅值归一化和频标换算是否正确排查完再上真实信号能省很多“玄学”时间。6.1 构造合成正弦波验证FFT链路构造一段长度1024点、在窗内完整震荡20个周期的正弦波。按fs10240Hz计算这个信号的频率是200HzFFT后峰值应当精确落在bin20。这是最直接的链路验证因为信号和FFT点参数是耦合的任何库配置错误都会反应在峰值位置错误上。#define TEST_N 1024 #define TEST_CYCLES 20 static float test_input[TEST_N * 2]; void build_sine(void) { for (uint32_t i 0; i TEST_N; i) { test_input[2 * i] sinf(2.0f * (float)M_PI * TEST_CYCLES * i / TEST_N); test_input[2 * i 1] 0.0f; } }逻辑说明峰值位置精确落在TEST_CYCLES索引是因为信号周期数恰好为整数频谱没有泄漏。如果峰值落点不对先查采样率和点数换算再查库函数参数。参数说明这个测试信号幅值为1.0FFT后bin20的模值应接近N/2即512。用这个值验证归一化公式2×512/10241.0刚好还原。加窗验证也同理幅值要再除以窗函数增益。6.2 幅值校准与真实信号验证构造正弦波验证通过后再接入真实信号源。稳定性好一点的信号发生器给已知幅值的正弦波通过ADC采集跑完整链路对比FFT算出的物理幅值与发生器设置值误差在几个百分点内说明模拟前端和归一化都正确。如果没有发生器用芯片内部DAC输出一个已知频率的方波或正弦也可以虽然DAC精度有限做不了高精度幅值校准但至少能验证频标和峰值位置。整个流程走一遍后续换电路板、换主频、改采样率时只改宏定义再重跑一次即可。我现在的习惯是每次新板子到手先跑这段自检确认FFT链路无误后再装业务代码。这个习惯帮我挡掉过不少“频谱看起来不对但不知道哪里错”的排查时间。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑