ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32F103实现1024点FFT频谱分析:从ADC采样到串口输出的完整工程示例

STM32F103实现1024点FFT频谱分析:从ADC采样到串口输出的完整工程示例

简介:这是一份面向STM32F103微控制器与ARM Cortex-M3平台的FFT例程包,适合嵌入式开发初学者和有一定单片机基础的工程师。例程基于STM32 HAL库构建,围绕快速傅里叶变换演示完整的频谱分析处理流程,可用于音频分析、振动检测、滤波器设计等典型场景。资源以zip压缩包发布,共179个文件,约7.34MB,主体包含C语言源码、H头文件、HAL库驱动和Keil工程配置文件,同时提供编译生成的HEX烧录文件、AXF调试文件、Map内存映射文件以及CubeMX的IOC配置文件。这些文件覆盖了从工程创建、代码编写、编译链接到下载调试的主要环节,目录结构直观,便于按需查阅。目前已有2130人学习下载。例程在代码中展示了外设初始化、中断与DMA配合采样、FFT计算、幅值转换等步骤,也使用了ARM官方库以提升稳定性和兼容性。开发者可以借助该压缩包减少环境搭建和基础代码编写的工作量,把精力集中在信号处理算法调试与性能优化上。

1. 一个带着完整链路的STM32F103 FFT例程:从波形到频谱

一块STM32F103,没有外扩RAM,没有浮点单元,能不能在几十毫秒内算完1024点FFT并把基频打印到串口?这个例程给出的答案是能。它基于ARM Cortex-M3这颗经典内核,把定时器触发ADC采样、DMA搬运、CMSIS-DSP软件库FFT计算、串口输出串成一条完整链路。做的不是PPT式波形演示,而是能在信号发生器上测出1kHz正弦峰值的真实例程。适合三类人:想给单片机加频谱分析功能的嵌入式开发者,在STM32上做振动、音频、电流检测的学生,以及把FFT当黑匣子、想揭开它底层流程的爱好者。

2. 跑通前的准备:开发环境、DSP库和硬件接线

2.1 选板子与接线:先搞清楚RAM够不够

例程默认跑1024点单精度FFT,这个选择是故意的。F103C8T6这颗芯片的RAM只有20KB,如果上2048点浮点FFT会很吃力,1024点刚好是甜点区。我习惯先算一笔账:FFT输入数组需要存放实部和虚部,1024点乘2乘4字节是8KB,ADC原始数据数组1024乘4字节是4KB,再加上串口缓冲和栈空间,20KB剩余不多,但裸机跑完全可行。如果你用的是F103ZET6这种大容量型号,RAM有64KB,那就可以放开手脚直接上2048点。

硬件接线比想象中简单,关键是信号源要和单片机共地,否则ADC读到的全是悬浮噪声。

信号来源STM32引脚说明
模拟信号输入PA1ADC1_IN1,输入电压范围0~3.3V
串口发送PA9接USB转串口模块的RX
串口接收PA10接USB转串口模块的TX
地线GND必须和信号源共地

Cortex-M3没有FPU,所以单精度浮点运算其实是在核心内部用软件模拟完成的。好在CMSIS-DSP里的FFT是基2蝶形实现,对M3的指令集做了充分优化。例程默认用float32接口,方便观察整个数据流,真正要压速度的话,后文会提到怎么换q15定点。

2.2 工程组织与CMSIS-DSP导入路径

这份例程的结构很清晰,拿到源码包之后先对着目录看一遍,比直接点编译更能建立全局感。

demo_fft/ ├─ Core/ │ ├─ Inc/ │ │ ├─ main.h │ │ ├─ adc_driver.h │ │ └─ fft_process.h │ └─ Src/ │ ├─ main.c │ ├─ adc_driver.c │ └─ fft_process.c ├─ Drivers/ │ ├─ CMSIS/ │ │ ├─ Include/ │ │ └─ DSP_Lib/ │ │ └─ Source/ │ └─ STM32F1xx_HAL_Driver/ └─ MDK-ARM/ └─ demo_fft.uvprojx

Core/Src下是用户代码,Drivers/CMSIS/DSP_Lib/Source是DSP库的源码,我一般直接把DSP源码放入工程编译,而不是用预编译lib,这样想改开关宏、替换q15版本时不用重新配链接路径。需要重点检查的是C编译器预定义宏,DSP库源码内部会根据不同内核切换指令集,F103必须定义ARM_MATH_CM3。漏掉这个宏,轻则编译报一堆函数未定义,重则生成错误指令,下载后直接进HardFault。

另外启动文件里的栈空间建议先改到4KB,也就是Stack_Size EQU 0x1000。例程默认值可能只有0x400,单精度FFT的局部变量和库函数调用栈叠加起来很容易把它冲爆,这个问题我会在避坑章节再展开。

2.3 第一次烧录与验证:串口看到1kHz才算跑通

编译下载后,我给信号发生器输出1kHz正弦波,峰峰值设置在1V左右,然后打开串口助手,波特率115200。预期打印类似下面这样:

FFT v1.0, points=1024, fs=8000Hz freq: 1000.0 Hz amp: 0.50 V

这里有两个数字值得注意。第一个是采样率8000Hz,对应频率分辨率是8000除以1024,约等于7.8125Hz。1kHz正弦正好落在第128个bin上,所以打印值会精确显示1000.0Hz。第二个是幅值,信号发生器设置的峰峰值1V意思是正负0.5V,所以FFT单谱线的峰值幅度就是0.5V,和打印结果能对上。如果这两个数字都对,说明从采样到算法再到输出的链路已经完整打通,之后改参数、换信号就都有据可依了。

3. 核心模块拆解:从定时器触发到串口打印的完整链路

3.1 采样源头:定时器触发ADC加DMA搬运

FFT对采样有一个硬性要求:采样点之间的时间间隔必须严格相等。如果靠主循环里调用HAL_ADC_Start去读,中断抖动和代码分支耗时会让采样点间距飘忽不定,FFT的结果就会杂乱无章。所以例程用的是定时器触发ADC,转换结果再由DMA自动搬进内存,CPU全程不参与逐点读取。

#define FFT_SIZE 1024 #define SAMPLE_RATE 8000 TIM_HandleTypeDef htim2 = {0}; ADC_HandleTypeDef hadc1 = {0}; DMA_HandleTypeDef hdma_adc1 = {0}; __IO uint32_t adc_raw[FFT_SIZE]; static void MX_TIM2_Init(void) { htim2.Instance = TIM2; htim2.Init.Prescaler = 0; htim2.Init.Period = 8999; /* 72MHz / 9000 = 8kHz */ htim2.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start(&htim2); } static void MX_ADC1_Init(void) { hadc1.Instance = ADC1; hadc1.Init.ContinuousConvMode = DISABLE; /* 只由定时器触发,不连续转换 */ hadc1.Init.ExternalTrigConv = ADC_EXTERNALTRIGCONV_T2_TRGO; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; HAL_ADC_Init(&hadc1); } static void MX_DMA_Init(void) { hdma_adc1.Instance = DMA1_Channel1; hdma_adc1.Init.Direction = DMA_PERIPH_TO_MEMORY; hdma_adc1.Init.PeriphInc = DMA_PINC_DISABLE; hdma_adc1.Init.MemInc = DMA_MINC_ENABLE; hdma_adc1.Init.PeriphDataAlignment = DMA_PDATAALIGN_WORD; hdma_adc1.Init.MemDataAlignment = DMA_MDATAALIGN_WORD; hdma_adc1.Init.Mode = DMA_NORMAL; /* 采满1024点后停止 */ HAL_DMA_Init(&hdma_adc1); __HAL_LINKDMA(&hadc1, DMA_Handle, &hdma_adc1); }

这套配置里,TIM2的计数时钟是72MHz,Prescaler=0不做分频,Period=8999让计数器计满9000个时钟溢出一次,于是TRGO输出更新事件,触发一次ADC转换。72MHz除以9000正好等于8000Hz采样率。注意ContinuousConvMode必须设为DISABLE,否则ADC会不管不顾地自己连续转换,定时器触发和连续转换会相互打架,采出来的数据序列根本不是等间隔的。

DMA配置为普通模式,方向是外设到内存,每次完成1024次转换后产生传输完成中断。在主循环里重新调用HAL_ADC_Start_DMA才能开启下一轮采集。这样做虽然损失了一部分连续采样能力,但保证帧与帧之间数据不会被覆盖,作为第一个跑通的版本非常稳妥。

3.2 FFT计算:用官方库函数代替手写蝶形

以前在学校学FFT,老师要求手写基2蝶形,过程写起来很爽,但放进单片机工程里容易出低级错误,比如旋转因子表格越界、位倒序错位。例程直接调用CMSIS-DSP的arm_cfft_f32,这套库在Cortex-M3上做了指令级优化,效率比自己写的强得多。

float32_t fft_in[FFT_SIZE * 2]; float32_t fft_out[FFT_SIZE]; void ComputeFFT(void) { /* 把原始ADC值转成电压,并填充实部,虚部置0 */ for (int i = 0; i < FFT_SIZE; i++) { fft_in[2 * i] = (float32_t)adc_raw[i] * 3.3f / 4095.0f; fft_in[2 * i + 1] = 0.0f; } /* 第3个参数0表示正变换,第4个参数1表示输出位倒序 */ arm_cfft_f32(&arm_cfft_sR_f32_len1024, fft_in, 0, 1); /* 计算每个bin的幅值,结果放入fft_out */ arm_cmplx_mag_f32(fft_in, fft_out, FFT_SIZE); /* 归一化:交流分量除以 N/2,直流分量严格说应除以 N */ for (int i = 0; i < FFT_SIZE; i++) { fft_out[i] *= 2.0f / FFT_SIZE; } }

arm_cfft_f32要求输入数组内存中交替存放实部和虚部,fft_in[2*i]放实部,fft_in[2*i+1]放虚部。因为输入的是一维实数波形,虚部直接置零。查找表arm_cfft_sR_f32_len1024是官方提供的1024点旋转因子表,直接传地址即可。arm_cmplx_mag_f32会计算每个bin的模值,输出长度等于FFT点数。

归一化这里有新手最容易踩的坑。FFT系数的幅值等于输入信号幅度乘以N/2,所以除回去要乘2/N。但直流分量是N乘平均电压,照搬2/N会把直流分量压小一倍。例程里峰搜索和串口打印针对的是交流信号,所以统一用了2/N,这点必须清楚。

3.3 找峰值与频率刻度:算出信号基频

FFT计算完,输出数组里每个下标对应一个频率bin,下标与频率的关系需要自己换算。这一步其实就是一个乘除法,但方向搞反的同学不在少数。

float freq_res = (float)SAMPLE_RATE / FFT_SIZE; /* 8000/1024 = 7.8125Hz */ float amp_max = 0.0f; int peak_bin = 1; for (int i = 1; i < FFT_SIZE / 2; i++) { if (fft_out[i] > amp_max) { amp_max = fft_out[i]; peak_bin = i; } } float peak_freq = peak_bin * freq_res;

频率分辨率等于采样率除以FFT点数,8000Hz采样率配合1024点,每个bin代表7.8125Hz。峰搜索从下标1开始,是为了跳过直流分量,否则电源产生的0Hz附近噪声会掩盖真正的信号峰。循环只扫到FFT_SIZE/2,因为实数信号FFT的后半部分是前半部分的镜像,直接看前一半就够了。

功率谱的物理意义也藏在bin和频率的对应关系里:如果输入1kHz正弦波,它的峰值会出现在第128个bin,因为1000除以7.8125等于128。如果信号频率不是分辨率整数倍,能量会泄漏到相邻几个bin,峰值幅值会略低于真实值,这时不要急着怀疑算法,先确认是否用了整周期采样或者是否已经加窗。

3.4 主循环状态机与串口打印

主循环的逻辑不复杂,关键在于DMA中断标志的处理方式。例程用一个volatile标志位通知主循环数据准备完毕,避免在中断里直接执行FFT。

volatile uint8_t adc_full_flag = 0; void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { if (hadc->Instance == ADC1) { adc_full_flag = 1; /* 一帧采样结束,通知主循环 */ } } int main(void) { HAL_Init(); SystemClock_Config(); MX_DMA_Init(); MX_TIM2_Init(); MX_ADC1_Init(); while (1) { HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adc_raw, FFT_SIZE); while (!adc_full_flag); /* 等待DMA传输完成 */ adc_full_flag = 0; HAL_ADC_Stop_DMA(&hadc1); /* 处理数据前,先断开DMA避免覆盖 */ ComputeFFT(); FindPeak(); PrintResult(); } }

串口输出我用的是重定向后的printf,在MDK工程里启用MicroLIB之后,fputc函数内部调用HAL_UART_Transmit向USART1发字符。这样打印浮点数会非常方便,代码量也小。PrintResult内部就是一句printf("freq: %.1f Hz amp: %.3f V\r\n", freq, amp),信息量足够,格式也干净。

这个主循环设计是入门级的,FFT计算和处理期间不会继续采样,所以频谱不是连续的。如果你做的是实时频谱灯或者在线振动监测,这种模式会丢帧,我在第5章会讲双缓冲升级方案。

4. 避坑记录:FFT例程翻车最常见的五个现象

4.1 输入1kHz,打印出来的频率却偏了

现象:信号发生器设置1kHz,串口打印937.5Hz或者1062.5Hz,而且变化是整段整体偏移。

原因:采样率不是代码里的8000Hz。最常见的情况是系统时钟没有跑到72MHz。比如外部HSE晶振没起振,代码自动切换到了内部HSI,主频变成8MHz,定时器分频全部失效。另外检查APB1预分频时要注意,TIM2挂在APB1总线上,当APB1分频系数不为1时,定时器时钟是APB1频率的两倍,这一条常常被忽略。

解决:先确认实际主频,再按公式反推。公式是采样率等于定时器时钟除以后分频系数加一再除以重装值加一。我在调试时会在初始化末尾加一句:

uint32_t sysclk = HAL_RCC_GetSysClockFreq(); printf("sysclk=%lu\r\n", sysclk);

如果打印出来是8000000而不是72000000,那就不是FFT的问题,而是时钟树没有配置好。先把系统时钟修正确,再回头算Period,否则调多少算法都是白费。

4.2 FFT结果全是低频大块,完全看不出主峰

现象:波形采进来了,但FFT输出最左边堆了一大块幅值,整个数组看起来像一条斜坡,找不到输入信号对应的峰。

原因:两个。第一,ContinuousConvMode被设成了ENABLE,ADC会在定时器触发的间隙自己连续转换,DMA缓冲区里填进来的不是等间隔采样而是连续转换结果,时间关系全乱。第二,输入信号是交流信号,没有加直流偏置,负半周被ADC内部钳位到0V,波形被削掉一半,频谱被直流和大量谐波淹没。

解决:把连续转换关掉,强制ADC只在TIM2的TRGO上升沿触发单次转换。同时给输入信号叠加1.65V直流偏置,让正弦波整体落在0到3.3V范围内。如果信号源本身不带偏置,最简单的做法是在PA1外面用一个100k电阻接到3.3V和GND分压,把中点偏置到1.65V,再串一个电容隔离直流。

4.3 程序跑到FFT函数后直接进HardFault

现象:编译下载后,主循环跑得挺好,一旦调用arm_cfft_f32,程序立刻卡死在HardFault_Handler里。

原因:栈空间不足。1024点单精度FFT的输入数组是8KB,局部变量和库函数调用时还会有临时变量,MDK默认栈可能只有1KB,直接冲爆。另一种情况是有人在定时器中断里直接调用FFT,中断栈更小,翻车更猛。

解决:启动文件里把栈加大到4KB:

Stack_Size EQU 0x1000

这行改完后重新编译,HardFault基本消失。同时要注意,FFT输入输出数组必须定义为全局变量,不要放在某个函数内部,全局数组放在静态区,不进栈。从那以后我拿到任何采集类例程,第一件事就是检查栈大小和数组位置。

4.4 开了编译器优化之后,打印数据全是0

现象:默认-O0优化等级下,幅值正常;把优化等级调到-O2,串口打印的freq和amp全部变成0。

原因:ADC缓冲区是由DMA外设写进去的,CPU没有执行任何写入操作。编译器优化时发现主程序只读不写,就把这段内存当作常量处理,或者把读循环优化掉了。本质是缺少volatile修饰。

解决:把ADC缓冲区和中断标志位用__IO或volatile修饰:

__IO uint32_t adc_raw[FFT_SIZE]; volatile uint8_t adc_full_flag = 0;

__IO在Cortex-M系列的CMSIS头文件里展开就是volatile,它告诉编译器这段内存可能被硬件随时修改,每次使用都必须从内存重新加载。加完之后-O2下的结果和-O0一致了。这个问题在DMA相关工程里很高发,排查方向要锁定在“谁在写、编译器知不知道谁在写”。

4.5 回调函数永远不触发,程序卡死在等待

现象:主循环停在while(!adc_full_flag),串口只打印第一行初始化信息,之后没有任何输出。

原因:DMA中断根本没有进入CPU。常见两个:初始化时忘了调用HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn),DMA传输完成信号发出来了但CPU没开对应的中断使能;另一个是中断优先级分组的配置和默认配置不一致,导致嵌套优先级异常。

解决:在DMA初始化后显式打开中断:

HAL_NVIC_SetPriority(DMA1_Channel1_IRQn, 2, 0); HAL_NVIC_EnableIRQ(DMA1_Channel1_IRQn);

同时建议在HAL_Init后设置中断优先级分组为NVIC_PRIORITYGROUP_4,把所有中断统一为抢占优先级模式。重新下载后,回调就能正常置位标志了。

5. 二次开发:把例程改成你自己的实时频谱工具

第一个推荐改造的是把单次帧处理升级成双缓冲连续采样。DMA设为循环模式,缓冲区大小开成两帧,利用半传输中断和全传输中断分别通知主循环处理前半段和后半段缓冲区。

__IO uint32_t adc_buf[FFT_SIZE * 2]; void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *hadc) { dma_half_flag = 1; } void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *hadc) { dma_full_flag = 1; }

主循环里分别处理adc_buf[0]和adc_buf[FFT_SIZE]两段数据。DMA在填充后半段的时候,CPU正在处理前半段,互不干扰。8000Hz采样率下,半帧512点用时64毫秒,而1024点FFT只需几毫秒,时间余量很充足。

第二个改造是加窗函数。矩形窗对非整周期采样非常敏感,频谱泄漏会让峰值变宽、幅值变低。加Hann窗只需要在填充FFT输入前乘一个窗系数:

for (int i = 0; i < FFT_SIZE; i++) { float w = 0.5f * (1.0f - arm_cos_f32(2.0f * 3.14159265f * i / (FFT_SIZE - 1))); fft_in[2 * i] = (float32_t)adc_raw[i] * w * (3.3f / 4095.0f); fft_in[2 * i + 1] = 0.0f; }

加窗后主峰附近更干净,但主瓣会展宽,两个频率相差小于约两倍分辨率时就很难分开了。验证方法也很直接,输入一个1000Hz和一个1015Hz的双音信号,加窗前看频谱是糊成一团,加窗后能隐约看到两个峰。

第三个改造方向是把float32换成q15定点版本,arm_cfft_f32换成arm_cfft_q15,输入数组换成q15_t,幅度换算时注意定点数到浮点电压的缩放关系。这套改完后,F103C8T6可以尝试跑2048点FFT而不爆RAM。

最后说一个所有采样类项目的共性。从那以后我拿到任何采集例程,第一件事都是核验采样率宏与实际定时器配置是否一致,再谈算法调优。采样率一旦偏差,后面所有频率刻度全是空中楼阁,这是FFT入坑路上代价最小也最容易后悔的教训。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进