第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库配置基本定时器,分四步走:

  1. 使能定时器时钟:__HAL_RCC_TIM6_CLK_ENABLE()
  2. 配置时基单元:预分频器(PSC)、自动重装载(ARR)、计数模式
  3. 配置中断(如果需要):使能更新中断、设置优先级
  4. 启动定时器: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库的中断处理流程是这样的:

  1. 定时器产生更新事件(UEV)
  2. 硬件置位SR寄存器中的UIF标志位
  3. 如果使能了中断,CPU跳转到TIM6_IRQHandler
  4. HAL库的HAL_TIM_IRQHandler处理中断
  5. 最终调用用户回调函数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和基本定时器的关系、配置流程、应用场景串起来了:

定时器知识体系 SysTick滴答定时器 基本定时器 TIM6/7 特性 24位递减计数器 内核自带,无需外设时钟 中断优先级可配置 特性 16位向上计数器 挂在APB1总线 可产生更新中断 配置流程 1. 设置重装载值 2. 选择时钟源 3. 使能计数器 4. 使能中断(可选) 配置流程 1. 使能定时器时钟 2. 配置PSC和ARR 3. 配置中断优先级 4. 调用Start/Start_IT 应用场景 HAL_Delay、操作系统心跳 应用场景 微秒延时、超时保护 互补使用 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的脉冲,比看一百遍代码都管用。