之前在调试一块基于 STM32 的采集板时,遇到一个很实际的需求:传感器输出的是一路脉冲信号,频率从几十 Hz 到几十 kHz 变化,单片机需要实时测量出频率并上报。最开始想用外部计数器方案,后来发现直接利用定时器的输入捕获功能就能解决,而且精度完全够用。本文基于 STM32C5A3R,从输入捕获的原理讲起,完整演示如何通过 STM32CubeMX 配置定时器输入捕获,并使用 HAL 库完成频率测量。如果你是刚接触定时器捕获功能的新手,或者想把“测频”这个功能快速落地到项目中,这篇文章应该能帮你少走不少弯路。
1. 背景与核心概念
1.1 为什么需要测量频率
频率测量是嵌入式开发里非常常见的需求。比如无刷电机调速时需要读取霍尔传感器的频率来计算转速,流量计输出脉冲信号时需要通过脉冲频率换算流量,红外避障模块、超声波模块、PPM 遥控接收机等很多传感器也都是以脉冲频率作为输出形式。
对于这类场景,单片机的定时器输入捕获是最直接、成本最低的解决方案。不需要额外增加专用测频芯片,只需要一个定时器通道和一个 GPIO 引脚,就能实现从几 Hz 到几百 kHz 甚至更高范围的频率测量。
1.2 输入捕获的工作原理
输入捕获(Input Capture)是定时器的一种工作模式。它的核心能力是:当外部信号出现指定的跳变沿(上升沿或下降沿)时,定时器硬件会立刻把当前计数器的值锁存到捕获/比较寄存器 CCRx 中,同时可以触发捕获中断。整个过程由硬件自动完成,CPU 不需要参与捕获动作。
这一点非常关键:输入捕获是硬件保存计数值,不是 CPU 在中断里去读。所以即使 CPU 正在处理其他中断,或者主循环很忙,只要捕获中断的响应没有慢到丢失下一次捕获,测量值本身依然是准确的。这保证了频率测量的时间精度很高。
1.3 频率测量常用方法对比
在嵌入式系统中,测量频率主要有三种思路:
| 测量方法 | 基本原理 | 适合频率范围 | 精度特点 | 典型实现 |
|---|---|---|---|---|
| 测频法(M 法) | 在固定闸门时间内记录脉冲个数,f = N / T | 高频信号 | 频率越高精度越好,低频时误差大 | 外部计数模式或定时器门控 |
| 测周法(T 法) | 测量一个信号周期对应的定时器计数次数,f = F_cnt / N | 中低频信号 | 频率越低精度越好,高频时计数太少误差大 | 输入捕获上升沿 |
| 等精度测量 | 同时测量频率和周期,并用同步闸门处理 | 宽频率范围 | 综合最优但实现复杂 | 两个计数器配合 |
本文重点讲解测周法,也就是通过定时器输入捕获来实现。这种方式实现简单,代码量小,适合绝大多数传感器信号测量场景。
2. 环境准备与开发工具
2.1 硬件环境
- STM32C5A3R 开发板一块。
- 信号源:可以使用另一块单片机开发板输出 PWM,也可以使用信号发生器。
- USB 转串口模块:用于把测量结果打印到电脑上观察。
- 杜邦线若干。
STM32C5 系列是 ST 新一代基于 Cortex-M33 内核的 MCU 系列,在定时器资源和 HAL 库接口上保持了良好的通用性。具体的引脚分配和定时器资源,建议以你手中的芯片数据手册和 CubeMX 生成的代码为准。
2.2 软件环境
- STM32CubeMX:用于图形化配置芯片引脚、时钟、定时器和中断。
- STM32CubeIDE 或其他支持 HAL 库的 IDE。
- 串口调试助手。
版本说明:STM32CubeMX 的版本以及 HAL 库版本不同,界面细节可能略有差异,但配置思路和 HAL 库 API 基本一致。本文以常见配置环境为例,重点演示配置思路。
2.3 测量方案概览
本文的测量方案是:
- 使用 TIM1 的通道 1 作为输入捕获通道。
- 外部脉冲信号接到对应的捕获引脚。
- 定时器以固定计数频率运行,每次捕获到上升沿时,硬件自动保存当前计数值。
- 在捕获中断中读取捕获值,计算相邻两次上升沿之间的计数差值。
- 用定时器计数频率除以计数差值,得到信号频率。
3. 输入捕获测量频率的核心原理
3.1 测周法频率计算的完整过程
先来看一个最简单的例子。
假设定时器的计数时钟是 1MHz,也就是每个计数周期为 1us。如果外部信号是 1kHz 的方波,周期就是 1ms,那么定时器在相邻两个上升沿之间会计数 1000 次。
第一次捕获上升沿时的计数值是 X,第二次捕获上升沿时的计数值是 Y,则:
周期计数值 D = Y - X 信号周期 T = D / F_cnt 信号频率 f = F_cnt / D带入数字就是:
f = 1,000,000 / 1000 = 1000 Hz这就是输入捕获测频的核心算式。
3.2 计数器溢出问题
上面的例子信号频率比较高,两次捕获之间计数器没有发生溢出,计算很简单。但如果信号频率很低,比如要测量 1Hz 的信号,一个周期是 1 秒,在 1MHz 计数时钟下,需要计数 1,000,000 次。
而 STM32 的通用定时器大部分是 16 位计数器,最大计数值是 65535。也就是说,计数器从 0 计到 65535 之后会溢出回 0,重新开始计数。一个 1Hz 的信号周期内,计数器会溢出 15 次左右。
如果只读捕获值,不处理溢出,计算结果就会完全错误。所以测低频信号时必须配合定时器的更新中断(Update Interrupt),每溢出一次就让一个溢出计数变量加一。
这样两次捕获之间的总计数增量就是:
总计数增量 = (当前捕获值 - 上次捕获值) + 溢出次数 × (ARR + 1)然后在捕获中断中,读取并清零溢出计数,避免溢出计数累积到下一次周期。
3.3 测量范围与精度分析
测量范围和精度主要由定时器计数时钟频率、定时器位宽和信号频率共同决定。以计数时钟 1MHz、16 位定时器 ARR=65535 为例:
| 信号频率 | 每周期计数次数 | 是否溢出 | 理论精度 |
|---|---|---|---|
| 100 Hz | 10000 | 否 | ±0.01% 量级 |
| 1 kHz | 1000 | 否 | ±0.1% 量级 |
| 100 kHz | 10 | 否 | 误差较大 |
| 1 Hz | 1000000 | 是(约 15 次) | 仍然可测 |
高频信号的问题是:每个周期内的计数次数太少,输入捕获的 ±1 个计数误差占比重变大。比如 100kHz 信号,每个周期只有 10 个计数,如果误差 1 个计数,频率误差就是 10%。
低频信号的问题可以通过溢出计数解决,计数总量很大,所以相对精度很高。如果需要测量很高的频率,建议改用测频法(M 法),即用外部信号本身作为计数时钟,在固定时间内统计脉冲个数。
4. STM32CubeMX 配置定时器输入捕获
4.1 新建工程与时钟配置
打开 STM32CubeMX,选择 STM32C5A3R 芯片,新建工程。
进入 Clock Configuration 页面,根据开发板实际情况配置时钟源和系统主频。这里需要注意一个重点:定时器挂在 APB 总线上,定时器时钟频率不一定等于系统主频。在 CubeMX 的时钟树页面中,可以直接看到 APB1 Timer Clocks 和 APB2 Timer Clocks 的频率数值。
以本文示例为例,假设 APB2 定时器时钟为 100MHz,后续配置参数都基于这个数值。如果你的实际配置不同,需要同步调整预分频参数。
4.2 TIM1 输入捕获参数配置
在 Pinout & Configuration 页面,找到 Timers,选择 TIM1:
- Clock Source 选择 Internal Clock。
- Channel1 选择 Input Capture direct mode。
- 配置参数如下:
- Prescaler(预分频):设置为 99。
- Counter Mode:选择 Up 向上计数模式。
- Counter Period(自动重装载值 ARR):设置为 65535。
- Auto-reload preload:Enable。
- Input Capture Channel 参数:
- Polarity:Rising Edge(上升沿捕获)
- IC Selection:Direct TI
- Prescaler:1(不分频)
- Filter:0(不滤波)
这里解释一下:Prescaler 设置为 99,实际分频系数是 PSC+1,等于 100。如果定时器输入时钟是 100MHz,那么计数时钟就是 100MHz / 100 = 1MHz,每个计数周期为 1us。这个数值方便计算频率,但这只是示例,实际工程中要根据被测信号的频率范围调整。
Counter Period 设置为 65535 是因为 TIM1 是 16 位定时器,最大自动重装载值就是 65535。
4.3 NVIC 中断配置
在 NVIC Settings 页面中,需要使能两个中断:
- TIM1 capture compare interrupt:用于捕获中断,读取捕获值。
- TIM1 update interrupt:用于计数器溢出中断,统计溢出次数。
分别勾选 Enabled,并可以设置优先级。通常捕获中断优先级应高于更新中断,这样在捕获发生时能优先处理捕获值,避免丢失边沿信息。
4.4 生成工程代码
配置完成后,点击 Generate Code,选择目标 IDE 工具链,生成初始化工程。CubeMX 会自动生成 GPIO 复用配置、时钟初始化、定时器初始化和中断服务函数的框架代码。
到这里,CubeMX 的配置工作就完成了。接下来要做的,就是在生成的 HAL 库工程中编写业务代码,实现频率计算逻辑。
5. HAL 库输入捕获代码实现
5.1 频率测量数据结构设计
为了方便管理测量状态,建议定义一个结构体来保存频率测量的中间变量:
/* 文件路径:Core/Inc/freq_measure.h */ #ifndef FREQ_MEASURE_H #define FREQ_MEASURE_H #include "main.h" typedef struct { volatile uint32_t overflow_cnt; /* 定时器溢出次数 */ volatile uint32_t last_ccr; /* 上一次捕获值 */ volatile uint32_t period_counts; /* 一个信号周期对应的计数次数 */ volatile uint32_t freq_hz; /* 测量得到的频率(Hz) */ volatile uint8_t first_capture; /* 是否第一次捕获标志 */ } freq_measure_t; extern freq_measure_t g_freq; #endif在 main.c 中定义这个全局变量:
/* 文件路径:Core/Src/main.c */ freq_measure_t g_freq;5.2 启动输入捕获与更新中断
在 main 函数中,完成定时器初始化之后,启动输入捕获:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM1_Init(); /* 启动 TIM1 输入捕获,并使能捕获中断 */ HAL_TIM_IC_Start_IT(&htim1, TIM_CHANNEL_1); /* 额外使能更新中断,用于统计计数器溢出次数 */ __HAL_TIM_ENABLE_IT(&htim1, TIM_IT_UPDATE); while (1) { /* 主循环中读取频率值即可 */ if (g_freq.freq_hz > 0) { printf("Freq = %lu Hz\r\n", (unsigned long)g_freq.freq_hz); } HAL_Delay(500); } }注意:HAL_TIM_IC_Start_IT只会使能捕获比较中断,更新中断需要单独使能。
5.3 捕获中断回调实现
在 HAL 库中,定时器捕获中断最终会回调到HAL_TIM_IC_CaptureCallback函数。我们需要在这个函数中实现频率计算的逻辑:
/* 文件路径:Core/Src/stm32c5xx_it.c 或用户回调文件 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { uint32_t current_ccr = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); uint32_t overflow_cnt = 0; /* 在读取溢出计数前关闭更新中断,防止读取过程中溢出计数被修改 */ __HAL_TIM_DISABLE_IT(htim, TIM_IT_UPDATE); overflow_cnt = g_freq.overflow_cnt; g_freq.overflow_cnt = 0; __HAL_TIM_ENABLE_IT(htim, TIM_IT_UPDATE); if (g_freq.first_capture == 0) { /* 第一次捕获,只记录基准值 */ g_freq.first_capture = 1; } else { /* 两次捕获之间的总计数增量 */ g_freq.period_counts = (current_ccr - g_freq.last_ccr) + overflow_cnt * (htim->Init.Period + 1); /* 频率 = 定时器计数时钟 / 周期计数值 */ /* 注意:这里的 1000000 要和 CubeMX 中配置的计数时钟保持一致 */ if (g_freq.period_counts > 0) { g_freq.freq_hz = 1000000UL / g_freq.period_counts; } } /* 记录本次捕获值,作为下一次计算的基准 */ g_freq.last_ccr = current_ccr; } }这里有一个关键点需要解释:current_ccr - g_freq.last_ccr使用的是无符号整数减法。即使两次捕获之间计数器发生过回绕,也就是当前捕获值小于上次捕获值,无符号减法的结果依然能正确表示两者之间的差值。因为计数器的回绕本质上是模运算,而无符号减法和模运算的规则是一致的。
5.4 更新中断回调实现
定时器的更新中断最终会回调到HAL_TIM_PeriodElapsedCallback。在这个函数中只需要做一件事:让溢出计数器加一。
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { g_freq.overflow_cnt++; } }这段代码体现了测周法的核心思想:利用硬件中断,把 16 位定时器的计数能力扩展到任意长度。即使信号频率低到 0.1Hz,只要溢出计数变量足够大,依然可以准确测量。
5.5 完整代码整理
为了方便理解整个逻辑,把核心代码整理到一起。注意,这只是一个汇总展示,实际项目中这些代码分布在不同的源文件中:
/* ========== CubeMX 生成的定时器初始化 ========== */ void MX_TIM1_Init(void) { TIM_IC_InitTypeDef sConfigIC = {0}; htim1.Instance = TIM1; htim1.Init.Prescaler = 99; /* 100MHz / 100 = 1MHz 计数时钟 */ htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 65535; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_IC_Init(&htim1); sConfigIC.ICPolarity = TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler = TIM_ICPSC_DIV1; sConfigIC.ICFilter = 0; HAL_TIM_IC_ConfigChannel(&htim1, &sConfigIC, TIM_CHANNEL_1); } /* ========== 频率计算回调 ========== */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { uint32_t current_ccr = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); uint32_t overflow_cnt = 0; __HAL_TIM_DISABLE_IT(htim, TIM_IT_UPDATE); overflow_cnt = g_freq.overflow_cnt; g_freq.overflow_cnt = 0; __HAL_TIM_ENABLE_IT(htim, TIM_IT_UPDATE); if (g_freq.first_capture) { g_freq.first_capture = 0; } else { g_freq.period_counts = (current_ccr - g_freq.last_ccr) + overflow_cnt * (htim->Init.Period + 1); if (g_freq.period_counts > 0) { g_freq.freq_hz = 1000000UL / g_freq.period_counts; } } g_freq.last_ccr = current_ccr; } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM1) { g_freq.overflow_cnt++; } }6. 运行验证与误差分析
6.1 输入信号连接
将信号源的输出引脚连接到 TIM1 的捕获输入引脚。具体引脚号需要查看你使用的 STM32C5A3R 芯片的引脚功能表,或者在 CubeMX 中直接观察 TIM1_CH1 被分配到哪个引脚。
需要注意的是,被测信号的电压范围必须在芯片 GPIO 的允许输入电压范围内。如果信号来自 5V 传感器,需要做电平转换或串联限流电阻,不能直接接到 MCU 引脚。
6.2 验证结果与计算过程
假设信号源输出 1kHz 方波,计数时钟为 1MHz,即每个计数周期为 1us。
理想情况下,相邻两次上升沿之间的计数差值应该是 1000,那么:
f = 1,000,000 / 1000 = 1000 Hz但实际输出可能会看到 999Hz 或 1001Hz,这是因为信号源本身存在频率误差,再加上 ±1 个计数周期的量化误差,在 1000Hz 信号上表现为千分之一的偏差,这是完全正常的。
再看一个低频例子。假设信号源输出 10Hz 方波,一个周期为 100ms,在 1MHz 计数时钟下对应 100,000 个计数。TIM1 是 16 位定时器,ARR=65535,所以一个周期内计数器会溢出一次。捕获中断中的计算过程为:
溢出次数 = 1 计数差值 = (当前捕获值 - 上次捕获值) + 1 × 65536最终计算出的周期计数值约为 100,000,频率约为 10Hz。这说明溢出处理逻辑在低频场景下是必要的。
6.3 误差来源分析
输入捕获测频的误差主要来自三个方面。
第一个是量化误差。定时器计数器每个计数周期加一,捕获时刻不可能刚好落在整数计数边界上,所以存在 ±1 个计数周期的固有误差。信号频率越高,每个周期的计数次数越少,这个误差占比越大。
第二个是时钟源误差。定时器计数时钟来自 HSE 或 HSI,如果晶振本身存在频率偏差,例如 20ppm 的晶振,那么测量结果也会有同等量级的相对误差。
第三个是信号边沿抖动。如果输入信号本身存在抖动或噪声,捕获到的上升沿位置会有微小波动,导致测量结果不稳定。
针对这些误差,实际工程中通常采用多次测量取平均、软件滤波、使用更高精度的计数时钟等手段来优化。
7. 常见问题与排查思路
7.1 捕获不到信号
如果程序运行后,频率值始终为 0,说明捕获中断没有触发。优先检查以下几项:
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 始终读不到频率 | 捕获引脚接错 | 对照 CubeMX 的引脚分配,确认信号接到正确引脚 |
| 始终读不到频率 | 信号电平不在 GPIO 输入范围 | 用示波器或万用表测量引脚电压 |
| 始终读不到频率 | 中断未使能 | 检查 NVIC 配置和HAL_TIM_IC_Start_IT是否调用 |
| 始终读不到频率 | 通道配置错误 | 确认使用的是 TIM1_CH1,而不是其他通道 |
7.2 频率值跳变不稳定
频率值忽高忽低,通常是输入信号边沿抖动造成的。可以在 CubeMX 的 Input Capture Filter 参数中配置数字滤波,这个参数本质上是一个数字信号滤波器的采样次数,能够滤除高频毛刺。
另外,如果信号上升沿比较缓慢,也可能导致捕获时刻不稳定。建议在输入引脚上并联一个 100nF 的小电容,或者使用施密特触发器对信号进行整形。
7.3 溢出计数不更新
低频信号测量结果仍然偏大或偏小,很可能是因为更新中断没有正确工作。
检查一下是否在启动代码中调用了__HAL_TIM_ENABLE_IT(&htim1, TIM_IT_UPDATE)。如果只启动了捕获中断而没有使能更新中断,HAL_TIM_PeriodElapsedCallback就不会被调用,溢出计数始终为 0。
另外要注意,HAL_TIM_PeriodElapsedCallback是一个通用回调函数,项目中可能被多个定时器共用,必须在函数内部通过htim->Instance判断是哪个定时器产生的更新事件。
7.4 高频信号丢失
如果被测信号频率很高,比如几百 kHz,捕获中断触发频率就会很高。如果中断处理函数执行时间过长,或者还有其他高优先级中断频繁抢占,就可能导致捕获覆盖。
解决思路是:
- 缩短捕获中断回调中的处理逻辑,不要在回调中做串口打印、复杂计算等耗时操作。
- 提高捕获中断的优先级。
- 如果需要测量非常高频率的信号,改为使用 M 法(外部计数方式)更合适。
8. 最佳实践与工程建议
8.1 输入信号处理
输入到 MCU 捕获引脚的信号必须保证电平兼容和边沿干净。对于传感器输出的脉冲信号,建议在硬件上做一级 RC 滤波,时间常数根据信号频率选择,避免高频噪声干扰触发边沿检测。
如果信号是集电极开路输出,外部必须接上拉电阻。如果信号是差分输出,则需要使用转换芯片转换为单端信号。不要直接在引脚上输入超出芯片供电电压的信号。
8.2 定时器参数选择策略
预分频值 PSC 和自动重装载值 ARR 的选择原则是:让被测信号在一个周期内的计数值尽量大,同时避免计数器溢出过于频繁。
举个例子,如果被测信号范围是 1Hz 到 10kHz:
- 计数时钟可以选择 1MHz,这样 10kHz 信号每周期有 100 个计数,1Hz 信号有 1,000,000 个计数。
- 16 位定时器 ARR 设置为 65535,低频时配合溢出计数处理。
如果被测信号范围是 100Hz 到 100kHz:
- 可以适当提高计数时钟到 10MHz,让高频段的计数分辨率更高。
- 但要注意,计数时钟越高,低频时溢出越频繁,溢出计数丢失的风险越大。
如果被测信号频率上限很高,超过定时器中断处理能力,就应该改用测频法,而不是继续调大计数时钟。
8.3 消除测量抖动
在实际项目中,直接输出每次捕获计算得到的频率值,显示往往会在一定范围内跳动。可以通过两个方式改善。
一个是在主循环中做滑动平均,取最近 N 次测量结果的均值作为最终输出,能明显平滑波动。
另一个是使用多周期测量法,也就是不是每次捕获上升沿都计算一次频率,而是每累计 N 个上升沿之后,才计算一次总时间的平均值。这样等效于延长了测量闸门,能抑制单周期的量化误差。
8.4 扩展:PWM 输入模式与 DMA 方式
STM32 的定时器除了单通道输入捕获之外,还支持 PWM 输入模式。开启 PWM 输入模式后,定时器可以同时测量输入信号的周期和占空比,并且只需要占用两个捕获通道。这对于需要同时检测频率和占空比的场景非常实用,比如遥控器信号解码、电机 PWM 反馈检测等。
另外,如果系统中已经有 DMA 控制器空闲,可以考虑使用 DMA 方式搬运捕获值到内存数组中,再周期性计算频率。这种方式可以大大降低中断频率,适合高频信号测量。
9. 总结与下一步学习方向
本文从输入捕获的基本原理出发,详细演示了在 STM32C5A3R 上通过定时器输入捕获测量信号频率的完整流程。包括 CubeMX 图形化配置、HAL 库代码实现、溢出处理逻辑、频率计算公式,以及常见问题排查思路。
掌握输入捕获之后,你可以继续学习这几个方向。
第一个是 PWM 输入模式。STM32 定时器可以同时测量周期和占空比,适合遥控器信号解码和电机控制反馈。
第二个是 DMA 配合定时器捕获。用 DMA 把多次捕获值自动搬运到内存,可以降低中断频率,提高高频测频能力。
第三个是等精度测频。当被测信号频率跨度很大时,可以用两个定时器配合,实现从 Hz 到 MHz 的宽范围等精度测量。
如果你在实际配置中遇到问题,建议先回到 CubeMX 确认定时器时钟频率、通道映射和中断配置,再检查回调函数中的计算逻辑。把参数打印出来,对照本文的算式逐步核对,问题基本都能定位。
如果这篇文章对你有帮助,可以收藏备用。后续我也会继续更新 STM32C5A3R 开发系列,包括 PWM 输出、编码器接口、DMA 传输等内容。