STM32 输入捕获测频率 学习笔记

建议先学完《STM32_中断定时器PWM学习笔记》再看本篇。本篇把"定时器"+"中断"再往前推一步:让定时器"测量"外部信号
示例用 STM32F103 + 标准外设库 V3.5.0,主角是通用定时器的 TIM5_CH1(PA0)


1. 输入捕获是什么

1.1 一句话定义

输入捕获(Input Capture):当引脚上出现指定边沿时,定时器硬件把当前计数器的值 CNT 原样"照下来"存进 CCR 寄存器,并可以产生一次捕获中断。

对比你学过的 PWM(输出比较):

PWM / 输出比较 输入捕获
数据方向 定时器 → 引脚(输出波形) 引脚 → 定时器(读入波形)
CCR 的用途 定时器比较 CNT 决定引脚电平 定时器把 CNT 抄进 CCR
本质 用定时器造波形 用定时器量波形

同一个 CCR 寄存器,方向完全相反。理解了这个"方向",输入捕获就懂了一半。

1.2 生活类比

想象一个一直在走的秒表(CNT)。你在跑道边,每次运动员跨过起跑线(上升沿),你按一下"记录"键,秒表把当前读数记下来(存进 CCR)。

  • 相邻两次记录的读数之差 = 运动员跑一圈的时间 = 周期 T
  • 频率 = 1 / 周期

输入捕获干的就是这件事:它是硬件版的秒表记录功能,而且"按键"速度极快(纳秒级),不会被程序卡住而漏记。

1.3 它有什么用

  • 测方波的频率 / 周期(本篇主题)
  • 测高电平 / 低电平持续时间 → 进一步算出 PWM 占空比
  • 测脉冲宽度(红外遥控解码、超声波测距的 echo 脉宽)
  • 测旋转编码器的转速 / 方向

1.4 前提:被测信号要是"干净"的方波

从《前置基础》的知识回顾:输入捕获靠"看边沿"来工作,所以被测信号必须是数字方波(有清晰的高/低电平、明确的边沿)。

  • 如果是正弦波、模拟信号 → 先经过比较器/整形电路变成方波再接进来。
  • 如果边沿"脏"(有毛刺、抖动)→ 用定时器自带的输入滤波(见 4.4)。

2. 测频率的两种思路(先懂方法再动手)

2.1 测周法(输入捕获专用,适合测低频/中频)

测一个周期有多长,再取倒数

f = 1 / T
  • 用输入捕获记下相邻两个上升沿的 CNT 差值 = 周期(多少个计数时钟 tick)
  • 周期 × 每 tick 时间 = 实际时间 T
  • 优点:低频测得很准(周期越长,tick 数越多,相对误差越小)
  • 缺点:高频时一个周期没几个 tick,分辨率差

2.2 测频法(数脉冲,适合测高频)

固定开一个"闸门"时间(比如 1 秒),数这段时间里来了多少个脉冲

f = 闸门时间内的脉冲个数 ÷ 闸门时间
  • 优点:高频测得很准(脉冲越多,计数越准)
  • 缺点:低频时一个闸门内没几个脉冲,误差大;闸门时间长则响应慢

2.3 怎么选

信号频率 推荐方法 原因
很低(几 Hz ~ 几百 Hz) 测周法 周期长、tick 多、误差小
中等(几百 Hz ~ 几十 kHz) 测周法 通用定时器 16 位量程通常够用
很高(几十 kHz ~ MHz+) 测频法 一个周期只有几个 tick,测周法分辨率崩了

本篇重点讲测周法(它直接用输入捕获,最能巩固你刚学的定时器 + 中断)。测频法在最后"进阶"一节给思路。


3. 输入捕获的原理(硬件内部怎么干活的)

3.1 信号通路

外部引脚 PA0(TIM5_CH1)
   │
   ▼
输入滤波 ICF ──(滤毛刺)──► 边沿检测(极性选择) ──► 输入预分频 ICPS ──► 触发捕获
                                                                     │
                        CNT(一直在数)◄────────── 捕获触发时把 CNT 抄进 CCR

