1. 从零开始:理解STM32定时器的“心脏”

很多刚开始玩STM32的朋友,一听到“定时器”三个字,可能觉得就是用来计时的,设置个时间,到点中断一下。这么想没错,但只对了一小半。STM32的定时器,更像是芯片内部一个功能极其强大的“瑞士军刀”,它远不止“闹钟”那么简单。我刚开始做项目时,也只用它来做延时,后来才发现,很多高级功能,比如控制电机的PWM波、测量脉冲宽度、读取旋转编码器,甚至是一些通信协议的时序生成,都离不开它。今天,我就把自己这些年摸爬滚打积累的经验,从最基础的配置讲起,一直聊到几个实战性很强的高级应用,希望能帮你把这把“瑞士军刀”用得更顺手。

首先,我们得知道STM32的定时器有好多类型,比如基本定时器(TIM6, TIM7)、通用定时器(TIM2-TIM5, TIM9-TIM14等)、高级定时器(TIM1, TIM8)。对于新手,不用被这些型号吓到,你可以简单理解:基本定时器功能最简单,就纯计时;通用定时器功能齐全,PWM、输入捕获、编码器都靠它;高级定时器则在通用基础上,多了些像带死区控制的互补PWM输出等电机控制专用功能。我们最常打交道的就是通用定时器。它的核心是一个计数器,可以向上数、向下数,或者来回数。我们配置定时器,本质上就是在配置这个计数器怎么数、数多快、数到多少算完。

那么,怎么让这个计数器动起来呢?关键在于三个参数:时钟源分频系数重装载值。你可以把定时器想象成一个水龙头往桶里滴水。时钟源就是水龙头的总水流速度,这个速度通常非常快(比如系统时钟72MHz)。直接这样“滴”,桶瞬间就满了,所以我们得加个“分频系数”,相当于给水龙头套个限流阀,让水流慢下来,比如72分频后,变成1MHz,也就是每秒滴100万次。而“重装载值”就是这个桶的容量,比如设置成1000,那么滴1000次水,桶就满了,产生一个“溢出”信号,这就是一次定时时间到。这个时间很好算:(分频系数/时钟频率)* 重装载值。比如(72/72,000,000 Hz)* 1000 = 0.001秒,也就是1毫秒。通过调整分频和重装值,我们就能得到任意想要的定时周期。

2. 基础配置:让你的第一个定时器跑起来

理论说再多,不如动手调一调。现在我们就用STM32CubeMX这个神器,配合HAL库,在5分钟内配置一个1毫秒中断的定时器。我强烈建议初学者从CubeMX开始,它能直观地帮你完成大部分底层配置,避免一开始就陷入寄存器操作的细节中。

打开CubeMX,选好你的芯片型号,比如常见的STM32F103C8T6。在Pinout & Configuration界面左侧,找到Timers,选择一个通用定时器,比如TIM2。首先,在Clock Source时钟源里,通常选择Internal Clock(内部时钟),这意味着定时器使用芯片的主时钟。然后切换到Parameter Settings参数设置选项卡,这里就是核心战场了。

  • Prescaler (PSC - 预分频器):这就是我们前面说的“分频系数”。如果系统时钟是72MHz,我们想要1MHz的计数频率,那么PSC就设置为72-1 = 71。这里要记住一个公式:实际分频系数 = 写入PSC寄存器的值 + 1。所以填71。
  • Counter Mode (计数模式):选择Up(向上计数),就是计数器从0开始,一直加到重装载值。
  • Counter Period (ARR - 自动重装载寄存器):这就是“桶的容量”。我们要1ms中断,计数频率是1MHz(周期1微秒),那么1毫秒就需要计数1000次。所以ARR设置为1000-1 = 999。同理,实际周期值 = 写入ARR的值 + 1
  • auto-reload preload (自动重装载预装载):建议使能Enable。这个功能的意思是,你可以在任何时候修改ARR的值,但这个新值要到下一次更新事件(计数器溢出)时才生效,这样可以防止在计数器运行时修改周期导致计数错乱。

配置完这些,一个1毫秒周期的定时器基础就设好了。但我们还需要它产生中断。点击NVIC Settings选项卡,找到TIM2 global interrupt,把后面的复选框勾上,使能全局中断。这样,每次计数器溢出时,就会跳转到中断服务函数里执行我们想要的代码。

