做信号测量类项目的时候,“测频率”这三个字永远是绕不开的需求。无论是做一个简易示波器、音频频谱显示器,还是给电机测速、检测振动信号,最终都要落到“这个信号到底有多快”这个问题上。而STM32平台最常用的两种测频路线,就是定时器输入捕获和FFT频谱分析。这两条路各管一段,踩过的坑也各自不同,这篇博文把我的实际使用经验完整拆开讲。
先说清楚这两个方案到底是什么定位:输入捕获解决的是“单一频率信号的精准测量”,典型场景是测一个PWM波、一个光电编码器输出、或者经过整形后的正弦波频率;FFT解决的是“复杂信号里有哪些频率成分”,典型场景是音频分析、振动故障诊断、电力谐波检测,它能把一堆混在一起的信号切片给你看。两者不是替代关系,而是互补关系。我会从原理、选型、配置、代码、排障五个维度把整条链路讲完整,适合正在做课程设计、毕业设计,或者刚入门嵌入式信号处理的读者直接参考。
1. 方案选型:先搞清楚你的信号长什么样
1.1 输入捕获的适用边界
输入捕获的核心思路很简单:定时器内部有一个自由运行的计数器,我让它在信号的上升沿(或下降沿)把当前计数值“抓住”存到捕获寄存器里,然后拿相邻两次捕获的计数值做差,就能得到信号周期,取倒数就是频率。
这个方案强在哪?在一个周期内就能出结果,响应速度快、精度可以做得非常高(取决于定时器时钟频率)。我举个实际例子:STM32F407的主频168MHz,定时器挂到APB1总线上就是84MHz,用它去捕获一个1kHz方波,一个周期内计数器走84000个Tick,计数误差只有±1个Tick,折算下来测频误差大概千分之一,这个精度在很多工业现场都够用了。
但输入捕获有两个致命限制。第一,它只能测“单一主导频率”的信号。如果被测信号是个方波、正弦波、或者经过施密特整形后的脉冲,没问题;但如果信号本身就是多个频率叠加的复合信号,比如音频信号、电机振动信号,输入捕获拿到的只是某个过阈值的边沿,结果会乱跳。第二,它本质上测的是周期,所以需要一个足够强的上跳沿,如果信号幅值太小、噪声又大,引脚上抖动会带来大量误触发,频率读数会跟抽风一样跳变。所以用到输入捕获的场景,多半要先用比较器或整形电路把信号变成干净的方波。
1.2 FFT能干什么、不能干什么
FFT(快速傅里叶变换)解决的完全是另一个问题:它在频域里把信号拆开,告诉你每个频率成分的幅值有多大。STM32F4系列自带FPU和DSP库,做实数FFT的速度非常可观,1024点FFT在168MHz频率下大概几百微秒就能跑完,实时性完全够。
FFT测频最典型的需求就是嵌入式频谱分析仪之类的东西。你给麦克风输入一段音频,FFT能把基频和各个谐波都显示出来;你做电机故障诊断,FFT能看到转频、倍频、轴承故障特征频率;你测一个带有大量谐波畸变的工频信号,FFT能帮你同时分离出50Hz和它的整数倍谐波。这些场景,输入捕获是彻底没戏的。
但FFT也有它天生别扭的地方:它需要等一整段采样数据攒够才能算一次,所以刷新率天然比输入捕获低;它的频率分辨率取决于采样率和FFT点数,不是想测多细就有多细;它还特别吃内存,做2048点FFT,光存放采样缓冲区和运算结果就要十几KB的RAM,小容量的芯片可能会直接卡死。选型的时候一定要先想清楚,你的信号到底是“单一频率”还是“多频率成分”,这决定了整个方案的方向。
1.3 我的选型判断标准
拿我自己的习惯来说,接到一个测频需求,我第一件事就是问三个问题:
- 信号是不是只有单一主频?
- 频率范围大概是多少?
- 需要多快的刷新率?
如果答案是“单一频率,几十Hz到几百kHz,刷新越快越好”,闭眼选输入捕获,成本最低、实现最快、精度也最靠谱。如果答案是“信号很复杂,我想知道里面有什么频率成分”,那就必须上FFT,没得商量。
还有一种情况比较特殊,就是既需要精准的单频测量,又需要知道波形质量。这种情况下我会把两种方案同时做进去:用输入捕获测基频,用FFT看谐波分量和总畸变率。STM32F407的定时器资源和DSP运算能力都够,工程里两个模块并存完全没问题。
2. 输入捕获测频的完整实现
2.1 定时器配置的几个关键点
用STM32CubeMX配置输入捕获,比我早年纯手写寄存器快太多了,但即使这样,也有几个地方特别容易配错。
首先是时基设置。定时器的时钟源、预分频系数、自动重装载值必须想清楚。我习惯的做法是:把定时器时钟配到84MHz(F4)或72MHz(F1),然后把预分频设置为83(F4)或者71(F1),这样计数器的Tick就是1MHz,计数值直接就是微秒数,调试的时候读数值非常直观,不用做除法。自动重装载值(ARR)要看最大被测周期来定,16位定时器最大65535个Tick,倒推一下:1MHz计数下,最长能测65535微秒,也就是大概15Hz的信号。如果要测更低频率,只能降低计数频率,比如把Tick降到100kHz,代价就是低频时精度下降。
然后是输入捕获通道配置。有一点必须说清楚:STM32的捕获通道和引脚不是死绑的,比如TIM2的通道1可以是PA0,也可以是PA5。用CubeMX的时候你只需要在Pinout视图里点选引脚,它会自动帮你分配定时器通道,看起来很方便,新手容易因此忽略定时器与引脚的复用关系。如果后面要改PCB,别忘了重新检查引脚复用表,别改完硬件代码跑不通还在那瞎查。
第三个是边沿选择。捕获上升沿还是下降沿,取决于你的信号形态。如果信号不是严格的50%占空比方波,测量上升沿间隔比测量高电平脉宽更不容易出错,因为脉宽测量受占空比影响大,上升沿到上升沿的时间才是完整的周期。我的习惯是设成上升沿捕获,然后通过软件判断是否超时。
2.2 捕获中断里的关键代码思路
配置完中断后,重点就在中断处理函数里。这里我强调一个非常容易踩的坑:只读捕获寄存器是不够的,还要处理定时器的溢出(Update)事件。
设想一个场景:被测频率比较低,比如20Hz,周期50ms,而定时器计数到65535就回零了,那么一个周期内计数器会回绕好几次。如果没有把溢出次数记录下来,你算出来的周期就是错的。正确的做法是:在Update中断里给一个变量加1(记录完整溢出次数),在捕获中断里,读出当前CCR值,再读当前溢出计数器,用完整时间戳相减。
我给出一个比较典型的处理流程:
volatile uint32_t overflow_cnt = 0; volatile uint32_t last_timestamp = 0; volatile uint32_t period_tick = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); overflow_cnt++; // 记录定时器回绕次数 } if (TIM_GetITStatus(TIM2, TIM_IT_CC1) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); uint32_t cur_timestamp = TIM_GetCapture1(TIM2) + (overflow_cnt << 16); period_tick = cur_timestamp - last_timestamp; last_timestamp = cur_timestamp; // 此时 period_tick 就是信号周期对应的Timer Tick数 } }用“当前捕获值+溢出次数×65536”拼出一个完整时间戳,这种方式比单纯用捕获值可靠得多。注意overflow_cnt要保证在读取的那一刻和CCR寄存器是配套的,否则临界区会出问题。处理方法是:先关定时器中断,读取溢出计数和捕获值,再开中断,或者用代码里“先读SR,再读CCR”的次序来规避。实操中我一般直接用临界区保护,逻辑简单不容易出错。
2.3 软件滤波和测量稳定性
硬件上信号再怎么干净,到了单片机引脚这里也难免有些毛刺。更别提有些场景直接把电机编码器的输出接进来,干扰是真的不小。我的习惯是,测频结果出来后一定要做软件滤波。
最简单有效的策略是“中值+平均”:连续采集5个周期值,去掉最大最小,剩下3个取平均。这个组合能同时抗单点毛刺和短时抖动,代码量也就十几行。如果对实时性要求高,可以改成滑动平均,窗口取4或8,移位操作即可,不用做复杂除法。
另外说一个很实用的细节:不要更新完频率值马上清零重测,最好让测量窗口重叠。比如用环形缓冲维护最近N个周期数据,每次新数据进来就把最旧的数据顶掉,更新频率的时候滚动计算平均。这样测频结果的刷新率更高,显示也不会有明显的间断感。
2.4 把信号整形干净的硬件建议
前面我说过,输入捕获需要干净的边沿。实测中,如果直接把话筒输出、或者未经整形的正弦波接到单片机引脚,捕获结果会非常不稳定。因为引脚的电平阈值附近有噪声时,一次上升沿会被触发好几次。
解决的办法,常规是加一个比较器或者施密特触发器。最简单的方案是用一个LM393比较器,把参考电压设在信号中点附近,输出端加上拉电阻,再接单片机引脚。如果信号本身幅值足够,也可以用单片机的内部施密特触发输入来改善,但抗噪能力依然有限,外部整形还是更稳一些。
我做过一个测速小车项目,直接用光电编码器的A/B输出接STM32的输入捕获引脚,编码器本身输出波形是方波,基本不需要额外整形。但如果是自己用红外对管+码盘搭的测速装置,接收端波形往往带着很长的上升/下降沿,那就必须在比较器里处理一下,否则速度显示一定会出乱子。
3. FFT测频:从采样到频谱输出的完整链路
3.1 采样率、FFT点数和频率分辨率的计算关系
FFT测频第一个要搞明白的参数是频率分辨率Δf。它的计算公式很硬核:Δf = fs / N,其中fs是采样率,N是FFT点数。举个例子,采样率设为10240Hz,做1024点FFT,分辨率就是10Hz。这意味着,如果你信号里有50.0Hz和55.0Hz两个频率分量,在频谱上它们只隔了0.5格,几乎无法区分。
这里还要注意采样定理的约束:采样率必须大于被测信号最高频率的两倍。比如你要分析20kHz以内的音频,那么采样率至少40kHz,保险起见我会留出20%的余量,配48kHz或51.2kHz。采样率一旦定了,FFT能分析的频率范围上限也基本定了,就是fs/2,也就是奈奎斯特频率。
我的习惯是先把目标频率范围确定下来,再反推采样率和点数。比如工程里需要监测0~5kHz的振动信号,分辨率希望做到5Hz以内,那么N=fs/Δf,若fs=10kHz,N至少2000点,实际取2048点,分辨率约4.88Hz,完全满足要求。如果用1024点,分辨率约9.77Hz,就偏粗了。
表格整理一下常见组合:
| 采样率fs(Hz) | FFT点数N | 分辨率Δf(Hz) | 最高分析频率(Hz) | 内存占用(float缓冲) |
|---|---|---|---|---|
| 10240 | 1024 | 10 | 5120 | 8KB |
| 10240 | 2048 | 5 | 5120 | 16KB |
| 48000 | 1024 | 46.9 | 24000 | 8KB |
| 48000 | 2048 | 23.4 | 24000 | 16KB |
内存占用这个数据大家心里要有数,老款STM32F103的RAM只有20KB,1024点FFT勉强能跑,2048点就有点吃紧;F4系列一般都有128KB以上,基本不用担心。
3.2 ADC采样:用定时器触发+DMA,别用阻塞采样
FFT对采样数据的等间隔要求非常严格。如果靠while循环里调用ADC采样,中间任何一条中断指令插入都会导致采样点时间不均匀,反映到频谱上就是噪声底抬高、杂散分量变多。正确做法是用定时器输出触发ADC采样,再用DMA自动搬运结果,整个过程CPU全程不参与,数据的时间均匀性由硬件保证。
以STM32F4为例,我用的是TIM3的TRGO事件触发ADC1,ADC1规则通道序列采集PWM口对应的引脚,DMA循环模式把结果搬运到内存数组。CubeMX里的配置思路大概是:TIM3配成PWM模式,输出比较频率就是采样率;ADC1开启定时器触发;DMA配置成循环模式,数据宽度半字(12位ADC结果)。
采样缓冲区的组织也要注意。做实数FFT有两种数据排布方式,一种是直接用arm_rfft_fast_f32,输入是一个float数组,实数序列按顺序填充;另一种是用arm_cfft_f32,输入是复数数组,实部放采样值,虚部填0。我个人的经验是,F4系列用CMSIS-DSP库的arm_rfft_fast_f32最方便,它内部做了Radix-4优化,性能和代码简洁度都很理想。
ADC采样值一般需要做一下电平搬移。很多信号源是单极性的,比如麦克风模块输出电压在0~3.3V之间摆动,中心点在1.65V附近,FFT对直流分量不敏感,直接算就行,不用额外处理。但如果想把频谱显示得更干净,可以先在软件里减掉直流均值再送FFT,这样0Hz处的巨大直流分量不会压得旁边的低频分量看不清。
3.3 DSP库FFT调用和幅值谱计算
CMSIS-DSP库让FFT代码变得非常薄。以STM32F407、Keil环境为例,首先在工程选项里勾选使用FPU,并把arm_cortexM4lf_math.lib加进来。然后调用方式如下:
#define FFT_SIZE 2048 float32_t input[FFT_SIZE]; float32_t output[FFT_SIZE]; float32_t mag_output[FFT_SIZE/2]; arm_rfft_fast_instance_f32 fft_handler; // 初始化 arm_rfft_fast_init_f32(&fft_handler, FFT_SIZE); // 假设input已经填好ADC采样数据 arm_rfft_fast_f32(&fft_handler, input, output, 0); // 0表示正变换 // 计算幅值 arm_cmplx_mag_f32(output, mag_output, FFT_SIZE/2);计算完幅值谱之后,找出幅值最大谱线对应的频率就简单了:循环搜索最大幅值的下标k,频率就是k×fs/N。但要提醒一点,直接取最大值谱线会存在栅栏效应,单根谱线的频率和真实频率可能有偏差。如果只是显示个大概频率,够用;但如果你要精确到0.1Hz,就要做频谱插值。
3.4 提高测频精度的抛物线插值法
当信号频率不是FFT分辨率的整数倍时,真实频率不会正好落在某根谱线上,而是“骑”在相邻两根谱线之间。这时频率估计会带系统偏差,最大可能有半个分辨率。解决办法是取幅值最大的谱线以及它左右两根谱线,用抛物线拟合峰值位置。
公式很朴素:假设最大谱线在k,幅值分别为y[k-1]、y[k]、y[k+1],则修正后的峰值位置delta为
delta = (y[k-1] - y[k+1]) / (2 * (y[k-1] - 2*y[k] + y[k+1]))
真实频率 f = (k + delta) * fs / N
我在实际工程中,用这个抛物线插值,50Hz工频信号的测量误差能从1Hz左右降到0.1Hz以内。对于一般测试显示完全够了。如果想更进一步,可以用加窗FFT+双谱线插值算法,但代价是计算量增加,普通项目没必要追求到这个程度。
3.5 加窗还是不加大有讲究
FFT有一个逃不掉的问题叫频谱泄漏:如果采样窗口内信号不是整数个周期,能量会“漏”到旁边的谱线上,导致频谱看起来糊成一片。解决思路是给数据加窗函数,常用的有汉宁窗(Hanning)和布莱克曼窗(Blackman)。
但加窗有代价:主瓣宽度变大,有效分辨率会变差。所以做测频的时候我的取舍是这样的:如果被测的是稳定的周期信号、频谱本身就比较干净,不加窗也没问题;如果是音频这类复杂信号,必须加汉宁窗,不然谐波之间的旁瓣会互相干扰;如果是分析瞬态冲击信号,用矩形窗(不加窗)反而更能保留时间特性。
CMSIS-DSP库里没有直接的加窗函数,需要自己写个循环。比如汉宁窗的系数计算方式为:
for (int i = 0; i < FFT_SIZE; i++) { float32_t w = 0.5f * (1.0f - arm_cos_f32(2.0f * PI * i / (FFT_SIZE - 1))); input[i] *= w; }加窗之后,幅值谱的数值会变小,因为窗函数把边缘数据削弱了。要恢复真实幅值,需要做幅度校正,通常除以窗函数的相干增益。汉宁窗的相干增益是0.5,所以加窗后幅值要乘以2才能对应真实正弦波的幅值。这个细节很容易被忽略,我早期调试时就遇到过明明输入1V的正弦波,频谱幅值却只有0.5V,排查半天才发现是没做幅度校正。
4. 实操中的高频问题与排查经验
4.1 输入捕获频率值乱跳
我在测速小车项目里遇到过最头疼的问题,就是速度显示值隔几秒就跳到几倍数值,然后又掉回来。排查过程分三步走,最后定位到是编码器A相波形毛刺太多,在边沿附近来回触发。
解决方法是把“捕获上升沿”改成“捕获上升沿+软件消抖”。具体实现:第一次捕获触发后,关掉捕获中断,软件延时2ms再重新开启;如果2ms内没有再次触发,就认为这个边沿是有效的;如果2ms内再次触发,说明上一个边沿是毛刺,丢弃。这个策略虽然会增加一些测量盲区,但对低速测速来说完全能接受。
4.2 FFT结果出现明显镜像分量
如果FFT出来的频谱在某个频率f位置有峰,同时在fs-f附近也出现了一个对称的峰,这通常不是真实信号,而是采样过程中混入了高频噪声导致的混叠(Aliasing)。最有效的解决方案是硬件上加RC低通滤波器,把高于fs/2的成分在进ADC之前滤掉。很多初学者忽略了这个抗混叠滤波器,结果频谱上总有一些说不清道不明的杂峰,改软件改到天亮也解决不了,最后发现是硬件问题。
如果不想动硬件,软件上可以把采样率尽量提高,让混叠频率落到分析频带之外,然后通过数字滤波处理。但总归不如硬件抗混叠来得干净。
4.3 浮点运算慢或者HardFault
FFT运算本身是浮点密集型的。如果你用的是不带FPU的F1系列,跑2048点实数FFT可能要几百毫秒,实时性就很差。解决办法有三个:换成F4或H7系列(带硬件FPU);用定点FFT库替代浮点库;降低FFT点数。
另外一个常见问题是HardFault,多半是内存越界。浮点FFT的临时缓冲是输入缓冲的2倍(因为复数的实部和虚部),如果只给output分配了FFT_SIZE个float,但arm_rfft_fast_f32在运算时会同时写实部和虚部,就会越界,直接HardFault。我用一个简单规则规避:所有FFT相关缓冲统一按2 * FFT_SIZE的大小去分配,宁可浪费一点内存,绝不踩越界。
4.4 上位机显示频谱的串口瓶颈
如果你的项目需要把频谱数据发到上位机显示,会遇到一个现实问题:1024点FFT,每点按浮点发出去,一个频率点动辄4字节,全发一遍要4KB数据。如果用115200波特率,1秒最多传大约11.5KB,意味着刷新率极限也就2~3帧每秒,而且CPU大部分时间都在等串口发送。
我的处理办法是对频谱做峰值提取和降采样。比如只在频谱里挑出幅值最大的10个频率点,把它们作为“特征频率”发送,数据量瞬间降到几十字节;或者对频谱做分段最大保持,把1024点压缩成128个“包络点”,上位机画出来的频谱形状完全够看,传输压力却小了一个数量级。这样刷新率能轻松做到10帧以上,上位机显示也流畅很多。
4.5 常见问题速查
| 现象 | 可能原因 | 处理方向 |
|---|---|---|
| 输入捕获频率是实际值N倍 | 引脚毛刺触发多次边沿 | 加施密特整形或软件消抖 |
| 输入捕获低频测不准 | 定时器溢出未处理 | 补全溢出次数,用完整时间戳 |
| 频率值为0 | 捕获中断没进入 | 检查引脚复用、NVIC、时钟使能 |
| FFT频谱杂散多 | 采样时间不均匀 | 改用定时器触发+DMA |
| FFT幅度值偏小 | 未做窗函数幅度校正 | 按相干增益补偿 |
| 高频段出现镜像峰 | 混叠 | 加抗混叠低通滤波器或提升采样率 |
| 程序HardFault | FFT缓冲越界 | 缓冲按2倍FFT_SIZE分配 |
| 串口显示刷新慢 | 数据量过大 | 峰值提取或分段降采样 |
5. 两种测频方案的性能对比与扩展建议
拿一个“信号测量模式切换”的思路收个尾。我在做嵌入式系统的时候,常把输入捕获和FFT做成两个可切换的模块,通过串口指令或按键切换模式。模式一是“精准频率计”,适合单一频率信号,刷新率能做到每周期更新一次;模式二是“频谱分析仪”,适合复杂信号查看频率成分,刷新率取决于FFT点数和采样时间。
两个方向后续可以扩展的地方挺多。输入捕获可以扩展出周期测量、脉宽测量、占空比测量,甚至可以接正交编码器做电机转速方向检测,这些其实都是同一个底层机制在支持。FFT方向则可以做加窗种类切换、频谱峰值自动跟踪、功率谱密度计算,配合DAC还能做简单的音频频闪显示或音乐节奏灯。仔细想想,这两个模块几乎能覆盖信号测量领域80%的基础需求。
我个人在实际项目里最大的体会是:无关方案高低,真正决定项目成败的是对信号本身的理解。先别急着堆代码,拿示波器看看待测信号的形态,再决定用输入捕获还是FFT,整个工程推进会顺畅得多。如果你正准备搭一个测频相关的小项目,建议先从定时器输入捕获练手,因为代码短、调试直观、出成果快;等熟练了再上FFT去啃复杂信号。这条路走下来,你对STM32定时器和信号处理的理解都会上一个台阶。