第4章 定时器基础:SysTick与基本定时器
定时器,说白了就是单片机的“心跳”。没有它,你连个精准的延时都做不出来。我刚开始学STM32时,总觉得定时器不就是个计数器嘛,有啥好研究的?结果第一次做PWM电机控制,电机抖得像跳舞——嗯,从那以后我再也不敢小看定时器了。
这一章,咱们把最常用的两个定时器讲透:SysTick系统滴答定时器,和基本定时器。你学会了它们,后面做延时、做调度、做Bootloader的超时保护,都会顺手很多。
4.1 SysTick系统滴答定时器
SysTick是Cortex-M内核自带的24位递减计数器。它不属于某个外设,而是内核的一部分。这意味着——不管你用哪家芯片,只要内核是M3/M4/M7,SysTick的用法都一样。
核心特点:
- 24位递减计数器,从设定值减到0
- 自动重装载,无需手动重置
- 可产生中断,优先级可配置
- 时钟源可选:AHB/8 或 AHB直接
我个人习惯把SysTick用作操作系统的心跳,或者做毫秒级延时。裸机开发时,它也是HAL库中HAL_Delay()的底层依赖。
4.1.1 SysTick寄存器
SysTick只有4个寄存器,简单到令人发指:
| 寄存器 | 地址偏移 | 功能 |
|---|---|---|
| SYST_CSR | 0x00 | 控制与状态寄存器(使能、中断使能、时钟源选择) |
| SYST_RVR | 0x04 | 重装载值寄存器(24位有效) |
| SYST_CVR | 0x08 | 当前值寄存器(读返回当前计数值,写清零) |
| SYST_CALIB | 0x0C | 校准寄存器(一般不用) |
配置流程其实就三步:设重装载值 → 选时钟源 → 使能计数器。我见过有人把这三步顺序搞反,结果SysTick死活不跑——你想想看,计数器都没使能,你设值有啥用?
4.1.2 使用HAL库配置SysTick
HAL库在HAL_Init()里已经帮我们配好了SysTick。默认配置是:时钟源为AHB/8,中断周期1ms。你打开stm32f1xx_hal.c看一眼,会发现这段代码:
__weak HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority)
{
/* 配置SysTick为1ms中断 */
if (HAL_SYSTICK_Config(SystemCoreClock / 1000) == 0)
{
return HAL_ERROR;
}
/* 配置SysTick中断优先级 */
HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority, 0);
return HAL_OK;
}
这里有个坑:SystemCoreClock / 1000 算出来的值不能超过2^24-1。如果你的主频是72MHz,72MHz/1000=72000,远小于16,777,215,没问题。但如果你用216MHz的主频,216000就超了?不,216000也没超。实际上,只有主频超过16.7GHz才会溢出——嗯,目前还没这种MCU。
小技巧: 如果你需要更快的SysTick中断(比如100μs一次),可以把除数从1000改成10000。但要注意,中断频率越高,CPU花在中断处理上的时间就越多。我曾经在一个项目中把SysTick设成10μs一次,结果中断处理占了CPU 30%的时间——得不偿失。
4.2 基本定时器配置
基本定时器(TIM6/TIM7)是STM32里最简单的定时器。它只有向上计数功能,没有输入捕获、没有输出比较、没有PWM。说白了,就是个纯粹的计数器。
但别小看它。我在做Bootloader时,经常用基本定时器做超时保护——比如等待Flash写入完成,如果超过500ms还没写完,就报错退出。这种场景,基本定时器比SysTick更合适,因为它不会影响系统的心跳。
4.2.1 基本定时器的时钟树
基本定时器挂在哪条总线上?这取决于芯片型号。以STM32F103为例:
- TIM6、TIM7挂在APB1总线上
- 如果APB1预分频器不为1,则定时器时钟 = APB1时钟 × 2
- 如果APB1预分频器为1,则定时器时钟 = APB1时钟
默认情况下,APB1预分频器是2,所以TIM6的时钟 = 36MHz × 2 = 72MHz。这个细节很多人搞错,我刚开始也踩过这个坑——配了半天定时器,发现时间总是不对,最后查时钟树才找到原因。
4.2.2 HAL库配置步骤
用HAL库配置基本定时器,分四步走:
- 使能定时器时钟:__HAL_RCC_TIM6_CLK_ENABLE()
- 配置时基单元:预分频器(PSC)、自动重装载(ARR)、计数模式
- 配置中断(如果需要):使能更新中断、设置优先级
- 启动定时器:HAL_TIM_Base_Start() 或 HAL_TIM_Base_Start_IT()
来看一个完整的配置示例:
/* 定时器句柄 */
TIM_HandleTypeDef htim6;
/* 定时器初始化函数 */
void MX_TIM6_Init(void)
{
htim6.Instance = TIM6;
htim6.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz
htim6.Init.CounterMode = TIM_COUNTERMODE_UP;
htim6.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz → 1ms
htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
if (HAL_TIM_Base_Init(&htim6) != HAL_OK)
{
Error_Handler();
}
}
/* 启动定时器中断 */
HAL_TIM_Base_Start_IT(&htim6);
这里PSC=71,ARR=999,算出来的中断频率是:72MHz / (71+1) / (999+1) = 1000Hz,也就是1ms一次中断。你想想看,如果PSC和ARR都设成0,那中断频率就是72MHz——CPU啥也别干了,光处理中断就忙不过来。
注意: PSC和ARR的值都是要加1的。因为定时器是从0开始计数的,所以实际分频系数是PSC+1,实际周期是ARR+1。这个“+1”的坑,我见过不下五个工程师栽在上面。
4.3 HAL_TIM_Base_Start_IT中断回调
启动定时器中断后,当计数器溢出(更新事件)时,会触发中断。HAL库的中断处理流程是这样的:
- 定时器产生更新事件(UEV)
- 硬件置位SR寄存器中的UIF标志位
- 如果使能了中断,CPU跳转到TIM6_IRQHandler
- HAL库的HAL_TIM_IRQHandler处理中断
- 最终调用用户回调函数HAL_TIM_PeriodElapsedCallback
你只需要实现这个回调函数:
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM6)
{
/* 1ms到了,该干活了 */
user_tick++;
if (user_tick >= 1000)
{
user_tick = 0;
toggle_led(); // 每1秒翻转一次LED
}
}
}
这里有个细节:回调函数里要判断是哪个定时器触发的。因为所有基本定时器都共用同一个回调函数。我见过有人忘了加这个判断,结果TIM6和TIM7的中断处理混在一起,调试了半天才发现。
4.4 实现微秒/毫秒延时
延时函数是嵌入式开发中最基础的工具。HAL库自带的HAL_Delay()只能做到毫秒级,而且它是阻塞的——调用期间CPU啥也干不了。如果你需要微秒级延时,或者不想阻塞CPU,就得自己动手。
4.4.1 基于SysTick的毫秒延时
HAL_Delay()的实现原理很简单:利用SysTick的1ms中断,维护一个全局变量uwTick。每次调用HAL_Delay(ms)时,不断查询uwTick是否达到了目标值。
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick();
while ((HAL_GetTick() - tickstart) < Delay)
{
}
}
注意这里用的是无符号减法,所以不用担心溢出问题。比如tickstart=0xFFFFFFF0,当前tick=0x00000010,差值就是20——即使溢出了,结果也是对的。这个技巧叫“环形计数器”,在嵌入式里很常用。
4.4.2 基于基本定时器的微秒延时
SysTick的精度只有1ms,做不了微秒延时。这时候就要请出基本定时器了。思路是:把定时器配成1MHz(1μs计数一次),然后轮询CNT寄存器。
void delay_us(uint32_t us)
{
/* 重置计数器 */
__HAL_TIM_SET_COUNTER(&htim6, 0);
/* 启动定时器(不使能中断) */
HAL_TIM_Base_Start(&htim6);
/* 等待计数到达目标值 */
while (__HAL_TIM_GET_COUNTER(&htim6) < us);
/* 停止定时器 */
HAL_TIM_Base_Stop(&htim6);
}
这个函数有几个要点:
- 用HAL_TIM_Base_Start而不是Start_IT,因为我们不需要中断
- 每次调用前重置计数器,避免上次的残留值影响
- 延时结束后关闭定时器,省电
经验之谈: 微秒延时函数里不要做太多操作,否则延时本身就不准了。我曾在延时函数里加了调试打印,结果延时变成了原来的两倍——打印比延时还耗时。
4.4.3 非阻塞延时
阻塞延时虽然简单,但在实际项目中往往不够用。比如你要同时处理按键扫描和LED闪烁,总不能按个键等半天吧?这时候就需要非阻塞延时。
思路是用一个全局变量记录“目标时间”,然后在主循环里不断查询:
static uint32_t led_timeout = 0;
void set_led_blink(uint32_t interval_ms)
{
led_timeout = HAL_GetTick() + interval_ms;
}
void loop(void)
{
/* 检查LED是否该翻转了 */
if (HAL_GetTick() >= led_timeout)
{
toggle_led();
led_timeout = HAL_GetTick() + 500; // 再等500ms
}
/* 同时还能做其他事 */
scan_key();
process_uart();
}
这种写法在裸机开发中非常实用。我做的Bootloader里,超时检测、LED指示、按键检测全是用这种方式实现的——一个主循环搞定所有任务,不需要RTOS。
4.5 本章知识体系
下面这张图把SysTick和基本定时器的关系、配置流程、应用场景串起来了:
4.6 常见问题与避坑
我曾经踩过的坑:
- SysTick中断优先级太低: 如果SysTick中断优先级比某个外设中断低,而那个外设中断处理时间很长,SysTick就会“饿死”。结果就是HAL_GetTick()不更新,所有依赖它的延时都失效。解决办法:把SysTick优先级设高一点。
- 定时器PSC和ARR算错: 我见过有人把72MHz配成72分频,ARR设成1000,以为能得到1ms中断。结果一算:72M/72/1000=1000Hz,没错啊?嗯,他忘了PSC和ARR都要+1。实际是72M/73/1001≈984Hz,差了1.6%。
- 中断回调里做太多事: 在HAL_TIM_PeriodElapsedCallback里调用HAL_Delay?死锁!因为HAL_Delay依赖SysTick中断,而你现在就在中断里。解决办法:回调里只设标志位,具体处理放到主循环。
定时器这东西,看着简单,用好了能解决很多问题。我建议你拿到开发板后,先把SysTick和基本定时器各配一遍,然后用示波器量一下中断引脚的波形——亲眼看到1ms的脉冲,比看一百遍代码都管用。