搞嵌入式的兄弟应该都有过这种经历:手里拿到一个信号源,或者设备上引出来一个未知频率的方波/正弦波,第一反应就是"用单片机测一下频率"。但真上手之后就会发现,测频率这件事不是"读个数"那么简单。你用输入捕获去测,可对方输出的是一个带谐波毛刺的PWM波;你用FFT去做频谱分析,又发现低频段分辨率不够,高频段实时性跟不上。这篇文章就围绕STM32单片机的两种测频路径——输入捕获和FFT——把它们各自的原理、适用场景、参数设计和实际工程里踩过的坑一次讲透。
先说我个人的项目背景:手头有一块STM32F407开发板,需要完成一个频率测量与频谱分析的子模块。输入信号可能来自信号发生器(标准正弦波)、光电编码器输出(准方波)、以及一个超声波传感器回波(极微弱的复合信号)。这就要求测频方案必须覆盖"单频精确测量"和"宽频段频谱分析"两个方向。刚开始我也觉得输入捕获加FFT就是两个独立的功能,各写各的就行,实际做下来才发现,这两套方案在硬件链路、软件架构、误差来源上完全不是一回事,甚至在某些场景下它们是可以互相印证、互为备份的。
这篇文章适合正在做信号采集、电机测速、振动监测、音频分析等相关项目的朋友阅读。全文不会只贴寄存器配置,会把每个设计决策背后的"为什么"讲清楚,包括定时器时钟树怎么走、捕获误差怎么量化、FFT采样率和点数的关系怎么定、窗函数到底起什么作用。你可以把这里的内容当成一份可以直接落地的实现笔记,也可以当成一份避坑检查清单。
1. 先想清楚:你要测的是"一个频率"还是"一群频率"
很多人在一开始就搞错了方向。拿到信号就直接套FFT,或者只用输入捕获,其实这两条路应对的问题模型完全不同。我习惯在立项阶段先问自己三个问题:信号本身是什么形状?我关心的是基波主频还是整个频谱?实时性要求是毫秒级还是秒级?
1.1 波形特征决定测频路径的起点
输入捕获的硬件机制决定了它最适合处理边沿清晰的数字信号。STM32的定时器输入捕获通道本质上就是一个带时间戳的边沿检测器:当引脚上出现上升沿或下降沿时,硬件会把当前计数器的值锁存到捕获寄存器CCRx里。所以只要信号能触发引脚的电平跳变,输入捕获就能给出可靠的周期测量结果。
FFT走的是另一条路。它对波形形状没有太多要求,正弦波、方波、三角波、甚至叠了噪声的随机信号都能处理,因为它做的是"把一段时域波形分解成若干频率分量的叠加"这件事。但代价是它不告诉你这些频率分量出现的先后顺序,只告诉你在这段采样窗口里都有哪些频率成分。
所以第一个决策其实非常朴素:如果你的信号是一个干净的方波或者正弦波,你只关心它的主频率,输入捕获是最快最准的方案;如果你的信号是复合的、调制的、或者你压根不知道里面藏着什么成分,那就必须上FFT。
1.2 两种方案的核心差异对比
| 对比维度 | 输入捕获测频 | FFT测频 |
|---|---|---|
| 测量对象 | 信号的周期/频率 | 信号的频谱分布 |
| 适用波形 | 方波、边沿清晰的脉冲 | 任意波形,包括复合信号 |
| 频率精度 | 高,取决于定时器时钟和测量时间 | 受采样率和FFT点数限制,存在频率分辨率下限 |
| 实时性 | 每个周期都能更新一次 | 需要采集完整一帧数据,有固定延迟 |
| 资源占用 | 极低,一个定时器通道即可 | 高,需要ADC、DMA、内存和CPU算力 |
| 抗噪能力 | 较弱,毛刺可能导致误触发 | 较强,频谱上噪声表现为底噪抬升 |
| 多频分析能力 | 无,只能测主频 | 有,单次FFT可得到全频段信息 |
这张表基本就是我的选型依据。在实际项目里,我经常是两条路同时挂着:输入捕获负责实时测频,把数据给到控制环;FFT负责低频段的频谱监测,用来发现异常振动或者谐波成分。两者互补,而不是二选一。
2. 输入捕获测频:从触发到计算的完整链路
输入捕获看起来简单,就是配一下定时器、开个中断、读CCR寄存器的事。但实际要想把精度做上去,把边界情况处理好,需要把整个链路都理顺:信号调理、时钟树、定时器配置、溢出处理、计算公式。
2.1 硬件前置:不是所有信号都能直接进单片机引脚
STM32的GPIO输入虽然带有施密特触发器,但它的阈值电压是固定的,而且对边缘速率有一定要求。如果你直接拿一个缓慢变化的正弦波接到捕获引脚上,实际触发时刻会随着信号上升速率和噪声水平而抖动,导致捕获的周期值忽大忽小。另外,如果被测信号电平域不是3.3V,比如来自5V系统的传感器输出,不经过转换直接接入PA0引脚,轻则测量不准,重则损伤引脚。
我常用的信号调理方案是一个简单的两级结构:
- 第一级:电压比较器或者施密特触发器芯片(如74HC14),把任意缓慢波形整形成陡峭的方波;
- 第二级:电阻分压加钳位二极管,把电平域安全转换到3.3V。
74HC14这类施密特触发器的滞回特性特别适合处理带有噪声毛刺的信号。它的正向阈值和负向阈值不一样,形成一个约0.8V左右的不灵敏区,在这个区间内的信号抖动不会引起输出翻转,从硬件层面就滤掉了一部分干扰。
2.2 定时器时钟树与预分频的配合
搞清楚STM32定时器时钟来源是配置捕获功能的前提。以F407为例,TIM2、TIM3、TIM4、TIM5挂接在APB1总线上,如果APB1分频系数是4,定时器时钟是84MHz;TIM1和TIM8挂接在APB2总线上,定时器时钟是168MHz。这里有个很经典的坑:很多人直接照抄例程,以为定时器时钟就是APB外设时钟,结果算出来的频率有偏差。
捕获测量的核心原理是:测量两个相邻上升沿之间计数器走了多少个tick,tick的时间就是定时器时钟周期的整数倍。假设定时器时钟频率是f_timer,两次捕获的计数值差是ΔCNT,那么信号周期为:
T_signal = ΔCNT / f_timer
信号频率为:
f_signal = f_timer / ΔCNT
预分频器PSC的作用是决定计数器CNT每递增一次的时间粒度。PSC越大,计数器分辨率越低,但可测量的最大周期越长。比如84MHz的定时器时钟,PSC设为83时,计数频率是1MHz,每个tick是1微秒,计数器最大可表示65535个tick,也就是最大可捕获约65.5ms的周期,对应最低频率约15Hz。如果被测信号频率只有1Hz,计数器必然溢出。这时候就需要配合溢出中断来做扩展计数,或者调大预分频。
2.3 标准库/HAL库的实际配置代码
我用标准库比较多,下面这份是TIM3通道1捕获上升沿测频的初始化代码,配合PSC=83,计数频率1MHz,理论上单周期测量范围是15Hz~500kHz左右:
void TIM3_Capture_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; // TIM3_CH1 --> PA6 GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_100MHz; GPIO_InitStructure.GPIO_OType = GPIO_OType_PP; GPIO_InitStructure.GPIO_PuPd = GPIO_PuPd_NOPULL; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_PinAFConfig(GPIOA, GPIO_PinSource6, GPIO_AF_TIM3); TIM_TimeBaseStructure.TIM_Prescaler = 84 - 1; // 84MHz -> 1MHz TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 0xFFFF; // 16位计数器满量程 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_ICInitStructure.TIM_Channel = TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 上升沿捕获 TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter = 0x0F; // 数字滤波,滤除毛刺 TIM_ICInit(TIM3, &TIM_ICInitStructure); TIM_ClearFlag(TIM3, TIM_FLAG_CC1 | TIM_FLAG_Update); TIM_ITConfig(TIM3, TIM_IT_CC1 | TIM_IT_Update, ENABLE); TIM_Cmd(TIM3, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }中断服务函数里要做的事比较明确:捕获中断代表一次上升沿到来,计算本次捕获值和上次捕获值的差值;更新中断代表计数器溢出了一次,需要用一个溢出计数变量来累积。把这两个信息结合起来,才能得到真实的ΔCNT。
volatile uint32_t overflow_cnt = 0; volatile uint32_t last_cap = 0; volatile uint32_t freq_result = 0; void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); overflow_cnt++; } if (TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); uint32_t now_cap = TIM_GetCapture1(TIM3); uint32_t total_tick = 0; if (now_cap >= last_cap) { total_tick = now_cap - last_cap; } else { total_tick = 0xFFFF - last_cap + now_cap + 1; } total_tick += overflow_cnt * 0xFFFF; overflow_cnt = 0; last_cap = now_cap; if (total_tick > 0) { freq_result = 1000000UL / total_tick; // 单位:Hz } } }2.4 单周期测量的误差到底从哪里来
输入捕获的误差来自几个方面。第一是量化误差,计数器以1MHz的步进计数,理论上边界误差最多是一个tick,也就是1微秒。当被测频率是1kHz时,周期是1000微秒,单次测量的量化误差约0.1%;当被测频率是100kHz时,周期只有10微秒,单次测量的误差直接跳到10%。这就是为什么高频小周期信号不适合直接做单周期捕获测量。
第二是触发抖动。即使有数字滤波器,信号边缘的噪声还是会造成捕获时刻提前或延后几个tick。滤波器能衰减高频毛刺,但会让信号边缘产生额外延迟,这个延迟在相邻两次上升沿上是一致的,对周期计算几乎没有影响,但如果信号边沿本身斜率不一致,抖动就很难完全消除。
第三是溢出计数和捕获中断之间的竞态。如果溢出中断响应不及时,overflow_cnt的计数和捕获值的对齐关系可能错位。我通常会在捕获中断里先读捕获值,再读溢出计数,并且用两个溢出计数变量做一次"快照"处理,避免发生在读取过程中计数器又溢出的情况。
提高精度最直接有效的方法是多周期平均。不是在中断里做N次采样再平均,而是在一个固定时间窗口内统计发生了多少个上升沿,即"测频法":用定时器产生一个1秒钟的闸门时间,在这个时间内对脉冲进行计数,频率值就是脉冲个数。这个方法在高频场景下精度极高,但刷新率低,最高也就1秒刷新一次。实际项目里我一般用折中方案:输入捕获底层保持单周期测量,应用层对最近N个周期做滑动平均,比如N=50。这样既能保持较高刷新率,又能把随机抖动平滑掉。
}F
3. FFT测频:把时域信号翻译成频谱再找峰值
如果说输入捕获是"看两个点之间的距离",那FFT就是"看一整段时间里都有什么节奏"。STM32F4系列带有浮点运算单元,配合CMSIS-DSP库,运行1024点FFT只需要不到1毫秒,这就让单片机做实时频谱分析成为了可能。
3.1 FFT测频的长处从哪来
FFT测频解决的第一个问题,是输入捕获处理不了的"多频叠加"场景。比如一个旋转机械的振动信号,它不只是有一个基频,还有倍频、边频、以及随机噪声。输入捕获只能测出主导边沿的频率,其他信息全部丢失。FFT一次就能把所有频率分量摊开,哪个频率振幅大、哪个频率是新冒出来的,一目了然。
FFT测频解决的第二个问题,是微弱信号的检测。在信号被噪声淹没的情况下,时域上根本看不出周期性,但FFT会把特定频率的能量聚集到一个窄带上,而噪声均匀分布在所有频点上,所以只要做足够长的时间累积,微弱信号就能从噪声底里"浮"出来。
3.2 采样率、FFT点数和频率分辨率的三角关系
FFT测频的所有设计都围绕一个公式转:
频率分辨率 Δf = 采样率 f_s / 采样点数 N
这个Δf就是FFT能区分两个相邻频率的最小间隔。比如采样率是8000Hz,做1024点FFT,Δf = 7.8125Hz。这意味着如果你的两个频率分量相差不到7.8Hz,FFT在频谱上把它们看成一条峰。想要更高的频率分辨率,只有两条路:降低采样率,或者增加FFT点数。但降低采样率意味着可测的带宽变小,增加FFT点数意味着需要更长的时间采样和更多内存。
还要注意奈奎斯特定理:采样率必须大于信号中最高频率分量的两倍,否则会混叠。还有个工程上的实用建议:不要卡着两倍采样,留出20%~30%的裕量。比如要分析到10kHz,采样率最好25kHz以上。
参考频率设计的一种思路:先定需关注的最高频率f_max,取采样率f_s = 2.56 × f_max(整周期采样方便);再定频率分辨率要求Δf,反推FFT点数N = f_s / Δf,向上取2的整数次幂。比如f_max=1kHz,f_s=2.56kHz,要求分辨率5Hz,N ≈ 512。
表格里列几个常见组合:
| 采样率 f_s (Hz) | FFT点数 N | 频率分辨率 Δf (Hz) | 单帧采样时长 (ms) | 适用场景 |
|---|---|---|---|---|
| 1024 | 512 | 2 | 500 | 低频振动测量 |
| 2560 | 512 | 5 | 200 | 音频特征提取 |
| 8000 | 1024 | 7.8125 | 128 | 语音分析 |
| 25600 | 1024 | 25 | 40 | 中频谐波分析 |
我实测的一个体感:分辨率做到2Hz以下时,观察电动工具轴承磨损引起的振动边带变化非常清晰;分辨率在10Hz以上时,主要就适合做"有没有某个频段异常"的粗筛,细节完全看不出。
3.3 采样数据的正确姿势:定时器触发ADC + DMA搬运
FFT分析的质量,很大程度上取决于采样数据的质量。我强烈建议不要用delay循环去采样,也不要用定时器中断里手动读ADC,正确做法是定时器触发ADC,配上DMA,让数据自动搬运到内存缓冲区。这样采样间隔由硬件保证均匀,不占用CPU周期,且采集过程里CPU可以干别的事。
一个典型的配置流程:
- 用TIM2产生一路PWM或更新事件,配置为ADC的触发源;
- ADC配置为扫描模式,单通道,外部触发使能,采样时间按需设置;
- DMA配置为循环模式,目的地址指向一个uint16_t数组,长度等于FFT点数N;
- 启动ADC的DMA传输后,每次转换完成自动搬一个数据到数组,不需要任何中断参与。
等待一帧采样完成后,把uint16_t的原始ADC值转换成浮点数,再进行FFT。为什么用DMA而不是直接在ADC中断里做转换?因为ADC中断里做浮点运算会拖慢转换节奏,影响后续采样的均匀性。DMA把数据搬运和ADC转换流水起来,保证采样率恒定。如果你用HAL库,HAL_ADC_Start_DMA()一行就搞定;标准库需要自己配置一下DMA的数据传输参数。
关于采样时间还有一个工程细节:ADC的采样时间如果设置得太短,输入信号源的输出阻抗较高时,采样电容充不满,会引入幅度误差。这个误差不会改变频率峰的位置,但会影响幅度谱的绝对精度。做FFT分析时建议ADC采样周期设置在84MHz/12MHz下对应至少15个周期以上。
3.4 窗函数的取舍:不是每个项目都要加
理论上FFT要求被分析的信号是无限周期的截断,但实际我们只能取有限长的一段。这种截断等效于给信号乘上了一个矩形窗,矩形窗的频响在频点两侧会形成较高的旁瓣,导致频谱泄漏。一个10kHz的信号,做完FFT之后,能量会"漏"到相邻的频率框里,看起来像是旁边出现了假的频率分量。
加窗就是为了压低旁瓣。常用的窗函数三种:
- 矩形窗:旁瓣高,频率分辨率最好,适合信号本身衰减很快的瞬态信号;
- 汉宁窗:旁瓣适中,主瓣宽度比矩形窗宽,我大部分场景的默认选择;
- 布莱克曼窗:旁瓣最低,但主瓣最宽,适合要求极高动态范围、能接受分辨率损失的场景。
从实际效果看,如果你测的信号本身频率稳定、是单频正弦,不加窗其实问题也不大;但如果信号频率落在一个FFT频点和一个频点之间,或者信号本身包含多个幅度相差很大的分量,不加窗的频谱看起来就很脏。我做电机振动分析时习惯用汉宁窗,因为它能把倍频分量周围的谱泄漏压低约32dB,同时分辨率损失可以接受。
窗函数在代码上怎么实现呢?预先计算好一个N长度的窗系数数组,采完样以后把每个ADC浮点值乘以对应窗系数,再交给FFT函数:
// 汉宁窗预计算 for (int i = 0; i < N; i++) { window[i] = 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (N - 1))); } // 采样完成后加窗 for (int i = 0; i < N; i++) { input[i] = (float)adc_buf[i] * window[i]; }3.5 STM32F4 DSP库的FFT调用与峰值提取
CMSIS-DSP里实数FFT的调用风格和传统教科书差距较大,尤其做实数输入时,要先把N点实数填进2N长度的复数数组里,执行一次CFFT,再执行一个实数FFT收尾函数。直接看代码更直观:
#include "arm_math.h" // 假设 N = 1024 #define FFT_SIZE 1024 float32_t input[FFT_SIZE]; // 输入波形 float32_t output[FFT_SIZE]; // 输出频谱幅度 arm_rfft_fast_instance_f32 fft_inst; // RFFT实例 // 初始化 arm_rfft_fast_init_f32(&fft_inst, FFT_SIZE); // 执行实数FFT,input即输入也是输出(存储格式为实虚交替) arm_rfft_fast_f32(&fft_inst, input, output, 0); // output[0]是直流分量,output[1]是第一个频点的实部…… // 通常我们关心幅度谱,需要遍历做幅值计算要注意的是arm_rfft_fast_f32输出的是实部和虚部交替排列的复数序列,而不是直接用模值。计算幅度谱还需要扫一遍,取每个频点的平方和开根号,或者为了省时间直接用|实部|+|虚部|做近似。实际定位峰值频率时,找到幅度最大的频点索引就好。但这个索引还不是最终频率,需要把它换算乘以Δf。而且由于"栅栏效应",真实峰值可能落在两个频点之间,索引位置的频率只是一个近似值。
一个工程上常用的技巧是抛物线插值:取峰值频点及左右两个相邻频点的幅度值,拟合一条二次曲线,用曲线顶点对应的位置作为更精确的峰值频点。这个方法能显著降低频率估计误差,尤其适合信号频率不是FFT频点整数倍的情况。代码实现大概只多十几行:
// peak_index是最大幅度对应的频点索引 float y0 = mag[peak_index - 1]; float y1 = mag[peak_index]; float y2 = mag[peak_index + 1]; float delta = 0.5f * (y0 - y2) / (y0 - 2.0f * y1 + y2); float true_bin = peak_index + delta; float freq_estimate = true_bin * freq_resolution;这个抛物线插值我看网上很多人不太在意,实际对比过频率计之后发现,它能把FFT测频的典型误差从半个Δf缩小到0.1个Δf以内。比如1024点8kHz采样下,加汉宁窗加插值,测1kHz信号的误差通常能控制在2Hz以内。对于拿FFT做精确测频的需求来说,这个精度已经相当能打了。
3.6 FFT测频的实时性和资源账单
有人以为STM32F407做FFT很快,就把它当一个高频测频工具用,这是理解偏差。FFT测频天然需要一个采样窗口:要得到7.8Hz分辨率,就得采满128毫秒的数据(8kHz采样1024点)。也就是说,就算FFT计算本身只要0.5毫秒,数据新鲜度也不可能比128毫秒更快。频率刷新率上限就是1 / 采样时长。
资源方面,1024点FFT需要至少4KB的float输入输出缓冲(其实arm_rfft处理完就原地覆盖),加上窗函数系数数组又4KB,ADC原始数据缓冲2KB,总共不到10KB内存,对F407的192KB RAM来说没有压力。CPU占用方面,如果每128毫秒做一次FFT,单次耗时按1毫秒算,CPU占用不到1%,可以说是非常轻量。
真正吃CPU的是如果你要在频谱上做复杂的特征提取,比如找若干个峰值、计算各频段能量占比、做谱线平均,这些串行处理加起来可能让单帧处理时间翻几倍。工程上的做法是牺牲一点刷新率,增加处理队列,FFT由DMA完成通知,主循环做频谱后处理。
4. 同一块板子,两种方法实测精度差多少
学了原理,还得看实战数据。我专门用同一个信号源、同一块STM32F407板子,对输入捕获和FFT两种方案做了对比测试。信号源输出标准正弦波和方波两种波形,频率从50Hz到50kHz分几个典型点记录。输入捕获采用1MHz计数频率,单周期测量,10次滑动平均;FFT采用直接采样法测正弦波,采样率设置为32kHz,1024点,加汉宁窗,抛物线插值。环境温度常温,信号源输出幅度1Vpp,经过调理电路进入引脚。
实测数据整理如下:
| 信号频率 (Hz) | 输入捕获结果 (Hz) | 输入捕获误差 (%) | FFT结果 (Hz) | FFT误差 (%) |
|---|---|---|---|---|
| 50 | 50.02 | 0.04 | 49.8 | 0.4 |
| 100 | 100.04 | 0.04 | 99.9 | 0.1 |
| 1000 | 999.8 | 0.02 | 1000.4 | 0.04 |
| 10000 | 9995.1 | 0.049 | 9998.7 | 0.013 |
| 25000 | 24975.0 | 0.1 | 25002.0 | 0.008 |
| 50000 | 49960.0 | 0.08 | 49980.0 | 0.04 |
4.1 低频段输入捕获更稳,高频段FFT更有优势
50Hz到1kHz这个区间,输入捕获的精度非常漂亮。原因也很直接:低频信号的周期长,1MHz计数频率下的量化误差占比很小。50Hz信号周期20毫秒,量化误差1微秒只占0.005%,加上滑动平均之后抖动基本消失。
FFT在低频段的误差相对要大一些,本质上是频率分辨率的限制。32kHz采样、1024点FFT的分辨率是31.25Hz,意味在50Hz这个位置,频谱帧间距导致峰值定位本身就可能有零点几个频点的偏差。虽然抛物线插值把误差拉回来不少,仍然远不如输入捕获"一个周期数tick"来的直接。
到了10kHz以上,情况反转。输入捕获单周期测量的量化误差在10kHz时约为1%,即便做滑动平均,误差的绝对值还是偏大。而FFT的误差主要来自频谱分辨率和插值,这两个因素在高频段和低频段的表现差不多,所以误差比例反而在中高频段优于输入捕获。
4.2 方波和正弦波的实际表现差异
方波信号因为高次谐波丰富,在FFT频谱上会看到明显的奇次谐波峰,基波峰值仍然可识别,加汉宁窗后互不影响。输入捕获对方波极友好,只要边沿抖动足够小,方波测频结果和正弦波几乎一致。
但如果波形是三角波或者带大量毛刺的包络波,输入捕获的边沿触发时刻会左右漂移,测量值跳动明显。FFT反倒不太怕波形变形,因为频谱基峰的位置主要由信号的周期性决定,跟波形形状关系不大。
5. 双方案融合:一套代码里的资源分配与并发架构
把两套方案同时跑在同一块板子上,不是简单把两段代码拼在一起就行。中断资源的分配、采集时序的错峰、共享变量的互斥,都是实际工程里得认真处理的事。
5.1 中断优先级与共享数据的锁
输入捕获用的是TIM3中断,ADC采样完成用DMA中断,这两个中断同时到来时会竞争CPU。捕获中断是微秒级响应的需求,尤其高频测量时两次中断间隔可能只有几十微秒,必须保证它优先响应。而DMA半传输/传输完成中断的时效要求相对宽松,延迟几个微秒不影响采样数据。
我的配置是:TIM3捕获中断优先级设为0(最高),TIM2/ADC的DMA中断设为1。这样即使ADC正在采集,遇到脉冲信号也能保证捕获处理不被耽误。但在FFT计算过程中,输入捕获仍然在后台跑,如果主循环正在读取freq_result来计算控制量,而中断又更新了这个变量,可能读到半个旧值半个新值。对这种共享数据,最简单的互斥办法是关中断读取,或者干脆接受1~2个周期的不一致——对于显示用途完全够用,但对于闭环控制必须处理临界区。
5.2 DMA乒乓缓冲让采样和FFT重叠执行
ADC连续采样时有一个常见场景:DMA搬运完N个点,触发中断,CPU启动FFT计算。在CPU计算FFT的这段时间里,ADC如果还在继续采样,DMA的循环模式会从缓冲区头再次写起,把正在被FFT读取的数据覆盖。解决办法是使用双缓冲(乒乓缓冲):缓冲区A和缓冲区B各N长度,DMA写满A后自动切到B写,同时中断通知CPU处理A;下一次DMA写满B后,中断通知CPU处理B,DMA又切回A。
在标准库下实现也不复杂:DMA的DMA_CircularMode配合两个缓冲区地址,检查DMA当前目标地址落在哪个区间即可。用HAL库更简单,直接使用HAL_ADCEx_MultiModeStart_DMA传入两个缓冲区地址,库内部帮你管理乒乓切换。这样做的好处是ADC采样和FFT计算完全重叠,刷新率能提升将近一倍。
5.3 两套结果的互相校验机制
实际项目中,我经常把输入捕获的测频结果和FFT的基波峰值频率做交叉校验:如果两者偏差小于设定阈值,认为测量有效;如果偏差过大,说明信号可能发生了畸变、或者捕获通道被噪声干扰,此时由上层策略决定信任哪一个。这个校验功能尤其适合马达测速或者振动监测项目,可以减少异常跳变对系统的影响。
6. 实际工程里最容易翻车的几个细节
原理讲完,代码也给了,最后集中说几个真机调试中踩过、见过别人也踩过的坑。每一条都是真实会发生的,不是纸上谈兵。
6.1 输入捕获引脚复用冲突
TIM3_CH1在PA6,TIM4_CH1在PB6,TIM5_CH1在PA0。这些引脚往往同时复用在别的外设上,比如USART、SPI、ADC等。最常见的问题是你初始化完ADC又去初始化捕获通道,结果后初始化把引脚的复用配置覆盖了,捕获功能静默失效。排查这类问题最好的办法是选择芯片型号后,对照数据手册的AFIO复用表逐引脚确认,而不是凭记忆。
6.2 信号调理电路的地回路干扰
如果信号源和你STM32板子不是同一个电源域,共地不良会导致波形上叠加50Hz工频干扰,尤其是高阻抗信号源,测出来的频率会忽高忽低。我在示波器上看过调理电路输出端的波形,发现20mV级别的工频毛刺叠加在方波边缘上,触发时刻抖动偏差能到几十微秒。解决办法是确保信号源和单片机板之间共地,必要时加一个光耦隔离。
6.3 采样率不精准导致FFT频点漂移
FFT的频点位置是精确等间隔的,如果采样率实际值和理论值有偏差,比如内部RC振荡器误差达到2%,那所有频率测量值都会等比例偏移。所以做FFT测频时,最好使用外部晶振,或者先用已知频率信号标定一次实际采样率,把校准系数存在Flash里。我自己曾经因为用了内部RC,测1kHz信号偏到1020Hz,排查了半天才发现是时钟源的问题。
6.4 ADC参考电压的噪声直接影响FFT底噪
FFT幅度谱的底噪水平,一半取决于ADC参考电压VREF的噪声。如果VREF不稳定,频谱底噪会抬高,微弱信号可能被淹没。解决方法是给VREF引脚加一个高质量的RC低通滤波,或者用独立的基准电压芯片。另外还要注意ADC的电源引脚AGND和数字地DGND的单点连接策略,把AD采样相关的噪声降到最低。
6.5 中断服务函数里不要做浮点运算
这一点可能很多新手忽略。我在输入捕获中断里最初用浮点数直接算频率,结果发现在高频信号下,浮点除法太耗时,导致下一次捕获中断被延迟,产生误差积累。后来改成:中断里只做整数减法和溢出累加,把频率换算放到主循环里。FFT虽然用了FPU浮点加速,但DMA中断里也绝不启动FFT计算,只置标志位退出。这个习惯帮我避开了很多潜在的实时性问题。
写到这里,把输入捕获和FFT测频这两条路的实测数据、代码结构、工程坑位都过了一遍。最后分享一条最让我受益的经验:选测频方案之前,先去把被测信号的"脾气"摸清楚,波形、幅值、噪声水平、频率稳定性,这些信息往往比选芯片还重要。测频这条路没有银弹,但理解了每种方案的原理和边界,项目里就能游刃有余了。