
1. 先搞清楚输入捕获到底在干啥定时器输入捕获说白了就是利用定时器内部的一个硬件机制——当外部信号出现跳变沿上升沿或者下降沿的时候硬件会自动把当前计数器的值“咔嚓”一下锁存到捕获寄存器里。整个过程不需要CPU干预纯粹是硬件行为CPU只需要在捕获事件发生之后去把寄存器里的值读出来就行。我当年第一次用这个功能的时候其实没太理解它和外部中断的区别。外部中断也能检测跳变沿也能在中断里读计数器那干嘛还要用输入捕获呢后来实际调了一段代码才发现外部中断从触发到CPU进入中断服务函数中间有响应延迟这个延迟是抖动的测低频信号还行一测高频PWM就露馅了。而输入捕获是硬件直接把计数器值锁存下来精度能做到一个定时器时钟周期以内这才是它不可替代的地方。这个功能最适合的场景就是PWM测量。比如说你用单片机去驱动一个电机驱动器驱动器回传一个速度反馈信号这个信号就是PWM你想知道当前电机转速就得去测这个PWM的频率和占空比。再比如说你在做飞控遥控器接收机输出的就是50Hz的PWM信号每个通道的脉宽从1000us到2000us不等你要解析遥控器通道值本质上也是在做PWM测量。还有就是读一些传感器的输出像什么超声波测距模块、红外避障模块很多都是PWM输出测这些东西通通可以用输入捕获一把梭。这套方案的核心价值在于一个定时器的两个通道就能同时测出PWM信号的周期频率和占空比全程不需要CPU死等测量精度能做到微秒级别。对于大多数嵌入式应用来说这个精度完全够用了。2. 原理拆解一次捕获事件到底发生了什么2.1 从边沿检测到计数器锁存硬件帮你做了三步要真正理解输入捕获得先知道信号从引脚进来之后在定时器内部走了怎样一条路。第一步信号到达定时器的输入引脚经过输入滤波器。滤波器的目的是剔除毛刺防止高频噪声触发误捕获。滤波器的采样频率和采样次数都是可配的不过滤波开太狠了会引入延迟这个后面讲配置的时候再说。第二步经过滤波的信号进入边沿检测器。你在CubeMX里配置的捕获极性其实就是告诉边沿检测器你是要抓上升沿还是要抓下降沿还是两个边沿都抓。第三步也是最核心的一步——边沿检测器检测到目标边沿之后会生成一个捕获事件这个事件会把当前CNT计数器的值硬件拷贝到捕获寄存器CCR里。注意这个拷贝是硬件自动完成的不占用CPU时间这就是为什么输入捕获能做到高精度。这里有一个很重要的细节不知道大家注意过没有捕获寄存器和比较寄存器在硬件上是同一个寄存器都是CCR。具体的功能方向由CCMR寄存器的CCxS位决定这位配置成00就是输出比较配置成01、10、11就是输入捕获。在HAL库里这个细节被封装掉了所以你用CubeMX配置的时候感受不到但理解这一点有助于你排查那种“寄存器值怎么改都不生效”的问题。2.2 PWM测量为什么需要“一周期两捕获”问题来了测量PWM占了比只抓一个边沿是不够的。我们来推一遍假设有一个PWM信号高电平时间是T_high周期是T_period那么占空比 T_high / T_period。需求分解一下我们需要两个数据一个是高电平持续了多少时间一个是整个周期是多少。这就需要至少三次捕获事件——第一次上升沿记录t1第二次下降沿记录t2那么t2 - t1就是高电平时间第三次上升沿记录t3那么t3 - t1就是周期。不过实际做的时候你不可能一直开着三个捕获通道多数情况下是用一个通道配合捕获极性的动态切换来实现。就是捕获到上升沿之后立刻把捕获极性切成下降沿捕获到下降沿之后立刻切成上升沿。这样一次捕获中断里记录的值配合状态机判断就能逐步解析出脉宽和周期。我在实际项目里更常用的做法是双通道方案定时器的CH1和CH2都配置成输入捕获CH1捕获上升沿CH2捕获下降沿用两个通道同时捕获通过CCR1和CCR2的差值直接算出高电平时间。这样做的优势是不需要在中断里切极性逻辑更简单不容易出bug。缺点是占用两个通道的资源。具体用哪种方案看你的资源情况来定。2.3 预分频和计数频率的配合决定测量分辨率输入捕获的测量分辨率和定时器的计数时钟频率直接挂钩。如果你用定时器时钟84MHz直接计数那么计数器每计一个数只过了大约11.9纳秒这个分辨率测什么PWM都绰绰有余。但问题是如果PWM频率很低比如测一个10Hz的信号周期是100ms而计数器的位数只有16位STM32F4的TIM2、TIM3、TIM4、TIM5是32位的但TIM1、TIM8以及其他通用定时器是16位的那么从捕捉到上一次上升沿到捕捉到下一次上升沿计数器早溢出好几回了。所以预分频器的设置需要在分辨率和溢出时间之间取一个平衡。举个例子STM32F4的定时器时钟通常是84MHz你要是设置PSC 84 - 1那么计数时钟变成了1MHz每个计数单位对应1微秒。这样计数器从0计到65535需要大约65.5毫秒才溢出一次。在这个配置下面你能测的最高PWM频率大概是几百kHz但实际不推荐测太高因为一个周期只有几个计数单位的话误差占比会很大你能测的最低频率也受限于溢出时间超过65ms的周期就得用溢出计数来扩展。2.4 溢出中断测低频信号必须处理的问题说到溢出就不得不提一个新手经常踩的坑只处理捕获中断不处理更新中断结果一测低频PWM数据就乱跳。原因很简单如果PWM周期大于定时器的溢出周期那么在一次完整的测量周期内计数器可能已经溢出归零了好几次。你捕获到的两个边沿值一个在溢出前一个在溢出后直接拿它们做差值算出来的时间就是错的。解决方案有两种思路。第一种把预分频加大让溢出周期变长保证一次测量周期内不会溢出。这种做法简单粗暴但会牺牲测量精度而且遇到极端低频信号还是会翻车。第二种更稳妥开启更新中断在更新中断里用一个变量记录计数器溢出次数计算时间差的时候把溢出次数乘以ARR 1再乘以计数时钟周期加上本次捕获的差值就是真实时间间隔。我一直用的就是第二种方案。具体来说定义两个全局变量比如uwOverflowCnt和uwLastCaptureVal。每次捕获中断里做这样几件事读取当前溢出计数值、读取捕获寄存器的值、结合上一次捕获的值算时间差。算的时候要注意如果两次捕获之间有过溢出时间差要加上溢出次数乘一个周期的时间。3. CubeMX配置一步一步把定时器调到能干活3.1 时钟树和定时器时钟源的选择配置输入捕获之前先确认定时器挂在哪条时钟总线上。STM32F407为例APB1上的定时器时钟是84MHzAPB2上的定时器时钟是168MHz但要注意如果APB1分频系数不等于1定时器时钟频率是APB1频率的两倍这个细节很多人搞错。CubeMX里配置好了主时钟之后可以在Clock Configuration页面里直接看到定时器输入时钟的频率建议养成习惯每次改完外部晶振频率之后都去瞄一眼这个值。输入捕获理论上可以用外部时钟作为定时器时钟源但在绝大多数的PWM测量场景里我们都是用内部时钟——因为我们要测的是外部信号的周期拿固定频率的内部时钟去度量它才是最合理的。3.2 具体的参数设置照着配基本能跑我用STM32F407VE这块板子举例目标是测量一个频率在1kHz左右、占空比在10%到90%之间变化的PWM信号。选择TIM3使用CH1和CH2双通道方案。在CubeMX的TIM3配置页面里做如下设置Clock Source选择Internal ClockChannel1选择Input Capture direct mode也就是直接采集TI1信号Channel2同样选择Input Capture direct modePrescaler设置为84-1这样计数时钟变成1MHz分辨率1微秒Counter Period设置为65535让16位计数器跑满其他选项比如Auto-reload preload、DMA等保持默认即可后续代码里需要的话再配置。然后切换到GPIO设置页面确认两个通道对应的引脚模式被自动设置成了Alternate Function至于引脚的输出速度、上下拉这些输入捕获模式下默认配置就够用不需要额外调整。3.3 极性、滤波器和映射关系这三项别乱动在输入捕获的配置页面里有几个选项值得专门说一下。Polarity选项也就是捕获极性在CubeMX里能看到Rising Edge、Falling Edge、Both Edge三个选项。如果你是单通道方案建议选Rising Edge然后在代码里动态切换极性如果你是双通道方案CH1选RisingCH2选Falling。Filter选项默认是0也就是不滤波。如果你的PWM信号来源于带有驱动芯片的电路通常信号质量还行可以不滤波。但如果信号是从电机电刷或者继电器触点直接引出来的那强烈建议加一点滤波比如设置为4或者8让信号先经过几个采样周期的滤波再去触发捕获。不过要注意滤波会引入额外的延迟会导致上升沿和下降沿的捕获时间点都向后偏移好在它们的偏移量基本一致做差值的时候大部分误差被抵消了对最终的脉宽和周期计算结果影响很小。Prescaler注意这个不是定时器时钟的预分频而是输入信号的分频选项默认的No division就行。这个功能是把捕获事件降频适合那种想每隔N次边沿才捕获一次的场景PWM测量一般不用它。3.4 为什么我推荐双通道而不是单通道切极性我最早做PWM测量用的是单通道动态切极性的方案逻辑是捕获到上升沿 → 记录CCR值 → 把极性切换成下降沿 → 捕获到下降沿 → 记录CCR值 → 算出高电平时间 → 把极性切成上升沿 → 下一轮周期开始。这套逻辑本身没什么问题HAL库也提供了__HAL_TIM_SET_CAPTUREPOLARITY这个宏可以在中断里快速切换极性。但实际用起来有两个痛点第一边沿触发和极性切换之间有时序窗口如果PWM频率很高事件间隔很短你还没来得及切极性下一个边沿已经来了捕获就会漏掉或者错乱。第二代码里状态判断变多调试的时候思路容易绕进去。双通道方案就没有这些问题。CH1和CH2同时在做捕获硬件上互不干扰中断里只需要分别读CCR1和CCR2然后做差值。唯一的代价是多占一个定时器通道但对于大多数应用来说这个代价完全值得。所以我个人建议资源允许的情况下优先考虑双通道方案。4. HAL库代码实现从初始化到回调函数4.1 初始化代码CubeMX生成之后要改哪几处CubeMX生成的定时器初始化代码一般长这样void MX_TIM3_Init(void) { TIM_IC_InitTypeDef sConfigIC {0}; htim3.Instance TIM3; htim3.Init.Prescaler 84-1; htim3.Init.CounterMode TIM_COUNTERMODE_UP; htim3.Init.Period 65535; htim3.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim3.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_IC_Init(htim3) ! HAL_OK) { Error_Handler(); } sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_RISING; sConfigIC.ICSelection TIM_ICSELECTION_DIRECTTI; sConfigIC.ICPrescaler TIM_ICPSC_DIV1; sConfigIC.ICFilter 0; if (HAL_TIM_IC_ConfigChannel(htim3, sConfigIC, TIM_CHANNEL_1) ! HAL_OK) { Error_Handler(); } sConfigIC.ICPolarity TIM_INPUTCHANNELPOLARITY_FALLING; if (HAL_TIM_IC_ConfigChannel(htim3, sConfigIC, TIM_CHANNEL_2) ! HAL_OK) { Error_Handler(); } }这里有两个地方要留意。一是如果你在CubeMX里已经配置好了通道这些代码是自动生成好的通常不需要手动改。二是TIM3的MSP初始化函数也就是HAL_TIM_IC_MspInit它会自动把GPIO时钟开启配置引脚复用模式还会使能TIM3的时钟这些也不需要动。如果你是在自己写的代码里初始化别忘记调用MspInit或者手动完成这几步不然定时器根本跑不起来。4.2 启动捕获和中断主函数里加哪几行初始化完成之后还需要启动定时器的输入捕获功能同时把中断打开。HAL库提供了一个统一的接口HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_1); HAL_TIM_IC_Start_IT(htim3, TIM_CHANNEL_2);而在PWM测量场景下还需要启用更新中断来记录溢出次数__HAL_TIM_ENABLE_IT(htim3, TIM_IT_UPDATE);但是要特别注意调用HAL_TIM_IC_Start_IT的时候HAL库只在启动通道对应的捕获中断不会自动使能更新中断所以要手动补上这行。另外如果你用的是定时器的基础周期中断可能已经在别处调用过HAL_TIM_Base_Start_IT那它的更新中断和这里的更新中断其实是同一个别重复初始化。还有一个细节调用HAL_TIM_IC_Start_IT之后定时器的CNT计数器会从0开始计数但捕获是边沿触发的得等外部信号的边沿真正到来才发生。所以启动函数执行之后可能过了一段时间才触发第一次捕获中断这在软件逻辑上是正常的。4.3 中断回调函数里做数据解析HAL库的中断处理是这么个流程中断触发后进入HAL_TIM_IRQHandler里面根据中断类型分发如果是捕获中断会调用HAL_TIM_IC_CaptureCallback如果是更新中断会调用HAL_TIM_PeriodElapsedCallback。所以我们需要重写这两个回调函数来干活。由于全局只有一个定时器中断服务函数入口你可以在回调里通过判断是哪个定时器来区分来源。先写更新中断的回调逻辑很简单如果溢出发生了计数器加一。uint32_t uwOverflowCnt 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { uwOverflowCnt; } }然后是捕获回调。这里是整个测量的核心逻辑首先要判断通道来源然后读取当前值和上一次捕获值做差再根据差值时机来累计时间。用代码来说就是uint32_t uwCaptureVal1 0, uwCaptureVal2 0; uint32_t uwFreq, uwDuty; volatile uint8_t ucCaptureFlag 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { static uint32_t uwLastRisingVal 0; uint32_t uwRisingVal, uwFallingVal; uint32_t uwPeriodCnt, uwHighCnt; if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { uwCaptureVal1 HAL_TIM_ReadCapturedValue(htim3, TIM_CHANNEL_1); uwRisingVal uwCaptureVal1; if (uwLastRisingVal ! 0) { uwPeriodCnt (uwRisingVal - uwLastRisingVal) uwOverflowCnt * (TIM3_ARR 1); if (uwPeriodCnt 0) { uwFreq 1000000 / uwPeriodCnt; } } uwLastRisingVal uwRisingVal; uwOverflowCnt 0; } else if (htim-Channel HAL_TIM_ACTIVE_CHANNEL_2) { uwCaptureVal2 HAL_TIM_ReadCapturedValue(htim3, TIM_CHANNEL_2); uwFallingVal uwCaptureVal2; uwHighCnt uwFallingVal - uwLastRisingVal; if (uwHighCnt 0) { uwDuty uwHighCnt * 100 / uwPeriodCnt; } } }这段代码的思路是CH1负责抓上升沿两次上升沿之间的计数差值就是周期注意要加上溢出补偿。CH2负责抓下降沿下降沿与最近一次上升沿之间的差值就是高电平时间除以周期再乘100就是占空比。实测下来这种方式计算量小、逻辑清晰。这里有一个细节容易踩坑uwOverflowCnt清零的时机。你会发现计算周期之后我马上把它清零了但如果在CH2的下降沿回调里也需要用到溢出计数这种清零方式就会导致高电平时间计算少算了一个溢出周期。实际项目中我一般会再多加一个值用来记录下降沿到来时的溢出计数以保护两个通道的计算互不影响。但这段代码只展示了基本的框架具体还是看你的信号频率和使用场景来设计。4.4 用DMA方式进一步降低CPU负担有一个进阶做法值得提一嘴如果你要测量的PWM信号是连续变化的而且你不想让CPU频繁进中断可以用DMA方式配合输入捕获。具体思路是配置定时器的捕获事件触发DMA请求把每次捕获到的CCR值自动搬运到一个内存数组里。CPU只需要在DMA传输完成中断里批量处理这些数据。HAL库提供了HAL_TIM_IC_Start_DMA接口使用方法和Start_IT差不多只是把处理数据的时机延后了。在低功耗或者高实时性要求场景下这个方案比中断方案更稳但也更复杂新手建议先从中断方案上手理解了之后再去尝试DMA。5. 实测过程中踩过的坑和排查技巧5.1 捕获值为0可能是滤波或者引脚配置不对最早跑输入捕获的时候最容易遇到的问题就是调用HAL_TIM_IC_Start_IT之后中断从来不触发读捕获寄存器永远是0。排查顺序我一般是这样先检查GPIO是否配置成了复用模式确保引脚确实连到了定时器通道上。然后再检查定时器时钟是否使能了如果用的是HAL库检查一下MSP初始化函数里有没有开TIM3的时钟。接着就是检查信号源是否真实存在拿示波器或者万用表量一下引脚有没有波形。最后检查通道映射关系确认Channel1配置的是TIM_CHANNEL_1而不是TIM_CHANNEL_2之类。如果这些都没问题但还是捕获不到那就考虑是不是滤波参数设太狠了导致边沿被滤掉了。我把ICFilter调回0试一下通常就能恢复。5.2 频率数值偏大或偏小先查预分频和单位换算有一个常见的认知误区预分频改小了觉得频率能测得更准。实际上预分频改小会导致计数器计数更快更容易溢出反而需要更复杂的溢出补偿逻辑。我在调试时遇到过这样一个案例设置PSC为8400计数时钟变成10kHz也就是每个计数单位0.1毫秒测量一个周期为20ms的PWM预期得到的周期计数值是200但实测读回来是199或者201。原因在于如果定时器时钟来源于锁相环倍频后的信号可能会存在很小的频率偏差在计数间隔较长的情况下会被放大。解决办法就是把预分频配得很低比如1MHz配合溢出中断这样精度会明显好很多。还有一点如果你的频率计算结果是基于“us”来算的要注意freq 1000000 / period这样的换算别把单位搞混了。经常有人用周期计数值直接当作频率那结果肯定错得离谱。5.3 占空比跳跃式变化多半是溢出补偿没做对我之前在调试一个低频PWM信号的时候发现占空比的显示会周期性跳变一会儿是正常的25%一会儿突然跳到40%。用示波器看了半天信号源发现信号没问题问题出在代码里CH2的下降沿捕获发生在溢出之后但我的高电平时间计算没有把溢出补偿加上。解决办法就是在下降沿回调里也同样读取并记录当时的uwOverflowCnt然后高电平时间 (当前捕获值 溢出次数 * (ARR1)) - 上次上升沿值。总之记住一个原则只要芯片从两次捕获之间发生过任何一次溢出就必须把这个溢出的时间补回来否则数据一定是错的。5.4 捕获中断里做串口打印系统直接卡死这个坑估计很多人踩过在HAL_TIM_IC_CaptureCallback里直接调用HAL_UART_Transmit发送调试信息结果程序跑不了几个循环就死机了。原因是串口发送一个字节在9600波特率下大约需要1毫秒如果你的PWM频率是10kHz即每100微秒就来一次捕获中断串口根本来不及发送完发送函数一直在等后面来的中断又进不来整个系统就堵死了。我的处理建议是不要在中断回调里做任何耗时操作。调试阶段可以把捕获值保存到一个全局变量里主循环里判断标志位再来处理串口打印或者用DMA方式发送串口数据把耗时的发送操作交给硬件去做。等确认逻辑没问题了再把打印关掉。5.5 用逻辑分析仪验证测量结果判断自己写的输入捕获程序是否正确最简单的方法就是拿一个已知频率的PWM信号源测试。我一般用另一个单片机输出指定频率的PWM或者用信号发生器更简单的方案是直接手写一段延时翻转GPIO的代码来产生一个粗略的方波。然后把设计好的PWM输入引到定时器捕获引脚上对比板子上读出来的频率值和理论值的差异。要注意的是用手写延时翻转IO来产生方波频率本身并不会特别精确所以对比时别太苛求数值一致。标准做法是使用信号发生器或者逻辑分析仪参考数据。6. 我把这个功能继续扩展开的几个方向输入捕获测PWM只是入门理解了这套机制之后你可以用它做很多事情。第一个方向是舵机控制参数的解析。航模遥控器的接收机输出PWM信号周期固定为50Hz20ms脉宽通常在1ms到2ms之间对应舵机转角的0到180度。拿输入捕获把这些通道解出来就能做成遥控小车、机械臂之类的项目。第二个方向是编码器测速。如果你用的是正交编码器它的输出信号是两路相位差90度的方波想要测转速本质上是测这两路信号的频率或者周期。用输入捕获单路测频再结合方向判断也能完成测速功能不过更专业做法是直接用定时器的编码器模式这个以后有机会再细说。第三个方向是脉冲计数和累计。如果你在做一个流量计或者转速计项目传感器输出的是一串脉冲每个脉冲代表固定容积或者固定角度的转动你只需要统计这段时间内来了多少个脉冲再除以时间就算出流量或者转速。这种场景用输入捕获记录每个脉冲的时间戳再通过软件累加比单纯用外部中断要稳定因为它不依赖中断响应的及时性即使中断偶尔被高优先级任务打断捕获到的硬件时间点也是准的事后依然可以算出来。第四个方向是IR解码。红外遥控信号的载波频率通常是38kHz实际传输的数据是不同长度的高低电平组合解码的时候需要精确测量每个脉冲的宽度和间隔。用输入捕获就天然适合做这种工作边沿捕获加上时间差计算基本就是为IR解码量身的方案。这些扩展应用的底层能力就是你从“会用HAL库调一个通道”到“真的吃透了输入捕获机制”的进阶之路。7. 最后补充一点个人心得和实际操作建议再分享一个在实际项目中总结出的建议做PWM测量的时候不要把输入捕获当作一个孤立的外设来用而是要把定时器的更新中断、捕获中断、甚至DMA通道看作一个整体来设计。经常有同事问我为什么输入捕获测出来的数值偶尔会跳一个很大的值十有八九就是只开了捕获中断没开更新中断导致溢出信息丢失了。这是输入捕获项目里最常见的坑几乎每个新手都会踩一次。另外HAL库的函数封装得比较厚用起来很方便但不要依赖得太深。建议你花一点时间去读一下HAL_TIM_IC_CaptureCallback这个函数是怎么被调用到的以及HAL_TIM_IRQHandler里是怎么分发中断的。理解了这层关系你就能在自己定义的中断服务函数里做一些更灵活的处理而不是被HAL库限制住手脚。如果你是从零开始做这个实验我的建议是先用信号发生器输出一个固定的1kHz、50%占空比的PWM信号把基础测量跑通然后试着改占空比观察读数的变化再试着降频率把溢出中断的逻辑加进去体会一下溢出补偿的重要性。这样一步步走过来你对定时器输入捕获的理解会比单纯看文档深刻得多。