简介:这份资源面向嵌入式开发初学者与数字信号处理方向的工程师,围绕STM32F1系列单片机(ARM Cortex-M3内核)讲解FFT快速傅里叶变换的完整实现方案,帮助读者把时域采样信号转换到频域,用于通信、音频与图像等场景的频率成分分析。压缩包共225个文件,约4.12MB,以60个h头文件与56个c源文件为核心,配合30个o目标文件、30个d依赖文件及27个crf编译记录,另有uvprojx、uvoptx工程文件、map映射表、hex烧录文件与bat脚本,覆盖从源码到编译产物的完整工程结构。资源涉及Cortex-M3内核特性、Cooley-Tukey蝶形运算与位反转、SRAM内存布局、TIM定时器中断触发、CMSIS-DSP库调用及误差与实时性分析等知识点,并附带TFTLCD、ADC、I2C、CAN等外设驱动源码,便于对照调试与性能优化。目前已有10174人学习下载,适合希望系统掌握STM32平台FFT落地方案的读者参考。1. 为什么我劝你先别急着上DSP:STM32做FFT的真实落地场景前阵子帮一个做振动监测的朋友看代码,他用STM32F103采集加速度传感器信号,想算出主频判断设备有没有异常。一开始他打算外挂一块专用DSP芯片,结果板子空间不够、成本也压不下来。我让他直接在STM32上跑FFT,用芯片自带的ADC采样、DMA搬运、CMSIS-DSP库做运算,整条链路在一颗几十块钱的单片机上就跑通了。这件事让我意识到,很多做嵌入式信号处理的人对“STM32单片机实现FFT”有误解,觉得精度不够、速度太慢,实际上对于几百Hz到几十kHz的振动、音频、电流谐波分析场景,STM32的算力完全够用。这篇笔记就围绕这个项目,把FFT在STM32上的完整实现路径拆开讲清楚,从采样配置到频谱输出,再到怎么改成你自己的应用。适合正在做嵌入式信号采集、想把频谱分析塞进单片机的开发者,也适合刚接触CMSIS-DSP库、想找一个能跑通的完整例程的人。2. 项目定位与技术选型:为什么是CMSIS-DSP而不是自己手写蝶形运算2.1 这个项目到底在做什么项目核心目标很明确:用STM32的ADC对模拟信号进行等间隔采样,把采样数据送进FFT算法,输出各个频率分量的幅值,从而判断信号的主频成分。典型应用包括电机振动监测、音频频谱显示、电网谐波分析、传感器信号特征提取。整个系统不需要操作系统,裸机跑主循环加中断即可,资源占用小,适合成本敏感的批量产品。技术栈上,主控选STM32F4系列(比如F407或F411),原因是它带FPU浮点运算单元,做浮点FFT时速度比F1系列快一个数量级。如果非要用F103,那就得用Q15定点格式,精度和动态范围会打折扣。开发环境用STM32CubeIDE或者Keil MDK都行,库依赖ST官方的HAL库加上ARM的CMSIS-DSP库。CMSIS-DSP是ARM官方为Cortex-M系列优化的数字信号处理库,里面已经写好了FFT、FIR、IIR等常用算法,直接调用就行,没必要自己手写蝶形运算——手写的版本在代码体积和运行效率上通常都不如官方库。2.2 采样率、点数与频率分辨率的关系这三个参数是FFT应用里最容易翻车的地方,必须先算清楚。频率分辨率等于采样率除以FFT点数。比如采样率设为10kHz,做1024点FFT,分辨率就是9.77Hz,意味着你能区分的最小频率间隔是9.77Hz。如果两个信号频率差小于这个值,频谱上就会糊在一起。另一个约束是采样定理:采样率必须大于信号最高频率的两倍。实际工程中一般取2.5到5倍,留出抗混叠滤波器的过渡带。比如你要分析最高5kHz的信号,采样率至少设12.5kHz,稳妥点设20kHz以上。点数选择上,STM32的RAM有限,F407有192KB SRAM,做4096点浮点FFT需要两个4096长度的float数组(实部+虚部),每个float占4字节,一共32KB,加上其他变量和栈空间,勉强够用。如果RAM紧张,可以用1024点或2048点,或者改用Q15定点格式把内存减半。参数典型值说明采样率10kHz~50kHz根据信号最高频率的2.5~5倍选取FFT点数1024 / 2048 / 4096点数越大分辨率越高,但RAM和运算时间增加频率分辨率采样率/点数例如10kHz/1024≈9.77Hz数据格式float32 或 q15F4带FPU优先用float322.3 整体数据流设计从信号到频谱,数据流是这样的:模拟信号经过抗混叠滤波(硬件RC低通或者运放搭建的有源滤波)进入ADC引脚,定时器触发ADC以固定频率采样,DMA把ADC结果搬运到缓冲区,攒够N个点后触发中断,在主循环或中断回调里调用CMSIS-DSP的FFT函数,最后对复数结果求模得到幅值谱。这个链路里,定时器触发保证采样间隔严格均匀,DMA搬运不占CPU,是保证FFT精度的关键。如果采样间隔不均匀,频谱会出现杂散,相当于人为引入了相位噪声。3. 工程搭建与核心代码:从CubeMX配置到FFT输出完整链路3.1 CubeMX里的时钟、ADC与DMA配置先在STM32CubeMX里把时钟树配好。以F407为例,外部晶振8MHz,经过PLL倍频到168MHz系统时钟。ADC时钟不能超过36MHz,所以APB2分频后给ADC的时钟要控制在36MHz以内。定时器时钟设为84MHz(APB1定时器时钟)。ADC配置为独立模式,规则通道,使能连续转换模式关闭(我们用定时器触发),外部触发源选定时器TRGO事件。DMA设置成循环模式,数据宽度半字(ADC是12位,用uint16_t存),内存地址递增,外设地址不变。定时器配置为向上计数,预分频器和自动重载值决定采样率。公式是:采样率 = 定时器时钟 / ((PSC+1)*(ARR+1))。比如定时器时钟84MHz,想要10kHz采样率,可以设PSC=0,ARR=8399,这样84MHz/8400=10kHz。// 定时器3初始化,产生10kHz触发信号 void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; // 不分频 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 8399; // 84MHz / 8400 = 10kHz htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim3); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig); // 配置TRGO输出,用于触发ADC sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig); }这段代码把TIM3配成10kHz更新事件,每次更新时TRGO输出一个脉冲触发ADC。参数说明:Prescaler是预分频,0表示不分频;Period是自动重载值,实际分频系数是Period+1;MasterOutputTrigger选TIM_TRGO_UPDATE表示更新事件触发。3.2 DMA搬运与采样缓冲区管理ADC采集到的数据需要连续不断地存到数组里,用DMA可以完全不占CPU。定义一个长度为FFT点数的uint16_t数组作为DMA目标地址,DMA循环模式会让它自动回卷。但要注意,FFT运算需要一帧完整的数据,所以通常用双缓冲或者半传输中断加传输完成中断来管理。#define FFT_SIZE 1024 uint16_t adc_buf[FFT_SIZE]; // ADC原始数据 float fft_input[FFT_SIZE * 2]; // FFT输入:实部+虚部交错 float fft_output[FFT_SIZE]; // FFT幅值输出 // DMA传输完成回调,一帧数据攒够后触发 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 把ADC数据转成float并去直流分量 float sum = 0.0f; for (int i = 0; i < FFT_SIZE; i++) { sum += (float)adc_buf[i]; } float mean = sum / FFT_SIZE; for (int i = 0; i < FFT_SIZE; i++) { fft_input[2*i] = ((float)adc_buf[i] - mean) * 3.3f / 4096.0f; fft_input[2*i+1] = 0.0f; // 虚部清零 } // 这里可以置标志位,在主循环里做FFT fft_ready = 1; }这段回调里做了三件事:求均值去直流、把ADC码值转成电压、虚部清零。去直流很重要,如果信号有直流偏置,FFT结果在0Hz处会有一个很大的峰,可能掩盖低频成分。电压转换系数3.3f/4096.0f是因为ADC是12位,参考电压3.3V。虚部清零是因为输入是实信号,FFT之前虚部必须为0。3.3 调用CMSIS-DSP的FFT函数CMSIS-DSP提供了多种FFT函数,实数FFT用arm_rfft_fast_f32效率最高,它内部会把实信号打包成复数再运算,比直接用复数FFT省一半计算量。初始化时先创建实例,然后调用初始化函数,最后执行变换。#include "arm_math.h" arm_rfft_fast_instance_f32 fft_inst; void fft_init(void) { // 初始化实数FFT实例,参数是点数 arm_rfft_fast_init_f32(&fft_inst, FFT_SIZE); } void fft_process(void) { // 执行FFT,fft_input是交错格式的实部虚部 arm_rfft_fast_f32(&fft_inst, fft_input, fft_output, 0); // 求模得到幅值谱,注意输出是对称的,只看前一半 arm_cmplx_mag_f32(fft_output, fft_output, FFT_SIZE / 2); // 找最大峰值对应的频率 float max_val; uint32_t max_idx; arm_max_f32(fft_output, FFT_SIZE / 2, &max_val, &max_idx); // 频率 = 索引 * 采样率 / FFT点数 float peak_freq = (float)max_idx * SAMPLE_RATE / FFT_SIZE; }arm_rfft_fast_f32的第三个参数是输出数组,第四个参数0表示正向变换(1是逆变换)。注意实数FFT的输出格式比较特殊:输出数组的前半部分是复数频谱的实部,后半部分是虚部,但arm_cmplx_mag_f32可以直接处理这种打包格式,所以不用手动拆。求模之后结果是对称的,只需要看前N/2个点。最后用arm_max_f32找峰值索引,乘以频率分辨率就得到主频。3.4 加窗与频谱泄漏处理如果信号频率不是频率分辨率的整数倍,FFT会出现频谱泄漏,峰值附近出现拖尾。工程上一般加汉宁窗或汉明窗来抑制。加窗的代价是主瓣变宽,频率分辨率略微下降,但峰值幅度更准确。// 生成汉宁窗系数 float window[FFT_SIZE]; for (int i = 0; i < FFT_SIZE; i++) { window[i] = 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); } // 在ADC回调里加窗 for (int i = 0; i < FFT_SIZE; i++) { fft_input[2*i] = ((float)adc_buf[i] - mean) * 3.3f / 4096.0f * window[i]; fft_input[2*i+1] = 0.0f; }加窗之后幅值需要乘以补偿系数,汉宁窗的相干增益是0.5,所以幅值要乘2才能还原真实幅度。如果只是找主频位置,不做绝对幅度测量,可以不加补偿。4. 避坑与常见问题:采样、内存和精度上的血泪经验4.1 采样率不对导致频谱整体偏移现象:频谱上峰值位置和信号源频率对不上,偏差很大且没有规律。原因通常是定时器配置算错了,或者ADC触发源没选对。比如定时器时钟不是你以为的84MHz,而是经过分频后的42MHz,采样率就变成5kHz,所有频率读数翻倍。解决方法是先用示波器测ADC触发引脚或者用GPIO翻转法确认实际采样率,再反推定时器参数。我一般会在定时器中断里翻转一个IO口,用示波器看方波频率,确认无误后再接传感器。4.2 内存溢出导致HardFault现象:程序跑一会儿就进HardFault,或者FFT结果全是乱码。原因多半是数组太大,栈溢出或者堆不够。F407的默认栈大小在启动文件里是0x400(1KB),如果FFT数组定义在局部变量里,1024点浮点数组就要8KB,直接爆栈。解决办法是把大数组定义成全局变量或者用static修饰,放到静态存储区。另外检查链接脚本里的RAM分配,确保堆栈区域够用。4.3 直流偏置淹没低频分量现象:频谱上0Hz附近有一个巨大峰值,低频信号完全看不见。原因是ADC采集到的信号有直流偏置,比如传感器输出叠加了1.65V的偏置电压。FFT对直流分量非常敏感,0Hz处的幅值会非常大。解决办法是在FFT之前减去均值,代码里就是先求和再减去平均值。如果偏置是缓慢漂移的,可以用高通滤波器先滤掉。4.4 频谱泄漏导致峰值判断错误现象:信号频率在两个频点之间时,峰值出现在错误的索引上,或者出现两个相近的峰。原因是没加窗或者窗函数选得不对。矩形窗泄漏最严重,汉宁窗和汉明窗抑制效果好但主瓣宽。解决办法是根据应用选窗:如果只关心主频位置,汉宁窗够用;如果需要区分相近频率,用矩形窗但增加点数。另外可以用抛物线插值法对峰值索引做修正,提高频率估计精度。4.5 浮点运算速度不够导致实时性差现象:FFT运算时间太长,采样数据来不及处理,缓冲区被覆盖。F103没有FPU,浮点FFT跑1024点可能要几十毫秒。解决办法是换F4系列带FPU的芯片,或者改用Q15定点格式。CMSIS-DSP提供了arm_rfft_q15函数,速度比浮点快很多,但要注意数据范围不能超过Q15的表示范围(-1到1),输入前要归一化。5. 二次开发与改造:把FFT例程变成你自己的产品代码5.1 从固定点数改成动态配置原始例程里FFT_SIZE是宏定义,编译时固定。实际产品可能需要根据上位机命令切换分辨率,比如低功耗模式下用256点,高精度模式下用4096点。改造思路是把FFT实例和缓冲区都做成结构体,运行时根据配置参数初始化。typedef struct { arm_rfft_fast_instance_f32 inst; float *input; float *output; uint16_t size; float sample_rate; } fft_handle_t; int fft_config(fft_handle_t *h, uint16_t size, float fs) { h->size = size; h->sample_rate = fs; h->input = (float *)malloc(size * 2 * sizeof(float)); h->output = (float *)malloc(size * sizeof(float)); if (!h->input || !h->output) return -1; arm_rfft_fast_init_f32(&h->inst, size); return 0; }这样改造后,上层应用只需要调用fft_config传入点数和采样率,不用关心底层细节。注意malloc在单片机上要慎用,容易产生碎片,更稳妥的做法是预分配最大尺寸的静态数组,运行时只改size参数。5.2 增加峰值插值提高频率精度直接取最大索引的频率分辨率有限,比如10kHz采样1024点,分辨率9.77Hz。如果信号是50.3Hz,峰值可能落在51.7Hz的索引上。用抛物线插值可以把精度提高到1Hz以内。// 抛物线插值修正峰值索引 float interpolate_peak(float *mag, uint32_t idx, uint16_t size) { if (idx == 0 || idx >= size - 1) return (float)idx; float alpha = mag[idx - 1]; float beta = mag[idx]; float gamma = mag[idx + 1]; float delta = 0.5f * (alpha - gamma) / (alpha - 2.0f * beta + gamma); return (float)idx + delta; }这个公式利用峰值左右两个点的幅值做二次拟合,delta是偏移量,范围在-0.5到0.5之间。修正后的索引乘以频率分辨率就是更准确的频率估计。注意如果峰值在边界上(索引0或N/2-1),插值会失效,需要特殊处理。5.3 多通道FFT与结果上报实际产品往往需要同时分析多路信号,比如三相电流的谐波。改造方法是给每个通道分配独立的ADC缓冲区和FFT实例,用DMA的多个通道或者ADC扫描模式轮流采集。运算时可以分时复用同一个FFT实例,只要保证数据不冲突。结果上报一般通过UART或CAN发给上位机。建议定义一个简单的协议帧,包含通道号、峰值频率、峰值幅值、时间戳。如果上位机需要完整频谱,可以把幅值数组按一定间隔抽取后发送,避免数据量过大。改造方向关键改动注意事项动态点数结构体封装+运行时初始化避免频繁malloc,预分配最大缓冲区峰值插值增加插值函数边界索引需特殊处理多通道多缓冲区+分时复用FFT实例注意DMA通道冲突结果上报自定义协议帧控制发送频率,避免阻塞采样5.4 验证方法:用信号发生器闭环测试改完之后怎么验证?我一般用信号发生器输出一个已知频率的正弦波,比如1kHz、幅度1Vpp,接到ADC输入端。然后看串口打印的峰值频率是不是1kHz附近,幅值是不是和输入匹配。如果频率对不上,先查采样率;如果幅值不对,查窗函数补偿和ADC参考电压。再用示波器看ADC输入引脚,确认信号没有失真。最后把信号频率改成非整数倍分辨率的值,比如1.005kHz,看插值后的结果是否准确。从那以后我每次做FFT相关的项目,都会先用信号发生器跑一遍单频点闭环,确认采样率、窗函数、插值三个环节都没问题,再接真实传感器。这个习惯帮我省了很多调试时间,也避免过把传感器装到设备上才发现频谱不对的尴尬。希望帮到你。本文还有配套的精品资源,点击获取
简介:这份资源面向嵌入式开发初学者与数字信号处理方向的工程师,围绕STM32F1系列单片机(ARM Cortex-M3内核)讲解FFT快速傅里叶变换的完整实现方案,帮助读者把时域采样信号转换到频域,用于通信、音频与图像等场景的频率成分分析。压缩包共225个文件,约4.12MB,以60个h头文件与56个c源文件为核心,配合30个o目标文件、30个d依赖文件及27个crf编译记录,另有uvprojx、uvoptx工程文件、map映射表、hex烧录文件与bat脚本,覆盖从源码到编译产物的完整工程结构。资源涉及Cortex-M3内核特性、Cooley-Tukey蝶形运算与位反转、SRAM内存布局、TIM定时器中断触发、CMSIS-DSP库调用及误差与实时性分析等知识点,并附带TFTLCD、ADC、I2C、CAN等外设驱动源码,便于对照调试与性能优化。目前已有10174人学习下载,适合希望系统掌握STM32平台FFT落地方案的读者参考。1. 为什么我劝你先别急着上DSP:STM32做FFT的真实落地场景前阵子帮一个做振动监测的朋友看代码,他用STM32F103采集加速度传感器信号,想算出主频判断设备有没有异常。一开始他打算外挂一块专用DSP芯片,结果板子空间不够、成本也压不下来。我让他直接在STM32上跑FFT,用芯片自带的ADC采样、DMA搬运、CMSIS-DSP库做运算,整条链路在一颗几十块钱的单片机上就跑通了。这件事让我意识到,很多做嵌入式信号处理的人对“STM32单片机实现FFT”有误解,觉得精度不够、速度太慢,实际上对于几百Hz到几十kHz的振动、音频、电流谐波分析场景,STM32的算力完全够用。这篇笔记就围绕这个项目,把FFT在STM32上的完整实现路径拆开讲清楚,从采样配置到频谱输出,再到怎么改成你自己的应用。适合正在做嵌入式信号采集、想把频谱分析塞进单片机的开发者,也适合刚接触CMSIS-DSP库、想找一个能跑通的完整例程的人。2. 项目定位与技术选型:为什么是CMSIS-DSP而不是自己手写蝶形运算2.1 这个项目到底在做什么项目核心目标很明确:用STM32的ADC对模拟信号进行等间隔采样,把采样数据送进FFT算法,输出各个频率分量的幅值,从而判断信号的主频成分。典型应用包括电机振动监测、音频频谱显示、电网谐波分析、传感器信号特征提取。整个系统不需要操作系统,裸机跑主循环加中断即可,资源占用小,适合成本敏感的批量产品。技术栈上,主控选STM32F4系列(比如F407或F411),原因是它带FPU浮点运算单元,做浮点FFT时速度比F1系列快一个数量级。如果非要用F103,那就得用Q15定点格式,精度和动态范围会打折扣。开发环境用STM32CubeIDE或者Keil MDK都行,库依赖ST官方的HAL库加上ARM的CMSIS-DSP库。CMSIS-DSP是ARM官方为Cortex-M系列优化的数字信号处理库,里面已经写好了FFT、FIR、IIR等常用算法,直接调用就行,没必要自己手写蝶形运算——手写的版本在代码体积和运行效率上通常都不如官方库。2.2 采样率、点数与频率分辨率的关系这三个参数是FFT应用里最容易翻车的地方,必须先算清楚。频率分辨率等于采样率除以FFT点数。比如采样率设为10kHz,做1024点FFT,分辨率就是9.77Hz,意味着你能区分的最小频率间隔是9.77Hz。如果两个信号频率差小于这个值,频谱上就会糊在一起。另一个约束是采样定理:采样率必须大于信号最高频率的两倍。实际工程中一般取2.5到5倍,留出抗混叠滤波器的过渡带。比如你要分析最高5kHz的信号,采样率至少设12.5kHz,稳妥点设20kHz以上。点数选择上,STM32的RAM有限,F407有192KB SRAM,做4096点浮点FFT需要两个4096长度的float数组(实部+虚部),每个float占4字节,一共32KB,加上其他变量和栈空间,勉强够用。如果RAM紧张,可以用1024点或2048点,或者改用Q15定点格式把内存减半。参数典型值说明采样率10kHz~50kHz根据信号最高频率的2.5~5倍选取FFT点数1024 / 2048 / 4096点数越大分辨率越高,但RAM和运算时间增加频率分辨率采样率/点数例如10kHz/1024≈9.77Hz数据格式float32 或 q15F4带FPU优先用float322.3 整体数据流设计从信号到频谱,数据流是这样的:模拟信号经过抗混叠滤波(硬件RC低通或者运放搭建的有源滤波)进入ADC引脚,定时器触发ADC以固定频率采样,DMA把ADC结果搬运到缓冲区,攒够N个点后触发中断,在主循环或中断回调里调用CMSIS-DSP的FFT函数,最后对复数结果求模得到幅值谱。这个链路里,定时器触发保证采样间隔严格均匀,DMA搬运不占CPU,是保证FFT精度的关键。如果采样间隔不均匀,频谱会出现杂散,相当于人为引入了相位噪声。3. 工程搭建与核心代码:从CubeMX配置到FFT输出完整链路3.1 CubeMX里的时钟、ADC与DMA配置先在STM32CubeMX里把时钟树配好。以F407为例,外部晶振8MHz,经过PLL倍频到168MHz系统时钟。ADC时钟不能超过36MHz,所以APB2分频后给ADC的时钟要控制在36MHz以内。定时器时钟设为84MHz(APB1定时器时钟)。ADC配置为独立模式,规则通道,使能连续转换模式关闭(我们用定时器触发),外部触发源选定时器TRGO事件。DMA设置成循环模式,数据宽度半字(ADC是12位,用uint16_t存),内存地址递增,外设地址不变。定时器配置为向上计数,预分频器和自动重载值决定采样率。公式是:采样率 = 定时器时钟 / ((PSC+1)*(ARR+1))。比如定时器时钟84MHz,想要10kHz采样率,可以设PSC=0,ARR=8399,这样84MHz/8400=10kHz。// 定时器3初始化,产生10kHz触发信号 void MX_TIM3_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim3.Instance = TIM3; htim3.Init.Prescaler = 0; // 不分频 htim3.Init.CounterMode = TIM_COUNTERMODE_UP; htim3.Init.Period = 8399; // 84MHz / 8400 = 10kHz htim3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim3); sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; HAL_TIM_ConfigClockSource(&htim3, &sClockSourceConfig); // 配置TRGO输出,用于触发ADC sMasterConfig.MasterOutputTrigger = TIM_TRGO_UPDATE; sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE; HAL_TIMEx_MasterConfigSynchronization(&htim3, &sMasterConfig); }这段代码把TIM3配成10kHz更新事件,每次更新时TRGO输出一个脉冲触发ADC。参数说明:Prescaler是预分频,0表示不分频;Period是自动重载值,实际分频系数是Period+1;MasterOutputTrigger选TIM_TRGO_UPDATE表示更新事件触发。3.2 DMA搬运与采样缓冲区管理ADC采集到的数据需要连续不断地存到数组里,用DMA可以完全不占CPU。定义一个长度为FFT点数的uint16_t数组作为DMA目标地址,DMA循环模式会让它自动回卷。但要注意,FFT运算需要一帧完整的数据,所以通常用双缓冲或者半传输中断加传输完成中断来管理。#define FFT_SIZE 1024 uint16_t adc_buf[FFT_SIZE]; // ADC原始数据 float fft_input[FFT_SIZE * 2]; // FFT输入:实部+虚部交错 float fft_output[FFT_SIZE]; // FFT幅值输出 // DMA传输完成回调,一帧数据攒够后触发 void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { // 把ADC数据转成float并去直流分量 float sum = 0.0f; for (int i = 0; i < FFT_SIZE; i++) { sum += (float)adc_buf[i]; } float mean = sum / FFT_SIZE; for (int i = 0; i < FFT_SIZE; i++) { fft_input[2*i] = ((float)adc_buf[i] - mean) * 3.3f / 4096.0f; fft_input[2*i+1] = 0.0f; // 虚部清零 } // 这里可以置标志位,在主循环里做FFT fft_ready = 1; }这段回调里做了三件事:求均值去直流、把ADC码值转成电压、虚部清零。去直流很重要,如果信号有直流偏置,FFT结果在0Hz处会有一个很大的峰,可能掩盖低频成分。电压转换系数3.3f/4096.0f是因为ADC是12位,参考电压3.3V。虚部清零是因为输入是实信号,FFT之前虚部必须为0。3.3 调用CMSIS-DSP的FFT函数CMSIS-DSP提供了多种FFT函数,实数FFT用arm_rfft_fast_f32效率最高,它内部会把实信号打包成复数再运算,比直接用复数FFT省一半计算量。初始化时先创建实例,然后调用初始化函数,最后执行变换。#include "arm_math.h" arm_rfft_fast_instance_f32 fft_inst; void fft_init(void) { // 初始化实数FFT实例,参数是点数 arm_rfft_fast_init_f32(&fft_inst, FFT_SIZE); } void fft_process(void) { // 执行FFT,fft_input是交错格式的实部虚部 arm_rfft_fast_f32(&fft_inst, fft_input, fft_output, 0); // 求模得到幅值谱,注意输出是对称的,只看前一半 arm_cmplx_mag_f32(fft_output, fft_output, FFT_SIZE / 2); // 找最大峰值对应的频率 float max_val; uint32_t max_idx; arm_max_f32(fft_output, FFT_SIZE / 2, &max_val, &max_idx); // 频率 = 索引 * 采样率 / FFT点数 float peak_freq = (float)max_idx * SAMPLE_RATE / FFT_SIZE; }arm_rfft_fast_f32的第三个参数是输出数组,第四个参数0表示正向变换(1是逆变换)。注意实数FFT的输出格式比较特殊:输出数组的前半部分是复数频谱的实部,后半部分是虚部,但arm_cmplx_mag_f32可以直接处理这种打包格式,所以不用手动拆。求模之后结果是对称的,只需要看前N/2个点。最后用arm_max_f32找峰值索引,乘以频率分辨率就得到主频。3.4 加窗与频谱泄漏处理如果信号频率不是频率分辨率的整数倍,FFT会出现频谱泄漏,峰值附近出现拖尾。工程上一般加汉宁窗或汉明窗来抑制。加窗的代价是主瓣变宽,频率分辨率略微下降,但峰值幅度更准确。// 生成汉宁窗系数 float window[FFT_SIZE]; for (int i = 0; i < FFT_SIZE; i++) { window[i] = 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); } // 在ADC回调里加窗 for (int i = 0; i < FFT_SIZE; i++) { fft_input[2*i] = ((float)adc_buf[i] - mean) * 3.3f / 4096.0f * window[i]; fft_input[2*i+1] = 0.0f; }加窗之后幅值需要乘以补偿系数,汉宁窗的相干增益是0.5,所以幅值要乘2才能还原真实幅度。如果只是找主频位置,不做绝对幅度测量,可以不加补偿。4. 避坑与常见问题:采样、内存和精度上的血泪经验4.1 采样率不对导致频谱整体偏移现象:频谱上峰值位置和信号源频率对不上,偏差很大且没有规律。原因通常是定时器配置算错了,或者ADC触发源没选对。比如定时器时钟不是你以为的84MHz,而是经过分频后的42MHz,采样率就变成5kHz,所有频率读数翻倍。解决方法是先用示波器测ADC触发引脚或者用GPIO翻转法确认实际采样率,再反推定时器参数。我一般会在定时器中断里翻转一个IO口,用示波器看方波频率,确认无误后再接传感器。4.2 内存溢出导致HardFault现象:程序跑一会儿就进HardFault,或者FFT结果全是乱码。原因多半是数组太大,栈溢出或者堆不够。F407的默认栈大小在启动文件里是0x400(1KB),如果FFT数组定义在局部变量里,1024点浮点数组就要8KB,直接爆栈。解决办法是把大数组定义成全局变量或者用static修饰,放到静态存储区。另外检查链接脚本里的RAM分配,确保堆栈区域够用。4.3 直流偏置淹没低频分量现象:频谱上0Hz附近有一个巨大峰值,低频信号完全看不见。原因是ADC采集到的信号有直流偏置,比如传感器输出叠加了1.65V的偏置电压。FFT对直流分量非常敏感,0Hz处的幅值会非常大。解决办法是在FFT之前减去均值,代码里就是先求和再减去平均值。如果偏置是缓慢漂移的,可以用高通滤波器先滤掉。4.4 频谱泄漏导致峰值判断错误现象:信号频率在两个频点之间时,峰值出现在错误的索引上,或者出现两个相近的峰。原因是没加窗或者窗函数选得不对。矩形窗泄漏最严重,汉宁窗和汉明窗抑制效果好但主瓣宽。解决办法是根据应用选窗:如果只关心主频位置,汉宁窗够用;如果需要区分相近频率,用矩形窗但增加点数。另外可以用抛物线插值法对峰值索引做修正,提高频率估计精度。4.5 浮点运算速度不够导致实时性差现象:FFT运算时间太长,采样数据来不及处理,缓冲区被覆盖。F103没有FPU,浮点FFT跑1024点可能要几十毫秒。解决办法是换F4系列带FPU的芯片,或者改用Q15定点格式。CMSIS-DSP提供了arm_rfft_q15函数,速度比浮点快很多,但要注意数据范围不能超过Q15的表示范围(-1到1),输入前要归一化。5. 二次开发与改造:把FFT例程变成你自己的产品代码5.1 从固定点数改成动态配置原始例程里FFT_SIZE是宏定义,编译时固定。实际产品可能需要根据上位机命令切换分辨率,比如低功耗模式下用256点,高精度模式下用4096点。改造思路是把FFT实例和缓冲区都做成结构体,运行时根据配置参数初始化。typedef struct { arm_rfft_fast_instance_f32 inst; float *input; float *output; uint16_t size; float sample_rate; } fft_handle_t; int fft_config(fft_handle_t *h, uint16_t size, float fs) { h->size = size; h->sample_rate = fs; h->input = (float *)malloc(size * 2 * sizeof(float)); h->output = (float *)malloc(size * sizeof(float)); if (!h->input || !h->output) return -1; arm_rfft_fast_init_f32(&h->inst, size); return 0; }这样改造后,上层应用只需要调用fft_config传入点数和采样率,不用关心底层细节。注意malloc在单片机上要慎用,容易产生碎片,更稳妥的做法是预分配最大尺寸的静态数组,运行时只改size参数。5.2 增加峰值插值提高频率精度直接取最大索引的频率分辨率有限,比如10kHz采样1024点,分辨率9.77Hz。如果信号是50.3Hz,峰值可能落在51.7Hz的索引上。用抛物线插值可以把精度提高到1Hz以内。// 抛物线插值修正峰值索引 float interpolate_peak(float *mag, uint32_t idx, uint16_t size) { if (idx == 0 || idx >= size - 1) return (float)idx; float alpha = mag[idx - 1]; float beta = mag[idx]; float gamma = mag[idx + 1]; float delta = 0.5f * (alpha - gamma) / (alpha - 2.0f * beta + gamma); return (float)idx + delta; }这个公式利用峰值左右两个点的幅值做二次拟合,delta是偏移量,范围在-0.5到0.5之间。修正后的索引乘以频率分辨率就是更准确的频率估计。注意如果峰值在边界上(索引0或N/2-1),插值会失效,需要特殊处理。5.3 多通道FFT与结果上报实际产品往往需要同时分析多路信号,比如三相电流的谐波。改造方法是给每个通道分配独立的ADC缓冲区和FFT实例,用DMA的多个通道或者ADC扫描模式轮流采集。运算时可以分时复用同一个FFT实例,只要保证数据不冲突。结果上报一般通过UART或CAN发给上位机。建议定义一个简单的协议帧,包含通道号、峰值频率、峰值幅值、时间戳。如果上位机需要完整频谱,可以把幅值数组按一定间隔抽取后发送,避免数据量过大。改造方向关键改动注意事项动态点数结构体封装+运行时初始化避免频繁malloc,预分配最大缓冲区峰值插值增加插值函数边界索引需特殊处理多通道多缓冲区+分时复用FFT实例注意DMA通道冲突结果上报自定义协议帧控制发送频率,避免阻塞采样5.4 验证方法:用信号发生器闭环测试改完之后怎么验证?我一般用信号发生器输出一个已知频率的正弦波,比如1kHz、幅度1Vpp,接到ADC输入端。然后看串口打印的峰值频率是不是1kHz附近,幅值是不是和输入匹配。如果频率对不上,先查采样率;如果幅值不对,查窗函数补偿和ADC参考电压。再用示波器看ADC输入引脚,确认信号没有失真。最后把信号频率改成非整数倍分辨率的值,比如1.005kHz,看插值后的结果是否准确。从那以后我每次做FFT相关的项目,都会先用信号发生器跑一遍单频点闭环,确认采样率、窗函数、插值三个环节都没问题,再接真实传感器。这个习惯帮我省了很多调试时间,也避免过把传感器装到设备上才发现频谱不对的尴尬。希望帮到你。本文还有配套的精品资源,点击获取