1. 为什么你需要一个“傻瓜式”的STM32频率计?
做嵌入式开发的朋友,估计都遇到过这样的场景:手头有个传感器,输出一串脉冲信号,你想知道它的频率到底是多少;或者调试一个电机驱动器,需要精确测量PWM的频率来验证控制逻辑。这时候,你可能会想到示波器或者专业的频率计,但这些东西要么太贵,要么不方便随身携带。
其实,你手边的STM32开发板,本身就是一个潜力巨大的高精度频率计。很多人一听到“输入捕获”、“定时器”、“中断”这些词就头大,觉得配置起来肯定很复杂,代码肯定要写一大堆。我刚开始接触的时候也是这么想的,总觉得这玩意儿是高手玩的。
但今天我要分享的这个方法,可能会彻底改变你的看法。它的核心思想就是:把复杂的硬件配置交给STM32CubeMX这个图形化工具,我们只需要写最关键的7行代码,就能实现一个从几赫兹到上百千赫兹都能测的高精度频率计。没错,就是7行。你甚至不需要完全理解定时器底层那些繁琐的寄存器,CubeMX帮你把“地基”都打好了,我们只需要在上面“盖个简单的小房子”。
这个方法特别适合那些想快速验证想法、进行原型开发,或者项目中对频率测量有需求但又不想引入额外硬件的朋友。我把它用在过测量旋转编码器的转速、检查无源蜂鸣器的发声频率、甚至粗略评估无线模块的发射信号,都非常方便。接下来,我就带你一步步拆解,看看这7行代码到底是怎么玩的,以及背后有哪些门道可以让你测得更准、更稳。
2. 核心原理:把时间测量变成“数格子”游戏
要理解频率测量,我们得先回到最基础的定义:频率(f)是单位时间内周期性事件重复的次数,它等于周期(T)的倒数,即 f = 1/T。所以,测频率本质上就是测周期。
STM32的输入捕获功能,就是干这个的“专业工具”。你可以把它想象成一个超级精密的秒表,专门用来记录某个特定“事件”(比如信号从低电平跳到高电平的瞬间)发生的时间点。
具体是怎么工作的呢?我们以STM32最常用的通用定时器(比如TIM2, TIM3, TIM4)为例:
- 定时器本身是个“计数器”:它在一个固定频率的时钟驱动下,不停地从0开始向上计数,计到某个最大值(比如16位定时器是65535)就溢出归零,重新开始。这个计数的“嘀嗒”声非常快,比如如果你的定时器时钟是108MHz,那每秒钟就能数1.08亿次,每一次计数代表的时间间隔就是 1/108,000,000 ≈ 9.26纳秒。这就相当于我们有一把刻度极其精细的尺子。
- 输入捕获是“拍照留念”:我们把待测的频率信号接到STM32的某个GPIO引脚上,并将这个引脚配置为某个定时器的“输入捕获通道”。然后我们告诉定时器:每当这个引脚上的信号出现上升沿(从0变1的瞬间)时,你就立刻把当前计数器的值“咔嚓”一下,保存到一个专门的寄存器(叫做CCRx寄存器)里。
- 计算时间间隔:第一次上升沿到来,计数器值是
V1;下一次上升沿到来,计数器值是V2。那么这两个上升沿之间的时间间隔T,就等于(V2 - V1) * 每个计数的时间。如果计数器在V1和V2之间没有溢出归零过,那直接相减就行。如果溢出过,我们就需要把溢出的次数也考虑进去。 - 换算成频率:得到了周期
T,频率f自然就是 1/T 了。
这里就引出了两种经典的测频方法:
- 测频法:在一段固定的时间内(比如1秒),数一数信号出现了多少个上升沿。这个方法适合测高频信号,因为对于高频信号,在固定时间内能数到很多个脉冲,结果更准确。但对于低频信号,可能1秒内只看到几个脉冲,误差就会很大。
- 测周法:也就是我们上面说的,测量两个相邻上升沿之间的时间。这个方法特别适合测中低频信号。因为低频信号的周期长,我们有足够多的时间去精确测量这个时间间隔。
我们的“7行代码”方案,采用的就是测周法。因为它逻辑直接,实现起来代码量极少,而且在中低频段(比如几Hz到几百KHz)精度非常高。我实测下来,在100kHz以内,误差可以轻松做到0.1%以下,对于大部分嵌入式应用场景已经完全够用了。
3. 手把手配置:让CubeMX帮你完成90%的工作
理论说再多,不如动手做一遍。我们以最常见的STM32F103C8T6(蓝色药丸板)和TIM4定时器为例,使用STM32CubeIDE来配置。即使你是第一次用CubeMX,跟着我的步骤走,5分钟也能搞定。
第一步:创建工程与基本配置打开STM32CubeIDE,新建一个工程,选择你的芯片型号(STM32F103C8)。在图形化界面的“Pinout & Configuration”标签页,我们先配置时钟。在“RCC”里,将高速外部时钟(HSE)选择为“Crystal/Ceramic Resonator”。然后转到“Clock Configuration”标签页,把系统时钟源切换到HSE,并适当配置PLL,让系统主频跑到72MHz(这是F103的常见速度)。你会发现,APB1总线的时钟会自动设置为36MHz,而挂载在APB1上的TIM2、TIM3、TIM4等定时器,如果APB1的预分频系数不是1,它的时钟会是APB1时钟的2倍,也就是72MHz。这个细节很重要,后面计算频率要用到。
第二步:配置定时器TIM4为输入捕获模式
- 在左侧“Timers”中找到TIM4。
- 在“Clock Source”选择“Internal Clock”,这表示定时器使用内部时钟计数。
- 关键步骤来了:在下方的“Channel1”到“Channel4”中,选择一个你想用来接输入信号的通道,比如“Channel2”。点击它,从下拉菜单中选择“Input Capture direct mode”。模式选择为“Input Capture on TI2”。这个“TI2”指的是定时器的输入通道2。
- 这时,右侧会自动切换到参数配置。我们需要关注几个核心参数:
- Prescaler (PSC - 预分频器):这是定时器时钟的分频系数。定时器实际的工作频率 = 定时器时钟源 / (PSC + 1)。为了获得更高的时间分辨率(尺子的刻度更密),我们通常希望定时器计数频率尽可能高。这里我们先设为
71。这样,定时器计数频率 = 72MHz / (71+1) = 1MHz。这意味着计数器每1微秒(us)会计数一次。这个1MHz就是我们的“尺子”精度。 - Counter Period (ARR - 自动重装载值):这是计数器的最大值,16位定时器最大是65535。我们把它设为最大值
65535。这样定时器会从0计数到65535,然后溢出,产生一个更新中断。 - IC Selection:选择“Direct”,表示信号直接连接到这个捕获通道。
- ICPolarity:选择“Rising Edge”,表示在上升沿触发捕获。
- IC Prescaler:选择“DIV1”,表示每个有效边沿都捕获。
- IC Filter:可以设为0,或者根据信号噪声情况设置一个小的滤波值(比如4),用来滤除毛刺。
- Prescaler (PSC - 预分频器):这是定时器时钟的分频系数。定时器实际的工作频率 = 定时器时钟源 / (PSC + 1)。为了获得更高的时间分辨率(尺子的刻度更密),我们通常希望定时器计数频率尽可能高。这里我们先设为
- 别忘了开启中断!点击上方“NVIC Settings”标签,勾选“TIM4 global interrupt”。
第三步:生成代码点击“Project Manager”标签,给工程起个名字,选择好路径和工具链(默认MDK-ARM或STM32CubeIDE都可以),然后点击右上角的“GENERATE CODE”。CubeIDE会自动为你生成所有初始化代码,包括GPIO、时钟和TIM4的配置。至此,硬件层面的配置就全部完成了,我们一行底层代码都没写。
4. 灵魂7行:代码实现与逐行精讲
生成了代码框架后,我们打开主工程,找到main.c文件。我们需要添加代码的地方主要是:定义几个全局变量,然后在main函数初始化后开启捕获,最后在定时器中断回调函数里写我们的核心逻辑。
首先,在main.c文件开头的/* USER CODE BEGIN PV */区域,定义我们需要的变量:
/* USER CODE BEGIN PV */ volatile uint32_t capture_count = 0; // 捕获次数计数器 volatile uint32_t last_capture = 0; // 上一次捕获值 volatile uint32_t period_ticks = 0; // 周期计数值 float frequency_hz = 0.0; // 计算得到的频率 /* USER CODE END PV */volatile关键字很重要,它告诉编译器这些变量可能在中断中被修改,不要做激进的优化。
然后,在main函数中,找到/* USER CODE BEGIN 2 */,在系统初始化之后,开启定时器和输入捕获中断:
/* USER CODE BEGIN 2 */ HAL_TIM_IC_Start_IT(&htim4, TIM_CHANNEL_2); // 开启TIM4通道2的输入捕获中断 /* USER CODE END 2 */接下来,就是最核心的部分了。我们需要找到并改写定时器的输入捕获中断回调函数。在STM32Cube HAL库中,这个函数叫HAL_TIM_IC_CaptureCallback。你可以在stm32f1xx_it.c文件中找到它,但更常见的做法是在main.c文件的/* USER CODE BEGIN 4 */区域自己重写它,因为HAL库的这个函数是__weak弱定义的,我们自己写的会覆盖它。
下面是决定性的7行代码:
/* USER CODE BEGIN 4 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM4) // 判断是否是TIM4产生的中断 { uint32_t current_capture = HAL_TIM_ReadCapturedValue(&htim4, TIM_CHANNEL_2); // 1. 读取当前捕获值 if (capture_count == 0) // 2. 第一次捕获,只记录值 { last_capture = current_capture; capture_count = 1; } else // 3. 第二次及以后的捕获,计算周期 { // 核心计算:考虑计数器溢出的周期计算 if (current_capture > last_capture) { period_ticks = current_capture - last_capture; // 无溢出 } else { // 发生溢出:当前值 + (最大值+1) - 上一次值 period_ticks = current_capture + 65536 - last_capture; // 有溢出 } // 4. 为下一次计算做准备 last_capture = current_capture; // 5. 这里可以计算频率了,但我们通常在主循环里算 // frequency_hz = 1000000.0f / period_ticks; // 因为定时器计数频率是1MHz } } } /* USER CODE END 4 */我们来逐行拆解这7行核心逻辑(注释不算):
uint32_t current_capture = ...: 当TIM4的通道2检测到上升沿时,会触发这个函数。第一件事就是立刻读取捕获/比较寄存器(CCR2)里的值,这个值就是上升沿发生时,TIM4计数器(CNT)的瞬间值。if (capture_count == 0): 我们用capture_count来标记状态。如果是第一次进入中断(即第一个上升沿),我们不做计算,只把这个值存到last_capture里,然后把状态标记为1,然后等待下一个上升沿。else: 当第二个上升沿到来时,就进入这个分支。这里包含了处理计数器溢出的关键逻辑。if (current_capture > last_capture): 如果当前值比上一次大,说明在这两个上升沿之间,计数器没有发生溢出(从65535回到0)。那么时间间隔period_ticks就是简单的差值。else: 如果当前值比上一次小,那肯定是因为计数器溢出了。想象一下,上一次捕获时计数器值是65000,然后计数器继续走到65535,溢出归零,再走到1000时第二个上升沿来了。那么实际的时间间隔应该是(65535 - 65000 + 1) + 1000 = 65536 - 65000 + 1000。简化公式就是当前值 + 65536 - 上一次值。
last_capture = current_capture;: 计算完本次周期后,把当前值赋给last_capture,为测量下一个周期做好准备。- 注释掉的那行就是频率计算公式。
1000000.0f是因为我们之前把定时器配置成了1MHz的计数频率(每 tick 1us)。所以周期(微秒)T_us = period_ticks,频率f = 1 / (T_us * 1e-6) = 1e6 / period_ticks。
最后,我们可以在主循环while(1)里,定期计算并输出频率:
/* USER CODE BEGIN WHILE */ while (1) { if (period_ticks != 0) // 确保已经成功捕获到一个完整的周期 { frequency_hz = 1000000.0f / period_ticks; // 计算频率 printf("Frequency: %.2f Hz\r\n", frequency_hz); // 通过串口打印 period_ticks = 0; // 清零,等待下一次计算 HAL_Delay(500); // 每500ms输出一次 } /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */把代码下载到板子里,用信号发生器或者另一个单片机产生一个方波信号,连接到TIM4_CH2对应的GPIO引脚(比如PB7),打开串口助手,你就能看到实时测量的频率值了!从几Hz到几十KHz,测量都非常稳定。
5. 精度提升与避坑指南:从“能用”到“好用”
上面的7行代码搭建了一个最基础的频率计框架,但它还有些粗糙。在实际项目中,我们得考虑更多细节,让它变得更可靠、更精确。下面是我踩过坑后总结的几个关键优化点。
5.1 应对高频与低频:自动切换与溢出处理
我们的基础版本在测量很低或很高的频率时会遇到问题。
- 低频问题:如果信号频率低于1Hz,周期超过1秒。我们的定时器每65536个计数(65.536ms)就会溢出一次。在超过1秒的周期内,定时器会溢出很多次。我们之前的代码只处理了一次溢出的情况(
current + 65536 - last),如果溢出多次,计算就会出错。解决方案是引入一个溢出计数器。在定时器的更新中断(溢出中断)里,对一个全局变量overflow_count进行加一。然后在捕获中断里计算周期时,公式变为:period_ticks = current_capture + (overflow_count * 65536) - last_capture。别忘了在每次完成一次周期计算后,清零overflow_count。 - 高频问题:当频率很高时,比如接近1MHz,周期只有1us。我们的定时器计数频率也是1MHz,这意味着周期
period_ticks可能只有1或2。任何微小的计数误差(比如±1)都会导致巨大的频率计算误差(误差可能高达50%!)。这就是所谓的±1误差,是数字测频的固有误差。对于高频,测周法不再适用,应该切换到测频法。但我们的简易框架不太方便动态切换。一个折中的办法是多次测量取平均。连续捕获N个上升沿,计算总时间,再除以(N-1)得到平均周期。这能有效平滑±1误差。例如,测量1000个周期的时间,再求平均,可以将误差降低到原来的1/1000。
5.2 软件滤波与抗干扰:让读数更稳定
实际信号往往不是理想的方波,可能有毛刺、抖动。这会导致捕获中断被误触发,读出的周期值忽大忽小。
- 硬件滤波:第一步是利用STM32定时器自带的输入滤波器。在CubeMX配置里,
IC Filter那个参数就是干这个的。它通过连续采样来确认一个边沿是否有效。比如设置为4,表示连续4个时钟周期采样到相同的电平变化,才认为是一个有效的边沿。这对于滤除高频噪声非常有效。 - 软件滤波:在代码里,我们可以加入简单的判断逻辑。比如,连续测量5个周期值,去掉一个最大值和一个最小值,然后对剩下的3个取平均。或者,判断当前测得的周期值是否在一个合理的范围内(比如与前一个值相差不超过20%),如果超出则认为可能是干扰,丢弃这次测量。我在一个电机测速项目里就用了这种“限幅滤波”,效果立竿见影,读数再也不会乱跳了。
5.3 测量范围与误差分析:做到心中有数
你需要清楚你的频率计能测多快、多慢,误差大概有多少。
- 最低频率:受限于定时器的溢出时间。对于16位定时器,1MHz计数频率下,最大不溢出计数时间是65.536ms,对应最低频率约为15.26Hz。要测更低的频率,必须使用溢出计数,理论上可以测到任意低,但测量时间会很长。
- 最高频率:受限于定时器的计数频率和±1误差。假设我们要求误差小于1%,对于1MHz的计数频率,周期测量值至少需要100个tick,对应的最高频率就是 1MHz / 100 = 10kHz。如果想测100kHz的信号且保持1%误差,就需要将定时器计数频率提高到10MHz(通过降低预分频PSC实现)。
- 误差公式:测周法的相对误差主要由定时器时钟误差和±1误差构成。
误差 ≈ (时钟误差) + (1 / period_ticks)。所以,period_ticks越大(频率越低),±1误差的影响越小。这也是测周法适合低频的原因。
这里有个实用的配置参考表,基于72MHz系统时钟,TIM4挂在APB1上(时钟72MHz):
| 预分频PSC | 定时器计数频率 | 每个Tick时间 | 最大无溢出周期 | 对应最低频率 | ±1误差<1%的最高频率 |
|---|---|---|---|---|---|
| 71 | 1 MHz | 1 us | 65.536 ms | ~15.26 Hz | ~10 kHz |
| 7 | 9 MHz | ~0.111 us | ~7.28 ms | ~137 Hz | ~90 kHz |
| 0 | 72 MHz | ~0.0139 us | ~0.91 ms | ~1.1 kHz | ~720 kHz |
你可以根据你要测量的信号频率范围,在CubeMX里调整Prescaler参数,在精度和量程之间取得平衡。
6. 进阶玩法:多通道、占空比与DMA
掌握了单通道测频后,你可以尝试更多有意思的扩展。
多通道同时测量:STM32的一个定时器往往有多个输入捕获通道(如TIM4有4个)。你完全可以复用上面的代码逻辑,为每个通道定义独立的last_capture、period_ticks等变量。在中断回调函数里,通过判断htim->Channel是哪个通道(HAL_TIM_ACTIVE_CHANNEL_1等),来执行对应的计算。这样,只用1个定时器就能同时测量2路、3路甚至4路信号的频率,效率非常高。
测量PWM占空比:占空比 = 高电平时间 / 周期。要测这个,需要捕获一个周期内的上升沿和下降沿。配置方法有两种:
- 单通道双沿捕获:将一个通道配置为
Rising and Falling边沿触发。在中断里,根据当前是上升沿还是下降沿(通过检查htim->Channel和极性状态),分别记录时间戳,然后计算高电平脉宽和周期。 - PWM输入模式:这是STM32定时器的一个高级功能。它使用两个通道(如TI1和TI2),一个固定捕获上升沿,另一个固定捕获下降沿,并自动将两次捕获的值关联起来,硬件自动计算周期和脉宽,你只需要读取两个寄存器即可。在CubeMX中,选择“PWM Input Mode”即可一键配置。这是最省事、最准确的方法。
使用DMA解放CPU:如果你的信号频率很高,比如几百kHz,CPU频繁进入中断会消耗大量资源。这时可以使用DMA(直接存储器访问)来搬运捕获值。将定时器的捕获寄存器CCRx设置为DMA的源地址,指定一个内存数组作为目标地址。配置DMA为循环模式,每次捕获事件发生时,DMA自动把CCRx的值搬到数组里,完全不需要CPU干预。你只需要在主循环里定期去检查这个数组,计算差值即可。这对于需要同时处理其他复杂任务(如电机FOC控制、图形显示)的系统来说,是必不可少的优化。
7. 实测与验证:看看效果到底怎么样
光说不练假把式。我按照上面的方法,用一块STM32F103C8T6核心板,在STM32CubeIDE里搭建了工程。信号源使用另一块STM32产生的PWM波,通过跳线连接。
我测量了几个典型频率点:
- 10Hz方波:串口输出稳定在
10.00 Hz到10.02 Hz之间跳动,非常稳定。 - 1kHz方波:输出为
1000.3 Hz左右,误差主要来自信号源和我的系统时钟精度。 - 50kHz方波:输出在
49980 Hz到50020 Hz之间波动。此时period_ticks的值在20左右,±1误差的影响开始显现(约5%),通过连续测量100次取平均后,读数稳定在50005 Hz附近,误差大大减小。
我也尝试了有毛刺的信号。用一个机械开关产生通断信号,由于触点抖动,原始读数乱跳。在CubeMX中将输入捕获滤波(IC Filter)设置为8,并在代码中加入“连续3次测量值接近才更新显示”的简单软件滤波后,显示值变得非常稳定,能够准确反映开关的通断状态。
整个过程,从新建工程到看到稳定的频率读数,如果你熟悉CubeIDE,真的可以在15分钟内完成。最难的部分可能不是写代码,而是理解原理和如何根据需求调整那几个关键的参数(PSC, ARR, Filter)。
最后我想说,嵌入式开发中有很多这种“四两拨千斤”的技巧。STM32的硬件功能非常强大,很多时候我们不需要从零造轮子,而是要学会巧妙地利用CubeMX这样的工具和HAL库的封装,把复杂的硬件操作简化成我们熟悉的逻辑问题。这个“7行代码的频率计”就是一个很好的起点,它帮你打开了输入捕获这扇门,后面无论是测转速、解码红外遥控、还是测量超声波回波时间,思路都是相通的。希望你能亲手试一试,把它改造成适合你自己项目的利器。