STM32单片机FFT信号处理实战:从ADC采样到频谱分析

📅 2026/7/29 11:55:29 👁️ 阅读次数 📝 编程学习
STM32单片机FFT信号处理实战:从ADC采样到频谱分析

1. 项目概述:当单片机遇上信号处理

在嵌入式开发领域,STM32系列单片机因其强大的性能和丰富的外设,早已成为工程师手中的“瑞士军刀”。但很多时候,我们用它来处理的任务,还停留在按键扫描、LED闪烁、串口收发这些基础层面。你有没有想过,让这块小小的芯片,去做一些更“聪明”的事情?比如,让它“听懂”一个复杂信号里到底藏着什么秘密。

这就是我们今天要聊的核心:在STM32单片机上使用傅里叶变换解析信号。简单来说,傅里叶变换就像是一个“信号翻译官”,它能把一个随时间变化的信号(时域信号),分解成一系列不同频率、不同振幅的正弦波(频域信号)。想象一下,你听到一段混杂的音乐,傅里叶变换能帮你分析出里面到底有哪几个音符在响,每个音符有多大声。对于单片机而言,这意味着它能从传感器采集到的一堆看似杂乱无章的电压数据中,提取出有用的频率成分、幅值大小,甚至判断是否存在特定频率的干扰。

这有什么用?应用场景太多了。在工业现场,你可以用它来分析电机轴承的振动频谱,提前发现异常磨损;在智能家居中,可以用来识别特定的声音指令,实现非接触控制;在电力监测领域,可以分析电网的谐波含量;甚至可以用在简单的音乐频谱显示或者心电信号的特征提取上。STM32,特别是带有FPU(浮点运算单元)和DSP指令集的型号,为实时进行傅里叶变换这种计算密集型任务提供了可能。

这篇文章,就是带你从零开始,在STM32上实现一个实用的信号频域分析功能。我会避开枯燥的数学推导,聚焦于工程实现:如何选择算法、如何准备数据、如何调用库函数、如何优化性能,以及如何解读结果。无论你是正在做相关项目的学生,还是希望为产品增加高级诊断功能的工程师,这篇基于实际踩坑经验的总结,都能给你提供一条清晰的路径。

2. 核心思路与方案选型:为什么是FFT?

在单片机上实现傅里叶变换,我们几乎唯一的选择就是快速傅里叶变换(FFT)。这是傅里叶变换的一种高效计算算法,能将计算复杂度从O(N²)降低到O(N log N),让实时处理成为可能。我们的核心思路可以概括为:ADC采样 -> 数据预处理 -> FFT计算 -> 结果后处理与解读。

2.1 算法选型:实数FFT vs. 复数FFT

FFT算法通常处理复数序列。但我们的ADC采样值都是实数。这里就有两个主流方案:

  1. 直接使用实数FFT(RFFT):这是最直接的优化。STM32的CMSIS-DSP库直接提供了针对实数输入的FFT函数,如arm_rfft_fast_f32。它内部会通过巧妙的打包方式,利用复数FFT的蝶形结构来计算实数序列的FFT,效率最高,内存占用也相对较少。对于绝大多数只需要分析幅值谱的应用,这是首选。

  2. 使用复数FFT(CFFT):将ADC采集的实数序列的虚部全部置零,形成一个复数序列,然后调用复数FFT函数(如arm_cfft_f32)。这种方法更通用,可以同时得到幅值和相位信息,但计算量和内存占用都是实数FFT的两倍。除非你的应用明确需要相位信息(如计算阻抗角、进行相关分析),否则不推荐。

实操心得:一开始我为了“功能全面”使用了复数FFT,结果在STM32F103这种没有FPU的芯片上,计算256点就感到吃力。后来切换到arm_rfft_fast_f32,不仅速度提升一倍,内存也节省了一半。所以,明确你的核心需求是幅值分析,就果断用实数FFT。

2.2 点数选择:256,512,还是1024?

FFT的点数N决定了频率分辨率(Δf = 采样频率Fs / N)和能分析的最高频率(奈奎斯特频率,Fs/2)。点数越多,频率分辨率越高,但计算量也越大。

  • 计算量:FFT计算时间大致与 N log₂(N) 成正比。256点比1024点快很多。
  • 分辨率:若Fs=10kHz,256点FFT的Δf≈39Hz,512点Δf≈19.5Hz,1024点Δf≈9.8Hz。你需要根据待分析信号的最小频率间隔来选择。
  • 内存:一个N点的实数FFT,需要约N个float型数据的内存。1024点就是4KB,对于资源紧张的芯片需要考虑。

