STM32标准库FFT双通道正弦波幅值频率相位差测量

发布时间:2026/10/26 18:33:36
STM32标准库FFT双通道正弦波幅值频率相位差测量 简介面向嵌入式信号处理学习者与STM32开发者这份工程资料围绕在STM32F407上使用标准库实现FFT展开重点解决正弦波信号幅值、频率与相位差的精准测量问题适合做电机控制、音频分析或无线通信前级频谱分析的读者参考。压缩包共283个文件约9.98MB以46个C源文件、60个头文件及配套的50个D依赖文件、50个O编译中间文件、49个CRF浏览信息文件为主同时包含Keil工程、HEX可执行文件、LIB库与MAP映射等构建产物便于直接打开工程、复现编译与对照配置。已有1668人学习下载。资料覆盖ADC采样、窗函数处理、FFT变换、频谱分析与结果输出等环节可帮助读者理解标准库FFT接口的调用方式、频域参数换算与工程目录组织并借助现成工程缩短搭建与调试周期。1. 用标准库在 STM32 上跑 FFT把正弦波的三项参数一次测出来有个做课程设计的同学抱着一块板子来找我说示波器上两路正弦波明明差了 30 度他拿 ADC 采完做 FFT相位差一会儿 28 度、一会儿 -150 度怀疑是 FFT 算法写错了。后来查下来算法没大问题翻车在双通道采样不同步和相位没归一化上。这个项目要解决的就是这类事用 STM32 标准库把两路正弦波采进来在频域里算出幅值、频率和相位差而不是靠示波器数格子。技术栈是 STM32F1 标准库外设驱动、定时器触发 ADC、DMA 搬运、基 2 FFT适合做课程设计、传感器信号分析、阻抗测量前端的人参考。代码不依赖 HAL老工程直接能搬。频域算法的坑不少下面按能跑起来、能改动的顺序拆。2. 技术选型与工程结构标准库外设 自写 FFT 的组合2.1 为什么这个项目还坚持用标准库标准库SPL这几年在新项目里确实少了但拆老工程、做课程设计、跑 72MHz 的 F1 时它仍然省事寄存器级配置直观中断入口和启动文件简单外设初始化函数一眼能看懂。这个项目里标准库只负责三件事——定时器产生固定采样率、ADC 双通道同步采样、DMA 把数据搬进内存。FFT 和频谱分析这部分标准库本身不提供常见做法有两种一是引入 CMSIS-DSP 里的arm_rfft_fast_f32二是自己写一个基 2 的浮点 FFT。前者性能好、经过大量验证但输出数据格式是实部虚部交替排列第一次用容易读错后者代码短、可控1024 点以内在 F1 上跑几十毫秒做慢速正弦波测量完全够。我一般会把 FFT 实现单独放在Algorithm/fft.c里外设配置放Hardware/下主循环只做流程调度。这样换芯片时算法文件基本不用动只改外设层。CMSIS-DSP 和标准库并不冲突外设用 SPL、DSP 用 CMSIS 是常见混搭方式前提是工程里把 CMSIS 的核文件和 DSP 库路径加对。2.2 工程目录与模块职责目录结构按“外设层—算法层—应用层”切命名直白方便后面加通道或换屏幕FFT_SineMeter/ ├── CMSIS/ // 内核文件可选 DSP 库 ├── STM32F10x_StdPeriph_Driver/ // 标准库 ├── Hardware/ │ ├── bsp_adc.c / bsp_adc.h // 双 ADC 规则同步 DMA │ ├── bsp_tim.c / bsp_tim.h // 采样率定时器 │ └── bsp_usart.c / bsp_usart.h// 结果输出 ├── Algorithm/ │ ├── fft.c / fft.h // 基 2 FFT │ ├── spectrum.c / spectrum.h // 幅值、频率、相位差 │ └── window.c / window.h // 去直流、加窗 ├── User/ │ ├── main.c │ ├── stm32f10x_it.c │ └── stm32f10x_conf.h └── Project/ └── MDK-ARM/ // 工程文件各文件职责尽量单一bsp_adc.c只负责把两路模拟量变成两个float数组fft.c只认输入输出指针spectrum.c不碰任何寄存器。这样调算法时不用连板子拿一段 MATLAB 或 Python 生成的正弦数据灌进去就能验证。常见做法是给spectrum.c配一个#define SIM_DATA 0的开关在 PC 上编译时喂仿真数组查完公式再上板。2.3 采样链路定时器触发 ADCDMA 循环搬运采样链路的设计决定了频率和相位差能不能测准。软件触发 ADC 的采样间隔靠循环抖动频率一高就散ADC 连续扫描模式虽然快但两路通道之间有时间差测相位差会引入固定偏移。这个项目用 TIM2 的更新事件作为 TRGO去触发 ADC1 和 ADC2 的规则同步转换转换结果由 DMA 搬进一个 32 位数组低 16 位是 ADC1高 16 位是 ADC2。整个链路没有 CPU 参与采样时刻由硬件卡死。DMA 配成循环模式缓冲区长度等于 FFT 点数。主循环查询半满和全满标志或者干脆在 DMA 中断里置一个adc_done标志主循环看到标志后关掉 DMA 请求、处理数据、再重新打开。这样处理一帧的时间不会影响下一帧的采样起点避免“算得慢导致采样间隔被拉长”的连锁问题。注意 DMA 缓冲区要用uint32_t因为双 ADC 同步模式下 ADC1-DR 读出的是 32 位拼接值用uint16_t会丢一半通道。2.4 采样参数怎么定一张表把关系说清采样率Fs、点数N、频率分辨率Fs/N是一根绳上的三个结。测 50Hz 工频信号分辨率取 5Hz 左右就够看主瓣测 1kHz 音频段分辨率要压到 1Hz 以内就得加大 N 或者降采样率。这个项目默认用 10kHz 采样、2048 点分辨率 4.88Hz能覆盖几十赫兹到两三千赫兹的正弦信号。参数默认值说明采样率 Fs10 kHzTIM2 更新频率72MHz / 7200FFT 点数 N2048必须是 2 的整数次幂频率分辨率4.88 HzFs / NADC 分辨率12 位参考电压按 3.3V 算窗函数汉宁窗抑制非整周期泄漏测频范围约 20 Hz ~ 4 kHz受模拟前端和采样率限制采样率不是越高越好F1 的 ADC 时钟上限 14MHz采样时间加转换时间约十几个周期10kHz 采样两路绰绰有余采样率太高反而让每周期点数变少相位差的分辨率下降。实际调试时我一般先用信号源打一个已知频率把Fs反算一遍确认定时器实际输出和理论值对得上再往下做。3. 核心算法拆解从 ADC 采样点到幅值、频率、相位差3.1 整周期采样与频谱泄漏FFT 假设输入信号在采样窗口内是周期的。如果信号频率不是Fs/N的整数倍窗口首尾对不上频谱就会泄漏到相邻 bin幅值变矮、相位偏移峰值位置也会偏。测 50Hz、Fs10kHz、N2048 时50/4.88≈10.24不是整数直接取峰值 bin 会得到 48.8Hz 或 53.7Hz 的读数这就是很多人第一次做 FFT 测频觉得“不准”的来源。工程上有两条路一是调整采样率或点数让目标频率尽量落在整周期上比如把 Fs 改成 10240分辨率变 5Hz50Hz 正好是第 10 个 bin二是接受非整周期加窗抑制泄漏再用峰值左右两个 bin 做插值把频率和幅值修回来。这个项目两条都支持默认走第二条因为现场信号频率往往不是你能定的。整周期采样时相位最准非整周期时相位差要靠两路加同样的窗来抵消窗函数引入的相位偏移这一点在 3.5 节展开。3.2 预处理去直流与加汉宁窗ADC 采进来的正弦波通常带一个直流偏置比如 1.65V 的虚拟地。直流分量在 FFT 里会落在第 0 个 bin幅值很大还会通过窗函数泄漏到低频段把靠近直流的信号峰淹没。所以进 FFT 之前先减均值这一步比加窗还靠前。// 去直流减去本帧均值避免 0Hz 分量泄漏 void Remove_DC(float *x, uint16_t n) { uint16_t i; float sum 0.0f; for (i 0; i n; i) { sum x[i]; } float mean sum / n; for (i 0; i n; i) { x[i] - mean; } } // 汉宁窗周期性形式相干增益为 0.5 void Window_Hanning(float *x, uint16_t n) { uint16_t i; for (i 0; i n; i) { x[i] * 0.5f * (1.0f - cosf(2.0f * PI * i / n)); } }Remove_DC的均值必须用本帧数据现算不能沿用上一帧否则信号幅值变化时直流估计会滞后。Window_Hanning用的是周期性汉宁窗分母是n而不是n-1这样窗的离散傅里叶变换只有三个非零项后面做幅值修正时相干增益正好是 0.5。加窗会让信号峰值幅度降到原来的一半所以算幅值时要乘 2 补回来相位方面两路信号加同一个窗窗引入的相位偏移在相减时抵消前提是两路峰值落在同一个 bin。3.3 基 2 FFT 的位倒序与蝶形运算FFT 的核心是把长序列拆成短序列减少复数乘法次数。基 2 按时间抽取的写法分两步先把输入按二进制位倒序重排再做蝶形迭代。位倒序这一步很多人直接抄代码但不知道为什么要做——因为蝶形运算要求参与运算的两个点间隔固定原始顺序不满足重排后每一级的输入都来自上一级已经算好的子序列。// 位倒序把下标按二进制反转例如 011 - 110 static void fft_bit_reverse(float *re, float *im, uint16_t n) { uint16_t i, j 0, k; for (i 1; i n; i) { k n 1; while (j k) { j ^ k; k 1; } j ^ k; if (i j) { float t re[i]; re[i] re[j]; re[j] t; t im[i]; im[i] im[j]; im[j] t; } } }fft_bit_reverse的j始终是i的位反转值内层while用来找下一个反转位置交换只在下标i j时做一次避免换回去。这段代码对任意 2 的幂长度都成立不依赖查表省 RAM。// 蝶形运算逐级合并旋转因子递推计算 static void fft_butterfly(float *re, float *im, uint16_t n) { uint16_t len, i, j; for (len 2; len n; len 1) { float ang -2.0f * PI / len; float wlen_r cosf(ang); float wlen_i sinf(ang); for (i 0; i n; i len) { float wr 1.0f, wi 0.0f; for (j 0; j len / 2; j) { uint16_t a i j; uint16_t b a len / 2; float tr re[b] * wr - im[b] * wi; float ti re[b] * wi im[b] * wr; re[b] re[a] - tr; im[b] im[a] - ti; re[a] tr; im[a] ti; // 旋转因子递推避免每个点都调 cos/sin float nwr wr * wlen_r - wi * wlen_i; wi wr * wlen_i wi * wlen_r; wr nwr; } } } }len是当前级的蝶形跨度从 2 倍增到nwlen_r/wlen_i是这一级单位旋转因子内层用递推wr/wi代替三角函数调用2048 点能省下几万次cosf/sinf。输出数组re[]/im[]里下标 0 到 N-1 对应频率从 0 到 Fs 的复数频谱实信号的有效信息在前 N/2 个点后一半是镜像算幅值时只取前一半。3.4 幅值、频率和相位差的计算频谱算出来后三项参数的计算各有各的细节。幅值要在单边谱上乘 2 再除 N加窗后还要除窗的相干增益频率直接取峰值 bin 会落在分辨率格点上要用相邻 bin 做抛物线插值相位差则是两路信号在同一峰值 bin 上的atan2相减再归一化到 ±180 度。typedef struct { float amp1; // 通道 1 幅值单位 V float freq; // 信号频率单位 Hz float phase_diff; // 通道 1 减通道 2 的相位差单位度 } SpectrumResult; void Spectrum_Analyze(float *re1, float *im1, float *re2, float *im2, uint16_t n, float fs, SpectrumResult *out) { uint16_t k, half n / 2; uint16_t k_max 1; float mag, max_mag 0.0f; float mag_left 0.0f, mag_right 0.0f; // 找通道 1 的峰值 bin跳过直流 for (k 1; k half; k) { mag sqrtf(re1[k] * re1[k] im1[k] * im1[k]) * 2.0f / n; if (mag max_mag) { max_mag mag; k_max k; } } // 峰值左右各取一个 bin用于频率插值 mag_left sqrtf(re1[k_max - 1] * re1[k_max - 1] im1[k_max - 1] * im1[k_max - 1]); mag_right sqrtf(re1[k_max 1] * re1[k_max 1] im1[k_max 1] * im1[k_max 1]); // 抛物线插值delta 是峰值偏离整数 bin 的小数部分 float delta 0.0f; float denom mag_left - 2.0f * max_mag mag_right; if (fabsf(denom) 1e-9f) { delta 0.5f * (mag_left - mag_right) / denom; } out-freq (k_max delta) * fs / n; // 汉宁窗相干增益 0.5幅值再乘 2若用矩形窗则去掉这个 2 out-amp1 max_mag * 2.0f; // 两路信号在同一 bin 上取相位相减后归一化 float p1 atan2f(im1[k_max], re1[k_max]); float p2 atan2f(im2[k_max], re2[k_max]); float d p1 - p2; if (d PI) d - 2.0f * PI; if (d -PI) d 2.0f * PI; out-phase_diff d * 180.0f / PI; }max_mag在找峰时已经乘了2/n所以后面补汉宁窗增益时只再乘 2如果换成矩形窗这一行改成乘 1。delta的公式用的是峰值左右幅度做抛物线顶点偏移汉宁窗下会有小偏差但工程上够用要求更高时可以换能量重心法或比值法。相位差用k_max而不是插值后的频率点是因为两路信号同频时峰值 bin 相同用同一个 bin 相减最稳如果两路频率有微小差异先确认是不是信号源本身的问题。atan2f返回弧度相减后必须做 ±π 归一化否则会出现 179 度跳到 -179 度的现象。3.5 相位差测量的前提两路必须同一时刻采样相位差测的是两个信号在同一时刻的相位关系如果两路不是同时采样时间差会直接变成相位误差。假设两路采样相隔 10 微秒测 1kHz 信号相位误差就是 3.6 度测 10kHz 信号误差 36 度完全不能用。所以这个项目用 ADC1 和 ADC2 的规则同步模式由同一个 TIM2 触发信号同时启动两路转换硬件保证采样时刻对齐。模拟前端也要对齐。两路输入如果各有一个 RC 低通滤波电阻电容的容差会让两路在相同频率下产生不同的附加相移这个相移会叠加到测量结果里表现为“同相信号也有几度偏差”。常见做法是两路用同一批次的电阻电容或者在软件里做一次两点校准输入同一个信号到两个通道记录各频率点的相位差作为偏移表测量时减掉。校准比换器件便宜适合小批量仪表。4. 关键代码逐段讲定时器、双 ADC、DMA 与结果输出4.1 定时器配置把采样率钉死在硬件上TIM2 挂在 APB1 上F1 的 APB1 预分频不为 1 时定时器时钟会倍频到 72MHz。这里按 72MHz 算周期寄存器写 7199更新频率就是 72MHz/720010kHz。把更新事件选为 TRGOADC 就能拿到硬触发。void BSP_TIM2_Init(void) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); // 72MHz / 7200 10kHz 更新频率 TIM_TimeBaseStructure.TIM_Period 7200 - 1; TIM_TimeBaseStructure.TIM_Prescaler 0; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); // 更新事件作为 TRGO去触发 ADC TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); TIM_Cmd(TIM2, ENABLE); }TIM_Period决定采样间隔改采样率时只动这个值比如要 20kHz 就写 3599。TIM_SelectOutputTrigger这一句容易被漏掉漏了 ADC 收不到触发DMA 一个数都不搬现象是程序卡在等标志。另外 TIM2 的时钟源要跟系统时钟树对一遍APB1 分频系数不同实际定时器时钟可能是 36MHz 或 72MHz按错会整体偏一半。4.2 双 ADC 规则同步 DMA 搬运ADC1 和 ADC2 都配成规则同步模式ADC1 是主ADC2 是从两路各采一个通道。DMA 只开 ADC1 的请求ADC2 的转换结果会拼到 ADC1-DR 的高 16 位所以 DMA 缓冲区一个元素就包含两路数据。#define FFT_N 2048 uint32_t adc_buf[FFT_N]; // 低 16 位 ADC1高 16 位 ADC2 void BSP_ADC_DMA_Init(void) { ADC_InitTypeDef ADC_InitStructure; DMA_InitTypeDef DMA_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_ADC2, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); DMA_DeInit(DMA1_Channel1); 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_N; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_Word; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_Word; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel1, DMA_InitStructure); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_InitStructure.ADC_Mode ADC_Mode_RegSimult; ADC_InitStructure.ADC_ScanConvMode DISABLE; ADC_InitStructure.ADC_ContinuousConvMode DISABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_T2_TRGO; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel 1; ADC_Init(ADC1, ADC_InitStructure); ADC_Init(ADC2, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC2, ADC_Channel_1, 1, ADC_SampleTime_55Cycles5); ADC_ExternalTrigConvCmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC2, ENABLE); ADC_DMACmd(ADC1, ENABLE); // 只开 ADC1 的 DMA 请求 ADC_Cmd(ADC1, ENABLE); ADC_Cmd(ADC2, ENABLE); // 校准顺序不能省否则零点漂移 ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); ADC_ResetCalibration(ADC2); while (ADC_GetResetCalibrationStatus(ADC2)); ADC_StartCalibration(ADC2); while (ADC_GetCalibrationStatus(ADC2)); }ADC_Mode_RegSimult是规则同步模式的关键ADC2 的触发源虽然也写了 T2_TRGO实际由 ADC1 同步启动。ADC_DMACmd只对 ADC1 使能ADC2 的数据通过内部拼接进 ADC1-DR两个都开反而可能触发两次 DMA 请求。ADC_SampleTime_55Cycles5对应输入阻抗较高的信号源采样时间太短会导致采样电容充不满幅值读数偏低尤其是信号源内阻大时明显。校准放在ADC_Cmd之后、启动转换之前顺序反了读出来全是零。4.3 主循环拆分双通道、FFT、算参数DMA 搬满一帧后在中断里置标志主循环看到标志就把 32 位数据拆成两个float数组转成电压再去做频谱分析。大数组一定要放全局或staticF1 的栈就几 KB四个 2048 点的float数组放栈上直接 HardFault。static float ch1[FFT_N], ch2[FFT_N]; static float re1[FFT_N], im1[FFT_N]; static float re2[FFT_N], im2[FFT_N]; volatile uint8_t adc_done 0; void DMA1_Channel1_IRQHandler(void) { if (DMA_GetITStatus(DMA1_IT_TC1)) { DMA_ClearITPendingBit(DMA1_IT_TC1); adc_done 1; } } int main(void) { SpectrumResult result; uint16_t i; SystemInit(); BSP_USART_Init(115200); BSP_TIM2_Init(); BSP_ADC_DMA_Init(); DMA_ITConfig(DMA1_Channel1, DMA_IT_TC, ENABLE); while (1) { if (adc_done) { adc_done 0; // 拆分高低 16 位转成电压 for (i 0; i FFT_N; i) { ch1[i] (float)(adc_buf[i] 0xFFFF) * 3.3f / 4095.0f; ch2[i] (float)(adc_buf[i] 16) * 3.3f / 4095.0f; } // 去直流、加窗、搬进复数数组 Remove_DC(ch1, FFT_N); Remove_DC(ch2, FFT_N); Window_Hanning(ch1, FFT_N); Window_Hanning(ch2, FFT_N); for (i 0; i FFT_N; i) { re1[i] ch1[i]; im1[i] 0.0f; re2[i] ch2[i]; im2[i] 0.0f; } fft_bit_reverse(re1, im1, FFT_N); fft_bit_reverse(re2, im2, FFT_N); fft_butterfly(re1, im1, FFT_N); fft_butterfly(re2, im2, FFT_N); Spectrum_Analyze(re1, im1, re2, im2, FFT_N, 10000.0f, result); printf(A1%.3fV F%.2fHz Dphi%.2fdeg\r\n, result.amp1, result.freq, result.phase_diff); } } }adc_d本文还有配套的精品资源点击获取

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询