生成代码后,在IDE里打开工程。我们需要在main.c文件里启动定时器,并编写中断回调函数。在main函数的初始化部分/* USER CODE BEGIN 2 */之后,添加启动语句:HAL_TIM_Base_Start_IT(&htim2);。这句代码的意思是,以中断模式启动TIM2的基础定时功能。

真正的魔法发生在中断回调函数里。我们不需要直接去修改复杂的中断服务函数,HAL库为我们提供了一个更安全的回调函数。在stm32f1xx_it.c文件中,你可以找到TIM2_IRQHandler函数,它已经自动调用了HAL_TIM_IRQHandler。我们用户要做的,是在main.c或者单独的模块文件里,重写这个回调函数。找到/* USER CODE BEGIN 4 */区域,添加如下函数:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2) {
        // 这里放置每1毫秒要执行的代码
        // 例如,翻转一个LED灯的状态
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    }
}

这个函数是HAL库定义的弱函数,我们在这里重写它。通过判断htim->Instance是哪个定时器,可以管理多个定时器中断。把代码下载到开发板,如果连接了LED,你就会看到它开始以1毫秒的间隔闪烁(实际上太快了,人眼看到的是常亮或微亮,通常我们会用一个变量累加计数来实现更长间隔,比如累加1000次才翻转一次LED,实现1秒间隔)。到这里,你的第一个定时器中断就成功跑起来了!这是所有高级应用的地基,务必理解透彻。

3. 输出PWM:用定时器驱动舵机和电机

PWM(脉冲宽度调制)可以说是定时器输出功能里使用率最高的一个。无论是调节LED亮度、控制舵机角度,还是驱动直流电机速度,都离不开它。PWM的本质,就是产生一个周期固定,但高电平时间(脉宽)可调的方波。定时器在这里完美胜任了“精准波形发生器”的角色。

我们还是用TIM2来举例,假设我们想用它的通道1(CH1)来输出PWM。在CubeMX中,之前基础配置的Clock SourceParameter Settings(PSC和ARR)依然决定了PWM波的频率。PWM频率 = 定时器时钟频率 / ((PSC+1) * (ARR+1))。例如,定时器时钟1MHz,ARR设为999,那么PWM频率就是1MHz / 1000 = 1kHz。

配置完基础定时,关键一步来了:在TIM2的配置界面,找到Channel1,选择PWM Generation CH1。这时,对应的GPIO引脚(比如PA0)会自动被配置为复用输出模式。然后,下方会多出一个PWM Generation Channel的设置子项。这里有个重要参数:Pulse(脉冲值)。这个值就是控制脉宽的关键,它决定了在一个周期内,计数器计数到多少时,输出电平发生翻转。占空比 = (Pulse / (ARR+1)) * 100%。例如,ARR是999,Pulse设为500,那么占空比就是50%。在代码中,我们可以动态修改这个Pulse值来改变占空比。

生成代码后,在main.c的初始化部分,启动PWM输出:HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);。这样,对应的引脚就会输出默认占空比的PWM波了。如果你想在运行中改变舵机角度或电机速度,只需要调用设置比较值的函数:__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, 新的Pulse值);。这个函数会立即修改捕获/比较寄存器(CCR),从而改变PWM的占空比。

这里分享一个我踩过的坑:驱动舵机时,对周期和脉宽精度要求很高。常见舵机控制信号周期是20ms(50Hz),高电平脉宽在0.5ms到2.5ms之间对应0-180度。如果定时器时钟是72MHz,即使分频后,也可能因为ARR设置不当,无法精确得到20ms周期。计算时要注意整数除法的精度损失。我的经验是,先确定一个足够大的ARR值(比如65535),然后反推PSC,让定时器计数周期尽可能接近目标周期,这样可以获得更精细的脉宽调节分辨率。另一个高级技巧是使用定时器的DMA传输来更新Pulse值。当你需要生成一个复杂的、预定义的PWM波形序列时,可以把一组Pulse值存放在数组里,然后配置DMA,让定时器在每次更新事件时,自动从数组中取出下一个值加载到CCR寄存器,完全不需要CPU干预,这对于生成精密控制波形非常有用。

4. 输入捕获:精准测量脉冲宽度与频率

如果说PWM是定时器“向外发送”信号,那么输入捕获就是“向内聆听”信号。它的主要用途是测量外部脉冲信号的宽度(高电平时间或低电平时间)或者频率。比如,测量超声波模块的回响高电平时间来计算距离,或者测量红外接收头的脉冲频率来解码遥控器信号。