一个实用的权衡策略:先从256点开始,如果发现频率分辨率不够,再提升到512点。对于电机振动分析(基频几十到几百Hz),256或512点通常足够。对于音频分析(20Hz-20kHz),可能需要1024或2048点,并考虑使用更高主频和带DSP的型号(如STM32F4/F7/H7)。

2.3 库的选择:CMSIS-DSP是王道

STM32生态中,CMSIS-DSP库是进行FFT等数字信号处理的不二之选。它是ARM官方优化的库,针对Cortex-M内核(尤其是M4/M7/M33的DSP指令和FPU)做了大量汇编级优化,效率远超自己手写的C语言FFT代码。

如何获取与添加

  1. 如果你使用STM32CubeMX初始化工程,在Software Packs中选择ARM::CMSIS-DSP即可自动添加。
  2. 如果手动添加,需要将CMSIS-DSP的源码(通常位于CubeIDE或CubeMX安装目录的Drivers/CMSIS/DSP下)加入工程,并包含头文件路径。
  3. 最关键的一步:在编译器中开启FPU支持(对于F4/F7等型号),并添加预定义宏ARM_MATH_CM4(或对应内核的宏)。

3. 硬件与软件环境搭建

3.1 硬件平台选择

并非所有STM32都适合做FFT。主要考量两点:

  1. FPU(浮点运算单元):FFT涉及大量浮点乘加运算。有FPU(如STM32F4/F7/H7系列)比没有FPU(如STM32F1/F0系列)的速度可以快几十倍。强烈建议选择带FPU的型号
  2. 主频与内存:主频越高,计算越快。FFT运算需要缓冲区,点数越多,对RAM需求越大。例如,1024点实数FFT,输入输出缓冲区就需要至少1024 * 4字节 = 4KB的RAM(使用float类型)。

推荐入门型号STM32F407STM32F429。它们主频高(168MHz以上),带有FPU和DSP指令,RAM足够(192KB+),性价比高,资料丰富。

3.2 软件工程配置

以STM32CubeIDE和HAL库为例,关键配置步骤如下:

  1. 时钟树配置:将系统时钟(SYSCLK)配置到芯片允许的最高频率,以提升整体性能。
  2. ADC配置
    • 采样频率(Fs):根据奈奎斯特定理,Fs必须大于你感兴趣的最高信号频率的两倍。例如,想分析1kHz以下的信号,Fs至少需要2kHz。通常设置到信号最高频率的5-10倍以获得较好波形。在CubeMX中,这通过ADC的时钟分频和采样周期来设定。
    • 触发方式:推荐使用定时器触发。配置一个定时器(如TIM2),使其溢出频率等于你想要的Fs。然后将ADC的触发源设置为该定时器的触发输出(如TIM2_TRGO)。这样可以实现精准、等间隔的采样,是保证FFT结果准确性的基础。
    • DMA配置:必须启用DMA!将ADC配置为DMA循环模式(Circular)。这样ADC会在定时器的驱动下,自动将转换结果通过DMA搬运到你指定的内存数组中,完全不需要CPU干预,实现了极低开销的数据采集。
  3. 开启FPU:在Project -> Properties -> C/C++ Build -> Settings -> MCU Settings中,将Floating-point hardware设置为FPv4-SP-D16(对于F4系列)。
  4. 添加CMSIS-DSP库:如前所述,通过CubeMX添加或手动添加源码。

4. 代码实现与核心环节解析

4.1 数据采集模块

这是整个系统的源头,必须稳定可靠。

// 宏定义 #define FFT_LENGTH 512 // FFT点数 #define ADC_BUFF_LEN (FFT_LENGTH * 2) // DMA缓冲区长度,通常是FFT点数的两倍,用于乒乓操作 // 全局变量 float32_t adc_buffer[ADC_BUFF_LEN]; // ADC原始值缓冲区 float32_t fft_input[FFT_LENGTH]; // FFT输入缓冲区 float32_t fft_output[FFT_LENGTH]; // FFT输出缓冲区 volatile uint8_t half_buffer_flag = 0; // 半缓冲区标志 // ADC DMA半传输/传输完成中断回调函数 void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef* hadc) { half_buffer_flag = 1; // 前半缓冲区就绪 } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef* hadc) { half_buffer_flag = 2; // 后半缓冲区就绪 } // 主循环中的数据搬运 void Process_ADC_Data(void) { if(half_buffer_flag != 0) { uint32_t start_idx = (half_buffer_flag == 1) ? 0 : ADC_BUFF_LEN/2; // 将ADC原始值(0-4095)转换为电压值或直接用于FFT的浮点数 for(int i=0; i<FFT_LENGTH; i++) { // 方法1:直接使用(如果关心相对幅值) // fft_input[i] = (float32_t)adc_buffer[start_idx + i]; // 方法2:转换为电压(更直观) // fft_input[i] = ((float32_t)adc_buffer[start_idx + i]) / 4095.0f * 3.3f; // 方法3:去除直流分量(非常重要!) static float32_t dc_offset = 1.65f; // 假设1.65V是直流偏置 fft_input[i] = ((float32_t)adc_buffer[start_idx + i]) / 4095.0f * 3.3f - dc_offset; } half_buffer_flag = 0; // 清除标志 // 此时fft_input中已经准备好了待处理的数据,可以触发FFT计算 Do_FFT_Calculation(); } }