拆开看就是四步:

  1. 滤波(ICF):对输入信号做"连续采样 N 次确认",滤掉毛刺。
  2. 极性选择(ICPolarity):选上升沿 / 下降沿 / 双沿触发。
  3. 输入预分频(ICPS):每 1 / 2 / 4 / 8 个边沿才捕获一次,用来降低捕获频率(一般用 1)。
  4. 捕获:满足条件时,把当前 CNT 的值拷进 CCR,并置 CC1IF 标志,可触发中断。

3.2 "时间戳"思想(核心)

把每次捕获到的 CCR 当成一个时间戳

第1次上升沿 → 时间戳 t1 = CCR = 1000
第2次上升沿 → 时间戳 t2 = CCR = 3000

周期 = t2 - t1 = 2000 个 tick

前提:计数时钟的频率是已知的(比如 1MHz,每个 tick = 1μs),那么:
T = 2000 × 1μs = 2msf = 1 / 2ms = 500Hz

所以"测频率"本质上就是"测两个相邻上升沿之间隔了多少个时钟 tick"。这个思想贯穿全篇。

3.3 为什么用"记时间戳"而不是"软件数脉冲"

记时间戳对 CPU 来说是被动接收:硬件在边沿瞬间自动抄 CNT,ISR 只负责读走。即使程序正在忙别的,也不会漏掉边沿(只要 ISR 足够快)。这就是硬件外设比软件数数可靠的原因。


4. 关键参数与标准库配置

4.1 输入捕获的标准库配置(核心就一个函数)

标准库里配置输入捕获的核心结构体是 TIM_ICInitTypeDef,用 TIM_ICInit() 写进去:

TIM_ICInitTypeDef ic;

TIM_ICStructInit(&ic);                      // 先填一套默认值(推荐)
ic.TIM_Channel     = TIM_Channel_1;         // 用通道1
ic.TIM_ICPolarity  = TIM_ICPolarity_Rising; // 上升沿触发
ic.TIM_ICSelection = TIM_ICSelection_DirectTI;  // 直连:CH1 用自己引脚 TI1
ic.TIM_ICPrescaler = TIM_ICPSC_DIV1;        // 每 1 个边沿捕获一次
ic.TIM_ICFilter    = 0x00;                  // 不滤波
TIM_ICInit(TIM5, &ic);

4.2 四个参数逐个解释

参数 常用取值 作用
TIM_Channel TIM_Channel_1 ~ TIM_Channel_4 用哪个通道捕获
TIM_ICPolarity Rising / Falling / BothEdge 上升沿还是下降沿触发捕获
TIM_ICSelection DirectTI / IndirectTI 直连本通道引脚 / 借旁边通道的引脚
TIM_ICPrescaler DIV1 / DIV2 / DIV4 / DIV8 每几个边沿才捕获一次
  • DirectTI(直连):CH1 就捕获 CH1 自己的引脚(TI1)。绝大多数场景用它。
  • IndirectTI(间接):CH1 借用 CH2 的引脚(TI2)来捕获。测 PWM 占空比时会用到(见 6.2)。
  • ICPrescaler:如果信号边沿非常密(高频),可以把捕获频率降下来,避免中断太频繁。测频率一般用 DIV1(每个边沿都抓)。

4.3 极性决定"记哪个时刻"

  • 想测周期:只关心"每个上升沿",用 Rising 即可。
  • 想测高电平时间:需要"上升沿记一次、下降沿记一次",要么用双通道,要么在中断里切换极性(见 6.2)。

4.4 输入滤波 ICF(数电知识的用武之地)

TIM_ICFilter 是一个数字滤波器:要求信号连续稳定 N 个采样周期才认这个边沿。值越大,越抗噪,但响应越慢、越可能漏掉窄脉冲。

场景 建议
信号很干净(开发板按键、方波发生器) 0x0(不滤波)
引脚可能受干扰(长导线、电机旁边) 0x0A 左右(折中)
信号边沿很慢 / 有抖动 调大滤波值

滤波器"连续采样 N 次确认"的思路,就是数电里典型的数字滤波。没有数电基础也不用慌:记住"滤波 = 拿稳定换速度"就行。

4.5 定时器时钟与量程(决定你能测多慢/多准)

