在嵌入式开发里,“测量一个信号的频率”看起来是个入门级需求,但真正动手做的时候,很多人才发现事情没那么简单:用 GPIO 中断读引脚翻转,高频信号一来 CPU 就被中断打爆;用阻塞延时数脉冲,主程序什么都干不了;即使选用了定时器输入捕获,也容易在极性配置、溢出处理、误差计算上栽跟头。
这篇文章的主角是 STM32C5A3R 这颗芯片,主题就是围绕它的定时器输入捕获功能,讲清楚如何配置并实现频率测量。相比外部中断方案,定时器输入捕获的核心优势在于硬件级的时间戳记录,不占用 CPU 指令周期,特别适合测量 PWM 信号频率、占空比、脉冲间隔等场景。读完这篇文章,你能得到三条明确结论:第一,输入捕获的底层原理是什么,为什么它比 GPIO 中断更适合测频;第二,用 STM32CubeMX 加 HAL 库如何一步步配置定时器;第三,测频法、测周法、多周期平均法分别适合什么场景,误差从哪里来,怎么把误差压下去。
如果你正在做电机转速检测、气象风速仪、红外遥控解码、流量计脉冲计数,或者任何需要测量外部方波信号频率的项目,这篇文章值得收藏备用。
1. 这篇文章真正要解决的问题
先说清楚一个容易被忽视的事实:测频率不是“数脉冲”这么简单。
你当然可以写一个while循环,用 GPIO 不断读取引脚电平,检测到上升沿就给计数器加一,然后每秒输出一次计数值。这种做法在信号频率很低、CPU 没有其他任务的时候勉强能用。但一旦频率上升到几十 kHz,或者系统同时要处理显示、通信、控制算法,问题就会集中爆发:
- CPU 被频繁中断淹没,主循环调度出现明显卡顿;
- 引脚抖动、毛刺信号导致误计数;
- 计数的起止时刻没有硬件对齐,测量结果跳变严重;
- 无法同时获得频率和占空比信息。
定时器输入捕获要解决的问题,正是把“记录边沿到来时刻”这件事从 CPU 手里接管过来。定时器硬件会在输入引脚检测到指定边沿的瞬间,把当前计数器的值锁存到捕获寄存器里,并触发一次中断或 DMA 请求。CPU 只需要在中断里读取两次捕获值,做一个减法,就能得到信号的周期,进而换算成频率。
这里真正的关键点是:CPU 只需要处理“事件发生之后”的数学计算,不需要参与“事件发生瞬间”的响应。即便系统正在执行其他高优先级任务,定时器依然会忠实地记录下每一个边沿的时间戳。这就是硬件定时器方案比 GPIO 中断方案稳定得多的根本原因。
所以,这篇文章要解决的核心问题包括:
| 问题 | 说明 |
|---|---|
| 输入捕获怎么配置 | 定时器时钟、通道极性、预分频、自动重载值的设置逻辑 |
| 频率公式怎么推导 | 捕获值差值与信号周期之间的关系 |
| 误差怎么控制 | 预分频、计数频率、溢出处理的取舍 |
| 不同频率段怎么选方案 | 低频用什么方法,高频用什么方法 |
| 调试不好使怎么办 | 引脚没波形、捕获值不动、频率跳变等典型问题的排查路径 |
2. 定时器输入捕获的核心概念与适用场景
2.1 输入捕获是什么
输入捕获(Input Capture)是定时器的一种工作模式。定时器内部有一个自由运行的计数器,它会按照设定的时钟频率不断累加。当输入引脚上出现目标边沿时,定时器硬件会把当前计数器的值“快照”到捕获寄存器中。
用过示波器的读者可以这样理解:示波器在触发信号满足条件时,会记录下当前波形的位置。定时器输入捕获做的事情类似,只不过它记录的不是模拟波形,而是计数器的数值。
以测量一个方波信号的周期为例:
- 配置定时器输入捕获通道检测上升沿;
- 第一次捕获到上升沿时,计数器值被存入捕获寄存器;
- 第二次捕获到上升沿时,计数器值再次被存入捕获寄存器;
- 两次捕获值之差,就是一个完整周期内计数器走过的步数;
- 周期 = 计数器步数 × 计数器时钟周期;
- 频率 = 1 / 周期。
用公式表达就是:
Freq = TimerClock / (CaptureValue_N - CaptureValue_N-1)如果定时器时钟是 1 MHz,两次捕获值相差 1000,那么信号周期就是 1000 微秒,频率就是 1 kHz。
2.2 三个易混淆的概念:测频法、测周法、多周期平均
很多新手看到“输入捕获”就直接开干,结果发现不同频率范围测出来的结果千差万别。这里要先建立方法论:
测频法:在固定时间窗口内(例如 1 秒)统计输入引脚上的脉冲个数。脉冲个数除以时间就是频率。适用于频率较高的信号,因为窗口内脉冲数越多,量化误差占比越小。
测周法:通过输入捕获测量单个周期的时间长度,再取倒数得到频率。适用于频率较低的信号,因为低频信号周期本来就长,计时本身就足够精确。
多周期平均法:连续捕获 N 个上升沿,用“总时间 / N”得到平均周期。这种方法兼顾了高频和低频场景,通过增加采样周期数来平滑抖动误差,是工程实践中最推荐的做法。
三者的关系可以用一个例子说明:要测量一个 50 Hz 的交流信号频率。如果测频法开 1 秒窗口,只能数到约 50 个脉冲,误差 1 个脉冲就带来 2% 的偏差;如果用测周法直接测周期,计时器频率够高时,精度远优于测频法。反过来,要测量 10 MHz 的信号,用测周法测单周期,计数器一个周期可能就走几十个步数,误差极大,此时测频法更合适。
2.3 适用场景
输入捕获适合测量以下类型的信号:
- PWM 方波信号的频率和占空比;
- 编码器输出的脉冲频率;
- 红外遥控接收头解调后的脉冲宽度;
- 流量计、水表、电表的脉冲输出;
- 电机转速传感器输出的频率信号;
- 超声波测距模块的回波脉冲宽度。
不适合用输入捕获的场景包括:
- 需要连续记录大量波形细节(应该用 ADC 或逻辑分析仪);
- 信号频率超过定时器计数能力(此时应使用外部硬件分频器或专用频率计);
- 边沿抖动严重、需要软件滤波的场合(仍可用输入捕获,但必须配合输入滤波配置)。
3. 环境准备与前置条件
3.1 硬件环境
- 开发板:基于 STM32C5A3R 芯片的开发板。STM32C5 系列是 ST 推出的新一代主流 MCU,Cortex-M33 内核,外设资源丰富。本文描述的是定时器输入捕获通用配置思路,同一套代码逻辑也适用于 STM32G4、STM32F3、STM32L4 等系列。
- 信号源:能输出可调频率方波信号的设备。如果没有信号发生器,可以使用另一块开发板的 PWM 输出,或者用单片机的 PWM 模块自产信号,把 PWM 引脚通过杜邦线连接到输入捕获引脚。
- 杜邦线若干。
需要注意:实际开发中,不同芯片的定时器映射引脚不同,请以芯片数据手册中的 AFIO 映射表为准。本文示例选择定时器的一个通道引脚,读者替换为自己的板卡对应引脚即可。
3.2 软件环境
- STM32CubeMX:用于图形化配置时钟树、定时器和 GPIO。版本请以实际安装为准,本文重点演示通用配置思路。
- STM32CubeIDE 或其他支持 HAL 库的编译环境。
- STM32C5 系列固件包(HAL 库)。版本请以实际项目为准,本文演示的是 HAL 库通用 API。
如果读者之前只用过标准外设库,建议先熟悉两个基本概念:
HAL 库的句柄结构体:每个外设都有一个xxx_HandleTypeDef类型的句柄,例如定时器是TIM_HandleTypeDef。句柄中包含了外设实例、初始化参数、状态等所有信息。
CubeMX 生成代码的目录结构:CubeMX 会根据芯片型号生成main.c、tim.c、gpio.c等文件。用户初始化代码放在USER CODE BEGIN和USER CODE END注释块之间,CubeMX 重新生成代码时不会覆盖这些区域。
4. STM32CubeMX 配置步骤
4.1 创建工程并选择芯片
打开 STM32CubeMX,点击 “New Project”,在芯片搜索框中输入 STM32C5A3R 系列对应型号。选择后进入主界面。
4.2 配置时钟树
在System Core > RCC中,根据开发板实际使用的晶振类型选择 HSE 时钟源。如果不确定,可以先选择内部时钟或使用默认配置。
在Clock Configuration标签页中,确认系统时钟主频符合实际芯片规格。时钟树配置直接影响定时器时钟频率,后续计算分频系数时会用到,建议这一部分单独截图保存,方便回头核算。
4.3 配置定时器为输入捕获模式
这里以定时器的一个通道为例。操作路径为:
Timers > TIMx > Slave Mode,保持 Disable 即可。在Mode一栏,将Channelx设置为Input Capture mode。
配置界面上有几个关键参数需要理解:
| 参数 | 含义 | 推荐值 |
|---|---|---|
| Prescaler | 预分频值,实际分频系数为 Prescaler + 1 | 根据目标计数频率计算 |
| Counter Mode | 计数方向 | Up |
| Counter Period | 自动重载值,计数到该值后清零或溢出 | 0xFFFF 或更大 |
| Internal Clock Division | 时钟分频,一般用于定时器内部逻辑 | No Division |
| Auto-Reload Preload | 自动重载预装载,使更新事件同步生效 | Enable |
| Polarity | 捕获极性,上升沿或下降沿 | Rising Edge |
| IC Selection | 捕获通道选择,单通道配置时保持默认即可 | Direct TI |
| Prescaler (IC) | 输入捕获信号分频,可配置 1/2/4/8 分频 | 1,即不分频 |
| Input Filter | 输入滤波,可滤除毛刺 | 按需配置 |
需要特别注意的是Prescaler和Counter Period的关系。输入捕获测量的精度取决于计数频率:计数频率越高,捕获寄存器的值分辨率越好,但计数器也更容易溢出。如果计数器溢出,两次捕获值之差就会出现严重错误。因此,实际项目中通常根据被测信号频率来调整定时器时钟分频,确保计数器不会在信号周期内溢出。
4.4 配置 NVIC 中断
在NVIC Settings标签页中,使能定时器的全局中断(例如TIMx global interrupt)。这是捕获回调函数能够被正常调用的前提。
如果后续使用 DMA 方式捕获多个周期,则需要配置对应的 DMA 请求,并注意 DMA 通道的优先级设置。
4.5 生成代码
点击GENERATE CODE,选择目标 IDE(STM32CubeIDE 或其他),生成工程。
5. 完整示例代码实现
5.1 简单单周期捕获测频
这是最基础的输入捕获代码,适用于频率不太高、对精度要求不高的场景。核心思路是维护一个捕获值缓冲区,在捕获中断回调中完成频率计算。
文件路径:Core/Src/main.c,在用户代码区添加变量:
/* USER CODE BEGIN PV */ volatile uint16_t g_capture_value = 0; volatile uint16_t g_capture_period = 0; volatile uint8_t g_capture_flag = 0; /* USER CODE END PV */在main()函数中,启动定时器输入捕获中断:
/* USER CODE BEGIN 2 */ HAL_TIM_IC_Start_IT(&htimx, TIM_CHANNEL_x); /* USER CODE END 2 */其中htimx替换为实际定时器句柄,例如htim2;TIM_CHANNEL_x替换为实际使用的通道,例如TIM_CHANNEL_1。
在main.c文件末尾或单独文件中实现捕获回调函数:
/* USER CODE BEGIN 4 */ void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIMx) { if (g_capture_flag == 0) { g_capture_value = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_x); g_capture_flag = 1; } else if (g_capture_flag == 1) { uint16_t current_value = HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_x); if (current_value > g_capture_value) { g_capture_period = current_value - g_capture_value; } else { g_capture_period = (0xFFFF - g_capture_value) + current_value + 1; } g_capture_flag = 2; } } } /* USER CODE END 4 */这段代码的逻辑是:第一次捕获到上升沿时,保存捕获值;第二次捕获到上升沿时,计算两个值之差作为周期。如果第二次捕获值小于第一次,说明计数器发生了溢出回绕,需要按 16 位计数范围折算。
主循环中轮询g_capture_flag == 2时,即可计算频率:
/* USER CODE BEGIN WHILE */ while (1) { if (g_capture_flag == 2) { uint32_t timer_clock = HAL_RCC_GetPCLK1Freq() * 2; // 注意定时器时钟来源 uint32_t prescaler = 1; // 替换为实际配置的预分频值 + 1 uint32_t freq = timer_clock / prescaler / g_capture_period; printf("Frequency: %lu Hz\r\n", freq); g_capture_flag = 0; } } /* USER CODE END WHILE */这里需要注意:定时器时钟频率未必等于HAL_RCC_GetPCLK1Freq()。如果定时器挂载在 APB1 或 APB2 上,且系统时钟分频系数不为 1,定时器时钟通常是对应 APB 时钟的 2 倍。更稳妥的做法是直接查看 CubeMX 时钟树中的Timerx Clock数值。
5.2 多周期平均测频(工程推荐)
单周期测量的缺点是抗干扰能力差,只要某个边沿受噪声影响偏移一点,结果就会跳变。工程上更推荐连续捕获 N 个上升沿,用总时间计算平均周期。
先在全局区定义:
/* USER CODE BEGIN PV */ #define CAPTURE_COUNT 10 volatile uint16_t g_capture_buffer[CAPTURE_COUNT]; volatile uint8_t g_capture_index = 0; volatile uint8_t g_capture_done = 0; /* USER CODE END PV */使用 DMA 方式采集多个周期,需要先配置 DMA。CubeMX 中在定时器的 DMA Settings 中添加TIM_CHx对应的 DMA Request,方向为 Peripheral To Memory,数据宽度为 Half Word。
代码中启动 DMA 捕获:
/* USER CODE BEGIN 2 */ HAL_TIM_IC_Start_DMA(&htimx, TIM_CHANNEL_x, (uint32_t *)g_capture_buffer, CAPTURE_COUNT); /* USER CODE END 2 */注意:HAL_TIM_IC_Start_DMA的缓冲区参数类型是uint32_t *,即使实际数据宽度是 16 位,函数签名也要求使用 32 位指针。这个细节在部分 HAL 库版本中容易引发类型告警,但不影响运行。
DMA 传输完成回调中标记测量完成:
void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIMx) { g_capture_done = 1; } }主循环中处理数据:
if (g_capture_done) { uint32_t total_period = 0; for (uint8_t i = 1; i < CAPTURE_COUNT; i++) { if (g_capture_buffer[i] > g_capture_buffer[i - 1]) { total_period += g_capture_buffer[i] - g_capture_buffer[i - 1]; } else { total_period += (0xFFFF - g_capture_buffer[i - 1]) + g_capture_buffer[i] + 1; } } uint32_t average_period = total_period / (CAPTURE_COUNT - 1); // 然后根据定时器时钟计算频率 g_capture_done = 0; }多周期平均法的好处是:即使个别边沿存在抖动,平均值也能把误差摊薄。实际使用时,CAPTURE_COUNT可根据信号频率和响应速度调整。低频信号可以取小一点,高频信号可以取大一点。
5.3 双通道同时测量频率和占空比
如果不仅要测频率,还要测占空比,可以用两个通道配合:一个通道捕获上升沿,另一个通道捕获下降沿。或者使用同一个通道的上升沿与下降沿交替捕获。
这里仅提供思路,核心逻辑是:上升沿捕获值记为T1,紧接着的下降沿捕获值记为T2,下一个上升沿捕获值记为T3。周期 = T3 - T1,高电平时间 = T2 - T1,占空比 = (T2 - T1) / (T3 - T1)。
实现方式的差异在于:需要在前一次捕获完成后,动态切换捕获极性。HAL 库中可以通过__HAL_TIM_SET_CAPTUREPOLARITY宏实现。这个相对复杂,后续可以单独展开,本文将焦点放在频率测量上。
6. 运行结果与效果验证
6.1 验证步骤
完成代码编写后,编译下载到开发板。验证过程按以下步骤进行:
- 用信号发生器输出一个已知频率的方波,例如 1 kHz、3.3 V 电平;
- 将信号输出引脚连接到开发板的定时器捕获引脚;
- 打开串口助手,波特率与代码中 printf 重定向配置一致(例如 115200);
- 观察串口输出的频率数值是否接近 1000 Hz;
- 改变信号发生器频率,例如 10 kHz、100 kHz,观察测量结果。
如果没有信号发生器,可以用另一块开发板输出 PWM。在另一块板上配置一个定时器输出固定频率 PWM,连接两个板卡的引脚即可。
6.2 预期结果
正常情况下,串口输出频率值应该围绕设定值有微小波动。波动范围取决于输入信号质量、定时器时钟精度和测量方法:
- 单周期测量在信号稳定时,误差通常在 ±1 到 ±3 个计数步长;
- 多周期平均法可以有效降低随机抖动,显示结果更稳定;
- 如果输入信号本身带毛刺,需要开启定时器输入滤波功能,或使用施密特触发器整形电路。
6.3 如何判断代码是否正常工作
最重要的判断依据不是串口输出数字看起来对不对,而是捕获回调是否被正确触发。建议在调试初期,在回调函数入口加一个 GPIO 翻转语句,用示波器或逻辑分析仪观察翻转频率。如果翻转频率约为输入信号频率的一半,说明捕获事件正常触发。
如果串口输出始终为 0 或数值乱跳,优先检查以下三个位置:
- 定时器是否真正启动了捕获(
HAL_TIM_IC_Start_IT是否被调用); - 中断服务函数是否映射正确(HAL 库的中断回调名字不能写错);
- 输入引脚是否配置为复用功能(CubeMX 生成代码时通常会自动处理,但手工改 GPIO 时会漏掉)。
7. 常见问题与排查思路
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 捕获中断从不触发 | 通道未启动或引脚映射错误 | 检查 CubeMX 引脚配置和 GPIO 复用功能;在回调中加 GPIO 翻转进行硬件验证 | 确保调用HAL_TIM_IC_Start_IT;核对 AFIO 映射表 |
| 频率输出比实际值大很多 | 预分频或定时器时钟计算错误 | 打印定时器实际时钟频率,核对 CubeMX 时钟树 | 正确计算 PCLK 与定时器时钟的关系 |
| 频率输出跳变严重 | 信号有毛刺,或计数器溢出 | 用示波器观察输入波形;打印捕获值原始数据 | 开启输入滤波;增大 Counter Period;改用多周期平均法 |
| 低频信号测不准 | 计数器溢出导致回绕处理错误 | 检查捕获值差值是否为负数或突变 | 增大预分频;处理 16 位回绕场景 |
| 高频信号测不准 | 计数频率不够 | 用示波器对比实际波形频率 | 提高定时器时钟频率;改用测频法 |
| 测量时主程序卡顿 | 中断过于频繁 | 检查输入信号频率是否过高,或捕获中断处理太耗时 | 改用 DMA 方式;中断回调中只做标记,主循环中计算 |
| 使用 DMA 时数据全为 0 | DMA 未正确启动或缓冲区大小不匹配 | 检查 DMA 配置和数据宽度 | 确认数据宽度为 Half Word,缓冲区类型正确 |
这些问题的共性规律是:先确认硬件事件有没有发生,再检查软件计算逻辑是否正确。很多初学者一上来就怀疑公式和代码,实际上问题出在引脚配置或中断没触发。
8. 最佳实践与工程建议
8.1 合理选择测量方法
根据被测信号频率范围选方法,是一个基本判断:
| 信号频率范围 | 推荐方案 | 说明 |
|---|---|---|
| 1 Hz ~ 1 kHz | 测周法 / 多周期平均 | 低频信号周期长,时间测量精度高 |
| 1 kHz ~ 1 MHz | 多周期平均 | 兼顾精度和响应速度 |
| 1 MHz 以上 | 测频法 / DMA 连续采样 | 单周期计数步数少,需要窗口计数 |
实际项目中,最忌“无论什么信号都用同一个配置”。建议在代码中预留配置项,方便根据被测对象切换。
8.2 输入滤波与硬件整形
输入捕获的准确性高度依赖输入信号质量。如果被测信号来自机械触点、编码器输出或长导线传输,边沿可能存在抖动甚至毛刺。此时有两个选择:
- 硬件上,在被测信号与 MCU 引脚之间加 RC 滤波或施密特触发器缓冲电路;
- 软件上,使用定时器输入捕获的滤波功能。输入滤波的本质是利用定时器时钟对输入信号进行多次采样,只有连续多次采样到同一电平才认为边沿有效,滤波判定基于配置的 Interval 值。
不要试图在捕获中断回调里做软件消抖。中断回调必须短小精悍,复杂过滤逻辑会拖垮系统实时性。
8.3 溢出处理的统一封装
16 位计数器从 0 计数到 65535 后会回绕。在单周期测量中,可能遇到第一次捕获值接近 65535、第二次捕获值接近 0 的情况。此时必须处理回绕。
推荐的做法不是每次都判断大小,而是使用一个 32 位扩展计数器:在定时器更新中断(溢出中断)中,为一个全局变量加一。真正计算时,把两个捕获值连同溢出次数一起换算成 32 位时间戳。虽然代码量稍大,但逻辑清晰,几乎不会算错。
volatile uint32_t g_timer_overflow_cnt = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIMx) { g_timer_overflow_cnt++; } }使用 32 位扩展时间戳后,捕获值差值计算就无需再关心回绕。
8.4 中断服务函数的最小化原则
很多输入捕获测量项目最终败在现场,不是原理不对,而是中断服务函数写得过长。一旦中断处理时间超过输入信号的周期,下一次捕获事件已经被硬件确认,但 CPU 还没来得及处理,就会出现计数丢失。
建议在中断回调中只做两件事:
- 保存捕获值到缓冲区;
- 置位一个标志变量。
所有计算、滤波、显示、通信操作都放在主循环中完成。如果数据量确实很大,再考虑 DMA 方式。
8.5 测试环境的安全操作提醒
连接信号源和开发板时,务必确认信号电平不超过 MCU 的 IO 容忍范围。对于 3.3 V 供电的 MCU,输入信号不应超过 3.6 V(具体以数据手册为准)。使用外部信号源时,先在线缆连接完成、双方共地之后再上电,避免热插拔导致引脚损伤。
涉及批量测量或产线测试时,建议加入超时判断逻辑:如果一段时间内没有捕获到边沿,自动报告“无信号”并复位状态机,而不是让程序卡在等待标志位的地方。
9. 总结与后续学习方向
这篇文章围绕 STM32C5A3R 的定时器输入捕获,把频率测量这条链路从头到尾拆了一遍。核心可以归纳为几句话:
- 输入捕获是硬件级的时间戳记录机制,比 GPIO 中断更适合测频;
- 测频法、测周法、多周期平均法的适用场景不同,选择比实现更影响精度;
- CubeMX 配置时,预分频和自动重载值决定了计数分辨率和溢出边界,这是最重要的一组取舍;
- 中断回调要短,计算在主循环做,DMA 是高频率场景的升级路线;
- 排查问题时先确认硬件事件是否发生,再检查软件计算逻辑。
下一步值得继续深入的方向有三个:一是双通道捕获实现频率和占空比同时测量,这在电机控制、开关电源调试中非常常见;二是把输入捕获与 DMA 结合,利用循环缓冲区连续采集多组周期数据,提升抗干扰能力;三是将这套代码移植到其他系列芯片,理解不同型号的定时器时钟树差异。
如果你正在做基于 STM32C5A3R 的项目,建议先把单周期捕获跑通,再用多周期平均法替换,最后根据实际信号特征调整滤波和分频参数。这一整套流程验证之后,频率测量这个功能基本就是一劳永逸了。