STM32标准库实现FFT:精准测量正弦波幅值、频率与相位差 简介本资源面向嵌入式开发工程师与信号处理学习者提供在STM32F407平台上基于标准库实现FFT算法的完整工程用于对正弦波信号进行频域分析精准测量幅值、频率与相位差适用于电机控制、音频分析与无线通信等实时信号处理场景。压缩包共283个文件约9.98MB以C源码与头文件为核心包含stm32f4xx系列外设驱动、工程配置与编译产物并附带少量说明文档与烧录脚本结构完整可直接复用。资源围绕ADC采样、窗函数处理、FFT变换、频谱分析与结果输出等环节展开借助Cortex-M4的浮点运算单元提升运算效率。目前已有1665人学习下载适合希望快速搭建FFT测量框架、理解频域分析流程并对照工程结构查漏补缺的开发者参考。1. 从一块“跑不准”的板子说起STM32 上做 FFT 测正弦波到底难在哪前阵子帮一个做电机控制的朋友看代码他用 STM32 采集一路正弦波信号想实时算出幅值、频率和相位差结果频率总在 ±2Hz 上下跳相位差更是飘得没法看。他一开始以为是 ADC 采样不准换了基准源、加了滤波电容折腾一周没解决。问题其实出在 FFT 本身——点数选少了频率分辨率不够点数选多了标准库那点 RAM 又扛不住再加上没做整周期采样频谱泄漏直接把峰值糊成一片。这个项目就是冲着这类场景来的在 STM32 上用标准外设库实现 FFT把正弦波信号的幅值、频率、相位差三个参数稳定测出来。它适合正在做电力监测、音频分析、传感器解调这类需要频域信息的嵌入式开发者尤其是那些不想上 DSP 芯片、只想用现有 STM32 资源把活干完的人。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序把这份代码拆开讲一遍。2. 项目定位与技术选型为什么是标准库 定点 FFT2.1 这个项目解决的具体问题先把需求说清楚。输入是一路正弦波信号经过 ADC 采样变成离散序列输出是三个数幅值V 或 ADC 码值、频率Hz、以及两路信号之间的相位差度。听起来简单但嵌入式里做这件事有几个硬约束RAM 通常只有几十 KB主频几十到一百多 MHz没有浮点单元或者浮点很慢。所以这个项目的核心不是“实现一个 FFT”而是“在资源受限的 STM32 上用可接受的精度把这三个参数测出来”。它覆盖的典型场景包括电机电流环里的谐波分析、音频信号的基频检测、传感器激励与响应的相位差测量。这些场景的共同点是信号频率不高通常几百 Hz 到几十 kHz但对实时性和稳定性有要求。2.2 技术栈拆解标准库、CMSIS-DSP 与定点运算项目用的是 STM32 标准外设库Standard Peripheral Library不是 HAL 也不是 LL。这一点很关键——标准库在中小容量型号上代码体积小、执行路径短很多老项目和新项目都还在用。FFT 部分通常有两种做法一是自己写基 2 的蝶形运算二是调用 CMSIS-DSP 里的arm_cfft_q15或arm_rfft_q15。这个项目走的是 CMSIS-DSP 定点路线用 Q15 格式16 位定点范围 -1 到 1-2^-15来存采样数据。为什么用定点而不是浮点STM32F1、F0 这类没有 FPU 的型号浮点 FFT 一次 1024 点要几十毫秒定点版本能压到几毫秒。即使有 FPU 的 F4定点在批量运算时功耗和确定性也更好。代价是动态范围受限需要做输入缩放这个后面避坑章节会细说。选型项方案理由外设库标准库体积小、路径短、老项目兼容FFT 实现CMSIS-DSP Q15定点快、RAM 占用可控采样方式ADC DMA 定时器触发保证等间隔采样点数1024 点可配 256/512兼顾分辨率与 RAM窗函数汉宁窗抑制频谱泄漏2.3 目录结构与模块划分一份典型的工程目录大概长这样我按功能把文件归了类project/ ├── CMSIS/ # 内核与 DSP 库 │ ├── core_cm3.h │ └── DSP_Lib/ │ └── Source/ # arm_cfft_q15.c 等 ├── Libraries/ │ └── STM32F10x_StdPeriph_Driver/ ├── User/ │ ├── main.c # 主流程 │ ├── adc_dma.c # 采样配置 │ ├── fft_process.c # FFT 与参数计算 │ ├── fft_process.h │ └── stm32f10x_it.c # 中断 └── Output/adc_dma.c负责把定时器触发的 ADC 数据通过 DMA 搬进缓冲区fft_process.c是核心做加窗、FFT、求模、找峰、算相位。这种划分的好处是采样和算法解耦换 ADC 型号或者换 FFT 点数时改动范围小。3. 核心模块拆解从 ADC 采样到三个参数输出3.1 采样链路定时器触发 DMA 搬运采样最怕两件事采样间隔不均匀、CPU 被搬运数据拖死。所以标准做法是用定时器产生固定频率的触发信号ADC 每次转换完由 DMA 自动搬走CPU 完全不参与。下面是一段配置示例// adc_dma.c #define FFT_SIZE 1024 #define SAMPLE_RATE 10000 // 采样率 10kHz __IO uint16_t adc_buf[FFT_SIZE]; void ADC_DMA_Init(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; TIM_TimeBaseInitTypeDef TIM_InitStructure; // 1. 定时器产生采样触发频率 SAMPLE_RATE RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); TIM_InitStructure.TIM_Period SystemCoreClock / SAMPLE_RATE - 1; TIM_InitStructure.TIM_Prescaler 0; TIM_InitStructure.TIM_ClockDivision 0; TIM_InitStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_InitStructure); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); // 更新事件触发 ADC // 2. ADC外部触发规则通道 RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1, ENABLE); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); // 3. DMA循环模式搬 FFT_SIZE 个半字 RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_InitStructure.DMA_PeripheralBaseAddr (uint32_t)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (uint32_t)adc_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize FFT_SIZE; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_Cmd(ADC1, ENABLE); TIM_Cmd(TIM2, ENABLE); }这段代码的关键参数有三个SAMPLE_RATE决定频率分辨率分辨率 采样率 / 点数FFT_SIZE决定 RAM 占用和运算时间ADC_SampleTime要匹配信号源阻抗。DMA 用循环模式采满一缓冲后自动从头覆盖主循环里通过判断半传输或传输完成标志来决定何时处理数据。注意 ADC 的触发源必须和定时器对应F1 系列里 T2_TRGO 是常见选择换型号时要查参考手册的触发映射表。3.2 FFT 运算Q15 格式、加窗与位反转采样数据是 12 位无符号整数直接丢给 Q15 的 FFT 会出问题——Q15 是有符号的而且要求输入在 -1 到 1 之间。所以要先做直流偏置去除和归一化再转成 Q15。加窗是为了减小频谱泄漏汉宁窗是最常用的折中方案。// fft_process.c #include arm_math.h #define FFT_SIZE 1024 q15_t fft_in[FFT_SIZE * 2]; // 复数格式实部虚部交替 q15_t fft_out[FFT_SIZE]; q15_t window[FFT_SIZE]; void FFT_Process(void) { uint32_t i; float32_t mean 0.0f; // 1. 求直流均值 for (i 0; i FFT_SIZE; i) { mean adc_buf[i]; } mean / FFT_SIZE; // 2. 去直流 归一化到 Q15 加汉宁窗 for (i 0; i FFT_SIZE; i) { float32_t v ((float32_t)adc_buf[i] - mean) / 2048.0f; // 12位ADC float32_t w 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); fft_in[2 * i] (q15_t)(v * w * 32767.0f); // 实部 fft_in[2 * i 1] 0; // 虚部 } // 3. 调用 CMSIS-DSP 复数 FFT arm_cfft_instance_q15 cfft; arm_cfft_init_q15(cfft, FFT_SIZE); arm_cfft_q15(cfft, fft_in, 0, 1); // 0正变换, 1位反转 // 4. 求模取前 N/2 个点 arm_cmplx_mag_q15(fft_in, fft_out, FFT_SIZE); }这里有几个容易翻车的点。arm_cfft_q15的输入是复数交织数组长度是 2×FFT_SIZE很多人第一次用会只分配 FFT_SIZE 导致越界。arm_cfft_init_q15在较新版本的 CMSIS-DSP 里才有老版本要手动填arm_cfft_instance_q15结构体。归一化系数 2048 是按 12 位 ADC 满量程 4096 的一半取的如果你的 ADC 是 10 位或 16 位这个数要跟着改否则幅值算出来差一个固定倍数。3.3 参数计算幅值、频率、相位差的提取FFT 出来的是频谱要变成物理量还得做几步。幅值看峰值点的模值频率看峰值点对应的 bin 序号相位差则要比较两路信号在同一 bin 上的相位角。// 幅值、频率、相位差计算 #define SAMPLE_RATE 10000.0f #define FFT_SIZE 1024 float32_t get_amplitude(q15_t *mag, uint32_t peak_bin) { // Q15 模值转电压除以 32767 还原再乘 2/N 补偿 FFT 增益 // 汉宁窗相干增益 0.5所以再乘 2 float32_t amp (float32_t)mag[peak_bin] / 32767.0f; amp amp * 2.0f / FFT_SIZE * 2.0f; return amp * 3.3f; // 参考电压 3.3V } float32_t get_frequency(uint32_t peak_bin) { return peak_bin * SAMPLE_RATE / FFT_SIZE; } float32_t get_phase_diff(q15_t *fft_a, q15_t *fft_b, uint32_t bin) { // 实部虚部取出算各自相位角再相减 float32_t re_a fft_a[2 * bin]; float32_t im_a fft_a[2 * bin 1]; float32_t re_b fft_b[2 * bin]; float32_t im_b fft_b[2 * bin 1]; float32_t ph_a atan2f(im_a, re_a); float32_t ph_b atan2f(im_b, re_b); float32_t diff (ph_a - ph_b) * 180.0f / PI; if (diff 0) diff 360.0f; return diff; }找峰值 bin 的时候别只比较模值大小最好加一个抛物线插值否则频率分辨率被 FFT_SIZE 卡死。比如 1024 点、10kHz 采样bin 间隔约 9.77Hz不插值的话频率误差可能到 ±5Hz。插值公式用峰值点和左右邻点的模值做二次拟合能把误差压到 1Hz 以内。相位差计算要求两路信号用同一个触发源同步采样否则相位基准对不上算出来没有意义。4. 避坑与排查那些让 FFT 结果“飘”的常见原因4.1 频谱泄漏导致幅值偏小、频率跳动现象是幅值测出来比实际小一截频率在真实值附近来回跳。原因通常是非整周期采样——信号频率不是频率分辨率的整数倍能量从主瓣泄漏到旁瓣。解决办法有两个一是加窗汉宁窗能把泄漏压下去但主瓣变宽二是做整周期采样用信号过零点触发采样起始保证采到整数个周期。实际项目里我一般先加窗如果精度还不够再上过零触发。4.2 Q15 溢出导致频谱出现虚假峰值现象是频谱上冒出一些不该有的峰或者底噪明显抬高。原因是输入信号幅度超过 Q15 范围蝶形运算中间结果溢出。Q15 的 FFT 每一级都会放大1024 点大概有 10 级理论增益 2^10。所以输入不能满量程通常要右移几位或者把归一化系数调小。CMSIS-DSP 的arm_cfft_q15有个bitReverseFlag参数但缩放得自己控制。稳妥做法是输入幅度控制在满量程的 1/4 以内。4.3 采样率与定时器配置不匹配现象是频率算出来整体偏大或偏小一个固定比例。原因是定时器实际触发频率和代码里写的SAMPLE_RATE对不上。比如系统时钟 72MHz预分频和重装载值算错实际采样率可能是 9.8kHz 而不是 10kHz。排查方法是拿示波器量定时器触发引脚或者用一个已知频率的信号源反推实际采样率。这个坑我踩过调了一下午才发现是预分频少减了 1。4.4 相位差测量不同步现象是相位差每次测量结果都不一样或者两路信号明明同相却测出几十度偏差。原因是两路 ADC 没有用同一个触发源或者 DMA 搬运顺序导致采样时刻错开。解决方法是让两路 ADC 工作在双重模式同步规则模式由同一个定时器触发这样两个通道的采样时刻严格对齐。如果型号不支持双重模式那就用两个 ADC 但共用一个触发源并且保证 DMA 配置一致。4.5 浮点运算拖慢实时性现象是主循环处理一帧数据要几十毫秒跟不上采样速度。原因是加窗和归一化用了浮点在没有 FPU 的型号上很慢。优化方向是把窗函数表预先算好存成 Q15 数组归一化用整数移位代替浮点除法。如果必须用浮点至少把arm_cos_f32换成查表。实测在 F103 上把加窗改成查表后单帧处理时间从 18ms 降到 6ms。5. 二次开发与改造把这份代码变成你自己的测量工具拿到这份代码后最直接的改造是换 FFT 点数。256 点 RAM 占用小但分辨率低2048 点分辨率高但 F103 上跑起来吃力。我的经验是信号频率低于 1kHz 用 1024 点1kHz 到 5kHz 用 512 点再高就用 256 点配合过零触发。改点数时记得同步改arm_cfft_init_q15的参数、窗函数表长度和 DMA 缓冲大小这三处漏一个就会跑飞。第二个改造方向是加实时性。如果要在中断里做 FFT建议用 DMA 的半传输中断——采满一半就开始处理前一半同时 DMA 继续采后一半这样处理和采样并行。但要注意 FFT 函数本身不是可重入的别在两个中断里同时调用。第三个方向是精度提升。抛物线插值是最划算的改动十几行代码能把频率精度提高一个数量级。相位差那边可以加一个参考通道做自校准把通道间的固定延迟扣掉。如果对幅值精度要求高可以做多点平均连续测 8 帧取中位数能有效抑制随机跳动。改造项改动位置预期收益点数切换fft_process.c adc_dma.c适配不同频率范围抛物线插值峰值搜索函数频率精度提升 5~10 倍半传输并行DMA 中断 主循环实时性提升约 40%多帧中位数参数输出层抑制随机跳动查表加窗窗函数初始化单帧耗时降低 60%最后说一个我自己的习惯每次改完 FFT 相关参数我都会先用一个信号发生器灌一个已知频率和幅值的正弦波进去把测出来的三个参数和设定值对一遍确认误差在可接受范围再接实际信号。这个步骤看起来笨但能帮你把“算法问题”和“信号问题”分开省下大量瞎调的时间。从那以后我每次动采样率或者点数都强制走一遍这个校准流程。希望帮到你。本文还有配套的精品资源点击获取