捕获的"时间戳"单位是计数时钟的 tick。所以要先选好计数时钟:

  • tick 时间 = 1 / 计数时钟。计数时钟越高,每个 tick 越短,分辨率越高(测得更精细)。
  • 但 CCR 是 16 位(最大 65535),计数时钟越高,能装下的时间越短,量程越小
PSC 计数时钟 每 tick 单次最大周期 不计溢出的最低频率
71 1 MHz 1 μs 65.535 ms ≈ 15 Hz
719 100 kHz 10 μs 655.35 ms ≈ 1.5 Hz

经验:先估被测信号的频率范围,再选计数时钟。通用场景用 1MHz(PSC=71) 起步,够测几 Hz ~ 几十 kHz。

4.6 别忘了:GPIO 是输入模式

PWM 输出时引脚配 GPIO_Mode_AF_PP,但输入捕获时引脚是"输入",要配成输入模式:

gpio.GPIO_Pin  = GPIO_Pin_0;
gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING;   // 浮空输入;由外部信号源驱动
GPIO_Init(GPIOA, &gpio);
  • 信号源能同时驱动高、低电平 → 用 IN_FLOATING(浮空)。
  • 信号是"开漏 + 上拉"之类 → 用 IPD 下拉或 IPU 上拉,看电路。
  • 千万别用 AF_PP(那是输出模式,输入捕获不会有波形)。

5. 16 位溢出问题(最容易翻车的地方)

5.1 问题

CCR 和 CNT 都是 16 位(0 ~ 65535)。如果被测周期超过 65535 个 tick:

CNT 数到 65535 → 再 +1 → 回到 0(溢出)→ 产生"更新事件 / 更新中断"

这时两次捕获之间的差值不再等于真实周期,必须把"溢出了几圈"也算进去。

5.2 解决办法:开更新中断,数溢出圈数

真实周期(tick) = (本次 CCR - 上次 CCR) + 溢出圈数 × (ARR + 1)
  • 溢出圈数:在更新中断里累加,每次 CNT 回 0 就 +1。
  • 捕获中断里读到新 CCR 后,把溢出圈数清零,开始数下一个周期。

5.3 或者:干脆让量程不溢出

如果被测信号不慢(周期 < 65535 tick),可以直接用 16 位最大值做 ARR

tim.TIM_Period = 0xFFFF;   // 65535,让计数器尽可能晚溢出

这样只要信号周期小于 65.535ms(f > 约 15Hz),就永远不会溢出,代码更简单。先按这个最简单的情况跑通,再上溢出处理


6. 示例代码

6.1 示例 1:TIM5_CH1 测频率(基础版,不处理溢出)

场景:PA0 输入一个方波,用 TIM5_CH1 测它的周期 → 算频率。计数时钟 1MHz,ARR 用最大 0xFFFF。

前提:被测频率 > 约 15 Hz(周期 < 65.535ms),否则先看 5.2 的溢出版。

#include "stm32f10x.h"

volatile uint32_t period_ticks = 0;   // 最近一次测到的周期(tick 数),供主循环读

void TIM5_IC_Config(void)
{
    GPIO_InitTypeDef gpio;
    TIM_TimeBaseInitTypeDef tim;
    TIM_ICInitTypeDef ic;
    NVIC_InitTypeDef nvic;

    // ① 开时钟:TIM5 在 APB1;GPIOA
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM5, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    // ② PA0 = TIM5_CH1,输入捕获引脚要配输入模式(浮空)
    gpio.GPIO_Pin  = GPIO_Pin_0;
    gpio.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &gpio);

    // ③ 定时器时基:72MHz / 72 = 1MHz,ARR 拉满 16 位
    tim.TIM_Prescaler         = 71;            // 1MHz
    tim.TIM_CounterMode       = TIM_CounterMode_Up;
    tim.TIM_Period            = 0xFFFF;        // 尽量晚溢出
    tim.TIM_ClockDivision     = TIM_CKD_DIV1;
    tim.TIM_RepetitionCounter = 0;
    TIM_TimeBaseInit(TIM5, &tim);

    // ④ 输入捕获配置:上升沿、直连 TI1
    TIM_ICStructInit(&ic);
    ic.TIM_Channel     = TIM_Channel_1;
    ic.TIM_ICPolarity  = TIM_ICPolarity_Rising;
    ic.TIM_ICSelection = TIM_ICSelection_DirectTI;
    ic.TIM_ICPrescaler = TIM_ICPSC_DIV1;
    ic.TIM_ICFilter    = 0x00;
    TIM_ICInit(TIM5, &ic);

    // ⑤ 开捕获中断(基础版不需要更新中断)
    TIM_ClearFlag(TIM5, TIM_FLAG_CC1 | TIM_FLAG_Update);
    TIM_ITConfig(TIM5, TIM_IT_CC1, ENABLE);

    // ⑥ NVIC
    nvic.NVIC_IRQChannel = TIM5_IRQn;
    nvic.NVIC_IRQChannelPreemptionPriority = 1;
    nvic.NVIC_IRQChannelSubPriority        = 0;
    nvic.NVIC_IRQChannelCmd                = ENABLE;
    NVIC_Init(&nvic);

    // ⑦ 启动定时器
    TIM_Cmd(TIM5, ENABLE);
}

