资讯详情

资讯详情

STM32 FFT频谱分析实战:从ADC采样到CMSIS-DSP库应用

简介STM32F1单片机的快速傅里叶变换FFT完整Keil工程模板面向嵌入式开发者及数字信号处理初学者解决在Cortex-M3内核上高效完成频谱分析的需求。压缩包共225个文件约4.12MB其中包含60个h头文件、56个c源文件如定时器、ADC、LCD驱动等以及编译生成的o/d/crf中间文件、uvprojx工程配置和hex烧录文件便于直接阅读代码结构、编译下载并烧录验证。已有10172人浏览学习。通过该工程可系统学习FFT蝶形运算与位反转实现、STM32F1定时器触发采集与中断控制、内存布局优化等核心知识点同时可参考CMSIS-DSP库调用、误差分析及功耗管理思路对于嵌入式信号处理项目具备直接借鉴价值与复用空间。1. STM32单片机实现FFT别把频谱分析想成PC上的事第一次在STM32单片机上做FFT的人多半会掉进同一个坑直接把输入数组丢进库函数然后从结果里找峰值发现频率对不上、幅值全是乱的。原因不是库不好用而是单片机上的FFT有一套自己的规矩——采样率怎么定、数据用什么格式喂进去、结果怎么从复数里换算成幅值每一步都在影响最终频谱图。这篇笔记要拆的就是一个跑在Cortex-M4内核MCU上的FFT频谱分析Demo从工程结构、核心代码到调试方法完整过一遍。适合手里有开发板、想把波形变成频谱、打算做音频分析或振动监测的开发者也适合刚接触数字信号处理、想在真实芯片上验证教材公式的人。读完你不仅能跑通还能按自己的采样率和FFT点数改出合用的版本。2. 整体设计与技术选型一条从ADC到频谱图的完整链路2.1 硬件选型为什么是Cortex-M4而不是M3或M0实现FFT的芯片选择直接影响代码写法和性能。这个Demo基于Cortex-M4内核的STM32F4系列单片机核心原因只有一个硬件FPU浮点运算单元。FFT的本质是大量复数乘加运算如果让Cortex-M0或M3用软件模拟浮点单次256点FFT可能要跑几毫秒甚至十几毫秒而M4配合单精度浮点指令同样点数能把时间压到几百微秒以内。如果你手里的芯片是M7内核性能更强代码基本可以直接复用因为外设和库接口是同一套。除了内核外设配置上这块板子用了三个关键资源ADC1 DMA连续采集模拟信号不占用CPU逐个搬运数据定时器触发ADC采样保证采样间隔精确可控串口或OLED把频谱结果送出去观察这套配置是常见做法ADC负责把连续信号变成离散序列DMA负责把序列搬进内存环形缓冲定时器决定采样率FFT在CPU空闲时对缓冲数据做变换。整个链路里没有一块是多余的任何一个环节配置错了频谱图都会表现出对应的奇怪现象后面避坑章节会细说。2.2 工程目录结构与数据流工程按标准库风格组织或者用图形化配置工具生成后按功能拆成几个目录大致结构如下Project/ ├── Core/ │ ├── Inc/ // 头文件 │ └── Src/ │ ├── main.c │ ├── adc.c // ADC DMA 定时器触发采样 │ ├── tim.c // 采样定时器配置 │ ├── usart.c // 结果输出 │ └── fft_process.c // FFT封装、幅值计算、频谱输出 ├── Drivers/ │ ├── CMSIS/ // 内核相关定义 │ └── HAL_Driver/ // 外设驱动库 └── Middlewares/ └── DSP_Lib/ // 数字信号处理库含FFT函数数据流也很清晰定时器触发ADC → DMA把结果写入双缓冲数组 → 主循环检测到一帧采集完成 → 调用封装好的FFT处理函数 → 得到频谱数组 → 通过串口打印或OLED绘图。整个过程中CPU只在最后一步参与计算采集期间处于休眠或处理其他事务这是单片机信号处理的标准姿势。2.3 DSP库的选择用芯片厂商提供的还是自己写FFT的实现方式有三种自己写蝶形算法、用现成的DSP库、用第三方算法库。这个Demo直接用了芯片厂商配套的CMSIS-DSP库原因很朴素它针对Cortex-M4的FPU做过指令级优化支持q15、q31、f32三种数据类型并且函数接口稳定。自己写的FFT可以作为学习练习但工程上用库函数更稳妥——你不需要关心蝶形运算的旋转因子表怎么存只需要知道初始化、执行、数据处理三个步骤。后面核心代码章节会把这几个步骤逐一拆开讲。3. 核心模块拆解一个请求从进入到返回的完整链路3.1 参数定义和缓冲区规划FFT相关的代码通常集中在一个文件里头文件定义几个关键宏源文件里实现初始化、采集回调、FFT执行、幅值换算四个函数。先把参数定义贴出来/* fft_process.h */ #define FFT_POINTS 256 // FFT点数必须是2的整数次幂 #define SAMPLE_RATE 32000 // 实际采样率由定时器分频决定 #define FFT_BINS (FFT_POINTS / 2) // 有效频谱条数 /* fft_process.c */ #include fft_process.h #include arm_math.h static float32_t fft_input[FFT_POINTS * 2]; // 复数数组实部虚部交替 static float32_t fft_output[FFT_POINTS]; // 幅值结果 arm_cfft_instance_f32 fft_inst; // FFT实例保存旋转因子和位反转表这里有个新手很容易忽略的细节fft_input的长度是FFT_POINTS * 2不是FFT_POINTS。因为CMSIS-DSP的复数FFT接口要求数据按实部、虚部交替排列即[re0, im0, re1, im1, ...]数组长度是点数的两倍。初始化时传入的也是这个数组的首地址。幅值结果数组长度则是FFT_POINTS / 2因为实信号的频谱关于fs/2对称我们只关心正频率部分。3.2 FFT初始化与执行三行代码背后的逻辑初始化只需要在系统启动时调用一次执行部分在主循环或中断里反复调用/* 初始化在main函数开头调用 */ void fft_init(void) { arm_cfft_init_f32(fft_inst, FFT_POINTS); } /* 执行喂入采样数据得到复数频谱 */ void fft_execute(float32_t *sample_array) { /* 把实数采样值填入复数数组虚部置0 */ for (int i 0; i FFT_POINTS; i) { fft_input[2 * i] sample_array[i]; fft_input[2 * i 1] 0.0f; } /* 执行复数FFT ifftFlag0 表示正变换 doBitReverse1 表示库内部做位反转 这两个参数固定传0和1即可 */ arm_cfft_f32(fft_inst, fft_input, 0, 1); /* 计算幅值频谱 */ arm_cmplx_mag_f32(fft_input, fft_output, FFT_POINTS); }参数说明arm_cfft_init_f32的第一个参数是实例结构体第二个参数是点数内部会计算旋转因子表并做上下文初始化arm_cfft_f32的第二个参数是输入输出共用的内存即就地变换所以执行完后fft_input里存的是复数频谱arm_cmplx_mag_f32是复数求模函数输入是交替排列的复数组输出是实部的幅值数组内部对每个频点做了sqrt(re^2 im^2)执行完这一步fft_output[0]是直流分量fft_output[1]是第一个频点的幅值每个频点代表的实际频率需要用下面的公式换算。3.3 频点与幅值的物理意义换算FFT结果只是抽象的数字序列要变成频谱图上的横轴和纵轴需要两步换算/* 计算第k个频点对应的实际频率 */ float freq_of_bin(int k) { return (float)k * SAMPLE_RATE / FFT_POINTS; } /* 幅值修正直流分量除以N交流分量乘以2/N */ float amplitude_of_bin(float magnitude, int k) { if (k 0) { return magnitude / FFT_POINTS; } else { return 2.0f * magnitude / FFT_POINTS; } }为什么有这个修正因为FFT输出的第k个频点幅值是N个采样点叠加的结果信号的能量分散在整个变换过程中要还原真实幅值必须除以N。实信号频谱对称负频率那半没有物理意义所以交流分量的幅值再乘2把能量从对称的两半合并回来。直流分量只有一处不需要乘2直接除以N。频率分辨率由采样率和点数共同决定SAMPLE_RATE / FFT_POINTS比如32kHz采样率、256点FFT分辨率是125Hz。如果两个信号的频率差小于125Hz它们在频谱上是分不开的这是傅里叶变换本身的特性不是代码的问题。想要更细的分辨率要么降低采样率受信号带宽限制要么增加FFT点数受RAM和计算时间限制这是个权衡问题。3.4 用定时器触发ADC采样保证采样率稳定FFT对采样间隔的一致性非常敏感。如果采样间隔抖动频谱上会出现额外噪声所以这个Demo没用软件延时轮询ADC而是用定时器触发/* 定时器配置32kHz触发ADC */ void sampling_timer_init(void) { /* 根据定时器时钟频率计算重装载值 */ uint32_t timer_clock 84000000; // 定时器时钟84MHz uint16_t prescaler 84 - 1; // 分频到1MHz uint16_t period 31; // 1MHz / 32 31250 Hz约32kHz htim.Instance-PSC prescaler; htim.Instance-ARR period; htim.Instance-EGR TIM_EGR_UG; // 更新事件立即生效 HAL_TIM_Base_Start(htim); HAL_TIM_ADC_Start(htim, hadc1); }ADC的DMA传输配置成循环模式每一帧采满后通过DMA半传输/传输完成中断做缓冲切换这是双缓冲的常见做法。主循环只需要检查dma_half_complete或dma_transfer_complete标志然后从对应的缓冲区取出一帧数据传给fft_execute。这样采样和FFT处理完全解耦即使FFT偶尔占用了较多CPU时间下一帧采样数据也不会丢。4. 验证与运行怎么确认你的FFT算对了4.1 喂入已知信号做自检写完FFT代码第一件事不是接麦克风或传感器而是喂一个已知频率的信号验证算法正确性。最简单的方式是程序内部生成一个正弦波数组频率设为1kHz幅值设为1.0采样率32kHz/* 自检模式生成1kHz正弦波做验证 */ void fft_self_test(void) { float32_t test_signal[FFT_POINTS]; for (int i 0; i FFT_POINTS; i) { test_signal[i] arm_sin_f32(2.0f * PI * 1000.0f * i / SAMPLE_RATE); } fft_execute(test_signal); /* 打印前10个频点的幅值1kHz对应第8个频点 */ for (int k 0; k 10; k) { printf(bin[%d] freq%.0fHz amp%.3f\r\n, k, freq_of_bin(k), amplitude_of_bin(fft_output[k], k)); } }期望结果是第8个频点因为1000Hz / (32000Hz / 256) 8的幅值接近1.0其余频点接近0。如果你看到峰值确实在第8个频点幅值在0.9到1.0之间因为数字正弦波不是完美的单频信号会有细微泄漏说明整条链路是正确的。这一步能帮你把算法问题跟硬件问题分开排查。4.2 串口输出与上位机绘图验证频谱最简单的方式是用串口把频点数据发到电脑上用任何支持串口绘图的工具或Python脚本查看。数据处理部分注意一点直接printf浮点数在某些开发环境下会拖慢速度可以先把幅值乘以100转成整数或者用sprintf分批发送。/* 每个频点一行格式便于上位机解析 */ void fft_dump_spectrum(void) { for (int k 0; k FFT_BINS; k) { uint16_t amp_int (uint16_t)(amplitude_of_bin(fft_output[k], k) * 1000.0f); printf(%d,%u\r\n, k, amp_int); } }看到完整频谱形状后接下来的常见问题是运行时间和稳定性这就进入下一章的排查思路。5. 常见问题与避坑指南5.1 现象频谱图中0频直流分量异常巨大信号分量被淹没如果把ADC采集的原始电压直接做FFT大概率会看到第0个频点的幅值比其他所有频点都大很多真正关心的信号峰反而被压得看不清。原因是ADC输入往往带有直流偏置比如信号在1.65V上波动FFT会把直流偏置当成一个超强分量算进0频。解决办法分两种。如果是单极性AD转换信号本身叠加了直流分量在送入FFT前先做去均值处理/* 去直流把每个采样值减去本帧均值 */ float32_t mean 0.0f; for (int i 0; i FFT_POINTS; i) { mean raw_buffer[i]; } mean / FFT_POINTS; for (int i 0; i FFT_POINTS; i) { sample_array[i] raw_buffer[i] - mean; }如果只是显示问题也可以不改数据直接忽略频谱中的第0个频点不画。两种方案各有适用场景做幅值测量必须去直流做频率检测可以直接忽略0频。5.2 现象馈入的解析正弦波FFT结果却在多个频点都有幅值频谱泄漏这是FFT新手最容易困惑的现象。原因有两个层次一是输入信号频率不是频点分辨率的整数倍能量泄漏到相邻频点二是没有加窗函数矩形窗的旁瓣泄漏在32kHz、256点FFT下很可观。解决方法是加窗函数汉宁窗是常用选择/* 汉宁窗加窗后信号两端平滑过渡到0 */ static float32_t hanning_window[FFT_POINTS]; void window_init(void) { for (int i 0; i FFT_POINTS; i) { hanning_window[i] 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_POINTS - 1))); } } /* 使用采集完一帧后先乘窗再送FFT */ void apply_window(float32_t *sample_array) { for (int i 0; i FFT_POINTS; i) { sample_array[i] * hanning_window[i]; } }加窗的代价是幅值精度下降约一半汉宁窗的相干增益是0.5如果做定量测量需要在幅值修正里补偿窗的增益。如果只做频率检测或图形显示加窗后的频谱干净得多这点取舍很值得。5.3 现象定时器触发的ADC采样DMA半传输中断里做了FFT程序运行变卡甚至死机FFT计算不是免费午餐。256点复数FFT在Cortex-M4上大约需要几百微秒如果放在中断服务函数里这段时间内所有其他中断都会被阻塞包括串口发送、按键扫描、系统时钟。症状就是屏幕刷新变慢、串口丢数据、程序像死机了一样。正确做法是中断里只置标志位主循环里做FFT处理volatile uint8_t dma_flag 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc-Instance ADC1) { dma_flag 1; // 中断里只做标记 } } /* 主循环检测标志位再处理 */ while (1) { if (dma_flag) { dma_flag 0; fft_execute(current_buffer); fft_dump_spectrum(); } }这条经验是血泪总结很多第一次把FFT跑在中断里的开发者都会翻车。中断里能做的事越少越好这是嵌入式开发的铁律FFT这种耗时大户尤其要注意。5.4 现象串口打印浮点数输出乱码或程序卡死在printf里部分嵌入式开发环境的C库默认不支持浮点格式化输出或者支持但栈空间不足。症状是整数能正常打印一打印浮点数就乱码或HardFault。解决方式是开启微库或调整栈大小但更稳妥的办法是转成整数再打印前面fft_dump_spectrum里的amp_int用的就是这种方式。这类问题看起来像玄学实际是C库配置问题记住一个原则在资源紧张的MCU上尽量避免在关键路径上做浮点格式化。6. 二次开发方向从频谱Demo到可用的信号分析工具跑通基本FFT之后真正的价值在于按自己的场景去改。这里列三个最常做的方向第一是分辨率调整第二是数据源替换第三是显示端升级。分辨率调整如果做音频频谱显示往往需要多个分辨率档位切换。把FFT_POINTS做成运行时可变的参数初始化时根据当前档位调用arm_cfft_init_f32采样率不变时分辨率跟着变化。注意RAM占用512点需要4KB的float缓冲区1024点需要8KB。如果觉得RAM吃紧可以改用q15定点FFT内存减半速度更快代价是动态范围和精度下降适合显示频谱但不做定量测量的场景。数据源替换把定时器触发的ADC换成I2S音频输入或者把采样数据换成传感器振动信号。I2S的采样率由外部晶振和分频器决定配置思路类似仍然是DMA搬运数据、双缓冲切换、主循环FFT只是触发源从定时器变成了I2S时钟。显示端升级串口输出适合调试但不适合做产品。常见做法是接一块SPI接口的屏幕把fft_output映射到屏幕坐标画柱状条或者曲线。这里有个细节屏小的话别把256个频点全画出来对数映射或分段映射到几十个柱状条观感会好很多。频率轴用log映射更符合听觉习惯幅值轴用dB刻度能看出更多细节。我个人的改造习惯是只改一个变量先把SAMPLE_RATE和FFT_POINTS抽出来做成全局参数所有换算函数都从这两个值推算这样后续换芯片、换传感器、加功能时代码其他部分一行都不用动。这个习惯帮我省了无数调试时间。从那以后我每次拿到一个新的FFT工程都强制自己先过一遍采样率、点数、数据类型三个参数的匹配关系再碰其他业务代码按这个路径走基本不会翻车。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →