STM32F103 定时器输入捕获实战:测频测周、编码器测速、红外解码全流程拆解
调试无刷电机驱动板时,要实时监测外部控制器发来的 PWM 调速信号频率。第一版用外部中断配合软件数脉冲:每个上升沿进一次中断,中断里读计数器、累加变量、还要防重入。几十行代码写出来,信号频率一过几千赫兹,CPU 占用率肉眼可见地飙,测量结果还时不时跳变------中断压栈出栈的开销全摊在每一条边沿上了。换用定时器输入捕获后,测量从「软件数脉冲」变成了「硬件盖时间戳」,边沿再多也只是往寄存器里写个值,CPU 完全解放,问题一次解决。本文从原理到代码拆解输入捕获的完整用法,并扩展到编码器测速与红外遥控解码。
一、输入捕获是什么:硬件版「盖时间戳」
定时器正常工作是数自己的时钟。输入捕获在此基础上加了一件事:当引脚出现指定边沿(上升沿/下降沿)时,硬件把计数器 CNT 的当前值瞬间锁存到捕获寄存器 CCRx。
关键在「瞬间」两个字------从边沿出现到值被锁存,全程没有 CPU 参与,延迟是固定的几个时钟周期。你可以把它理解成一个硬件时间戳:每个边沿进来,CCR 里就留下了「此刻 CNT 是多少」的记录。相比之下,软件读 CNT 有两个天生缺陷:读到的是「读了那一刻」的值,不是「边沿那一刻」的值;而且读的时候 CNT 可能正在变化,读出来是撕裂的。捕获寄存器把这两件事都堵死了。
对比外部中断方案:每来一个边沿,CPU 都要被中断打断、读计数器、存变量、处理完再回去------信号频率一高,CPU 全耗在数脉冲上,还容易丢边沿。输入捕获则只在需要取数时(或者干脆不取)才打断 CPU。
有了两个相邻边沿的时间戳,就能算周期:周期 = 两次捕获值之差(单位是计数)。计数频率 1MHz 时,差 5000 就是 5ms,频率 200Hz。测占空比同理:再配一路下降沿捕获(或者用 PWMI 模式让 TI1、TI2 同时触发),高电平时间 ÷ 周期就是占空比,一次中断里两个 CCR 都能读到。
二、测频率的两种范式:测频法 vs 测周法
拿到时间戳后怎么换算成频率,有两条路,误差来源正好相反。
测频法:在固定的闸门时间 T 里数出完整脉冲个数 N,频率 f = N / T。信号越快,T 里数到的 N 越多,相对误差 1/N 越小。适合高频。
测周法:只测一个完整周期,用高精度基准时钟去数这个周期里有几拍。周期越长(频率越低),数到的拍数越多,误差越小。适合低频。
两条路的误差在某个频率点上相等,这个点叫中界频率:fm = √(fc/T)。以计数频率 1MHz、闸门 1s 为例,fm ≈ 1kHz------高于 1kHz 用测频法,低于 1kHz 用测周法,这是一个常用的粗略分界。
实际工程里两种做法都常见:测频法适合「只关心频率」的场景(比如转速表),测周法适合「连周期抖动都要看」的场景(比如 PWM 信号分析)。F103 的输入捕获天然适合测周法------两个边沿就是两个时间戳,周期精确到 1µs;要做测频法则需要软件维护闸门窗口,多一层逻辑。本文主案例用测周法,把捕获的中断次数和溢出处理一起讲透。

三、主案例:测量 1kHz PWM 信号频率
硬件:PA0 接外部 1kHz 方波(另一块板输出),TIM2_CH1 默认就在 PA0,不用重映射。两个接线前提:信号源与本板共地,且电平在 3.3V 以内------F103 引脚不是高压耐受,5V 方波要分压或加电平转换。先用 1MHz 计数(1µs/计数),ARR 放最大 0xFFFF------这样单段可测到 65.535ms(约 15.3Hz 以上),溢出用中断补。

3.1 初始化五步
顺序固定:时钟 → 引脚 → 时基 → 捕获通道 → 中断与使能。