// 中断服务函数
void TIM5_IRQHandler(void)
{
    static uint16_t prev = 0;      // 上一次捕获值(用 static 记住)
    static uint8_t  first = 1;     // 第一次捕获还没有"上一次",不算周期

    if (TIM_GetITStatus(TIM5, TIM_IT_CC1) != RESET)
    {
        TIM_ClearITPendingBit(TIM5, TIM_IT_CC1);

        uint16_t now = TIM_GetCapture1(TIM5);   // 读走 CCR1(时间戳)

        if (first)               // 第一次:只记录基准,不计算
        {
            prev  = now;
            first = 0;
        }
        else                     // 之后:周期 = 本次 - 上次
        {
            period_ticks = (uint32_t)now - prev;
            prev = now;
        }
    }
}

int main(void)
{
    uint32_t freq = 0;
    NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);
    TIM5_IC_Config();

    while (1)
    {
        if (period_ticks != 0)
        {
            freq = 1000000UL / period_ticks;    // 1MHz 计数时钟 → 频率 = 时钟 / 周期tick
            printf("freq = %lu Hz, period = %lu us\r\n", freq, period_ticks);
            delay_ms(500);                      // 你的延时函数
        }
    }
}

说明

  • printf 要重定向到串口(用你现有的串口打印方法,后续会专门学 USART)。
  • 频率 = 计数时钟 ÷ 周期(tick)。因为 1 tick = 1μs,所以 period_ticks 直接就是微秒数,1 秒 = 1000000 μs。
  • 第一次捕获不计算,因为还没有"上一次时间戳"。这是输入捕获最常见的初始化细节。

6.2 示例 2:测 PWM 占空比(进阶,可先跳过)

被测信号是 PWM,我们想要周期 + 高电平时间,从而算占空比:

占空比 = 高电平时间 ÷ 周期

双通道:CH1 上升沿捕获(记周期),CH2 下降沿捕获(记高电平结束)。

// 在 6.1 配置基础上,再加一个通道2的捕获配置:
TIM_ICStructInit(&ic);
ic.TIM_Channel     = TIM_Channel_2;              // CH2 借用 CH1 的信号
ic.TIM_ICPolarity  = TIM_ICPolarity_Falling;     // 下降沿
ic.TIM_ICSelection = TIM_ICSelection_IndirectTI; // 间接:借 TI1(同一条信号)
ic.TIM_ICPrescaler = TIM_ICPSC_DIV1;
ic.TIM_ICFilter    = 0x00;
TIM_ICInit(TIM5, &ic);

// 中断里:
//   CH1 上升沿 → 算周期 = 相邻两次 CCR1 差值
//   CH2 下降沿 → 高电平时间 = CCR2 - 上次的 CCR1
//   占空比 = (CCR2 - prev_CCR1) / (CCR1 - prev_CCR1)

这一节看懂思路即可。等需要测舵机 / 红外遥控时再回来细抠。


7. 量程与分辨率怎么权衡

测周法的两个指标:

指标 含义 怎么变好
分辨率 最小能分辨的时间变化 = 1 个 tick 提高计数时钟(减小 PSC)
量程 能测的最长周期(16 位限制) 降低计数时钟 / 处理溢出

矛盾:计数时钟越高,分辨率越好但量程越短。选法:

先定量程:被测最低频率 f_min → 需要量程 ≥ 1/f_min → 选计数时钟 ≤ 65535 × f_min
再提分辨率:在量程允许内,尽量把计数时钟调高

误差来源(不展开数电,但要有概念):

  • ±1 tick 的量化误差:边沿落在两个 tick 之间。不可避免,但相对误差 = 1/周期tick,周期越长误差越小。
  • 触发点抖动、被测信号本身不稳定。

8. 常见坑

  1. GPIO 配成 AF_PP:输入捕获引脚要输入模式IN_FLOATING / IPU / IPD),不是输出复用。
  2. 忘了开捕获中断TIM_ITConfig(TIM5, TIM_IT_CC1, ENABLE)。同时 NVIC 也要使能,两个开关(回顾中断篇"四步曲")。
  3. 第一次捕获就拿来算周期:没有"上一次时间戳",算出来是垃圾。要跳过第一次。
  4. 16 位溢出没处理:周期超过 65535 tick 时差值失真。要么用溢出计数,要么确保量程足够。
  5. 极性搞反:想测上升沿却配了 Falling,得到的是"两个下降沿"的周期,如果信号占空比不是 50% 就不对。
  6. CCR 读数"抖":先怀疑信号噪声,加 TIM_ICFilter;再怀疑被测信号本身不稳定。
  7. 主循环读 period_ticks 没加保护:中断在改、主循环在读,至少加 volatile;追求严谨可临时关中断再读。
  8. 频率数值对不上:确认定时器时钟算的是 72MHz(APB1 分频 ≠ 1 时通用定时器 ×2),不是 36MHz。

9. 调试自查清单

  • 引脚是输入模式吗?信号确实接在 TIMx_CHy 对应引脚上了吗?

  • 定时器时钟算对了吗(72MHz,不是 APB1 的 36MHz)?

  • 捕获中断 + NVIC 都开了吗?

  • 串口能打印吗?先用示波器 / 信号发生器确认有信号。

  • 第一次捕获跳过了吗?

  • 周期值数量级对吗?(1MHz 时钟下,1kHz 信号应测到约 1000 tick)

  • 频率太高导致 tick 太少?换测频法;频率太低导致溢出?处理溢出或降时钟。


附录:本笔记用到的标准库函数

函数 作用
TIM_ICStructInit(&ic) 给输入捕获结构体填默认值
TIM_ICInit(tim, &ic) 配置输入捕获通道
TIM_GetCapturex(tim) 读第 x 通道的 CCR 值(时间戳)
TIM_SetComparex(tim, val) 写 CCR(输出比较 / PWM 用,对照理解)
TIM_ITConfig(tim, TIM_IT_CCx, ENABLE) 开捕获中断
TIM_GetITStatus(tim, TIM_IT_CCx) 查捕获中断标志
TIM_ClearITPendingBit(tim, TIM_IT_CCx) 清捕获中断标志
TIM_SelectInputTrigger / TIM_SelectSlaveMode 高级玩法:把定时器配成"外部信号复位/门控"(测频法用到)

进阶:测频法(想清楚思路,以后用得上)

高频信号用测周法会崩(周期 tick 太少)。测频法思路:

  1. 把被测信号接到定时器的外部时钟输入(ETR 或某个 TI),让硬件直接数脉冲
  2. 用另一个定时器产生一个精确的闸门时间(比如 1 秒)。
  3. 闸门开 → 计数器开始数 → 闸门关 → 读出计数值 = 频率。

STM32 标准库里涉及的函数是 TIM_ETRConfigTIM_SelectInputTriggerTIM_SelectSlaveMode(门控模式)。等把本篇的测周法吃透,再研究它。


学习路线衔接:输入捕获 = "定时器读波形",配合你已学的中断、定时器、PWM,已经把定时器这个外设玩转了八成。接下来建议按路线学 串口(USART)——很多调试信息要打印出来你才能看到(本篇示例里的 printf 就是靠它)。