输入捕获的工作原理是:当配置为输入捕获的通道引脚上,出现指定的边沿(比如上升沿)时,定时器当前的计数值会被瞬间“捕获”并锁存到一个专用的寄存器(CCRx)中。同时,可以产生一个中断。我们在中断里读取这个捕获值,再结合定时器的计数周期,就能算出从上次捕获到这次捕获经过的时间。如果连续捕获一个脉冲的上升沿和下降沿,两者捕获值之差,就是这个脉冲的高电平时间。

在CubeMX中配置输入捕获,我们以TIM2的通道2为例。选择Channel2Input Capture direct mode。在下方参数中,需要关注:

  • ICPolarity(输入捕获极性):选择是在上升沿Rising Edge还是下降沿Falling Edge捕获。
  • ICSelection(输入选择):通常选择Direct(直接),表示信号直接连接到这个通道。
  • ICPrescaler(输入分频):可以设置每N个边沿捕获一次,用于滤除噪声。
  • ICFilter(输入滤波器):设置数字滤波器的采样频率和窗口,对于消除信号毛刺非常有效,在电机编码器等有噪声的环境下必不可少。

生成代码后,启动输入捕获中断:HAL_TIM_IC_Start_IT(&htim2, TIM_CHANNEL_2);。当指定边沿到来时,会触发中断,并调用回调函数HAL_TIM_IC_CaptureCallback。在这个函数里,我们可以通过HAL_TIM_ReadCapturedValue(&htim2, TIM_CHANNEL_2)来读取捕获到的计数器值。

一个实用的技巧是测量脉冲宽度。通常我们需要设置两次捕获:第一次在上升沿捕获,然后立即将捕获极性切换为下降沿;第二次在下降沿捕获,得到另一个值。两次值之差(考虑计数器溢出的情况)乘以计数周期,就是脉冲高电平宽度。这里有个细节:定时器计数器是16位或32位的,它可能会溢出。比如一个很长的脉冲,计数器从0数到ARR(如65535)后溢出归零,再继续数。为了正确处理溢出,我们需要在定时器的溢出更新中断(就是基础定时中断)里,用一个全局变量记录溢出次数。在计算脉冲宽度时,公式为:脉冲时间 = (溢出次数 * 完整周期时间) + (第二次捕获值 - 第一次捕获值) * 计数周期时间。这个逻辑稍微复杂,但却是实现长脉冲精确测量的关键。

5. 编码器模式:轻松读取旋转位置与速度

编码器模式是定时器的一个“智能”功能,它专门用来处理正交编码器信号。旋转编码器在电机控制、位置反馈中无处不在。它输出两路相位差90度的方波(A相和B相)。定时器的编码器接口可以自动根据这两路信号的相位关系和边沿,判断旋转方向,并自动递增或递减计数器。这样,我们只需要读取计数器的值,就知道转了多少个“刻度”,通过定时采样还能算出转速。

在CubeMX中配置编码器模式非常简单。以TIM2为例,因为它有两个通道,正好接编码器的A、B相。我们将Channel1Channel2都设置为Encoder Mode。下方参数中,Encoder Mode有三种选择:

  • Encoder Mode TI1: 仅在TI1(通道1)的边沿计数。
  • Encoder Mode TI2: 仅在TI2(通道2)的边沿计数。
  • Encoder Mode TI1 and TI2: 在TI1和TI2的边沿都计数。这是最常用的模式,分辨率最高,每个编码器刻度会计数4次(A、B相的上升沿和下降沿),这就是所谓的“4倍频”。

IC Polarity通常保持默认Rising Edge即可。生成代码后,启动编码器接口:HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL);。注意,这里启动函数不需要中断版本,除非你需要特定事件中断。启动后,定时器的计数器就会随着编码器的旋转自动增减了。

读取位置值很简单:int32_t count = __HAL_TIM_GET_COUNTER(&htim2);。但这里有个非常重要的点:计数器是16位的,范围0-65535。而编码器通常是连续旋转的,计数器会溢出。为了得到绝对位置,我们需要用一个有符号的32位甚至64位全局变量来累计位置。在定时中断(比如1ms)里,读取当前的计数器值,并与上一次的值比较,差值累加到全局位置变量中,同时处理计数器从65535跳到0或从0跳到65535的溢出情况。判断方向可以使用库函数__HAL_TIM_IS_TIM_COUNTING_DOWN(&htim2),返回SET表示正在向下计数(反转)。

我曾经遇到一个需求,想在一个定时器上同时使用编码器模式和PWM输出。理论上,通用定时器有多个通道,应该可以。但实际配置时发现,当通道被设置为编码器模式后,整个定时器的计数行为都被编码器接口接管了,其计数方向、时钟源都受外部信号控制,无法再独立产生PWM。所以,编码器模式会占用整个定时器资源,不能与其他功能(如PWM输出、输入捕获)在同一定时器上复用。如果你的项目需要同时使用编码器和PWM,务必为它们分配不同的定时器资源。

6. 高级话题与避坑指南

掌握了以上几种核心功能,你已经能解决STM32定时器90%的应用场景了。但在实际项目中,总会遇到一些更棘手或者更细节的问题。这里我分享几个进阶技巧和常见坑点。

定时器级联与同步:有时候我们需要一个非常长的定时周期,但单个定时器的ARR值有限。这时可以把两个定时器级联起来,用一个定时器的更新事件作为另一个定时器的时钟。在CubeMX中,可以通过Master/Slave配置来实现。将主定时器(Master)的Trigger Output设置为Update Event,然后将从定时器(Slave)的Trigger Source选择为ITR0(具体是哪个ITRx需要查芯片手册,对应主定时器的输出)。这样,主定时器每溢出一次,从定时器才计一个数,实现了周期相乘的效果。

互补PWM与死区插入:这是高级定时器(TIM1, TIM8)的独门绝技,主要用于驱动三相电机或H桥电路。互补PWM可以生成两路完全相反(互补)的PWM信号,分别控制桥臂的上管和下管。但为了防止上下管同时导通导致短路,必须在它们切换的瞬间插入一个“死区时间”,让两路信号都保持为关断状态。高级定时器的Break and Dead-time功能可以自动生成带死区的互补PWM,你只需要配置死区时间参数即可。这个功能对安全性至关重要,切勿在电机驱动中省略。

DMA在定时器中的应用:除了前面提到的用DMA更新PWM占空比序列,DMA还可以和定时器在更多场景联动。比如,在输入捕获模式下,可以将捕获到的计数值直接通过DMA传输到内存数组中,实现高速、无CPU干预的连续脉冲宽度记录。在输出比较模式下,可以用DMA自动更新比较寄存器,产生任意复杂的波形。合理使用DMA,能极大减轻CPU负担,并实现更高精度的定时控制。

关于精度与误差:定时器的精度最终取决于时钟源的精度。如果使用内部RC振荡器作为系统时钟源,其精度和温漂可能无法满足高精度定时需求(如精确定时1小时)。对于要求高的应用,务必使用外部晶振作为时钟源。另外,中断响应延迟、代码执行时间都会给定时带来微小的、非确定性的误差。对于绝对精确定时(如生成精准的1MHz方波),可以考虑使用定时器的输出比较模式直接翻转引脚,或者使用主从定时器的硬件联动,完全由硬件触发,不经过中断。

最后,关于RTC(实时时钟)的误区。原始文章提到一个点:想断电保存时间,需要外接32.768kHz晶振,仅用电池引脚供电可能不行。这个描述很关键。很多STM32芯片的RTC时钟源可以选择内部低速RC振荡器(LSI)或外部低速晶振(LSE)。LSI精度差,且断电后无法维持。即使你给VBAT引脚接了电池,也只是维持了RTC备份域(包括RTC计数器和备份寄存器)的供电。RTC的时钟源本身(振荡器)在芯片深度休眠或断电时,如果使用外部LSE晶振,且电路设计正确(包括匹配电容),是可以由VBAT供电继续振荡的。但如果你选择的是内部LSI,或者外部LSE电路设计有问题(比如负载电容不匹配),那么即使VBAT有电,时钟也可能停振。所以,确保长计时精度的关键是:1) 使用外部32.768kHz晶振;2) 参考数据手册,设计正确的晶振负载电路;3) 在RTC配置中,明确选择LSE作为时钟源。

Logo

智能硬件社区聚焦AI智能硬件技术生态,汇聚嵌入式AI、物联网硬件开发者,打造交流分享平台,同步全国赛事资讯、开展 OPC 核心人才招募,助力技术落地与开发者成长。

更多推荐