注意事项去除直流分量(去趋势)是FFT前至关重要的一步。ADC采集的信号往往包含一个直流偏置(比如信号在1.65V上下波动)。这个直流分量在频域会体现在0Hz处一个巨大的谱线,可能会淹没你关心的低频小信号。简单的做法是减去采集信号的平均值。

4.2 FFT计算模块

这是核心处理单元。

#include "arm_math.h" #include "arm_const_structs.h" // 包含FFT结构体定义 // 定义FFT实例结构体 arm_rfft_fast_instance_f32 fft_instance; // FFT初始化 void FFT_Init(void) { arm_rfft_fast_init_f32(&fft_instance, FFT_LENGTH); } // 执行FFT计算 void Do_FFT_Calculation(void) { // 执行实数FFT // fft_input 是输入实数数组(长度FFT_LENGTH) // fft_output 是输出复数数组(长度FFT_LENGTH),但其存储格式是特殊的 arm_rfft_fast_f32(&fft_instance, fft_input, fft_output, 0); // 最后一个参数0表示正向FFT // 计算幅值谱 Compute_Magnitude(); }

4.3 幅值谱计算与解读

FFT的直接输出(fft_output)是一个复数数组,存储格式为:[实部0, 实部1, 虚部1, 实部2, 虚部2, ..., 实部N/2, 虚部N/2]。其中实部0对应直流分量(0Hz),实部N/2对应奈奎斯特频率分量(Fs/2)。其余点共轭对称,所以我们只需要前N/2+1个点。

float32_t fft_mag[FFT_LENGTH/2 + 1]; // 幅值谱数组 void Compute_Magnitude(void) { // 计算直流分量(0Hz)的幅值 fft_mag[0] = fft_output[0] / FFT_LENGTH; // 注意:需要除以N // 计算中间频率点的幅值 for(uint16_t i=1; i < FFT_LENGTH/2; i++) { float32_t real = fft_output[2*i]; float32_t imag = fft_output[2*i + 1]; // 幅值 = sqrt(real^2 + imag^2) / (N/2) // 除以N/2是为了将幅值校正为实际物理幅值(对于实数FFT) arm_sqrt_f32(real*real + imag*imag, &fft_mag[i]); fft_mag[i] = fft_mag[i] * 2.0f / FFT_LENGTH; } // 计算奈奎斯特频率点(Fs/2)的幅值 fft_mag[FFT_LENGTH/2] = fft_output[1] / FFT_LENGTH; // 此时,fft_mag[i] 对应的频率是:freq = i * (Fs / FFT_LENGTH) }

如何解读fft_mag数组?假设采样频率Fs = 1000 HzFFT_LENGTH = 512

  • fft_mag[0]:对应0Hz(直流分量)的幅值。
  • fft_mag[1]:对应1 * (1000/512) ≈ 1.95 Hz的频率分量的幅值。
  • fft_mag[10]:对应10 * (1000/512) ≈ 19.53 Hz的频率分量的幅值。
  • fft_mag[256]:对应256 * (1000/512) = 500 Hz(即Fs/2)的频率分量的幅值。

如果你输入的是一个标准的1kHz正弦波(但注意,根据奈奎斯特定理,1kHz对于1000Hz的Fs来说已经无法被正确采样),你应该会在对应的频点看到一个尖峰。

4.4 主程序流程整合

int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_DMA_Init(); MX_ADC1_Init(); // 配置ADC和定时器触发 MX_TIM2_Init(); // 配置触发定时器 FFT_Init(); // 初始化FFT // 校准ADC(可选但推荐) HAL_ADCEx_Calibration_Start(&hadc1, ADC_SINGLE_ENDED); // 启动ADC,开始DMA采集 HAL_ADC_Start_DMA(&hadc1, (uint32_t*)adc_buffer, ADC_BUFF_LEN); HAL_TIM_Base_Start(&htim2); // 启动触发定时器 while (1) { Process_ADC_Data(); // 处理采集到的数据 // 可以将fft_mag数组通过串口发送到上位机显示,或者本地进行门限判断等 Send_Spectrum_To_UART(); HAL_Delay(100); // 控制频谱更新速率 } }

