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()这个函数,底层就是靠它实现的。你看它的工作流程:
- 设置重装载值(通常为系统时钟的千分之一)
- 使能计数器,开始递减
- 减到0时触发SysTick中断
- 中断服务函数里累加一个全局变量
- HAL_Delay()不断查询这个变量,直到达到目标值
代码其实很简单,但有个坑:HAL_Delay()是阻塞的,如果在中断里调用它,整个系统就卡死了。我曾经在串口中断里调了HAL_Delay(10),结果程序直接跑飞,查了半天才发现是这个问题。
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);
4.4 通用定时器——功能最丰富
通用定时器(TIM2~TIM5)是STM32定时器的“主力军”。它比基本定时器多了几个关键功能:
- 多种计数模式:向上、向下、中央对齐
- 捕获/比较通道:每个定时器有4个通道,可以独立配置
- 外部触发:可以用外部信号控制定时器启停
- 编码器接口:直接读取正交编码器信号
我个人觉得,通用定时器最常用的就是PWM输出和输入捕获。这两个功能后面会单独讲,这里先看它的基础配置。
通用定时器的时钟源比基本定时器复杂一些。它可以选择内部时钟(CK_INT)、外部时钟模式1/2、或者从其他定时器级联。我画了一张图,帮你理清时钟路径:
配置通用定时器时,除了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); // 启动但不使能中断
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指针区分是哪个定时器触发的。
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和捕获就是水到渠成的事。