简介:面向STM32嵌入式信号处理学习者的FFT实战工程,基于STM32F407与标准库,围绕正弦波采样后的频域分析,完成幅值、频率与相位差的测量。压缩包共283个文件,约9.98MB,包含46个.c与60个.h源码、Keil工程文件(uvprojx/uvoptx)、编译中间文件.o/.crf、库文件.lib及可烧录.hex等,便于查看工程结构、复现实验与对照源码。已有1668人学习下载。资源覆盖ADC采样、窗函数处理、FFT变换、频谱分析与结果输出等环节,适合电机控制、音频分析、无线通信等实时信号处理场景的入门与进阶读者,可据此理解频域测量流程并迁移到自己的采样与算法调试中。
1. FFT 不是算法课上的符号,是手边没有示波器时的替代方案
手头只有一块 STM32 小板和一个信号源,却要量出正弦波的幅值、频率,还要两路信号之间的相位差——示波器不在工位上的时候,这件事只能交给 FFT。快速傅里叶变换把一窗时域采样点搬进频域,峰值所在的 bin 给出频率粗值,峰值幅度按窗增益折算回幅值,两个通道在同一 bin 上的相位角相减就是相位差。三句话讲完,真动手做时,采样率算错一档、窗函数忘了归一化、两路 ADC 根本不是同一时刻采的,结果就全歪。这套实现适合已经会点灯、会配 ADC 但没做过频域测量的人,也适合手写浮点 FFT 跑得太慢、想换定点实现压耗时的人。
2. 采样链路先行:TIM 触发 + ADC + DMA 的三件套
频域测量的精度上限,在采样这一层就定死了。FFT 本身只是把已经采到的数据换个坐标系,采的时候丢了的东西,后面无论怎么插值都补不回来。所以这一章不碰蝶形运算,只把“多久采一个点、谁来触发、数据怎么进内存”这三件事钉死。
2.1 频率分辨率和采样率:先把指标算在纸上
频率分辨率 Δf = fs / N,fs 是采样率,N 是 FFT 点数。这句话的正确读法是:频域上相邻两根谱线的间距,等于每个采样点时间间隔的倒数的 N 分之一。想测 45~65 Hz 的工频偏移,Δf 至少要压到 0.5 Hz 以内才看得出趋势;想看清 50 Hz 附近的谐波分布,Δf 得压到 0.1 Hz 量级。
采样定理要求 fs > 2·fmax,但工程上按 2 倍采会非常难受:抗混叠滤波器的过渡带做不出来,混叠分量直接糊在目标频点上。常见做法是取 4~10 倍,然后在模拟前端加一级 RC 低通做粗滤。例:目标频段 10~1000 Hz,取 fs = 8000 Hz,N = 1024,则 Δf = 7.8125 Hz;对 50 Hz 的信号来说,一个 bin 就代表 15.6% 的频率误差,光靠找峰值 bin 完全不够,必须靠 4.2 节的插值把误差压下来。
另一条路是相干采样:让信号频率恰好落在整数 bin 上,即 fs = f · N / k(k 为整数)。这时矩形窗不产生泄漏,幅值和频率都能一次读准。代价是信号源频率和采样时钟必须同源,或者你能容忍频率漂移。做离线验证、做算法自测时,相干采样是首选;做在线测量,信号源频率不受控,就得靠窗函数加插值。
| N 点数 | fs = 8 kHz 时 Δf | 单通道 float32 复数占 RAM | M3 @72 MHz 浮点 FFT 耗时量级 |
|---|---|---|---|
| 256 | 31.25 Hz | 2 KB | 1 ms 级 |
| 512 | 15.625 Hz | 4 KB | 3 ms 级 |
| 1024 | 7.8125 Hz | 8 KB | 10 ms 级 |
| 2048 | 3.90625 Hz | 16 KB | 40 ms 级 |
表里的耗时只是量级参考,实际的数要看编译器优化等级和旋转因子是查表还是现算。重点看最后一列的增长趋势:N 翻一倍,蝶形运算量翻一倍多一点(N·log₂N),但 RAM 占用是实打实翻倍。20 KB RAM 的片子,1024 点基本就是天花板,还得给 ADC 缓冲和栈留余地。
2.2 定时器触发 + ADC 扫描 + DMA 循环的完整配置
采样时钟不能靠软件循环延时,那个抖动会直接把相位差测废。正确做法是让 TIM 的更新事件去做 ADC 的外部触发源,每次更新事件触发一轮规则组转换,DMA 在后台把结果搬进缓冲区,CPU 全程不参与。
#define FFT_N 1024 #define FS_HZ 8000 #define ADC_BUF_LEN (FFT_N * 2) /* 双通道交错,每点两个半字 */ volatile uint16_t g_adc_buf[ADC_BUF_LEN]; void Sample_Init(uint32_t fs_hz) { GPIO_InitTypeDef gpio; ADC_InitTypeDef adc; DMA_InitTypeDef dma; TIM_TimeBaseInitTypeDef tim; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_ADC1, ENABLE); RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); /* ADC 时钟 = PCLK2 / 6 = 12 MHz;超过 14 MHz 转换结果会整体偏小 */ RCC_ADCCLKConfig(RCC_PCLK2_Div6); gpio.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; gpio.GPIO_Mode = GPIO_Mode_AIN; GPIO_Init(GPIOA, &gpio); /* TIM2 更新事件做 TRGO:fs = 定时器时钟 / ((PSC+1)*(ARR+1)) */ tim.TIM_Prescaler = 0; /* 分频取 1,ARR 分辨率最高 */ tim.TIM_Period = 72000000UL / fs_hz - 1; /* 8 kHz -> ARR = 8999 */ tim.TIM_CounterMode = TIM_CounterMode_Up; tim.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM2, &tim); TIM_SelectOutputTrigger(TIM2, TIM_TRGO_Update); TIM_Cmd(TIM2, ENABLE); /* DMA1_Channel1:外设地址固定,内存递增,循环模式 */ DMA_DeInit(DMA1_Channel1); dma.DMA_PeripheralBaseAddr = (uint32_t)&ADC1->DR; dma.DMA_MemoryBaseAddr = (uint32_t)g_adc_buf; dma.DMA_DIR = DMA_DIR_PeripheralSRC; dma.DMA_BufferSize = ADC_BUF_LEN; 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.DMA_M2M = DMA_M2M_Disable; DMA_Init(DMA1_Channel1, &dma); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_DeInit(ADC1); adc.ADC_ScanConvMode = ENABLE; /* 两个通道依次转换 */ adc.ADC_ContinuousConvMode = DISABLE; /* 必须由外部触发,不能连续转换 */ adc.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_TRGO; adc.ADC_DataAlign = ADC_DataAlign_Right; adc.ADC_NbrOfChannel = 2; ADC_Init(ADC1, &adc); ADC_RegularChannelConfig(ADC1, ADC_Channel_0, 1, ADC_SampleTime_55Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_1, 2, ADC_SampleTime_55Cycles5); ADC_DMACmd(ADC1, ENABLE); ADC_ExternalTrigConvCmd(ADC1, ENABLE); /* 上电校准只做一次,漏做会带来几个 LSB 的固定零偏,直接进幅值 */ ADC_ResetCalibration(ADC1); while (ADC_GetResetCalibrationStatus(ADC1)); ADC_StartCalibration(ADC1); while (ADC_GetCalibrationStatus(ADC1)); }几个参数值得单拎出来说。TIM_Period里的72000000UL是 TIM2 的计数时钟:TIM2 挂在 APB1 上,APB1 预分频不为 1 时定时器时钟会被硬件乘 2,所以即便总线是 36 MHz,到这里仍是 72 MHz。这条规则记不住,采样率就会差整整一倍,频谱上所有峰值频率一起偏,而且偏得很“合理”,极难发现。
ADC_SampleTime_55Cycles5配上 12.5 个周期的转换时间,单次转换约 68 个 ADC 时钟,12 MHz 下约 5.7 μs。8 kHz 采样率下一个采样周期是 125 μs,余量充足。如果你的信号源内阻较大(比如经过了大阻值分压),采样时间要往上加,否则采样保持电容充不满,幅值会系统性偏低,而且低得跟频率有关——这是最容易误判成“算法有 bug”的一类误差。
ADC_ContinuousConvMode必须是DISABLE。开了连续转换,ADC 会在上一次转换结束后立刻开始下一次,定时器的触发就形同虚设,实际采样率变成 ADC 自由运行的速度。这时候频谱看起来“有峰”,但频率轴是错的。
提示:调试阶段强烈建议拿一个空闲 GPIO,在 DMA 传输完成中断里翻转一次,用示波器或者逻辑分析仪量它的周期,反算实际采样率。这一步做完,后面所有频率读数的可信度才成立。
2.3 从原始码值到物理量:分通道、去直流、定标
DMA 缓冲区里是交错的:buf[0]是通道 0,buf[1]是通道 1,buf[2]又是通道 0。不能直接把整段缓冲丢进 FFT,那等于把两路信号按 2 倍采样率混成了第三路不存在的信号。
static float32_t s_ch0[FFT_N]; static float32_t s_ch1[FFT_N]; void Split_Scale_RemoveDC(void) { uint32_t i; float32_t sum0 = 0.0f, sum1 = 0.0f; /* 1) 拆通道 + 累加求均值 */ for (i = 0; i < FFT_N; i++) { s_ch0[i] = (float32_t)g_adc_buf[2 * i]; s_ch1[i] = (float32_t)g_adc_buf[2 * i + 1]; sum0 += s_ch0[i]; sum1 += s_ch1[i]; } float32_t dc0 = sum0 / (float32_t)FFT_N; float32_t dc1 = sum1 / (float32_t)FFT_N; /* 2) 去直流 + 折算成伏特:3.3V 参考、12 位、4096 个码 */ const float32_t lsb = 3.3f / 4096.0f; for (i = 0; i < FFT_N; i++) { s_ch0[i] = (s_ch0[i] - dc0) * lsb; s_ch1[i] = (s_ch1[i] - dc1) * lsb; } }去直流这一步不是可选项。正弦波如果叠加了 1.65 V 的偏置,直流分量会落在 bin 0,能量极大,而矩形窗的旁瓣衰减很慢,泄漏会顺着频率轴一路爬上来,把低频段的真实谱线淹没。均值减法是最省事也最有效的做法,前提是一窗数据里包含整数个周期——非整数周期时均值本身带一点残余,但那个量级通常在千分之一以下,可以接受。
lsb = 3.3f / 4096.0f里的参考电压要按实际板子改。用 USB 供电、LDO 又没做独立滤波的时候,VDDA 会随负载波动几十毫伏,幅值测量直接体现为百分之几的漂移。这不是算法问题,是硬件问题,后面第 5 章还会专门提。
3. 在标准库工程里接入 FFT:三条路线和内存这一关
标准库(StdPeriph)工程的特点是干净、可控、不依赖 HAL 那一套回调,缺点是官方不再往里加东西,DSP 相关的支持得自己接。这一章把三条可选路线摆开,重点讲手写基 2 的实现细节和 RAM 占用怎么算。
3.1 手写浮点、CMSIS-DSP 浮点、CMSIS-DSP 定点
三条路线的取舍,本质是“RAM / 耗时 / 移植成本”三角。
手写基 2 时域抽取浮点 FFT 的优点是零外部依赖,标准库工程直接编译,出错也好定位,每一行都是自己的;缺点是要自己维护旋转因子表,N = 1024 时sinf/cosf初始化表占 4 KB,加上实部虚部各 4 KB,还没算输入缓冲。CMSIS-DSP 的arm_cfft_f32内部管理旋转因子,arm_cfft_init_f32之后结构体里存的是指针,实际表在库的常量区(Flash),RAM 只占实部虚部,能省下 4 KB。定点arm_cfft_q15把数据压到 16 位,RAM 再砍一半,速度也最快,代价是动态范围被限制在约 96 dB,小信号的信噪比会变差。
我一般的选法是:RAM 够、只做验证,用 CMSIS-DSP 浮点;RAM 吃紧到寸土寸金、且信号动态范围不大,用定点;需要把算法吃透、或者工程受限不能引入额外库,手写浮点。下面按手写路线讲透,因为另外两条路线的调用方式只是几行封装,理解手写版的每一步,才知道库函数里那些参数在干什么。
3.2 手写基 2 时域抽取:位反转和蝶形
先算清楚 RAM。N = 1024 时,s_ch0和s_ch1各占 4 KB,如果直接把它们当作 FFT 的实部输入,就省掉了拷贝;虚部数组 4 KB;旋转因子表 4 KB;ADC 原始缓冲 4 KB。合计约 16 KB,20 KB 的片子剩下的 4 KB 要装栈、装全局变量、装库代码的静态区,非常勉强。所以现实一点的做法是把 N 降到 512,或者只在调试时开双通道,量产版本单通道 N = 1024。
#include <math.h> #define FFT_N 512 #define FFT_LOG2 9 #define FFT_PI 3.14159265358979f static float32_t s_re[FFT_N]; static float32_t s_im[FFT_N]; static float32_t s_tw_re[FFT_N / 2]; static float32_t s_tw_im[FFT_N / 2]; void FFT_Init(void) { uint32_t i; for (i = 0; i < FFT_N / 2; i++) { /* 旋转因子 e^(-j*2*pi*i/N),负号对应正变换 */ float32_t ang = -2.0f * FFT_PI * (float32_t)i / (float32_t)FFT_N; s_tw_re[i] = cosf(ang); s_tw_im[i] = sinf(ang); } } void FFT_Radix2(float32_t *re, float32_t *im) { uint32_t i, j, k, len; /* 1) 位反转重排:把输入按二进制位倒序摆好 */ for (i = 1, j = 0; i < FFT_N; i++) { uint32_t bit = FFT_N >> 1; for (; j & bit; bit >>= 1) { j ^= bit; } j ^= bit; if (i < j) { float32_t t; t = re[i]; re[i] = re[j]; re[j] = t; t = im[i]; im[i] = im[j]; im[j] = t; } } /* 2) 蝶形:len 从 2 逐级翻倍到 N */ for (len = 2; len <= FFT_N; len <<= 1) { uint32_t half = len >> 1; uint32_t step = FFT_N / len; /* 旋转因子表步进 */ for (i = 0; i < FFT_N; i += len) { for (k = 0; k < half; k++) { float32_t wr = s_tw_re[k * step]; float32_t wi = s_tw_im[k * step]; uint32_t a = i + k; uint32_t b = i + k + half; /* 复数乘法 (re[b]+j*im[b]) * (wr+j*wi) */ float32_t tr = re[b] * wr - im[b] * wi; float32_t ti = re[b] * wi + im[b] * wr; re[b] = re[a] - tr; im[b] = im[a] - ti; re[a] = re[a] + tr; im[a] = im[a] + ti; } } } }位反转那段的写法值得单独看一眼。常见的错误写法是每次都用循环算i的反转值,复杂度 O(N·logN),白白多花时间;上面这个版本用j做增量维护,摊还下来是 O(N)。逻辑是:从i-1的位反转推到i的位反转,从最高位开始找第一个 0,把前面连续的 1 全部清零、这个 0 置 1,正好是二进制加法的反向操作。
step = FFT_N / len是这张全表复用的关键。旋转因子表只存了前 N/2 个值,第len级蝶形需要的是W_N^(k·N/len),所以下标是k * step。如果不做这个映射,就得存log₂N张表,RAM 立刻爆掉。
len循环里i += len和k < half的组合是标准的 Cooley-Tukey 分解:每一级把 N 个点分成 N/len 组,每组内做half个蝶形。写完拿 N = 8 在纸上推一遍,比看十遍公式管用。
3.3 换成 CMSIS-DSP:编译宏和输入格式是两道坎
想省掉手写调试的时间,把arm_cortexM3l_math.lib挂进工程是更快的路。标准库工程接这个库要改三处:全局宏里加ARM_MATH_CM3(M4 的片子改成ARM_MATH_CM4并开ARM_MATH_LOOPUNROLL);__FPU_PRESENT定义成 0(M3 没有硬件浮点单元,定义成 1 会链接到 FPU 指令直接 HardFault);ARM_MATH_MATRIX_CHECK这类参数检查宏建议关掉,省 Flash 也省时间。
#include "arm_math.h" static float32_t s_fft_in[FFT_N * 2]; /* 交错复数:[re0,im0,re1,im1,...] */ static arm_cfft_instance_f32 s_cfft; void DSP_FFT_Run(void) { uint32_t i; arm_cfft_init_f32(&s_cfft, FFT_N); /* 实信号转交错复数:虚部填 0,直流已经在上一步去掉了 */ for (i = 0; i < FFT_N; i++) { s_fft_in[2 * i] = s_ch0[i]; s_fft_in[2 * i + 1] = 0.0f; } /* 最后一个参数 1 = 正变换(bit reversal 由库内部做) */ arm_cfft_f32(&s_cfft, s_fft_in, 0, 1); }注意输入数组长度是2 * FFT_N,不是FFT_N。库要求实部虚部交错排列,这一点和手写版的两个独立数组完全不同,也是最容易在移植时踩的坑——数组长度写错,库不会报错,只会把相邻内存当数据算,结果是一堆看起来“有谱线”的垃圾。
arm_cfft_f32的第三个参数是ifftFlag,0 表示正变换;第四个参数bitReverseFlag传 1 表示让库内部做位反转重排。如果你在预处理阶段已经排过序(比如做了窗函数加实数打包优化),这个参数要相应改掉,否则等于排了两次,结果完全错乱。
3.4 窗函数:什么时候必须加,加了怎么补回去
非相干采样的直接后果是频谱泄漏:能量从真实频点向两侧扩散,峰值幅度被削低,峰值位置向邻近 bin 偏移。矩形窗的主瓣最窄(分辨力最好)但旁瓣只衰减 13 dB,汉宁窗旁瓣衰减到 -31 dB 但主瓣宽一倍。
/* 汉宁窗,长度 FFT_N;对称形式,分母用 N-1 */ void Apply_Hann(float32_t *re, float32_t *im) { uint32_t i; for (i = 0; i < FFT_N; i++) { float32_t w = 0.5f - 0.5f * cosf(2.0f * FFT_PI * (float32_t)i / (float32_t)(FFT_N - 1)); re[i] *= w; im[i] *= w; } }加窗之后幅值必须除以窗的相干增益,否则读数会系统性偏低:矩形窗 1.0,汉宁窗 0.5,海明窗 0.54,布莱克曼窗 0.42。忘除这个系数,测出来的幅值刚好是真实值的一半(汉宁窗),而且误差比例固定,容易被误认为“分压电阻算错了”。
#define WINDOW_CG 0.5f /* 汉宁窗相干增益;矩形窗填 1.0f */选择策略很简单:做算法自测、信号频率可控、能凑成相干采样,用矩形窗,此时幅值误差为零;在线测量、频率漂移不可控,用汉宁窗,接受主瓣变宽,靠 4.2 节的插值把频率精度补回来。别在一窗数据上又加窗又假设相干采样,那样两头的补偿会互相打架。
4. 三个被测参数怎么读出来:幅值、频率、相位差的提取与修正
FFT 跑完,内存里是 N 个复数。接下来要做的不是“找个最大值”,而是从这几个复数里反推出物理量,每一步都带修正。
4.1 幅值:单边谱折算、窗增益补偿与 Dirichlet 核修正
实信号的 FFT 是共轭对称的,正负频率各有一半能量。工程上只看 0~fs/2,所以要乘 2 把负半轴的能量折回来——除了 bin 0(直流)和 bin N/2(奈奎斯特)这两个点,它们没有镜像可折。
#define FS_HZ 8000.0f #define WINDOW_CG 0.5f typedef struct { uint32_t bin; float32_t freq; float32_t amp; } PeakInfo_t; PeakInfo_t Find_Peak(const float32_t *re, const float32_t *im) { uint32_t k, kmax = 1; float32_t p, pmax = 0.0f; PeakInfo_t res; /* 从 bin 1 开始扫,跳过直流;扫到 N/2 为止 */ for (k = 1; k < FFT_N / 2; k++) { p = re[k] * re[k] + im[k] * im[k]; if (p > pmax) { pmax = p; kmax = k; } } res.bin = kmax; res.freq = (float32_t)kmax * FS_HZ / (float32_t)FFT_N; res.amp = 2.0f * sqrtf(pmax) / ((float32_t)FFT_N * WINDOW_CG); return res; }这段代码给出的是“整数 bin 上的粗值”。真实频率落在两个 bin 之间时,峰值幅度会被主瓣削掉,矩形窗最坏情况约 -3.9 dB,汉宁窗约 -1.42 dB。补偿思路是:先用插值求出真实频率偏离峰值 bin 的距离 δ(单位是 bin),再按窗函数的主瓣形状除回去。
矩形窗的主瓣是 Dirichlet 核,幅度响应正比于 |sin(πδ) / (πδ)|,所以补偿系数就是它的倒数:
/* 矩形窗幅值修正:d 为真实频率相对峰值 bin 的偏移,-0.5 <= d <= 0.5 */ float32_t Rect_Amp_Fix(float32_t amp_raw, float32_t d) { float32_t x = FFT_PI * d; if (fabsf(x) < 1e-6f) { return amp_raw; /* 正好落在 bin 上,不用修 */ } return amp_raw * x / sinf(x); }汉宁窗的主瓣是三个 Dirichlet 核的叠加,形状更平缓,且峰值两侧的衰减比较对称,用两点插值直接求 δ 再乘一个经验系数就够用,不必推到解析式。实践中的做法是:截一段已知幅值的信号,扫频记录“真实幅值 / 测量幅值”的比值曲线,拟合成 δ 的函数,做成 8~16 点的查表。这个表跟你的窗长、加窗方式强相关,换 N 就得重做。
幅值这条路还有个隐藏项:ADC 前端的输入阻抗和信号源内阻构成分压。测量链路自己也要标定一次——把信号源直接接到 ADC 引脚(中间只留必要的偏置电路),记录各频点的增益,做成频响补偿表。频率高到几百 kHz 时,这个衰减会明显起来,不做补偿的话,幅值误差随频率单调下降,特别像“FFT 高频不准”。
4.2 频率:峰值 bin 只是粗值,三点重心法把它压到 0.01 bin 量级
频率精度直接受限于 Δf。以 fs = 8 kHz、N = 512 为例,Δf = 15.625 Hz,只看峰值 bin 的话,对 50 Hz 信号毫无意义。插值的作用就是把这个粒度再细分两到三个数量级。
三点重心法(也叫能量重心法)的出发点是:窗函数主瓣内的能量分布关于真实频率对称,所以用主瓣内三个 bin 的能量做加权平均,得到的重心位置就逼近真实频率。
float32_t Freq_Fine(const float32_t *re, const float32_t *im, uint32_t k0) { float32_t num = 0.0f, den = 0.0f, p; int32_t k; for (k = (int32_t)k0 - 1; k <= (int32_t)k0 + 1; k++) { if (k <= 0 || k >= FFT_N / 2) { continue; /* 边界 bin 不参与 */ } p = re[k] * re[k] + im[k] * im[k]; num += (float32_t)k * p; den += p; } if (den < 1e-12f) { return 0.0f; } return (num / den) * FS_HZ / (float32_t)FFT_N; }这个方法的误差量级取决于信噪比和窗类型:信噪比 40 dB 以上、汉宁窗,典型能做到 0.01~0.05 个 bin;信噪比掉到 20 dB,误差会明显变大,因为噪声也被算进权重里了。所以别指望它在弱信号下依然准,前端增益该给还是要给。
还有一点常被忽略:FS_HZ的值必须是实测值,不是设定值。TIM 的 ARR 如果是72000000/8000 - 1 = 8999,实际采样率是 72000000/9000 = 8000.0 Hz,没问题;但如果目标频率是 7919 Hz 这种除不尽的数,ARR 取整后实际采样率就偏了,所有频率读数会按同一个比例偏。这个比例误差用插值补不回来,只能靠实测采样率回填。
频率修正拿到 δ 之后,还能反过来喂给 4.1 节的幅值修正,两个量互相依赖,实践中按“先频率、后幅值”的顺序算一遍就够,不需要迭代。
4.3 相位差:atan2 相减只是起点,通道对齐才是重头
两路信号各自的相位来自同一 bin 上的复数角:
#define TWO_PI 6.28318530718f float32_t Phase_Diff(const float32_t *re0, const float32_t *im0, const float32_t *re1, const float32_t *im1, uint32_t k) { float32_t p0 = atan2f(im0[k], re0[k]); float32_t p1 = atan2f(im1[k], re1[k]); float32_t d = p1 - p0; /* 解缠到 (-pi, pi] */ while (d > FFT_PI) d -= TWO_PI; while (d < -FFT_PI) d += TWO_PI; return d; }atan2f返回的就是 (-π, π],两个相位相减后范围是 (-2π, 2π),所以要再做一次解缠。这一步在相位差接近 ±180° 时会频繁触发,如果不做,读数会在 +179° 和 -179° 之间来回跳,看起来像“相位不稳定”,实际是数值没归一。
真正麻烦的是通道一致性。2.2 节用的是 ADC1 扫描模式,通道 0 和通道 1 是先后转换的,间隔约 5.7 μs。对 50 Hz 信号,5.7 μs 对应的相位是 0.1°,可以忽略;对 1 kHz 信号,同样间隔对应 2°,已经能看出来;对 10 kHz,直接就是 20°,完全不能用。这个误差是频率的线性函数,混在真实相位差里,表现为“测出来的相位差随频率一路往上飘”。
两条路解决。第一条是硬件层面:改用双 ADC 规则同步模式,ADC1 和 ADC2 由同一个触发沿同时启动,两路采样保持在同一时刻,通道间时差降到纳秒级。代价是多占一个 ADC 和几个引脚,配置复杂度上一个台阶。第二条是软件标定:把同一个信号并联送到两个通道,扫一遍频点,记录每个频点的测量相位差,作为“系统相位误差表”;实测时按频率查表扣除。
/* 校准表:freq[i] 对应的系统相位误差(弧度),由并联输入标定得到 */ typedef struct { float32_t freq; float32_t err; } PhaseCal_t; static const PhaseCal_t s_phase_cal[] = { { 100.0f, 0.0009f }, { 500.0f, 0.0044f }, { 1000.0f, 0.0088f }, { 2000.0f, 0.0176f }, }; #define CAL_NUM (sizeof(s_phase_cal) / sizeof(s_phase_cal[0])) float32_t Phase_Cal_Interp(float32_t freq) { uint32_t i; if (freq <= s_phase_cal[0].freq) { return s_phase_cal[0].err; } for (i = 1; i < CAL_NUM; i++) { if (freq <= s_phase_cal[i].freq) { float32_t f0 = s_phase_cal[i - 1].freq, f1 = s_phase_cal[i].freq; float32_t e0 = s_phase_cal[i - 1].err, e1 = s_phase_cal[i].err; return e0 + (e1 - e0) * (freq - f0) / (f1 - f0); } } return s_phase_cal[CAL_NUM - 1].err; }表里的数值是按 5.7 μs 时差和相位差公式 2πfΔt 估出来的量级,实际值必须自己在板子上标,不能照抄。标定时注意保持接线和负载不变——换一根探头、加一个衰减电阻,标定表就失效了,得重来。
还有一层:相位差在数学上是“同一频率成分”的相对关系。如果两路信号的频率不完全一致(比如两个独立信号源),那相位差本身就在随时间漂移,单次 FFT 测出来的只是一个瞬时值。这种情况下要做的是连续多窗测量、观察相位差的漂移速率,而不是纠结单次读数准不准。
5. 五个把我按在工位上的现场问题
这一节记录的都是真出过的状况,按“现象 → 原因 → 解决”写,方便对照。
一、频谱上除主峰外全是高台噪底,幅值只有理论值的三分之一。现象看着像“信号被噪声淹了”,但换更大的信号源幅度,噪底跟着一起涨,说明不是外部干扰。原因是采样率算错了:TIM2 挂在 APB1 上,APB1 分频不为 1 时定时器时钟被硬件乘 2,代码里按 36 MHz 算 ARR,实际跑的是 72 MHz,采样率整整差一倍。频率轴整体偏,幅值因为能量散到更多 bin 里而变小。解决是先用 GPIO 翻转实测采样率,把实测值写回FS_HZ;同时养成习惯,定时器时钟永远按“总线时钟 ×(分频是否为 1 ? 1 : 2)”显式写出来,不靠记忆。
二、50 Hz 信号测出来在 47 和 55 之间来回跳。现象是频率读数不稳定,且跳动幅度接近一个 Δf。原因是非相干采样配矩形窗,泄漏严重,峰值 bin 在两个相邻 bin 之间来回选。解决有两条:能控源就改成相干采样(fs = f·N/k),让信号正好落在 bin 上;不能控源就换汉宁窗,再叠加重心法插值。两条都不做,只靠“多测几次取平均”,只会得到一条平滑但依然错的曲线。
三、每次上电幅值差 3%,同一块板子、同一个信号源。现象是重复性差,不是准确度差。原因是 VDDA 用了开关电源直接供,纹波几十毫伏,而且 ADC 上电校准没做(或者做在了基准稳定之前)。解决是给 VDDA 加 LC 滤波、上电延时 100 ms 后再做ADC_StartCalibration,并且把参考电压实测值写进lsb常量,而不是想当然填 3.3f。有条件的直接用外部基准,2.5 V 或 3.0 V 的基准芯片比 MCU 内部参考稳一个量级。
四、相位差在低频正常,1 kHz 以上随频率线性增大。现象非常有欺骗性,因为线性增长看起来“很物理”。原因是 2.2 节的扫描模式让两个通道串行转换,固定时差 Δt ≈ 5.7 μs,相位误差 2πfΔt 随频率线性上升。解决是改双 ADC 同步规则模式,或者按 4.3 节做频点标定扣除。判断方法很简单:把两路输入并接到同一个信号上,如果测出来相位差不为零且随频率线性增长,就是这个坑,跟被测对象无关。
五、FFT 结果前半段正常、后半段全是垃圾。现象是频谱形状诡异,或者干脆是 NaN。原因是 DMA 配置和缓冲区尺寸对不上:DMA_BufferSize填的是字节数而不是传输个数,或者DMA_PeripheralDataSize配成了 Word 而缓冲区是uint16_t,导致每次搬运吃掉两个采样点、内存越界。解决是核对三处——缓冲区元素类型、DMA 数据宽度、BufferSize的单位(是“传输次数”,不是字节)。查完还不放心就编译
本文还有配套的精品资源,点击获取