5. 性能优化与精度提升技巧

在资源受限的单片机上做FFT,优化是永恒的主题。

5.1 计算速度优化

  1. 使用CMSIS-DSP库并开启优化:确保编译器优化等级设置为-O2-O3。CMSIS-DSP库在开启优化后,会大量使用内联函数和SIMD指令。
  2. 合理选择数据类型:对于有FPU的芯片,全程使用float(单精度浮点)。对于没有FPU但需要一定速度的场合,CMSIS-DSP也提供了q15q31格式的定点数FFT函数(如arm_rfft_q15),但需要处理数据的定标,复杂度较高。
  3. 减少非必要计算:幅值谱计算中的开方运算arm_sqrt_f32比较耗时。如果只需要比较幅值大小(如寻找峰值频率),可以直接比较real^2 + imag^2的值,省去开方和除法,这在实时性要求高的场景非常有效。
  4. 利用双缓冲区与DMA:如前所述,使用DMA双缓冲区(乒乓缓冲区)可以实现采集与处理的完全并行,CPU只需在缓冲区切换时搬运数据,最大化利用计算时间。

5.2 频率精度与频谱泄漏处理

频谱泄漏是FFT的一个固有现象。如果输入信号的频率不是频率分辨率的整数倍,其能量会“泄漏”到相邻的频点上,导致谱线变宽、幅值不准。

解决方案:加窗函数在FFT前,将采样数据乘以一个窗函数(如汉宁窗Hamming、汉明窗Hanning),可以抑制频谱泄漏。

// 应用汉宁窗 for(int i=0; i<FFT_LENGTH; i++) { float32_t window = 0.5f * (1.0f - arm_cos_f32(2 * PI * i / (FFT_LENGTH - 1))); // 汉宁窗公式 fft_input[i] = fft_input[i] * window; } // 然后再进行FFT

注意:加窗会降低幅值精度,并且需要额外的计算。通常对于周期性明显的信号,或者进行精确幅值测量时使用。对于单纯的频率检测,可以不加窗或使用较简单的窗。

5.3 提高频率分辨率:Zoom-FFT思路

如果关心的信号频带很窄(比如只关心50Hz±5Hz),但采样频率又必须很高(比如10kHz以抗混叠),直接做1024点FFT分辨率也只有约9.8Hz。这时可以采用Zoom-FFT的思路:

  1. 将原始信号下变频(数字混频)到基带。
  2. 进行低通滤波和抽取,降低有效采样率。
  3. 对降采样后的信号做FFT。 这样可以在不增加FFT点数的情况下,大幅提高目标频带内的频率分辨率。但这在单片机上实现较为复杂,需要额外的滤波器和抽取器。

6. 常见问题与调试实录

在实际操作中,你肯定会遇到各种奇怪的现象。下面是我踩过的一些坑和解决方法。

6.1 频谱结果全是噪声或不对

现象可能原因排查步骤与解决方案
频谱杂乱,没有明显峰值1. ADC采样时钟或触发不稳定
2. 信号本身噪声太大
3. 未去除直流分量
1.检查定时器配置:确保定时器触发ADC的间隔绝对均匀。用示波器测量ADC的转换完成引脚或使用一个GPIO在采样中断中翻转来观察节奏。
2.检查信号源:输入一个已知频率和幅值的纯净正弦波(如信号发生器产生)进行测试。
3.fft_input赋值前,增加一行减去平均值的代码
频谱峰值频率与预期严重不符1. 采样频率Fs计算错误
2. 频率索引计算错误
1.重新计算FsFs = 定时器时钟 / (PSC+1) / (ARR+1)。用逻辑分析仪确认ADC的实际采样间隔。
2.检查幅值谱下标i到频率freq的转换公式freq = i * Fs / FFT_LENGTH
只有一个巨大的0Hz峰值,其他频率幅值很小直流分量过大严格进行去直流处理。计算fft_input数组的平均值并减去。确保信号是交流耦合的,或者在硬件上使用隔直电容。
频谱出现镜像频率(如输入100Hz,在900Hz也出现峰值)发生了频谱混叠确保采样频率Fs大于信号最高频率的2倍。在ADC前端增加一个抗混叠滤波器(低通滤波器),其截止频率略低于Fs/2。

