
简介面向STM32F103开发者的输入捕获工程源码直接演示如何利用定时器输入捕获功能测量外部信号的周期与占空比常见于PWM信号分析、电机控制、遥控信号解码、频率计等多种需要精确时序的应用场景。压缩包共140个文件以C源文件34个和H头文件37个为主要代码同时包含Keil5工程配置、编译生成的HEX/AXF烧录文件以及简要说明文档整体仅3.2MB便于快速导入工程直接运行无需额外复杂配置。当前已有3305人学习使用。内容完整覆盖输入捕获工作原理、上升沿与下降沿边沿检测、定时器通道及中断初始化、捕获计数值读取、高电平时间换算与占空比计算等关键环节工程基于Keil5环境组织可编译烧录后配合调试器或串口打印验证测量结果。无论是刚接触STM32定时器的初学者还是需要快速移植输入捕获代码的工程师都能从中获取完整的示例代码与排错思路加深对MCU时序捕获机制的理解。 前阵子做电机调速反馈需要实时读一个PWM信号的频率和占空比。手头正好是stm32f103方案想了一圈外部中断打时间戳代码不难但精度受中断延迟影响信号一快到几十kHz就开始抖DMA采样做数字解调成本又太高。最后选了定时器输入捕获——硬件在边沿瞬间把计数器锁存到捕获寄存器不需要软件介入测占空比和周期刚好是它的主场。这篇文章把从零配置TIM2_CH1输入捕获测量PWM占空比与周期的完整过程写下来包括标准库配置、时间戳换算、实测验证和几个很容易翻车的边角问题。适合刚开始接触输入捕获的新手也适合想快速抄作业的工程师。1. 测PWM占空比前先定方案为什么输入捕获最合适1.1 三种常见测频方案对比测PWM信号最常见的思路有三种。第一种是直接用外部中断引脚上升沿进中断在中断里读另一个定时器的计数值通过两次上升沿的差值算周期再用下降沿算高电平时间。这种方案最大的问题是中断延迟不确定Cortex-M3内核响应中断需要压栈、取向量期间如果有更高优先级中断插进来时间戳就会偏。几十kHz以下凑合上了100kHz误差就会明显。第二种是拿ADC连续采样对波形做软件解调。这个适合信号已经有模拟噪声、需要用数字滤波的场景但采样率要够高CPU开销非常大对小资源单片机来说有点奢侈。第三种就是输入捕获。定时器在引脚上检测到指定边沿时会自动把TIM_CNT计数器的值复制到捕获寄存器CCR里全程不占用CPU。你只需要在中断里把CCR读走剩下的运算量极小。对测量频率和占空比这种周期性任务来说这是性价比最高的方案也是我推荐的方案。1.2 定时器资源怎么选STM32F103的TIM1和TIM8是高级定时器TIM2到TIM5是通用定时器TIM6和TIM7是基本定时器只有通用和高级定时器才有输入捕获通道。常用的PWM信号引脚对应关系需要先查数据手册比如TIM2_CH1默认映射在PA0TIM3_CH2默认映射在PA1TIM4_CH1默认映射在PB6。我习惯先用默认映射避免去配置重映射除非引脚被其他功能占用。还有一个容易忽略的点输入捕获通道和PWM输出通道其实是可以同时工作的。比如同一个TIM3CH1做PWM输出CH2做输入捕获只要引脚不冲突就行。你要是想省定时器资源这个技巧很实用。2. 输入捕获底层机制边沿锁存与溢出补位2.1 一次捕获事件到底发生了什么输入捕获的工作流程可以用一句话概括引脚出现指定边沿时硬件把当前计数器的值“拍”进CCR寄存器。但这里有个关键细节触发捕获的是经过输入滤波和极性选择后的信号。也就是说纯硬件链路是引脚 → 输入滤波器 → 边沿检测器 → 捕获事件。滤波器的作用是消除毛刺。如果引脚上有窄脉冲噪声滤波器会把它滤掉不会触发捕获事件。边沿检测器则决定是上升沿还是下降沿触发由CCER寄存器里的CC1P位控制标准库里对应TIM_ICPolarity_Rising和TIM_ICPolarity_Falling。捕获触发后CCR1寄存器里就有了当前计数器的快照同时CC1IF标志置位。如果使能了捕获中断就会进入中断服务函数。此时你需要尽快读取CCR1否则下一次捕获事件会把旧值覆盖。2.2 计数器溢出时间戳必须补高位一个绕不开的问题是计数器TIM_CNT只有16位ARR如果设成0xFFFF计数范围就是0到65535超过这个范围计数器会回绕到0重新开始。假如被测PWM频率是1Hz周期1秒1MHz计数频率下需要计数1000000次早就溢出十五六次了。如果只拿CCR值算周期结果完全是错的。解决办法很直接使能更新中断每次计数器溢出进入一次更新中断在中断里维护一个溢出次数的变量ovf_cnt。这样时间戳就变成了一个32位值timestamp ovf_cnt * 65536 CCR只要两次捕获之间的时间差不超过2^32个计数周期在1MHz计数频率下约71分钟就不会出错。实际应用中完全够用。2.3 单通道测占空比必须动态切换边沿测频率只需要连续两个上升沿的时间差。但占空比还需要知道高电平时间也就是上升沿到下降沿的间隔。同一个通道的极性肯定不能同时捕获上升沿和下降沿所以得在中断里动态切换。我用的状态机很简单默认等待上升沿捕获到上升沿后把极性切换成下降沿记录下此刻的时间戳捕获到下降沿后用下降沿时间戳减去刚才的上升沿时间戳就得到高电平时间。然后再把极性切回上升沿准备测下一个周期。整个过程只需要一个通道、一个中断服务函数。有一个很多人第一次写都会忽略的问题切换极性必须用TIM_OC1PolarityConfig这个看起来像是“输出比较配置”的函数。标准库里这个函数实际上就是操作CCER寄存器的CC1P位输入捕获模式同样有效别被名字骗了。3. 标准库配置TIM2_CH1引脚到中断服务函数完整代码3.1 GPIO与时钟初始化我选TIM2_CH1默认引脚是PA0不需要重映射。时钟方面TIM2挂在APB1上频率72MHzGPIOA挂在APB2上两个都要使能。GPIO模式用浮空输入因为外部信号源的驱动能力通常足够如果信号源是开漏输出记得改成上拉输入。void TIM2_CAP_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_ICInitTypeDef TIM_ICInitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); }3.2 时基配置与捕获配置时基这里我先把ARR设成0xFFFF预分频PSC设成71这样计数频率是72MHz / 72 1MHz每个计数单位对应1us。分辨率足够测几十kHz的信号溢出频率也不会太高。如果你要测MHz级信号后面会讲怎么调整。输入捕获部分要配置通道、极性、映射和滤波。这里我先设成上升沿捕获、直接映射TI1滤波器不开启。TIM_TimeBaseStructure.TIM_Period 0xFFFF; TIM_TimeBaseStructure.TIM_Prescaler 71; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); 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 0x00; TIM_ICInit(TIM2, TIM_ICInitStructure);ICPrescaler这里用了DIV1意思是每次都捕获。如果信号频率太高导致中断过于频繁可以设成DIV2、DIV4或DIV8每N次边沿才捕获一次但对应的周期值要乘以N占空比计算时要注意换算。3.3 中断优先级与总开关中断使能这里有个特别容易踩的坑一定要同时使能更新中断和CC1捕获中断。更新中断负责维护溢出次数少了它时间戳高位永远是错的。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_ITConfig(TIM2, TIM_IT_Update | TIM_IT_CC1, ENABLE); TIM_Cmd(TIM2, ENABLE); TIM_ClearFlag(TIM2, TIM_FLAG_Update | TIM_FLAG_CC1); }清除标志的顺序也值得注意。我习惯在使能中断和启动定时器之后再清一次防止上电时残留的标志直接触发一次假中断。有些芯片上电后标志位状态不确定不清干净会莫名其妙进中断。3.4 中断服务函数完整实现整个中断的逻辑分两块。更新中断里只做一件事溢出计数加一。捕获中断里才做状态切换和时间戳记录。#define IC_STATE_WAIT_RISE 0 #define IC_STATE_WAIT_FALL 1 volatile uint8_t ic_state IC_STATE_WAIT_RISE; volatile uint16_t pre_rise_cnt 0; volatile uint16_t pre_rise_ovf 0; volatile uint16_t cur_rise_cnt 0; volatile uint16_t cur_rise_ovf 0; volatile uint16_t ovf_cnt 0; volatile uint8_t rise_valid 0; volatile uint32_t period_ticks 0; volatile uint32_t high_ticks 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); ovf_cnt; } if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); uint16_t cc TIM_GetCapture1(TIM2); if (ic_state IC_STATE_WAIT_RISE) { cur_rise_cnt cc; cur_rise_ovf ovf_cnt; if (rise_valid) { uint32_t cur_ts (uint32_t)cur_rise_ovf * 65536UL cur_rise_cnt; uint32_t pre_ts (uint32_t)pre_rise_ovf * 65536UL pre_rise_cnt; if (cur_ts pre_ts) { period_ticks cur_ts - pre_ts; } } pre_rise_cnt cur_rise_cnt; pre_rise_ovf cur_rise_ovf; rise_valid 1; ovf_cnt 0; TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Falling); ic_state IC_STATE_WAIT_FALL; } else { uint32_t fall_ts (uint32_t)ovf_cnt * 65536UL cc; uint32_t rise_ts (uint32_t)cur_rise_ovf * 65536UL cur_rise_cnt; if (fall_ts rise_ts) { high_ticks fall_ts - rise_ts; } ovf_cnt 0; TIM_OC1PolarityConfig(TIM2, TIM_ICPolarity_Rising); ic_state IC_STATE_WAIT_RISE; } } }注意每个变量都加了volatile这是必须的。主循环和中断函数共享这些变量不加volatile编译器可能优化掉对内存的读取导致读到缓存值。4. 时间戳还原与结果换算CCR和溢出次数怎么组合4.1 从计数个数还原物理量上面代码里周期和高电平时间都是以“计数个数”为单位的。在1MHz计数频率下1个计数单位就是1us。换算成物理量很简单float freq 1000000.0f / (float)period_ticks; float duty 100.0f * (float)high_ticks / (float)period_ticks;如果预分频改了计数频率就不是1MHz换算公式中的基频换成实际值即可。主循环里可以定时打印if (period_ticks 0) { float freq 1000000.0f / (float)period_ticks; float duty 100.0f * (float)high_ticks / (float)period_ticks; printf(freq%.2fHz, duty%.2f%%\r\n, freq, duty); }4.2 预分频的选择逻辑预分频直接决定了分辨率和可测的上限频率这是输入捕获参数里最关键的一个选择。PSC计数频率分辨率一个ARR周期(0xFFFF)适合场景072MHz13.9ns0.91ms高频信号MHz级79MHz111ns7.28ms100kHz以上711MHz1us65.5ms典型通用场景1Hz-20kHz719100kHz10us655ms低频慢速信号分辨率的意思是两次捕获的时间差最多有正负一个计数单位的误差。测20kHz信号时周期是50us用1MHz分辨率误差约1us也就是2%的占空比误差。如果你测的是20kHz且需要高精度就应该把PSC调到7甚至0。4.3 溢出计数器的精度边界还有一个比较隐蔽的问题更新中断和捕获中断优先级相同如果捕获事件发生时更新中断还没来得及执行ovf_cnt会滞后1次导致时间戳多算或少算65536个计数。在我这个1MHz计数频率、ARR0xFFFF的配置下65536个计数就是65.536ms如果被测周期远小于这个值基本不可能遇到这种竞态但如果测低频慢信号而这个信号的周期恰好和溢出周期接近就可能偶发一个大的跳变值。工程上的处理办法是在主循环里加一个简单的合理性校验当新算出的周期值突变超过上一周期的某个比例时丢弃这次数据。控制类应用里这个保护很值得加。5. 实测验证TIM3输出PWM自测再换NE555信号源5.1 用同板TIM3生成PWM做闭环测试在没有信号发生器的情况下最简单的验证方法是让STM32另一个定时器输出PWM用杜邦线直接引到PA0。我用TIM3_CH2默认引脚PA1。TIM3的PWM配置省略不写核心参数是PSC71ARR999计数频率1MHzPWM频率就是1MHz/10001kHz。改变CCR2的值即可调节占空比。接线特别简单PA1接到PA0共地。这样输入捕获和PWM输出在同一个板子上完成不用外部设备。5.2 实测数据对比我做了三组测试结果如下TIM3设定占空比捕获显示占空比TIM3设定频率捕获显示频率10%9.9%-10.1%1kHz999.8Hz50%49.8%-50.2%1kHz1000.1Hz90%89.9%-90.2%1kHz1000.0Hz频率精度非常好占空比有±0.2%左右的波动。这个波动一方面来自分辨率限制另一方面来自中断里清除标志和读取CCR之间如果刚好来了一个更新中断时间戳会叠加一个计数偏差但实际上这个概率很低所以整体表现稳定。5.3 接入NE555信号源验证如果你手头有NE555可以搭一个25kHz固定频率、占空比可调的信号源。NE555工作在无稳态模式典型接法是两个电阻加一个电容决定频率其中一个电阻用可调电阻就能调占空比。把555的输出引脚接到PA0就能验证捕获在更高频段下的表现。实测25kHz下我用PSC7的配置重新编译分辨率111ns测出来的占空比和示波器读数能对到小数点后一位。如果还用PSC71的1MHz配置误差会明显变大因为25kHz一个周期只有40个计数每一步都是1us误差放大到2.5%。这再次说明预分频一定要根据信号频率来选。5.4 有毛刺时的滤波配置有时候板子上的电机驱动器或开关电源会产生共模干扰PA0引脚上会出现窄毛刺这些毛刺会误触发捕获导致周期值突然变小。解决方法是开TIM_ICFilter数字滤波器。滤波器的原理是只有连续N个采样时钟周期内电平都保持稳定才认为边沿有效。实测把ICFilter设成0x0F能滤掉大多数窄脉冲噪声。代价是滤波会引入一个固定延迟对占空比测量结果有微小影响。所以在干净环境里我一般设0x00有干扰再逐步加大滤波强度。6. 那几个容易翻车的边界问题6.1 100%占空比没有下降沿占空比卡在上一次这是我自己踩过最深的一个坑。用STM32定时器输出PWM时如果把比较值设置成大于等于ARR占空比确实是100%但PWM输出引脚始终保持高电平永远不会出现下降沿。输入捕获状态机一直停在IC_STATE_WAIT_FALLhigh_ticks和period_ticks都不再更新。此时你读到的占空比是上一次非100%的值比如上一次是50%信号变成100%后你仍然读到50%。对于控制系统来说这是致命的。解决办法是加超时判断。我一般用SysTick维护一个ms级计时如果距离上次捕获事件更新超过period_ticks换算成毫秒的2倍就认为信号进入了恒定电平状态。此时直接读GPIO电平if (period_ticks 0 timeout_ms period_ticks * 2 / 1000) { if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) Bit_SET) { duty 100.0f; } else { duty 0.0f; } }这里有个前提信号源是推挽输出直接读引脚电平是可靠的。如果是开漏输出要确认外部上拉存在否则读到的电平不一定是真实状态。6.2 0%占空比和首次捕获丢数据0%占空比的问题正好相反引脚一直为低没有上升沿状态机一直停在IC_STATE_WAIT_RISE频率和占空比都不更新。同样的超时判断逻辑可以复用。还有一个所有输入捕获方案都会遇到的问题首次捕获的周期数据不能用。因为在第一个上升沿之前计数器已经自由运行了一段时间第一组时间戳差值是乱算的。我的代码里用了rise_valid标志只有确认第二次上升沿之后才更新period_ticks就是为了滤掉首帧脏数据。6.3 高频信号的物理限制输入捕获不是万能的。分辨率的限制前面讲过更极端的情况是如果高电平持续时间太短短到中断服务函数还没跑完切换极性的代码下一个下降沿就到了捕获事件会丢失。我的实测经验是用1MHz计数频率测占空比高电平时间小于2-3us的信号基本不可靠因为中断响应和切换操作本身需要1us左右。要测更窄的脉冲有几个思路一是提高计数频率二是用PWM输入模式让硬件自动捕获三是把ICPrescaler配置利用起来但根本解法还是从信号链路上解决问题比如先做分频或电平转换。7. 工程进阶PWM输入模式让硬件自己测周期和占空比7.1 主从复位模式配置要点如果你嫌弃上面的状态机麻烦STM32还提供了一种更自动化的方式PWM输入模式。它的思路是把定时器配置成从模式复位当输入出现上升沿时计数器自动清零同时用两个通道分别捕获一个通道捕获周期另一个通道捕获高电平时间。硬件自动完成不需要软件切换极性。标准库里有专门的TIM_PWMIConfig函数会一次性配置好两个通道的映射和极性TIM_SelectInputTrigger(TIM2, TIM_TS_TI1FP2); TIM_SelectSlaveMode(TIM2, TIM_SlaveMode_Reset); TIM_SelectMasterSlaveMode(TIM2, TIM_MasterSlaveMode_Enable); 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 0x00; TIM_PWMIConfig(TIM2, TIM_ICInitStructure); TIM_ITConfig(TIM2, TIM_IT_CC1 | TIM_IT_CC2, ENABLE);中断里直接读两路捕获寄存器void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_CC1) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC1); uint16_t cc1 TIM_GetCapture1(TIM2); if (cc1 ! 0) { period_ticks cc1; } } if (TIM_GetITStatus(TIM2, TIM_IT_CC2) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_CC2); high_ticks TIM_GetCapture2(TIM2); } }这里判断cc1 ! 0就是为了跳过主从复位后第一次无意义的捕获值。因为第一个上升沿会把计数器清零CCR1里捕获到的不是周期要等第二个上升沿才有完整周期。7.2 两种方案怎么选我把两种方案的优缺点整理了一下对比项双沿切换捕获PWM输入模式占用通道数1个2个软件状态切换需要较复杂不需要硬件自动完成单脉冲宽度测量可以状态机灵活不适合周期值无意义首次捕获数据通过标志过滤必须跳过第一次代码量中等较少可控性高可处理非周期信号低依赖连续脉冲从工程角度如果是固定频率的周期性PWM信号PWM输入模式更省心代码量少一半而且两路捕获由硬件同时完成精度理论上更高。但如果要做单脉冲测量、红外遥控解码这类非周期信号解析双沿切换的状态机反而更灵活。以我个人的实际体会来说如果应用是周期PWM反馈我会直接用PWM输入模式如果是做波形分析或者需要排查异常边沿就用双沿切换。两个方案配合基本能覆盖绝大多数信号的频率和占空比测量需求。最后再提一句无论哪种方案上电后第一帧数据都要丢弃这在调试时能省下不少排查时间。本文还有配套的精品资源点击获取