
简介这是一份面向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字节是8KBADC原始数据数组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.uvprojxCore/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, points1024, fs8000Hz 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的计数时钟是72MHzPrescaler0不做分频Period8999让计数器计满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*i1]放虚部。因为输入的是一维实数波形虚部直接置零。查找表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被设成了ENABLEADC会在定时器触发的间隙自己连续转换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中断栈更小翻车更猛。解决启动文件里把栈加大到4KBStack_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入坑路上代价最小也最容易后悔的教训。希望帮到你。本文还有配套的精品资源点击获取