直接分享一个我最近在STM32上折腾完的测量方案:输入捕获 + FFT测频。
这俩词儿单看都不陌生,定时器输入捕获是单片机入门经典功能,FFT是数字信号处理里的常客,但是把这两者放一起、在同一个项目里做频率测量,并让它们分工协作,这里面的门道比想象中多。我最初的需求很简单:一个频率范围和信号形状都不固定的传感器输出信号,需要稳定读出基频。试过纯输入捕获,也试过纯FFT,都各自碰了一鼻子灰,最后把两者结合才彻底解决。这篇就把完整的实现思路、代码细节、参数计算和踩过的坑都写出来。
1. 输入捕获那套逻辑,测方波没问题,测非方波就抓瞎
先说输入捕获是怎么回事。STM32的定时器(以通用定时器TIM2-TIM5为例)内部有一个输入捕获通道,信号从通道引脚进来后,可以被边沿检测电路捕捉,硬件自动把当前计数器的值锁存到捕获寄存器里,然后触发中断。这样我们就拿到了一个边沿出现的时间戳,相邻两次上升沿(或下降沿)的时间差就是信号的周期,频率自然就是周期的倒数。
这套机制在测方波时非常顺手。比如一个输出固定频率方波的传感器,或者PWM信号,占空比和形状都是规整的,上升沿和下降沿都是陡峭的跳变,捕获到的边沿时间差非常准确,测频精度可以做到很高。
但一旦信号变成正弦波、三角波或者带有谐波失真的波形,输入捕获的短板就暴露出来了:
- 正弦波在过零点附近的斜率比较缓,如果信号经过比较器整形后送入捕获引脚,边沿会产生明显的抖动,导致每次捕获的周期值来回跳。
- 如果信号本身含有多个频率分量(比如电机振动信号,基频之外有大量谐波分量),输入捕获只能告诉你上升沿间隔对应的周期,这个值可能代表基频,也可能代表某个幅度较大的谐波,甚至可能出现计数乱跳的情况,根本拿不到稳定的主频。
- 输入捕获本质上测的是信号的时域周期,它永远回答不了"这个信号里都有哪些频率成分"这个问题。
这时候就需要FFT登场了。FFT(快速傅里叶变换)把时域波形变换到频域,能明确看出哪个频率分量能量最大,也就是信号的主频。哪怕波形里有复杂的谐波、噪声,只要信噪比不是太离谱,基频那个峰通常都是最突出的。
我遇到的实际场景就有意思了:一个振动传感器,输出是带有明显畸变的正弦波,理想状态是干净的单频,但实际带了很多高频毛刺,信号幅度还会波动。用输入捕获直接测,频率读数在400Hz和800Hz之间来回跳,因为谐波被整形出来后触发了两次捕获。用FFT一分析,频谱里400Hz的峰值高出一大截,主频确定无误。所以最终方案的逻辑很清晰:低频/方波用输入捕获,高频/复杂波形用FFT,两者配合覆盖全场景。
2. 输入捕获的硬件细节与代码实现:比你想的要多注意几个寄存器
输入捕获的实现方式很直接,但有几个关键点不注意到的话,测出来的数据会让人怀疑人生。
2.1 定时器时钟源与预分频的选择
定时器挂在APB1或APB2总线上,STM32F103的定时器时钟通常是72MHz(如果APB1预分频系数不为1,定时器时钟会是系统时钟的2倍)。这里的预分频器(PSC)决定了计数器的计数时钟。
我测的信号范围是1Hz-100kHz,如果直接用72MHz计数,1Hz信号对应72000000个计数,早就溢出16位计数器了(0-65535)。所以必须配合溢出中断来扩展计数范围,或者干脆把时基配成上升沿触发、下降沿触发交替的模式。
我这里列出捕获模式的核心配置思路:
// 以STM32F103的TIM3为例,PA6作为CH1输入 TIM_ICInitTypeDef TIM_ICInitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; // 1. 时基设置:72MHz计数时钟,预分频71,得到1MHz计数频率 TIM_TimeBaseStructure.TIM_Prescaler = 71; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period = 0xFFFF; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); // 2. 输入捕获配置:上升沿捕获,直接映射到TI1 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 = 0x0; // 数字滤波,抖动太大会用 TIM_ICInit(TIM3, &TIM_ICInitStructure);这段配置里有三个参数是重点:预分频、捕获极性和滤波系数。预分频决定了每个计数脉冲对应的物理时间,我配成1MHz计数频率,也就是每个计数值代表1微秒,1Hz信号的计数值就是1000000,这个值放在32位变量里(两个16位寄存器拼接)完全够用。滤波系数后面单说,抖动的信号必须加。
2.2 边沿时间差计算:溢出不处理,永远是垃圾数据
核心代码是捕获中断处理函数:
uint32_t cap_value = 0; // 当前捕获值 uint32_t prev_cap = 0; // 上一次捕获值 uint32_t period_ticks = 0; // 周期(计数个数) uint16_t overflow_cnt = 0; // 计数器溢出次数 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); cap_value = TIM_GetCapture1(TIM3); if (cap_value >= prev_cap) { period_ticks = cap_value - prev_cap + overflow_cnt * 65536; } else { // 计数器回绕,说明中间发生了溢出 period_ticks = cap_value - prev_cap + 65536 + overflow_cnt * 65536; } prev_cap = cap_value; overflow_cnt = 0; // 每次有效捕获后清溢出计数 } if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM3, TIM_IT_Update); overflow_cnt++; // 溢出中断累加 } }这里最容易被忽略的就是溢出处理。如果计数器溢出中断没开启,或者开了但不在中断里累加,那么当信号周期大于65535微秒(预分频71、1MHz计数的情况)时,计数器会在两次捕获之间发生回绕,period_ticks会变成一个小得离谱的错值,频率瞬间跳成高频。我早期调试时频率读数从低频跳到几十万Hz就是这个问题。说白了,溢出计数就是给计数器扩展了位数,从16位撑到了32位。
频率计算公式:
float frequency_hz = 1000000.0f / (float)period_ticks; // 因为计数频率是1MHz2.3 输入捕获的适用边界:从原理上推出来
输入捕获能测多高的频率?理论上只要两个相邻上升沿之间的计数值大于1,就能计算频率。但实际上中断响应本身有延迟,如果频率太高,比如1MHz以上,定时器捕获中断可能还没来得及读完CCR寄存器,下一轮捕获已经把值覆盖了,或者中断频繁进入导致主程序卡死。一般建议输入捕获适合10Hz到几十kHz的中低频信号,再高就得上方法二了。
实际项目里我还发现一个细节:FFT方法不需要关心信号是什么波形。正弦波、三角波、锯齿波、方波,只要有周期性特征,FFT都能在频谱上看到一个清晰的峰。输入捕获对波形边缘形状敏感,FFT才是从根源上绕开了整形电路带来的误差。这也是我把FFT作为"兜底方案"的根本原因。
3. FFT测频链路拆解:从ADC采样到频谱峰提取的完整过程
FFT测频的整体链路是:模拟信号进入ADC,以固定采样率连续采样,得到时域序列;对这序列做FFT变换,得到频域幅度谱;再在幅度谱里寻找峰值,峰对应的频率就是信号主频。
3.1 ADC采样率和采样点数的配对关系
先讲最关键的参数配对。FFT的频率分辨率由公式决定:
频率分辨率 = 采样率 / FFT点数这意味着,采样率固定后,点数越多,频谱上能看到的最小频率间隔越小,频率读数的精度越高。我用的配置是:采样率Fs = 20kHz,FFT点数N = 1024,那么分辨率就是:
20kHz / 1024 ≈ 19.5Hz也就是说在频谱上,19.5Hz以内的频率差是区分不开的。对我实际场景来说,400Hz的基频测出来可能是390还是410,误差在±10Hz左右,对于初步诊断够用,但如果你要精确测某个频率,这个分辨率就得提高。两个方向可选:
- 提高采样点数到2048或4096,但MCU计算时间会成倍增加。
- 降低采样率到10kHz,但要保证采样率满足采样定理:采样率必须大于信号最高频率的两倍。如果信号里有超过10kHz的高频分量,ADC采样会引入混叠,反而污染低频段的频谱。
所以正确做法是:先大致确认信号的最高频率,再定采样率,然后根据需要的频率精度定点数。这三个参数互相牵制,没法单独拍脑袋。我定的20kHz采样率,就是对传感器输出信号摸底之后的结果——信号能量主要集中在2kHz以内,留10倍裕量。
3.2 ADC连续采样+定时器触发,别用连续模式裸采
STM32的ADC可以工作在扫描+连续转换模式,也可以由定时器触发单次转换。这里强烈建议用定时器触发采样:拿一个定时器的更新事件触发ADC转换,这样采样间隔是硬件精确控制的,不像在主循环里启动转换那样有不可控的抖动。ADC采样的时间均匀性是FFT准确性的前提,采样间隔抖动会让频谱毛刺变多,峰值不集中。
配置逻辑是这样的:
// 定时器2作为ADC触发源 TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz/72 = 1MHz TIM_TimeBaseStructure.TIM_Period = 50; // 1MHz/50 = 20kHz 采样率 TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // ADC配置 ADC_InitStructure.ADC_ExternalTrigConv = ADC_ExternalTrigConv_T2_CC2; // 定时器2事件触发 ADC_InitStructure.ADC_ContinuousConvMode = DISABLE; // 单次转换,由触发控制这样每个20kHz触发脉冲到来时,ADC就采样一次,得到确定的采样时间点。DMA设置为循环模式,把ADC转换结果自动搬到内存数组里,攒够1024个点后通知CPU做FFT,中途不占CPU时间。
3.3 窗函数的选择:别小看这个细节
直接对截断后的时域数据做FFT,会出现频谱泄漏——一个干净的单一频率信号,会在频谱上抹出一坨能量,旁边的频点都有幅度,这样找峰值时偏差会比较大。
解决方式就是加窗函数。我实测对比过三种窗:
- 矩形窗:相当于不加窗,主瓣窄但旁瓣高,适合频率相隔较远的强信号,频率精度高但容易泄漏污染。
- 汉宁窗:主瓣稍宽,但旁瓣衰减明显,适合大多数周期信号,是工程里最常用的折中选项。
- 布莱克曼窗:旁瓣衰减更大,但主瓣也更宽,频率分辨率变差,适合需要区分弱信号和强信号,不追求精确频率的场景。
我最终用了汉宁窗,因为它对主频估计偏差足够小,代码实现也简单(查正弦表,乘一次即可)。窗函数系数是预先算好存在数组里的,每次FFT前把ADC原始数据乘以对应系数,得到加窗后的序列再送进FFT。
3.4 从频谱里找主频:直流分量和幅值谱的取舍
FFT输出是复数数组,每个点包含实部和虚部。幅度谱计算:
mag[ k ] = sqrt( real[k]^2 + imag[k]^2 )但实际做的时候没必要每个点都开方,可以用近似公式|x| ≈ |real| + |imag|或者直接比较平方和,省掉开方运算,在MCU上提速明显。
找主频时最容易被坑的地方是直流分量。信号带个直流偏置的话,第0个频点的幅度巨大,直接找峰值会锁定在0Hz。处理办法是从第1个频点开始找(跳过第0点),或者采样时做直流偏置消除:先累加1024个采样值求平均,再用每个采样值减掉平均值,把信号整体拉到0V附近。
主频计算:
// peak_index 是幅度谱中幅度最大的频点索引 float freq = (float)peak_index * Fs / N; // Fs=20000, N=10243.5 FFT的ST官方库与自写的坑
实现FFT有两种途径:要么用ST官方DSP库里的arm_cfft_f32(需要配合CMSIS-DSP库),要么用网上流传的各种精简FFT算法(比如基2时间抽取法)。
我一开始图省事用了STM32F1标准外设库里自带的cr4_fft_1024_stm32,这个函数只输出模方值,不需要额外算幅度,非常省事。但它的输入格式是固定的Q15格式,代码上需要对ADC采样值做左移处理,不然精度不够。后来换了STM32F4之后,直接用arm_cfft_f32,浮点运算速度提升明显,代码也更简单。
坑在换库时踩了一个:不同库对FFT输入输出的排列顺序定义不一样,有的库FFT后的频谱是自然顺序的(第k个元素对应k×Fs/N的频率),有的是位反转顺序的,直接套用数据下标算频率会算出完全错误的结果。解决方式是在调试阶段先输入一个已知频率的标准正弦波,检查峰值对应的索引和理论值是否吻合,不吻合就翻库的文档确认输出排列方式,而不是急着分析实际信号。
4. 测频方法融合策略:怎么让两种模式各干各的活,还能无缝切换
前面两套方案都讲完了,但真正的问题来了:两个方案不是独立的,要么切换,要么融合。我最终实现的是按信号频率范围自动切换,这里把切换逻辑和背后的考量说清楚。
4.1 为什么不用单一方案硬扛所有场景
有人会问,既然FFT什么波形都能测,为啥不干脆只用FFT?原因有二:
- 低频信号测不准。20kHz采样率采样1024点,只能覆盖约0.05秒的时域窗口,如果信号是10Hz,这个窗口里只有半个周期,做FFT后基频峰根本分辨不出来。要么把采样率降低到几百Hz,但那样高频场景又没法测,FFT天然受限于"时间窗长度与频率分辨率成反比"这条铁律。
- 实时性差。FFT计算一次1024点,在F103上是几毫秒的耗时,加上采样等待时间,整体刷新率有限;而输入捕获只要两次中断就能算一次频率,响应快得多。
所以正确的分工是:输入捕获扛低频段、扛实时性;FFT扛复杂波形、扛高频段。
4.2 自动切换的核心逻辑
我在代码里设了一个频率阈值(设为1kHz)。每次循环先看输入捕获模式有没有输出有效频率,有且数值在100Hz-1kHz之间,就采用输入捕获结果;如果捕获值一直为零,或者捕获结果反复横跳超过阈值范围,就自动切到FFT模式。
切换逻辑伪代码:
float freq_result; float cap_freq = get_input_capture_freq(); uint32_t adc_buf[1024]; if (cap_freq > 100.0f && cap_freq < 1000.0f) { // 输入捕获结果可信,直接采用 freq_result = cap_freq; } else { // 输入捕获异常或超过范围,启动FFT测量 adc_sample_and_fft(adc_buf); freq_result = fft_get_main_freq(adc_buf); }这个判定逻辑里有个细节:为什么不是捕获到频率就采用?因为这正是我前面说的"捕获到谐波"场景——信号有强烈的失真时,捕获逻辑也能稳定触发,比如一个400Hz基波叠加800Hz谐波,整形后可能每个基波周期触发两次边沿,读到的频率是800Hz。这种情况单纯看捕获值的稳定性是判断不出来的,必须辅助FFT。所以我的方案里只要捕获值落在"可疑区间"(通常是基频的两倍左右),就直接交给FFT去仲裁,而不是试图在捕获模式里做数字滤波。
4.3 切换时机与状态机设计
为了避免频繁切换导致测量值抖来抖去,我加了一个简单的状态机:三个状态(输入捕获模式、FFT准备模式、FFT测量模式),每次切换设定一个"滞留时间"——在FFT模式下至少测8次才会切回输入捕获,在输入捕获模式下如果连续3次捕获异常才切到FFT准备模式。这个滞回设计就是为了防止信号瞬态波动导致模式频繁切换,这是实测中特别容易遇到的问题。
5. 实测效果与校准方法:没有这一步,测出来的都是数字,不是数据
光把代码跑通还不能算完成,必须用标准信号源校准,记录误差。这里提三个关键的校准动作,每一步我都在项目里做过。
5.1 标准信号源扫频:确认两个模式的边界
我用信号发生器输出精确的方波和正弦波,从1Hz扫到100kHz,记录两种模式在不同频率下的误差:
| 信号类型 | 频率范围 | 测量模式 | 误差表现 |
|---|---|---|---|
| 方波 | 1Hz - 1kHz | 输入捕获 | ±0.1%,非常稳定 |
| 方波 | 1kHz - 20kHz | 输入捕获 | ±0.5%,读数随干扰轻微抖动 |
| 正弦波 | 100Hz - 1kHz | 输入捕获 | ±3%,边沿抖动导致读数不稳 |
| 正弦波 | 100Hz - 5kHz | FFT | ±1Hz以内,但需要用插值算法提升精度 |
| 失真锯齿波 | 100Hz - 2kHz | FFT | 主频峰清晰,±1Hz左右 |
这张表是我实测的典型数据,不同板子、不同晶振精度会有差异。测试的意义在于知道"哪个模式下哪个频段靠谱",这是后面定切换阈值的最客观依据。
5.2 频率读数的插值修正:FFT峰值偏一半分辨率的问题
FFT频谱里有一个天然的问题:如果信号频率正好落在两个频点之间,峰值会分布在两个相邻频点上,直接取峰值索引会得到整数倍的频率误差。最明显的例子是信号频率是395Hz,采样率20kHz,点数1024,分辨率19.5Hz,FFT峰值会落在390Hz或410Hz的频点上,读出来就偏差了。
工程上最简单的修正方法是抛物线插值(也叫二次插值法):取峰值频点p以及它左右各一个频点p-1和p+1的幅度,用二次曲线拟合出真实峰值的偏移量:
float delta = (mag[p-1] - mag[p+1]) / (2.0f * (mag[p-1] - 2.0f*mag[p] + mag[p+1])); float freq = (p + delta) * Fs / N;这个公式能把频率误差从±19.5Hz压到±1Hz以内,是FFT测频实战里性价比极高的一个步骤,强烈建议别省。但要注意,这套公式只在主峰比较尖锐、没有明显旁瓣干扰时有效,如果信号本身谐波丰富,插值偏移量会被旁边的峰拉扯,反而要慎重。
5.3 晶振误差与温度漂移:测的是"一致"而非"绝对准确"
最后提醒一下:MCU的HSE晶振本身有误差,常见的8MHz晶振精度是±20ppm,对应到测频上就是万分之二的误差。如果要更高的绝对精度,就得用外部高精度时钟源或者做温漂补偿。但如果你的场景是"监测信号频率变化趋势"或者"判断是否偏离正常范围",内部晶振完全够用,不需要额外折腾。
我实际运行中还有一个习惯:每次开机先测一个已知频率的基准信号(比如把PWM输出配置成精确的10kHz,用它自己测自己),把得到的偏差系数记录在校准值里,然后所有实测频率都乘以这个系数做修正。这样就算晶振有偏差,测出来的相对值也是准的——对大多数传感器监测场景来说,相对稳定性远比绝对精度重要。
6. 实测中的几个意外问题和排查思路
这部分挑三个我在项目里真实踩过、并且坑得比较深的点,每个都给出完整的排查链路,而不是直接给答案。
6.1 问题一:频率读数周期性跳变到极高值
现象:输入捕获模式测一个稳定的2kHz方波,正常情况下读数是2.000kHz,但每隔一两秒会跳一个50kHz的异常值,然后恢复正常。
排查链路:
- 第一步:怀疑是捕获中断丢失。用逻辑分析仪抓定时器的捕获输出引脚,确认信号本身是稳定的方波,排除信号源问题。
- 第二步:在中断处理函数里加一个计数器,每次进入捕获中断就累加,主循环里周期读取。结果发现中断触发次数明显少于方波边沿数,说明中断被其它更高级别的中断阻塞了。
- 第三步:检查中断优先级配置,发现ADC的DMA传输完成中断和捕获中断配成了同一优先级,而DMA中断服务函数里做了一些耗时的浮点运算,导致定时器捕获中断被长时间挂起。
- 第四步:把DMA中断优先级调低、捕获中断调高,并把DMA中断里的浮点运算移到主循环处理,问题解决。
根因:中断优先级配置不合理,高频率的DMA中断占用了大量CPU时间,导致捕获中断响应延迟过大。捕获中断本身不丢,但ARR计数值已经走了好几个周期,算出来的频率自然就偏高。
6.2 问题二:FFT主频峰偏移到谐波位置
现象:输入一个标称1kHz、波形有明显削顶失真的正弦波,FFT读出来的主频是2kHz或者3kHz。
排查链路:
- 第一步:用示波器看实际波形,确认它确实不是单纯的正弦波,削顶后的波形过零检测等效于一个方波,谐波能量非常大。
- 第二步:在采样时把波形同时送给示波器,确认ADC采样到的波形和示波器看到的一致,排除采样混叠导致的高频假象。
- 第三步:把FFT点数从1024放到2048,重新测,发现峰值还是落在2kHz,而且2kHz峰的幅度确实比1kHz高,说明真实的信号频谱里2kHz成分确实强于基频。
- 第四步:意识到问题不是FFT算错了,而是失真信号的基频在频谱上的幅度未必最大。有严重谐波失真时,谐波的幅度可以超过基频。这时候就得用"找第一个显著峰"而不是"找最高峰"的逻辑:从低频向高频扫,找到第一个幅度超过阈值的峰作为基频,再用基频位置校准后续峰为谐波。
根因:这是FFT测频最经典的方向错误——把"幅度最高"当成了"基频"。实际上,处理带有失真的信号时,基频的判定必须带上前面的"第一个显著峰"逻辑,否则失真带来的高次谐波会直接带走主峰。
6.3 问题三:输入捕获模式频率读数稳定但明显偏低
现象:信号发生器输出精确的100Hz方波,输入捕获给的是98.5Hz,偏了1.5%。
排查链路:
- 第一步:先怀疑预分频的配置,把计数频率从1MHz改成另外的值,对比读数。发现改预分频会整体改变偏差方向,说明问题在时基本身。
- 第二步:用定时器的PWM输出同时输出一路已知频率方波,再喂给输入捕获通道,发现自测结果完全准确。
- 第三步:对比信号发生器和单片机时基,发现信号发生器标称100Hz,但用频率计测实际输出是99.8Hz,最终判定是信号源自身偏了,而不是捕获电路偏了。
- 第四步:换成自带校准输出的信号源,实测读数与标称值误差小于0.05%,问题"消失"。
根因:测量系统只能告诉你"被测频率相对于参考时基是多少",参考时基本身有误差,测出来自然就有偏差。这个案例的价值在于提醒:遇到频率偏差先别急着改代码,先用一个独立且可信的频率基准(比如用另一块板子的晶振做参考)来确认误差来源是被测对象还是测量端。硬件排查和软件排查是两套思维,很多时候问题不在代码里。
7. 代码结构建议与扩展思路
到这里整个方案基本完整了。最后分享一点代码组织的经验。
我把整个测频模块拆成了三个文件:freq_input_capture.c(输入捕获驱动)、freq_fft.c(ADC采样和FFT处理)、freq_manager.c(模式切换和结果仲裁)。这样做的好处是,将来想换更高级的DSP库,或者把输入捕获换成编码器测速,只需要替换对应文件,模式切换逻辑完全不用动。
如果你后续要做的事不止测频,还可以在这个基础上扩展几个方向:
- 基于FFT结果的波形质量评估:既然频谱已经在手上了,顺手算个THD(总谐波失真)也不难,把基频幅度和谐波幅度做个比例即可。
- 多通道频率监测:如果有多个传感器信号要测,可以在ADC上扩展多通道,用DMA的乒乓缓冲交替采样,轮流做FFT,代价是每个通道的刷新率下降。
- 与PWM输出联动:测出主频后,用定时器自动调整PWM输出频率,做成一个简单的自适应激励系统(比如根据共振频率调整驱动频率)。
测频这件事,很多人以为拿个定时器数边沿就完事,但真正上项目就会发现,信号形态的复杂性决定了单一方法都有死角。输入捕获在时域上有直觉、有速度,FFT在频域上有全景、有精度,两者结合的方案才是嵌入式工程里"既要又要"的解法。希望这篇对正在鼓捣测频的朋友有帮助。