STM32F103 DSP库实现1024点FFT频谱分析完整指南
2026/9/16 20:12:05 网站建设 项目流程

简介:一份基于 STM32F103 的 ADC 采集 + 1024 点 FFT 频谱分析完整工程,面向嵌入式入门者以及需要快速在 MCU 上调用 DSP 库做信号处理的开发者。项目使用意法半导体官方 DSP 库完成快速傅里叶变换,重点打通模拟信号采集、ADC 配置、FFT 运算和串口输出的完整链路;代码中可见浮点运算、外设驱动的配合方式,可继续扩展到振动监测、音频分析、电力谐波检测等实际场景。压缩包共 231 个文件,约 6.9MB,以 C 源码、头文件、启动汇编文件为主,并包含 STM32 标准外设库、Keil 工程配置、链接脚本及编译生成的目标文件,适合一边阅读一边烧录验证。已有 2538 人学习下载,非常适合结合开发板复现从模拟采样到串口输出频谱结果的完整过程,也可帮助开发者理解 DSP 库调用、定时触发采样和信号实时分析的基本套路。

1. STM32F103 与 DSP 库:为什么 1024 点 FFT 值得在 Cortex-M3 上跑

拿到这份 STM32 工程,第一眼看到Template.axfstm32f10x_tim.c和 DSP 库调用时,大多数人会先问一个问题:Cortex-M3 没有硬件 FPU,跑 1024 点浮点 FFT 是不是太勉强了。实际结论是:在 72MHz 主频下,调用 ST 的 DSP 库完成一次 1024 点基 4 FFT 大约耗时 2~3ms,如果只做音频或振动频段的实时谱分析,这个速度完全够用。这个工程把 ADC 采样、FFT 计算和串口输出串成了完整链路,适合做振动监测、音频频谱、电机电流谐波分析等场景的入门骨架。它用的是标准外设库 SPL,不是 HAL 库,这决定了后续看代码的思路和时钟配置方式都不同。

2. 从工程文件到 DSP 库接入:Keil 配置与 TIM 定时触发 ADC 采样

2.1 为什么用 ST DSP 库而不是自己写 FFT

手写 FFT 的难点不在蝶形运算本身,而在索引逆序、旋转因子精度和定点溢出控制。STM32 的 DSP 库由 CMSIS 维护,基 4 算法经过指令级优化,对 Cortex-M3 的流水线做了适配,比自己写的 C 循环在同等优化等级下通常快 20% 以上。这个工程选择 DSP 库还有一个现实原因:库函数直接提供了arm_cfft_f32arm_cmplx_mag_f32这样封装好的接口,输入输出都是标准float32_t数组,省去了维护复杂数据结构的时间。

工程在 Keil 里跑,源文件以stm32f10x_开头,说明它依赖标准外设库 SPL。SPL 和 HAL 的差异在于 API 风格完全不同:SPL 是ADC_Init(...)TIM_TimeBaseInit(...)这种按外设分组的方式,HAL 则是HAL_ADC_Start_DMA(...)加回调函数。这份工程的代码风格和文件组织都朝 SPL 方向写的,如果你打算改成 HAL 库,外设时钟使能和数据结构都要重构,不只是简单换函数名。

2.2 Keil 工程里把 DSP 库加进来的具体步骤

DSP 库不是 Keil 默认带的,需要在工程里手动添加源文件和头文件路径。现在网上下载的 DSP 库大多按 CMSIS 目录结构打包,核心内容在CMSIS/DSP_Lib下。

第一步,向工程添加 FFT 需要的源文件。至少在TransformFunctions里添加arm_cfft_f32.carm_bitreversal.c,在SupportFunctions里添加arm_cmplx_mag_f32.c。如果你的库版本较老,接口是arm_cfft_radix4_f32,那就对应添加arm_cfft_radix4_f32.carm_cfft_radix4_init_f32.c

第二步,在 C/C++ 的 Include Paths 里加入 DSP 库头文件目录,例如:

..\CMSIS\DSP_Lib\Include ..\CMSIS\Include

第三步,在 C/C++ 的 Define 里补上:

ARM_MATH_CM3, __FPU_PRESENT=1

ARM_MATH_CM3告诉库在 Cortex-M3 上编译,使用 ARM 编译器的内建函数路径。__FPU_PRESENT=1表示“目标芯片可能有 FPU”,是为了保证头文件的声明一致,F103 本身没有 FPU 也不会因为这个宏而出错。

