1. 为什么建议直接学HAL库的输入捕获很多从标准库转到STM32Cube HAL开发的朋友第一次接触定时器输入捕获时最直观的感受是标准库那套“配置GPIO为复用模式、开定时器时钟、写CCMR/CCER/DIER寄存器、写中断服务函数”的操作在HAL库里被完全封装成了几个结构体和回调函数看起来简单了但真想改个参数、查个问题的时候又不知道底层发生了什么。这次我们要做的就是一个非常典型的场景用STM32F4系列定时器的输入捕获功能测量外部PWM信号的脉宽也就是高电平持续的时间。用的开发环境是STM32CubeMX加HAL库目标芯片以STM32F407为例但整篇文章的思路和方法在F1、F0、L4系列上都通用。脉宽测量在工程里的应用非常广比如遥控器接收机信号解析、舵机控制、电机测速、传感器脉冲宽度解码、红外遥控解码等等。测量一个PWM的高电平时间本质上就是测量两个边沿之间的时间差而定时器的输入捕获功能恰恰就是干这个的当检测到指定边沿上升沿或下降沿时硬件自动把当前计数器的值锁存到捕获寄存器里并且触发中断。我们只需要在中断里记录两次捕获值一减再乘以定时器时钟周期脉宽就出来了。这篇文章适合两类人一类是刚接触STM32CubeMX和HAL库开发想系统搞懂定时器输入捕获的朋友另一类是手里有项目要用到脉宽测量但不想被网上零零散散的代码带偏想看到一套完整、可复现、能直接改到工程里的方案的朋友。我会按照“原理拆解 → CubeMX配置 → 代码实现 → 常见问题”这个顺序来写把每一步为什么要这么做讲清楚。注意我这里说的脉宽测量特指“测量一个周期信号中高电平持续的绝对时间”不是测量频率也不是测量占空比。频率测量是上升沿到下一个上升沿的时间两者原理接近但捕获边沿的选择和数据处理方式不同别混在一起。2. 输入捕获测脉宽的底层原理2.1 边沿捕获到底捕获了什么定时器的输入捕获功能核心就一句话在输入端检测到设定的边沿跳变时把当前CNT计数器的值复制到CCRx捕获寄存器中这个过程完全由硬件完成不占用CPU时间。要理解这句话先要梳理一下信号的通路。外部信号从GPIO引脚进来后经过输入滤波器进入捕获/比较通道然后触发边沿检测器。边沿检测器判断当前信号是上升沿还是下降沿这个极性是可以配置的。一旦检测到满足条件的边沿就把CNT的值锁存到CCR寄存器里同时把捕获/比较中断标志位置1。这里有个容易混淆的点输入捕获并不“知道”信号本身是什么它只知道“在某个时刻引脚上出现了一个边沿当时计数器跑到了哪个数”。就像你站在赛道边手里掐着秒表看见选手经过起跑线时按下一次计时键记住当前时间选手再次经过时再按一次又记住一个时间。你记录的永远是“某个瞬间的时间点”而不是“选手跑得多快”。时间点拿到了后面怎么算速度、算距离是你自己的事。2.2 从两次捕获值算出脉宽知道了捕获机制测量脉宽的思路就非常直接了先配置通道捕获上升沿。检测到上升沿时记录当前CCR值为T1。立刻把捕获极性切换为下降沿或者不切换等待下一个上升沿这取决于你要测的是高电平时间还是整个周期。检测到下降沿时记录当前CCR值为T2。脉宽 (T2 - T1) × 定时器计数周期。如果只测一个完整周期内的脉宽比如占空比固定的PWM更高频的做法是上升沿启动捕获随后在中断里把边沿极性改成下降沿捕获到下降沿后再改回上升沿如此循环。这样一次中断只处理一次捕获逻辑清晰代码量也少。那为什么不直接在一个周期内连续捕获两次上升沿然后高频信号里通过占空比反推脉宽当然也可以但前提是你得先知道频率多一步计算而且对非周期信号比如遥控器那种一帧一帧的脉冲序列就不适用了。直接测高电平时长最通用。计数器溢出问题这里也提前说一句如果脉宽很长超过了定时器一个计数周期的最大范围CNT会溢出回0这时候直接做减法会得到一个负数或错误的小数。处理办法是在中断里用更新事件溢出中断做“进位计数”在计算差值时把溢出次数乘上定时器重载值补回来。后面代码部分我会给出完整实现。2.3 为什么用HAL库反而更容易写错很多人在标准库时代自己操作寄存器虽然代码繁琐但每一步都很清楚开时钟、配GPIO、设CCMR、设CCER、设DIER、开NVIC、写中断函数。到了HAL库这些步骤被CubeMX自动生成开发者的注意力变成了“在哪个回调函数里写什么”反而容易忽略一个关键问题HAL库的中断处理函数是分层的不是所有中断都直接进你写的回调。以输入捕获为例HAL_TIM_IC_CaptureCallback这个回调函数只有在HAL_TIM_IC_CaptureInterruptHandler里用定时器句柄和通道号做了匹配、确认是捕获事件后才会被调用。如果CubeMX配置里没开启对应的中断或者中断优先级设置不对或者你在中断里做了耗时的处理导致后续中断丢失都会出现“程序进了中断但回调不执行”或“捕获值错乱”的怪问题。所以这篇文章在讲代码之前先花篇幅把HAL库的中断处理链路讲清楚。理解了这条链路后面所有排查工作都会变得有方向。3. STM32CubeMX配置要点与完整步骤3.1 定时器和GPIO的配置打开STM32CubeMX先选好芯片型号比如STM32F407ZGT6。在Pinout Configuration界面里找到TIM2也可以选TIM3、TIM4、TIM5F4系列这几个定时器都有4个捕获通道把Channel1的模式设置为Input Capture mode。这里有几个关键参数要仔细选Prescaler预分频系数决定计数器的时钟频率。比如TIM2挂在APB1总线上APB1时钟通常是84MHz但定时器时钟是APB1的两倍也就是168MHz。设Prescaler为83则计数器时钟频率 168MHz / (831) 2MHz计数周期 0.5微秒。这个值要结合你测量的脉宽范围来定后面我会专门讲计算方法。Counter Period自动重载值决定计数器的溢出周期。默认65535也就是计数范围0~65535。在2MHz计数频率下满量程时间 65536 × 0.5us 32.768ms这个范围对一般舵机信号周期20ms脉宽1~2ms已经够用但如果要测更长的脉宽要把重载值调大或者采用溢出计数方案。Internal Clock Division保持默认No division就行。Auto-reload preload建议使能Enable这样TIM_PSC和TIM_ARR的修改在更新事件后才生效避免运行中修改预分频值时出现意外。Trigger Selection不需要改默认即可。Polarity Selection这里要选Rising Edge上升沿因为我们一开始要先捕获上升沿作为脉宽起始点。GPIO部分不用单独配置。当你在CubeMX里选择了TIM2的Channel1后对应的PA0引脚会自动变成复用功能模式Alternate Function信号从PA0进入定时器的通道1。如果你用的不是默认引脚可以在芯片图上自己找一下该定时器通道的复用引脚表。3.2 中断配置里容易踩的坑定时器输入捕获必须开启中断否则你只能靠轮询标志位来读CCR值实时性差很多。在CubeMX里进入NVIC Settings页签把TIM2 global interrupt勾上。注意不是勾上就完事了还要检查中断优先级。STM32F4的中断优先级是4位分为抢占优先级和子优先级。输入捕获中断里做的事情应该尽可能短所以优先级建议设置得高一些数字小优先级高比如抢占优先级0、子优先级0这样可以保证即使主程序中有其他中断在跑脉宽测量的边沿也不会丢。还有一个很隐蔽的坑如果你在同一个工程里既用了定时器的更新中断溢出中断又用了捕获中断两者都会进入TIM2_IRQHandler而HAL库是通过判断标志位来区分具体是哪种事件的。所以在CubeMX里如果你开启了Update Interrupt用于溢出计数和Channel1的捕获中断是并列关系都勾上即可。但如果只开捕获中断、没开更新中断溢出事件发生时HAL库虽然会清除标志位但不会调用更新回调你也就无法感知溢出这会导致长脉宽测量结果错乱。3.3 时钟配置的一个细节APB1定时器时钟为什么是168MHz很多人在CubeMX里看时钟树默认配出来APB1是42MHz然后觉得定时器也是42MHz结果一算频率就错了。实际上是这么回事STM32F4的定时器时钟TIMxCLK在APB1分频系数不为1时是APB1频率的2倍。CubeMX的时钟树里如果APB1设置的预分频是/4那APB1外设时钟是42MHz但定时器时钟自动变成84MHz如果APB1是/2APB1就是84MHz定时器时钟168MHz。那到底是多少要以CubeMX时钟树里显示的实际值为准。在Clock Configuration界面里能看到每个定时器时钟域的具体频率。TIM2、TIM3、TIM4、TIM5、TIM12、TIM13、TIM14挂APB1TIM1、TIM8、TIM9~TIM11挂APB2。F407的APB2定时器时钟也是168MHz。你只要在CubeMX里看到定时器时钟是168MHz那我前面说Prescaler83得到2MHz计数频率就是对的。如果时钟树里显示的是别的值那就按实际值重新算预分频。3.4 用CubeMX自动生成工程框架参数都配好后在Project Manager里设置工程名、路径、工具链选MDK-ARM或者STM32CubeIDE都可以Code Generator里勾选“Generate peripheral initialization as a pair of .c/.h files per peripheral”这个选项这样生成的代码是按外设分开的阅读和维护都方便。然后点击Generate Code等待生成完成后打开工程。打开生成的main.c会看到MX_TIM2_Init这么一个函数里面就是HAL库的定时器初始化代码。CubeMX帮我们把该写的都写了但这只是静态配置运行时的动态配置和中断回调还得自己补。4. 脉宽测量代码实现4.1 初始化后的动态配置CubeMX自动生成的代码里定时器还处于“配置好但没启动”的状态。我们要启动捕获功能并开启中断这行代码建议放在main函数里、初始化完成之后HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1);这行代码做了什么它使能了TIM2的通道1捕获并且使能了捕获中断。只有调用了这个函数信号进来后产生捕获事件CPU才能进中断。如果后续要同时用溢出中断做长脉宽处理还需要__HAL_TIM_ENABLE_IT(htim2, TIM_IT_UPDATE);这行是手动打开更新中断。注意在HAL库里更新中断并不会因为你调用了HAL_TIM_IC_Start_IT就自动开启所以要单独打开。4.2 中断服务函数与回调函数的关系打开stm32f4xx_it.c能看到一个定时器中断服务函数void TIM2_IRQHandler(void) { HAL_TIM_IRQHandler(htim2); }这个函数在定时器2的任何中断事件发生时都会执行里面调用了HAL库的中断分发函数。HAL_TIM_IRQHandler内部会检查具体的标志位如果是捕获事件就调用HAL_TIM_IC_CaptureCallback如果是更新事件就调用HAL_TIM_PeriodElapsedCallback。所以你在业务代码里根本不需要碰TIM2_IRQHandler只需要实现这两个回调函数即可。这也是HAL库相对标准库提升开发效率的地方中断分发逻辑被封装好了你只需要关心“事件来了以后干什么”。4.3 直接模式单次高电平脉宽测量最简单的场景外部输入一个PWM信号我要测量其中某一个高电平脉冲的宽度。逻辑是捕获到上升沿记录T1然后把捕获极性切换成下降沿捕获到下降沿记录T2计算出脉宽再把极性切回上升沿。代码如下直接放在用户代码区volatile uint32_t t1 0; volatile uint32_t t2 0; volatile uint32_t pulse_width 0; volatile uint8_t capture_done 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2 htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { if (capture_state 0) { t1 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); capture_state 1; } else if (capture_state 1) { t2 HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); if (t2 t1) { pulse_width t2 - t1; } else { pulse_width (65535 - t1) t2 1; } __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); capture_state 0; capture_done 1; } } }这段代码里有个很关键的细节__HAL_TIM_SET_CAPTUREPOLARITY这个宏用来修改捕获边沿极性。在测量过程中切换极性是脉宽测量最常用的手段。第一次捕获上升沿记录T1把极性改为下降沿第二次捕获下降沿记录T2算出脉宽再把极性改回上升沿。为什么不用TIM_SetCapturePolarity这种函数因为HAL库里没有这个API标准库的习惯要改过来。在HAL库里修改边沿极性就是用__HAL_TIM_SET_CAPTUREPOLARITY这个宏底层操作的是TIMx_CCER寄存器的CC1P位。上面代码里的溢出处理比较简单如果t2小于t1说明计数器在两次捕获之间发生了一次回绕用(65535 - t1) t2 1补回来。这个写法只适用于“最多溢出一次”的场合。如果脉宽很大可能多次溢出这个简单公式就失效了需要配合溢出计数一起处理。4.4 连续模式带溢出计数的完整实现在实际项目里我更推荐下面这种连续测量方案它不怕长脉宽而且每一次捕获完成后会自动开始下一次测量适合持续监测PWM信号的场景。#define CAPTURE_TIMER_CLOCK_HZ 2000000UL // 定时器计数频率2MHz #define CAPTURE_ARR_MAX 65535UL // 定时器自动重载值 volatile uint32_t overflow_count 0; volatile uint32_t capture_rising 0; volatile uint32_t capture_falling 0; volatile uint32_t pulse_width_us 0; volatile uint8_t capture_ready 0; volatile uint8_t capture_state 0; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2 htim-Channel HAL_TIM_ACTIVE_CHANNEL_1) { uint32_t ccr HAL_TIM_ReadCapturedValue(htim, TIM_CHANNEL_1); if (capture_state 0) { // 捕获到上升沿 capture_rising ccr; overflow_count 0; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); capture_state 1; } else { // 捕获到下降沿 capture_falling ccr; uint32_t diff; if (capture_falling capture_rising) { diff capture_falling - capture_rising; } else { diff (CAPTURE_ARR_MAX - capture_rising) capture_falling 1; } // 加上溢出补偿每溢出一次相当于计数器跑了一个完整的ARR1 uint32_t total_ticks diff overflow_count * (CAPTURE_ARR_MAX 1); pulse_width_us total_ticks * 1000000UL / CAPTURE_TIMER_CLOCK_HZ; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); capture_state 0; capture_ready 1; } } } void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { overflow_count; } }你可能会问溢出不应该是1us溢出一次吗为什么直接在中断里累加因为溢出中断的优先级和你捕获中断的优先级相同都是TIM2这个外设产生的中断两者不会互相打断所以不会出现捕获中断执行到一半溢出事件被漏掉的情况。但要注意越频繁的溢出意味着中断开销越大所以预分频的设计很关键。pulse_width_us算出来的单位是微秒这是基于计数频率2MHz来算的。每个计数tick是0.5us所以总ticks乘以0.5就得到微秒数。我写成total_ticks * 1000000UL / CAPTURE_TIMER_CLOCK_HZ是为了避免浮点运算在整数范围内直接得到微秒值。如果计数频率不是整数关系你可以调整这个公式原理是一样的。4.5 预分频和重载值怎么算这里我把参数计算方法单独拿出来讲因为这是很多人第一次配置时最容易犯迷糊的地方。公式只有一个计数器时钟频率 定时器输入时钟 / (PSC 1)。假设定时器输入时钟为168MHz我希望计数频率为2MHz那么PSC 1 168 / 2 84 PSC 83计数周期 1 / 2MHz 0.5us。接下来看脉宽范围。如果PWM脉宽最大只有2ms那需要计数的最大ticks为2000us / 0.5us 4000 ticks这个数远小于65536所以ARR保持默认65535完全够用而且还有很大的余量。但如果你要测的脉宽可能达到100ms比如某些传感器输出的长脉冲信号那就要提高分频让计数频率降下来。比如计数频率设为100kHz即PSC 1679计数周期10us那么100ms脉宽对应的ticks为10000也还在65536范围内。如果脉宽非常长比如秒级那就要在溢出计数上做文章了。比如计数频率200kHzARR65535溢出周期 65536 / 200000 327.68ms通过溢出计数理论上可以测量任意长度的脉宽只要你的中断处理来得及。提示预分频越大计数分辨率越低。测短脉宽时如果PSC设太大比如计数频率只有10kHz一个tick就是100us脉宽1ms的信号只能测出10个tick误差达到10%这在很多场合不可接受。所以PSC的选择一定要兼顾测量范围和分辨率不能只图省事。5. 实测过程中的典型问题排查5.1 一直捕获不到上升沿回调不触发这种问题的排查顺序先查硬件再查软件。先用示波器或逻辑分析仪确认信号真的到了PA0引脚而且电平幅值满足STM32的输入阈值要求。如果是3.3V的MCU外部给了5V的PWM信号虽然大部分STM32的IO是容忍5V的但保险起见建议加电平转换或分压。接着查CubeMX配置确认TIM2的Channel1确实配成了Input Capture mode而不是别的模式。然后确认代码里调用了HAL_TIM_IC_Start_IT而不是只调了HAL_TIM_IC_Start。HAL_TIM_IC_Start只开启捕获功能不开启中断。如果你只调了它CNT照样计数CCR照样更新但不会进回调。这个坑我见过不止一次因为在标准库时代“启动捕获”和“启动中断”经常是绑定做的到了HAL库被拆成了两个步骤。5.2 捕获值跳变脉宽测量结果忽大忽小先检查信号本身是不是干净的。输入捕获对毛刺非常敏感如果信号线上有振铃或噪声边沿检测器可能会误触发。解决办法是开启STM32的输入滤波器。在CubeMX里TIM2的Channel1配置界面有个Input Filter选项可以设置滤波器的采样频率和采样次数。比如数字滤波器可以对输入信号采样N次只有连续N次都检测到电平变化才认为是有效边沿。滤波深度越大抗干扰越强但信号延迟也越大。对于频率不高的PWM信号配置一个适中的滤波值比如采样频率为定时器时钟的1/32连续采样8次就够用了。另一种可能是你在中断回调里做了太长的处理导致第二次边沿到来时第一次的中断还没处理完捕获事件被覆盖了。TIM的捕获寄存器虽然会立即锁存CNT值但如果同一个通道两次捕获事件间隔极短库函数的处理顺序就可能出问题。解决办法是缩短中断处理时间尽量只做寄存器读取和几个变量赋值复杂计算放到主循环里做。5.3 脉宽测量结果整体偏大或偏小偏大或偏小先排除计算错误再排查信号源。如果你测出来的值比实际值固定大了一点比如100us的信号测出102us那多半是滤波器的额外延迟或者是在上升沿触发后你手动切换极性到下降沿但这个切换动作发生在中断里而中断处理本身有延迟导致下降沿错过了真实的信号跳变点。这种情况在频率较低的信号里不太明显但高频信号比如几十kHz就会暴露出来。如果偏小那可能是你读取CCR的时机不对。在捕获中断里CCR已经锁存了捕获时刻的CNT值这个值本身是准的。但如果你的代码在同一个回调里又调用了其他函数导致读CCR的时机晚了一个tick甚至几个tick那误差就出来了。解决办法是在进入回调后第一时间读CCR不要先做别的操作。5.4 计数器溢出导致测量结果完全不对前面说过简单模式下如果脉宽超过了一个计数器周期t2和t1的差就会出错。最直接的解决方案就是使用带溢出计数的连续模式。但在配置溢出中断时要记住更新中断溢出中断和捕获中断在同一个IRQHandler里HAL库会先检查捕获标志再检查更新标志顺序是固定的。如果你在两个回调里都做了比较耗时的事小心相互干扰。另外__HAL_TIM_ENABLE_IT(htim2, TIM_IT_UPDATE)这行代码最好在定时器启动之前调用。如果你先启动了捕获再打开更新中断有可能在打开中断的瞬间刚好错过了第一个溢出事件导致计数偏小。6. 一个更稳的写法利用TIM的从模式实现硬件自动切换前面讲的方法是在中断里切换边沿极性中断频率等于信号的每一个边沿即每个周期进两次中断。对于频率不高的信号比如几百Hz的遥控器信号这完全没问题。但如果信号频率很高比如几十kHz那么一秒钟就要进几十万次中断CPU开销会非常大而且中断延迟可能造成误差。更高阶一点的做法是利用定时器的从模式Slave Mode加复位模式Reset Mode配合PWM输入模式PWM Input Mode让硬件自动完成脉宽测量。这种模式下定时器的一个通道负责捕获上升沿另一个通道负责捕获下降沿硬件自动把两个边沿对应的计数器值锁存到不同的捕获寄存器里CPU只需要在更新事件到来时读取两个值不需要在边沿中断里做任何切换操作。在CubeMX里把TIM2的Channel1配置为PWM Input Mode on CH1Channel2配置为PWM Input Mode on CH2然后开启Update Interrupt。生成代码后在回调里读两个寄存器的值就可以了。这个配置下CH1捕获的是周期上升沿到下一个上升沿CH2捕获的是脉宽上升沿到下降沿。寄存器对比如下数据项捕获寄存器含义周期CCR1上升沿到下一个上升沿的计数个数脉宽CCR2上升沿到下降沿的计数个数占空比脉宽 / 周期用两个值相除得到这种方式对CPU负载极低特别适合高频PWM测频、测占空比。如果你第一次接触建议先用前一种手动切换的方式把原理吃透再切换到PWM输入模式理解起来会顺畅很多。写在最后的一点心得我从标准库时代用寄存器操作定时器到后来全面切到HAL库最大的感受是HAL库封装得越多越不能丢掉对寄存器行为的理解。尤其是定时器这种外设中断标志位的置位顺序、硬件自动动作的时序、CCR寄存器的锁存时机这些底层知识才是排查问题的根本。遇到脉宽测量结果不对不要急着怀疑库有bug先拿示波器看信号再拿调试器看CCR值一步一步定位问题往往很快就能水落石出。