4. 定时器基础:SysTick定时器、基本定时器、通用定时器、HAL_TIM_Base驱动详解

定时器,说白了就是单片机的“心跳”。没有它,很多时序相关的活儿都干不了。我刚开始用STM32时,总觉得定时器就是用来计时的,后来才发现它的玩法远不止这些——PWM、输入捕获、编码器接口,全都离不开它。

这一章,咱们先把定时器的“根”扎牢。从最简单的SysTick,到基本定时器,再到通用定时器,最后把HAL库的定时器驱动框架捋一遍。你想想看,搞懂了这些,后面做PWM和捕获就轻松多了。

4.1 定时器家族概览

STM32的定时器分好几类,功能从简到繁。我整理了一张表,方便你对照着看:

类型 定时器实例 主要特性 典型用途
SysTick SysTick 24位递减计数器,内核级 操作系统滴答、精确延时
基本定时器 TIM6、TIM7 16位,仅向上计数,无外部IO 基础定时、触发DAC
通用定时器 TIM2~TIM5 16/32位,向上/下/中央对齐,有捕获/比较通道 PWM、输入捕获、编码器
高级定时器 TIM1、TIM8 16位,带互补输出和刹车功能 电机控制、三相PWM

嗯,这里要注意:基本定时器没有外部引脚,所以不能直接输出PWM。我见过有人拿TIM6去接LED,折腾半天没反应——其实就是选错定时器了。

4.2 SysTick定时器——系统的心跳

SysTick是Cortex-M内核自带的定时器,24位递减,溢出后自动重装。它不依赖外设时钟树,所以用起来特别简单。

我个人习惯用SysTick做精确延时,尤其是HAL_Delay()这个函数,底层就是靠它实现的。你看它的工作流程:

  1. 设置重装载值(通常为系统时钟的千分之一)
  2. 使能计数器,开始递减
  3. 减到0时触发SysTick中断
  4. 中断服务函数里累加一个全局变量
  5. HAL_Delay()不断查询这个变量,直到达到目标值

代码其实很简单,但有个坑:HAL_Delay()是阻塞的,如果在中断里调用它,整个系统就卡死了。我曾经在串口中断里调了HAL_Delay(10),结果程序直接跑飞,查了半天才发现是这个问题。

避坑指南:不要在中断服务函数里调用HAL_Delay()!如果非要延时,用定时器中断或者状态机代替。

SysTick的配置通常在HAL_Init()里就完成了,你不需要手动操作。但如果想自己控制,可以这样写:

// 配置SysTick每1ms中断一次(假设系统时钟72MHz)
HAL_SYSTICK_Config(SystemCoreClock / 1000);
HAL_SYSTICK_CLKSourceConfig(SYSTICK_CLKSOURCE_HCLK);
HAL_NVIC_SetPriority(SysTick_IRQn, 0, 0);

4.3 基本定时器——最简单的硬件定时

基本定时器(TIM6、TIM7)只有向上计数模式,没有外部引脚,所以不能做PWM或捕获。它的核心就是一个16位计数器,加上预分频器和自动重装载寄存器。

为什么需要它?因为SysTick虽然方便,但精度有限,而且被HAL库占用了。如果你需要多个独立的定时任务,基本定时器就是最好的选择。

它的工作原理是这样的:

  • 时钟源经过预分频器(PSC)分频后,驱动计数器(CNT)递增
  • CNT的值与自动重装载寄存器(ARR)比较
  • 相等时,计数器清零,触发更新事件(UEV)
  • 如果使能了中断,就会进入中断服务函数

举个例子,你想让TIM6每100ms触发一次中断:

// 假设TIM6时钟为72MHz
// 预分频器设为7200-1,得到10kHz的计数频率
// 自动重装载设为1000-1,得到10Hz(100ms)的更新事件

htim6.Instance = TIM6;
htim6.Init.Prescaler = 7200 - 1;
htim6.Init.CounterMode = TIM_COUNTERMODE_UP;
htim6.Init.Period = 1000 - 1;
htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_Base_Init(&htim6);
HAL_TIM_Base_Start_IT(&htim6);
小技巧:预分频器和自动重装载的值都减1,是因为计数器从0开始计数。这个细节容易忘,我刚开始写的时候也踩过坑。

4.4 通用定时器——功能最丰富

通用定时器(TIM2~TIM5)是STM32定时器的“主力军”。它比基本定时器多了几个关键功能:

  • 多种计数模式:向上、向下、中央对齐
  • 捕获/比较通道:每个定时器有4个通道,可以独立配置
  • 外部触发:可以用外部信号控制定时器启停
  • 编码器接口:直接读取正交编码器信号

我个人觉得,通用定时器最常用的就是PWM输出和输入捕获。这两个功能后面会单独讲,这里先看它的基础配置。

通用定时器的时钟源比基本定时器复杂一些。它可以选择内部时钟(CK_INT)、外部时钟模式1/2、或者从其他定时器级联。我画了一张图,帮你理清时钟路径:

通用定时器时钟路径与功能结构 内部时钟 CK_INT 外部时钟模式1 外部时钟模式2 其他定时器级联 预分频器 PSC 计数器 CNT 自动重装载 ARR UEV 捕获/比较 CCR PWM/捕获 说明:通用定时器支持多种时钟源,经预分频后驱动计数器。计数器与自动重装载值比较产生更新事件。 捕获/比较通道可独立配置,用于PWM输出或输入捕获。

配置通用定时器时,除了PSC和ARR,还要选择计数模式。向上计数最常用,中央对齐模式适合需要对称PWM的场景。我一般这样配置:

htim2.Instance = TIM2;
htim2.Init.Prescaler = 7200 - 1;      // 72MHz / 7200 = 10kHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 5000 - 1;         // 10kHz / 5000 = 2Hz
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_Base_Init(&htim2);
HAL_TIM_Base_Start(&htim2);           // 启动但不使能中断
关键点:通用定时器的TIM2和TIM5是32位计数器(部分型号),TIM3和TIM4是16位。32位定时器的最大计数值更大,适合低频长周期场景。

4.5 HAL_TIM_Base驱动详解

HAL库把定时器的操作封装成了几个核心函数。我按使用场景把它们分成了三组:

函数 功能 使用场景
HAL_TIM_Base_Init() 初始化定时器时基单元 配置PSC、ARR、计数模式
HAL_TIM_Base_Start() 启动定时器计数 纯计数,不需要中断
HAL_TIM_Base_Start_IT() 启动并使能更新中断 需要定时中断服务
HAL_TIM_Base_Stop() 停止定时器 暂停计数
HAL_TIM_Base_DeInit() 反初始化定时器 关闭定时器并释放引脚

这里有个细节:HAL_TIM_Base_Start()和HAL_TIM_Base_Start_IT()的区别,就在于是否使能中断。如果你只是想让定时器跑起来,用前者就够了。需要定时触发回调函数时,才用后者。

中断回调函数的名字是固定的:

void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2)
    {
        // 定时器2的更新中断处理
        // 比如翻转一个LED
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    }
}

嗯,这里要注意:这个回调函数是弱定义的,你需要在用户代码里重新实现它。而且所有定时器的更新中断都走同一个回调,所以要用htim指针区分是哪个定时器触发的。

避坑指南:回调函数里不要做耗时操作!我曾经在回调里放了一个HAL_Delay(100),结果定时器中断被阻塞,后续中断全部丢失。如果非要处理复杂逻辑,建议用标志位+主循环轮询的方式。

4.6 定时器配置的常见陷阱

做项目这么多年,我总结了几条定时器配置的“血泪教训”:

  • 预分频器值要减1:PSC寄存器的值是分频系数减1,ARR也一样。这个最容易忘。
  • 时钟源要确认:APB1和APB2的时钟频率可能不同,TIM2~TIM7挂在APB1上,TIM1和TIM8挂在APB2上。查数据手册确认。
  • 自动重装载预装载:使能后,ARR的值会在更新事件发生时才生效,而不是立即生效。这在动态调整频率时特别重要。
  • 中断优先级:定时器中断的优先级不要设得太高,否则会抢占其他关键中断。我一般设到中等优先级。

举个例子,有一次我调试一个电机控制项目,PWM频率总是不对。查了半天,发现是APB1的时钟配置错了——我以为它是72MHz,实际只有36MHz。嗯,从那以后我每次都会先打印SystemCoreClock的值确认一下。

4.7 实战:用基本定时器实现多路软件定时

最后,咱们来个小实战。用TIM6实现一个软件定时器,支持多路定时任务:

// 定义软件定时器结构
typedef struct {
    uint32_t interval;    // 定时周期(ms)
    uint32_t counter;     // 当前计数值
    uint8_t flag;         // 超时标志
} SoftTimer_t;

SoftTimer_t timer1 = {100, 0, 0};  // 100ms定时
SoftTimer_t timer2 = {500, 0, 0};  // 500ms定时

// TIM6中断回调
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM6)
    {
        // 更新所有软件定时器
        timer1.counter++;
        if (timer1.counter >= timer1.interval)
        {
            timer1.counter = 0;
            timer1.flag = 1;
        }

        timer2.counter++;
        if (timer2.counter >= timer2.interval)
        {
            timer2.counter = 0;
            timer2.flag = 1;
        }
    }
}

// 主循环中查询
while (1)
{
    if (timer1.flag)
    {
        timer1.flag = 0;
        // 执行100ms任务
        HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin);
    }

    if (timer2.flag)
    {
        timer2.flag = 0;
        // 执行500ms任务
        HAL_GPIO_TogglePin(LED2_GPIO_Port, LED2_Pin);
    }
}

这个思路很实用。你想想看,一个定时器就能管理N个定时任务,省资源又灵活。我在很多项目里都用这个模式,比如按键扫描、LED闪烁、传感器轮询,全都能搞定。

好了,定时器的基础就讲到这里。记住一句话:定时器是STM32外设的“时间轴”,搞懂了它,后面做PWM和捕获就是水到渠成的事。