提示:如果用 Keil 打开工程时弹出找不到stm32f10x.h或芯片型号相关的报错,先安装 STM32F1 系列的 Device Family Pack,再检查魔术棒里 Device 是否正确选了 STM32F103C8 或 CB。

2.3 用 TIM2 定时触发 ADC,把采样率变得可预期

ADC 直接连续采样也能用,但采样间隔由 ADC 时钟随机决定,后续算频率分辨率时无从下手。这份工程里既然加了stm32f10x_tim.c,说明时序控制走的是定时器触发方案。

常见做法是用 TIM2 更新事件作为 ADC 外部触发源,把采样率固定在某个数值上。以下代码以 72kHz 采样率为例,对应 1024 点 FFT 时频率分辨率约为 70.31Hz,正好覆盖音频频段:

void TIM_ADC_Init(void) { GPIO_InitTypeDef gpio; ADC_InitTypeDef adc; TIM_TimeBaseInitTypeDef tim; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); gpio.GPIO_Pin = GPIO_Pin_1; gpio.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpio); tim.TIM_Prescaler = 72 - 1; tim.TIM_Period = 1000 - 1; tim.TIM_ClockDivision = 0; tim.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGOSource_Update); adc.ADC_Mode = ADC_Mode_Independent; adc.ADC_ScanConvMode = DISABLE; adc.ADC_ContinuousConvMode = DISABLE; adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 1; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 1, ADC_SampleTime_239Cycles5); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_Cmd(ADC1, ENABLE); TIM_Cmd(TIM2, ENABLE); }

这段代码的关键逻辑是:TIM2 的计数频率从 72MHz 分频得来,72 / 72 = 1MHz,再计数 1000 次溢出一次,得到 72kHz 触发频率。TIM_SelectOutputTrigger把更新事件映射到 TRGO 引脚,ADC 又把 TRGO 配置成外部触发源,所以每个定时器周期到来,ADC 就自动转换一次。

ADC 采样周期设为ADC_SampleTime_239Cycles5,在 ADC 时钟 14MHz 左右时约 17us,远小于 72kHz 对应的 13.9us 间隔一半,采样电容能充分充电,输入阻抗较高的传感器信号也能保证精度。如果你要采的是 10kHz 以内的信号,建议把 TIM2 分频和周期改成7200-11000-1,采样率降到 10kHz,分辨率变为 9.77Hz,频域细节更清楚。采样率、点数和频率分辨率三者是绑定关系,可以按这张表快速估算:

采样率FFT 点数频率分辨率最大可分析频率
72kHz102470.31Hz36kHz
10kHz10249.77Hz5kHz
8kHz10247.81Hz4kHz

3. arm_cfft 与 arm_cmplx_mag:1024 点 FFT 调用与幅值换算

3.1 AD 采样值进 FFT 之前为什么先减 2048

FFT 处理的是实数信号,但库函数接口要求复数输入。标准做法是把 ADC 读数放在数组偶数位,奇数位置零。直接放原始值会有问题:12 位 ADC 的中点大约在 2048,如果不过中点,FFT 结果里直流分量会占掉很大能量,你的有效信号可能被淹没在纵轴底部。

所以采样值要转换成以零为中心的浮点数:

#define FFT_SIZE 1024 static float32_t fft_buf[FFT_SIZE * 2]; static float32_t mag_buf[FFT_SIZE]; void PrepareFFTInput(uint16_t *adc_samples) { for (uint16_t i = 0; i < FFT_SIZE; i++) { fft_buf[2 * i] = ((float32_t)adc_samples[i] - 2048.0f) * 3.3f / 4096.0f; fft_buf[2 * i + 1] = 0.0f; } }

fft_buf[2*i]是第 i 个采样点的实部,fft_buf[2*i+1]是虚部,FFT 要求数据交替排列,这也是 CMSIS DSP 库的统一格式。把 ADC 原始值换算成实际电压值的好处是频谱纵轴能直接对应到 0V~3.3V 范围内的幅值,便于和示波器读数对照。

3.2 arm_cfft_f32 参数含义与老接口差异

新版 DSP 库中,1024 点 FFT 调用如下:

#include "arm_math.h" void RunFFT(void) { arm_cfft_f32(&arm_cfft_sR_f32_len1024, fft_buf, 0, 1); arm_cmplx_mag_f32(fft_buf, mag_buf, FFT_SIZE); }