c
void Capture_Init(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_ICInitTypeDef TIM_ICInitStructure;
NVIC_InitTypeDef NVIC_InitStructure;
/* ① 时钟:TIM2 挂 APB1,GPIOA 挂 APB2 */
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
/* ② PA0 浮空输入(信号源是推挽驱动) */
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
/* ③ 时基:72MHz ÷ 72 = 1MHz,1µs/计数 */
TIM_TimeBaseStructure.TIM_Prescaler = 71;
TIM_TimeBaseStructure.TIM_Period = 0xFFFF;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
/* ④ 捕获通道:上升沿、直连 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(TIM2, &TIM_ICInitStructure);
/* ⑤ 中断:捕获 + 溢出(Update)都开,最后开闸 */
TIM_ITConfig(TIM2, TIM_IT_CC1 | TIM_IT_Update, ENABLE);
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
TIM_Cmd(TIM2, ENABLE);
}
一个细节:第 ⑤ 步的 TIM_Cmd 必须放最后。计数器先于中断配置跑起来,第一次进中断时 CC1 标志可能带着旧值,第一个周期数就是错的。
3.2 中断里取数:周期 = 溢出补偿 + 两次捕获差值
只要测的周期可能超过 65535 计数(1MHz 下 65.5ms),就必须开 Update 中断数溢出次数,否则 CNT 回绕一次,周期值就乱一次。
c
volatile uint32_t ovf_cnt = 0; /* CNT 溢出(回绕)次数 */
volatile uint32_t last_ccr = 0; /* 上次捕获值 */
volatile uint32_t period_cnt = 0; /* 本次周期,单位:计数 */
void TIM2_IRQHandler(void)
{
/* 先处理溢出再加捕获值:两者同时挂起时,
捕获锁存的是回绕前的 CNT,先加 ovf 才不会少算一次 */
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {
ovf_cnt++; /* CNT 从 65535 回绕到 0 */
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
}
if (TIM_GetITStatus(TIM2, TIM_IT_CC1) != RESET) {
uint32_t ccr = TIM_GetCapture1(TIM2);
/* 周期 = 溢出补偿 + 两次捕获差值 */
period_cnt = ovf_cnt * 65536 + ccr - last_ccr;
last_ccr = ccr;
ovf_cnt = 0; /* 已折算进周期,清零 */
TIM_ClearITPendingBit(TIM2, TIM_IT_CC1);
}
}
主循环里换算并打印:
c
while (1) {
if (period_cnt > 0) {
uint32_t freq = 1000000UL / period_cnt; /* 1MHz 计数 */
printf("freq = %lu Hz, period = %lu us\r\n",
freq, period_cnt);
period_cnt = 0;
delay_ms(500);
}
}
这个量程对绝大多数应用够用。真要测更低频率(比如工频 50Hz、或者传感器慢速方波),两条路:把 PSC 加大降计数频率(比如 PSC=7199 → 10kHz 计数,量程拉到 6.5 秒,代价是分辨率变 100µs),或者干脆切到测频法数闸门里的脉冲。量程和分辨率是一对反着走的矛盾,按信号特点选即可。

四、扩展一:编码器模式,测转速还能判方向
测频率靠单通道捕获,测转速用编码器模式更省事:把编码器 A/B 两相分别接定时器的 CH1、CH2(TIM4 默认 PB6/PB7),硬件自动根据两相相位差给计数器加一或减一,方向判断不用写一行软件。
编码器模式按计数的边沿分三档:只在 A 相边沿计数(×1)、只在 B 相(×1)、A/B 双沿都计(×4)。用 TI12 模式3,一个机械周期数出 4 个沿,500 线编码器每圈 2000 计数,分辨率直接翻四倍。
和普通捕获通道对比,编码器模式把「判方向 + 计数」都下沉到了硬件:普通模式你要在中断里判断两个通道谁先触发,再手动加减自己的变量;编码器模式连中断都不用开,CNT 自己会加会减,主循环定时读一次就行------这正是「让硬件做硬件该做的事」的典型例子。