6.2 计算速度慢,系统卡顿

  • 问题:执行FFT时,其他中断响应变慢,或者幅值计算耗时过长。
  • 排查
    1. 检查优化等级:确认工程编译选项为-O2
    2. 使用性能分析:在FFT函数前后用GPIO翻转,用示波器测量高电平时间,即为FFT计算耗时。
    3. 检查数据类型:确认使用的是单精度浮点float32_t,并且链接了FPU库。
  • 解决
    1. 如果FFT计算耗时超过采样周期,考虑降低FFT点数(如从1024降到512)。
    2. 幅值计算中,如非必要,省去开方和除法,只计算模的平方进行比较。
    3. 将FFT计算放在低优先级任务或主循环中,确保高优先级中断(如电机控制)不被阻塞。

6.3 内存不足

  • 问题:编译通过,但运行时报错或行为异常,可能是堆栈溢出。
  • 排查:在CubeIDE的Project -> Properties -> C/C++ Build -> Settings -> Tool Settings -> MCU Settings中查看和调整Heap SizeStack Size。FFT的大数组是全局变量,在.bss段,不占用堆栈,但库函数内部可能会用到动态内存或较大的栈空间。
  • 解决
    1. 适当增加堆栈大小(例如将Stack Size从0x400增加到0x1000)。
    2. 减少FFT点数。
    3. 将大型数组(如fft_input,fft_output)用__attribute__((section(".ccmram")))放到CCM RAM(如果芯片有)中,可以加速访问并减轻主RAM负担。

6.4 与上位机联调技巧

将计算出的fft_mag数组通过串口发送到电脑,用Python(Matplotlib)或LabVIEW等工具绘图,是直观的调试方法。

// 简单的串口发送函数 void Send_Spectrum_To_UART(void) { printf("FFT_MAG_START\n"); // 发送起始标志 for(int i=0; i<FFT_LENGTH/2+1; i++) { printf("%.4f\n", fft_mag[i]); // 发送幅值,每行一个 } printf("FFT_MAG_END\n"); // 发送结束标志 }

在电脑端,你可以编写一个脚本,不断读取串口数据,识别STARTEND标志,然后将中间的数据绘制成动态的频谱图。这能让你实时观察信号频谱的变化,对于验证算法和调试硬件问题无比重要。

7. 进阶应用:从频谱到实际功能

得到频谱只是第一步,如何利用它才是关键。

7.1 峰值检测与频率估计

找出幅值谱fft_mag中的前N个最大值点,其对应的索引通过公式换算即可得到主要频率成分。为了提高频率估计精度,特别是当真实频率落在两个FFT频点之间时,可以采用插值算法(如重心法、抛物线拟合法)。

// 简单的峰值检测(寻找最大值) uint16_t find_peak_frequency(float32_t *mag, uint16_t len, float32_t fs, uint16_t *peak_index) { float32_t max_val = 0.0f; uint16_t max_idx = 0; // 忽略直流分量(0Hz),从第1个点开始找 for(uint16_t i=1; i<len; i++) { if(mag[i] > max_val) { max_val = mag[i]; max_idx = i; } } *peak_index = max_idx; // 计算峰值频率 float32_t peak_freq = (float32_t)max_idx * fs / FFT_LENGTH; return (uint16_t)peak_freq; }

7.2 谐波分析

对于电力或电机应用,基波(如50Hz)的谐波(100Hz, 150Hz...)含量是重要指标。在得到基波频率f0后,只需在频谱中查找2*f0,3*f0等对应频点附近的幅值即可。计算总谐波畸变率(THD)需要将所有谐波幅值的平方和开方,再除以基波幅值。

7.3 基于频谱的简单分类

你可以为不同的“声音”或“振动”模式建立一组“特征频谱模板”。当设备运行时,计算实时频谱,并与模板频谱进行比对(如计算欧氏距离、相关系数),找出最匹配的模板,从而实现状态分类或故障诊断。这需要离线学习建立模板,并在单片机上实现简单的模式匹配算法。

在STM32上实现傅里叶变换,最难的不是调用那个arm_rfft_fast_f32函数,而是理解整个数据流的来龙去脉,以及如何让ADC、定时器、DMA、内存和CPU协同工作,稳定地生产出可供FFT处理的“原料”。另一个难点是对结果的解读和优化,频谱泄漏、栅栏效应这些概念需要在实际的调试波形中才能真正理解。我的建议是,先用信号发生器产生一个干净的正弦波,确保你的系统能正确地在频谱上显示一个单峰,这是所有后续工作的基石。然后,再逐步引入更复杂的真实信号,去处理噪声、泄漏和实时性的挑战。这个过程,本身就是对嵌入式系统开发能力的一次极好锻炼。