&arm_cfft_sR_f32_len1024是由库预定义好的实例,里面保存了旋转因子表和位反转表,省去每次调用前初始化。第三个参数0表示执行正向 FFT,如果填1就是逆变换;第四个参数1表示开头的位反转由库内部处理,通常保持为1

如果你的工程文件里出现的是arm_cfft_radix4_f32,那属于老版本的 API 风格,调用方式变成:

arm_cfft_radix4_instance_f32 S; arm_cfft_radix4_init_f32(&S, FFT_SIZE, 0, 1); arm_cfft_radix4_f32(&S, fft_buf);

两者底层算法相同,只是新版本把实例定义从使用者代码移到了库内部,并且新版本要求Init必须提前执行,所以老接口在代码切换时记得留意这一点。

arm_cmplx_mag_f32的计算逻辑是sqrt(re*re + im*im)循环展开实现,输出数组按频率顺序排列。第 0 个元素是直流分量,第 1 到 511 个元素是正频率部分,第 512 到 1023 个元素是负频率镜像。音频和振动分析只取前 512 个值即可。

3.3 幅值换算系数和直流处理

1024 点 FFT 输出值不能直接当物理幅值读,需要换算。FFT 的线性幅值谱中,单频正弦信号的能量会扩散到正负频率两个谱线上,所以要还原真实幅值,需要对非直流谱线乘2 / N,直流谱线单独乘1 / N

for (uint16_t i = 0; i < FFT_SIZE / 2; i++) { if (i == 0) mag_buf[i] = mag_buf[i] / FFT_SIZE; else mag_buf[i] = 2.0f * mag_buf[i] / FFT_SIZE; }

mag_buf[1]对应频率为1 * 72000 / 1024 ≈ 70.31Hz的幅值,mag_buf[2]对应140.63Hz,以此类推。如果输入信号是 1V 峰值的正弦波,换算后的mag_buf[k]应接近 1.0,误差取决于 ADC 的量化噪声和频谱泄漏。

如果你只需要几个关键频率点的幅值,不必把整个频谱都算出来。arm_max_f32可以快速找到谱峰值,再用arm_float_to_q15把结果转成整数输出,能省几个毫秒的串口时间。

4. 串口输出频谱结果:位宽、帧格式与驱动侧问题

4.1 串口 115200 波特率够不够传频谱数据

1024 点 FFT 产生 512 个有效频点,如果每个频点以浮点四字节输出,一次完整频谱传输要 2048 字节,按 115200 波特率算,大约需要 180ms。这个时间远大于 FFT 计算本身,频谱刷新率就会被串口拖到 5Hz 以下。

这个工程的串口用途更多是给 PC 端做波形显示或上位机记录,建议不在串口上传全谱。更好的做法是提取峰值频率、峰值幅值、直流分量几个关键标量,外加把幅值量化到一字节,把一次传输压缩到 20 字节以内。

4.2 定义一个结构化帧,而不是直接 printf

直接printf("%f\r\n", peak_freq)在调试时没问题,但上位机解析浮点字符串效率低,而且不同编译器的%f实现大小差异很大。我在实际项目里会把结果封装成固定字节长度的帧结构:

typedef struct __attribute__((packed)) { uint16_t sync; // 0xAA55 uint16_t peak_idx; // 峰值所在谱线号 float peak_freq; // 峰值频率 Hz float peak_mag; // 峰值幅值 V uint16_t dc_value; // 直流分量 uint8_t crc8; // 前面所有字节的校验 } FFT_ResultFrame_t; FFT_ResultFrame_t result; void SendFFTFrame(void) { result.sync = 0xAA55; result.peak_idx = FindPeakIndex(); result.peak_freq = (float)result.peak_idx * 72000.0f / 1024.0f; result.peak_mag = mag_buf[result.peak_idx]; result.dc_value = (uint16_t)(mag_buf[0] * 1000.0f); result.crc8 = CalcCRC8((uint8_t *)&result, sizeof(result) - 1); uint8_t *p = (uint8_t *)&result; for (uint8_t i = 0; i < sizeof(result); i++) { USART_SendData(USART1, p[i]); while (USART_GetFlagStatus(USART1, USART_FLAG_TXE) == RESET); } }

sync用于上位机找帧头,避免数据错位;peak_idx直接给出谱线号,上位机不需要再反算频率;crc8用多项式 0x31 计算,能捕获到串口干扰产生的单字节错误。用结构体指针直接发送的方法,依赖编译器按 1 字节对齐,所以__attribute__((packed))不能省。

4.3 串口收到乱码或没数据的常见原因

先排除最容易忽略的硬件层问题:USB 转串口模块在 Windows 设备管理器里如果出现STM32 Virtual COM Port前面有黄色感叹号,说明驱动没装好;在 Keil 调试状态下载程序报Error: No STM32 Target Found时,优先检查 SWD 接口的 NRST 引脚是否有 100nF 对地电容、目标板供电是否正常。如果串口工具里出现连续乱码,多半是波特率不匹配或者 PA9/PA10 接反了。

5. 验证频谱分析结果:信号源标定、泄漏分析与 DMA 加速技巧

5.1 用信号发生器验证频谱读数是否可信

直接把 1kHz、1V 峰值的正弦波接到 PA1 和 GND,串口收到的peak_freq应该落在 960Hz~1100Hz 之间,具体值取决于谱线分辨率。采样率 72kHz、1024 点时,1kHz 对应谱线位置是1000 / 70.3125 ≈ 14.22,能量会扩散到第 14 和 15 根谱线上,所以峰值频率不是精确的 1000Hz,这是正常现象。

如果没有信号发生器,可以用另一块板子输出 PWM,经 RC 低通滤波后得到近似正弦波作为测试信号,幅值精度差一些,但验证频率读数和 FFT 链路是否跑通足够了。把输入信号的频率改成 2.5kHz,看peak_idx是否相应变为 35 或 36,这一条能同时验证采样率配置是否准确。

5.2 频谱泄漏时先调采样率,不要急着加窗

频率不是谱线分辨率的整数倍时,能量会向相邻谱线泄漏,此时峰值的幅值也会偏小。这个工程里 70.31Hz 的分辨率对 1kHz 信号来说误差不大,但对 50Hz 工频附近的振动信号就很致命,泄漏会淹掉真实峰值。

常见做法是让被测信号频率尽可能接近谱线整数倍,用表里的对应关系直接改 TIM2 的预分频和周期值即可:

被测信号频率采用采样率FFT 点数对应谱线
1kHz 正弦72kHz102414.22
1kHz 正弦70kHz102414.6
1kHz 正弦71.111kHz102414.78

如果信号频率本来就不可控,比如环境噪声,再考虑加汉宁窗。加窗需要在arm_cfft_f32之前对fft_buf的实部逐点乘窗系数,库本身不提供窗函数,需要手写一个查表。这一点和直接改采样率的做法不冲突,实测中对工程资源和响应时间的开销都很小。

5.3 用 DMA 把 1024 点采样隐藏到后台,释放 CPU

查询方式每次触发都要 CPU 去读ADC_DR并清标志,阻塞时间长。对这种周期比较固定的采集量,更好的配置是 ADC 加 DMA,数据自动搬运到内存数组,FFT 只在缓冲满时运行一次。DMA 配置核心如下:

DMA_InitTypeDef dma; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)adc_buffer; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = FFT_SIZE; dma.DMA_PeripheralInc = DMA_PeripheralInc_Disable; dma.DMA_MemoryInc = DMA_MemoryInc_Enable; dma.DMA_PeripheralDataSize = DMA_PeripheralDataSize_HalfWord; dma.DMA_MemoryDataSize = DMA_MemoryDataSize_HalfWord; dma.DMA_Mode = DMA_Mode_Circular; dma.DMA_Priority = DMA_Priority_High; DMA_Init(DMA1_Channel1, &dma); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_DMACmd(ADC1, ENABLE);

DMA_Mode_Circular让缓冲区自动循环写入,不需要软件判断是否该清零。配合ADC_DMACmd(ADC1, ENABLE),每次 ADC 转换结束硬件自动搬运,CPU 完全不参与单点采样。FFT 任务在主循环里检测当前 DMA 写位置,和上一次读取位置比较,积累满 1024 个新样本后开始计算。

一个小技巧是:DMA 传输完成中断中只置一个标志位,不在中断里做 FFT。FFT 计算会占用 2ms 以上,中断里执行会打乱其他实时任务,简单置标然后回主循环的RunFFT()处理即可。这样整个 ADC 采样的 CPU 占用几乎为零,72kHz 采样率下仍有大量余量给显示刷新或控制逻辑。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询