☰
STM32定时器输入捕获测PWM脉宽:HAL库配置与代码实战
2026/10/5 8:11:36 网站建设 项目流程

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 从两次捕获值算出脉宽

知道了捕获机制,测量脉宽的思路就非常直接了:

  1. 先配置通道捕获上升沿。
  2. 检测到上升沿时,记录当前CCR值为T1。
  3. 立刻把捕获极性切换为下降沿(或者不切换,等待下一个上升沿,这取决于你要测的是高电平时间还是整个周期)。
  4. 检测到下降沿时,记录当前CCR值为T2。
  5. 脉宽 = (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、TIM5,F4系列这几个定时器都有4个捕获通道),把Channel1的模式设置为Input Capture mode。

这里有几个关键参数要仔细选:

  • Prescaler(预分频系数):决定计数器的时钟频率。比如TIM2挂在APB1总线上,APB1时钟通常是84MHz,但定时器时钟是APB1的两倍,也就是168MHz。设Prescaler为83,则计数器时钟频率 = 168MHz / (83+1) = 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是/2,APB1就是84MHz,定时器时钟=168MHz。

那到底是多少,要以CubeMX时钟树里显示的实际值为准。在Clock Configuration界面里,能看到每个定时器时钟域的具体频率。TIM2、TIM3、TIM4、TIM5、TIM12、TIM13、TIM14挂APB1;TIM1、TIM8、TIM9~TIM11挂APB2。F407的APB2定时器时钟也是168MHz。你只要在CubeMX里看到定时器时钟是168MHz,那我前面说Prescaler=83得到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; } // 加上溢出补偿:每溢出一次,相当于计数器跑了一个完整的ARR+1 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范围内。

如果脉宽非常长,比如秒级,那就要在溢出计数上做文章了。比如计数频率200kHz,ARR=65535,溢出周期 = 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 CH1,Channel2配置为PWM Input Mode on CH2,然后开启Update Interrupt。生成代码后,在回调里读两个寄存器的值就可以了。这个配置下,CH1捕获的是周期(上升沿到下一个上升沿),CH2捕获的是脉宽(上升沿到下降沿)。寄存器对比如下:

数据项捕获寄存器含义
周期CCR1上升沿到下一个上升沿的计数个数
脉宽CCR2上升沿到下降沿的计数个数
占空比脉宽 / 周期用两个值相除得到

这种方式对CPU负载极低,特别适合高频PWM测频、测占空比。如果你第一次接触,建议先用前一种手动切换的方式把原理吃透,再切换到PWM输入模式,理解起来会顺畅很多。

写在最后的一点心得

我从标准库时代用寄存器操作定时器,到后来全面切到HAL库,最大的感受是:HAL库封装得越多,越不能丢掉对寄存器行为的理解。尤其是定时器这种外设,中断标志位的置位顺序、硬件自动动作的时序、CCR寄存器的锁存时机,这些底层知识才是排查问题的根本。遇到脉宽测量结果不对,不要急着怀疑库有bug,先拿示波器看信号,再拿调试器看CCR值,一步一步定位,问题往往很快就能水落石出。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询