配置就一句话的事:
c
/* PB6/PB7 = TIM4_CH1/CH2,上拉输入 */
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM4, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6 | GPIO_Pin_7;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; /* 上拉输入 */
GPIO_Init(GPIOB, &GPIO_InitStructure);
TIM_TimeBaseStructure.TIM_Period = 0xFFFF;
TIM_TimeBaseStructure.TIM_Prescaler = 0; /* 每沿计数 */
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseInit(TIM4, &TIM_TimeBaseStructure);
/* 模式3(TI12):A/B 双沿四倍频,方向由硬件判 */
TIM_EncoderInterfaceConfig(TIM4, TIM_EncoderMode_TI12,
TIM_ICPolarity_Rising, TIM_ICPolarity_Rising);
TIM_Cmd(TIM4, ENABLE);
测速用差值,别读绝对值------编码器模式下 CNT 在 0~0xFFFF 之间自动加减(不同型号到边界的表现有差异,有的回绕有的饱和),闸门时间要保证读数间隔内变化量远小于量程:
c
uint32_t last_cnt = 0;
void Speed_Task(void) /* 每 1s 调一次 */
{
uint32_t now = TIM_GetCounter(TIM4);
int32_t delta = (int32_t)(now - last_cnt); /* 有符号差值 */
last_cnt = now;
/* 500 线 ×4 倍频 = 每圈 2000 计数,1s 闸门 */
int32_t rpm = delta * 60 / 2000;
printf("rpm = %ld, %s\r\n", rpm,
delta >= 0 ? "正转" : "反转");
}
五、扩展二:红外 NEC 解码,一个定时器搞定
遥控器的 NEC 协议本质也是一串脉冲宽度:38kHz 载波、9ms 引导码(高 4.5ms 低),逻辑 0 是 560µs 高 + 560µs 低,逻辑 1 是 560µs 高 + 1.69ms 低。一帧数据 32 位:8 位地址 + 8 位地址反码 + 8 位命令 + 8 位命令反码------反码的存在让校验变得很简单:命令和命令反码按位相加应全为 1。
解码思路:捕获每个边沿,把相邻边沿的差值存进缓冲区,然后按阈值判位------间隙 560 计数(1MHz 下 560µs)判 0,1690 计数判 1,4500 计数的长低电平是引导码,帧收齐后校验通过即完成一次解码。
阈值判位就三行逻辑,输入捕获在这里的价值是脉冲宽度测量零误差累积 ------软件延时轮询解码,脉冲一抖就错位,硬件时间戳没有这个问题。一个易踩的细节:红外接收头(如 VS1838B)输出是反相的(收到载波输出低电平),判位阈值要按接收头实际输出波形重新标定,别直接套协议原始时序。
六、六个最容易翻车的点

- 外部中断代替捕获:高频下 CPU 飙升、抖动丢脉冲------测频率优先硬件捕获。
- 溢出不处理:周期超 65535 计数时 CNT 回绕,测量值乱跳------开 Update 中断,溢出次数 ×65536 修正。
- 毛刺重复触发:30cm 导线就能把边沿搞出抖动,一个周期触发两次------开 ICFilter 数字滤波(连续 N 次采样确认才认这个沿)。
- 极性配反:上升沿配成下降沿,周期被劈成两段------测周期用上升沿,测占空比用双沿或 PWMI。
- 从模式触发源限制:只有 TI1/TI2 能当从模式触发源,编码器模式也只认 CH1/CH2------信号接错通道,从模式不生效。
- 编码器测速细节:忘乘 ×4 倍频转速差 4 倍;读绝对值不读差值转速恒 0;A/B 相漏上拉计数乱跳。
排查顺序建议:先示波器看边沿干不干净,再打印 CCR 看量级,最后查溢出与倍频------六成问题第一轮就暴露。
小结
输入捕获的本质是硬件时间戳:边沿一来,CNT 自动进 CCRx,CPU 只需要在中断里做减法。测频测周二选一,编码器模式免费送方向判断,红外解码靠脉冲宽度测量------这三个场景覆盖了嵌入式测量的大半需求。
再往下走,捕获事件还可以接 DMA:每个边沿自动把 CCR 搬进内存数组,主循环一次性处理一批,连中断都省了------这是输入捕获的高阶玩法,信号密度再高也不怕。下篇预告:把捕获到的频率接进任务系统,用 uC/OS-II 的信号量把中断和采集任务串起来,做一版真正的多任务测速仪。