第3章 定时器详解:SysTick、基本定时器、PWM输出、输入捕获

定时器这东西,说白了就是单片机的「心跳」。我刚开始做嵌入式那会儿,总觉得定时器不就是计个数嘛,有啥好研究的?直到有一次做电机控制项目,PWM频率算错了,电机嗡嗡响就是不转……嗯,从那以后我老老实实把STM32的定时器家族捋了一遍。

这一章,咱们把四种最常用的定时器模式讲透:SysTick、基本定时器、PWM输出、输入捕获。你学完就能自己搭一个精准的定时框架。

核心认知:STM32的定时器本质都是「计数器+比较器」的组合。不同模式只是把这两个模块重新接线而已。
STM32 定时器知识体系 定时器核心 SysTick 系统滴答 / RTOS心跳 基本定时器 精准延时 / 时基 PWM输出 电机 / LED调光 输入捕获 脉宽 / 频率测量 FreeRTOS心跳 任务调度时基 HAL_Delay() 超时检测 舵机控制 呼吸灯 遥控器解码 转速测量 💡 所有定时器模式共享同一个硬件计数器,区别在于输出/输入的处理方式

3.1 SysTick:系统的心脏起搏器

SysTick是Cortex-M内核自带的24位递减计数器。它不依赖任何外设时钟树,内核上电它就存在。我个人习惯把SysTick叫做「系统的心跳」——FreeRTOS的任务调度、HAL_Delay()、各种超时检测,全指着它。

我的习惯:不管用不用RTOS,我都会把SysTick配置成1ms中断一次。这样后续所有软件定时器、超时计数都能复用这个时基。

配置SysTick其实就三步:

  1. 设置重装载值(LOAD寄存器)
  2. 清除当前计数值(VAL寄存器写0)
  3. 使能中断并启动计数器

重装载值怎么算?公式很简单:

重装载值 = (系统时钟频率 / 目标中断频率) - 1

// 举例:HCLK=72MHz,想要1ms中断一次
// 72,000,000 / 1000 - 1 = 71999
SysTick->LOAD = 71999;
SysTick->VAL  = 0;
SysTick->CTRL = 0x07;  // 使能 + 中断使能 + 使用HCLK

为什么减1?因为计数器从LOAD值减到0,一共是LOAD+1个时钟周期。这个坑我踩过——第一次写的时候没减1,结果中断频率快了0.0014%,虽然不大,但做精准时间同步时就露馅了。

注意:SysTick是24位计数器,最大重装载值是16,777,215。如果你的HCLK是168MHz,1ms中断的LOAD值就是167,999,没问题。但如果你想要1秒中断一次……嗯,168,000,000 - 1 = 167,999,999,超了。这时候得用基本定时器或者分频。

3.2 基本定时器:精准延时的底气

基本定时器(TIM6/TIM7)是STM32定时器家族里最「单纯」的成员——只有向上计数,没有外部引脚,没有PWM,没有捕获。但它有一个绝活:16位自动重装载+16位可编程预分频器。

预分频器是个好东西。它可以把计数器时钟再分一次,让你在低频场景下也能用上定时器。举个例子:

// 配置TIM6,产生1Hz中断
// 假设APB1时钟=36MHz
TIM6->PSC = 35999;   // 预分频:36MHz / (35999+1) = 1kHz
TIM6->ARR = 999;     // 自动重装载:1kHz / (999+1) = 1Hz
TIM6->CR1 |= TIM_CR1_CEN;  // 启动

这里有个细节:预分频器是缓冲型的。你写PSC寄存器后,它不会立刻生效,要等到当前计数周期结束才更新。我刚开始不知道这个,调试时发现频率不对,查了半天手册……

定时器类型 计数器位数 预分频器 外部引脚 典型用途
SysTick 24位 无(固定HCLK) 无 RTOS心跳、HAL_Delay
基本定时器 16位 16位可编程 无 精准延时、时基生成
通用定时器 16位 16位可编程 有(4通道) PWM、输入捕获、编码器
高级定时器 16位 16位可编程 有(带互补输出) 电机控制、三相PWM
避坑指南:基本定时器的更新事件(UEV)可以触发DMA。我曾经用TIM6的更新事件触发DMA搬运ADC数据,实现了「零CPU开销」的连续采样——这个技巧在高速数据采集场景下非常实用。

3.3 PWM输出:让硬件帮你干活

PWM输出,说白了就是让定时器在某个比较值处翻转电平。你想想看,如果让CPU用GPIO翻转来做PWM,频率稍微高一点CPU就啥也干不了了。而硬件PWM——配置好之后,CPU可以睡觉,PWM自己跑。

配置PWM的核心是三个寄存器:

  • ARR(自动重装载):决定PWM周期
  • CCR(捕获/比较):决定占空比
  • CCER(捕获/比较使能):决定输出极性

占空比的计算:占空比 = CCR / (ARR + 1) × 100%

// 配置TIM2_CH1输出50Hz PWM,占空比50%
// APB1时钟=36MHz,预分频后得到1kHz的计数器时钟
TIM2->PSC = 35999;   // 36MHz / 36000 = 1kHz
TIM2->ARR = 19;      // 1kHz / 20 = 50Hz
TIM2->CCR1 = 9;      // 占空比 = 10/20 = 50%

// 配置通道1为PWM1模式(向上计数时,CNT<CCR输出有效)
TIM2->CCMR1 |= (6 << 4);  // OC1M = 110 (PWM模式1)
TIM2->CCER |= TIM_CCER_CC1E;  // 使能输出
TIM2->CR1 |= TIM_CR1_CEN;     // 启动定时器
我的经验:做呼吸灯时,我一般用PWM模式1(向上计数,CNT<CCR时输出有效)。这样占空比从0到ARR逐渐增大,LED从暗到亮。反过来用PWM模式2也行,但逻辑是反的——我个人觉得模式1更符合直觉。

PWM频率和分辨率是矛盾的。举个例子:

  • 想要20kHz的PWM(电机控制常用),ARR=1800(72MHz/40000-1),分辨率约11位
  • 想要1kHz的PWM(LED调光常用),ARR=71999,分辨率约16位

分辨率越高,调光越细腻。但电机控制需要高频率来降低噪音——这就是个取舍。

曾经踩过的坑:有一次我用TIM1(高级定时器)做PWM,怎么配置输出都没波形。查了半天发现——高级定时器的PWM输出需要额外配置刹车输入和死区插入。如果你只是做普通PWM,用通用定时器(TIM2~TIM5)更省心。

3.4 输入捕获:测量外部信号的脉搏

输入捕获,就是定时器「偷看」外部信号的能力。当检测到指定的边沿(上升沿或下降沿)时,定时器会把当前CNT的值「咔嚓」一下锁存到CCR寄存器里。你连续捕获两次,一减,就知道信号的周期了。

测量频率的标准流程:

  1. 配置通道为输入捕获模式,选择上升沿触发
  2. 使能捕获中断
  3. 第一次捕获:记录CCR1值
  4. 第二次捕获:记录CCR2值,计算差值
  5. 频率 = 定时器时钟 / 差值
// 配置TIM3_CH1为输入捕获,上升沿触发
TIM3->CCMR1 |= TIM_CCMR1_CC1S_0;   // CC1通道配置为输入
TIM3->CCMR1 &= ~TIM_CCMR1_IC1F;    // 不滤波
TIM3->CCER |= TIM_CCER_CC1P;       // 上升沿触发(注意:CC1P=0是上升沿)
TIM3->DIER |= TIM_DIER_CC1IE;      // 使能捕获中断
TIM3->CR1 |= TIM_CR1_CEN;          // 启动

// 中断服务函数中
void TIM3_IRQHandler(void) {
    if(TIM3->SR & TIM_SR_CC1IF) {
        uint16_t capture = TIM3->CCR1;
        // 计算与上一次捕获的差值
        // 注意处理计数器溢出
        TIM3->SR = ~TIM_SR_CC1IF;  // 清除标志
    }
}
关键点:输入捕获一定要处理计数器溢出。如果信号周期大于定时器的溢出周期,差值会出错。我的做法是:在捕获中断里同时检查更新中断(UIF),如果发生了溢出,就把差值加上ARR+1。

测量脉宽(占空比)需要两个边沿:

  • 配置通道捕获上升沿,记录T1
  • 切换边沿极性,捕获下降沿,记录T2
  • 高电平时间 = T2 - T1

或者用两个通道:一个捕获上升沿,一个捕获下降沿。我个人喜欢用后者,省去切换极性的麻烦。

实用技巧:做遥控器解码时,我用输入捕获测量每个脉冲的宽度。NEC协议的逻辑1是2.25ms,逻辑0是1.12ms——捕获到脉宽后直接比较,比用GPIO中断+软件计时精准得多。

3.5 定时器中断优先级:别让RTOS饿死

最后聊一个容易被忽略的问题:中断优先级。SysTick的优先级在FreeRTOS里是固定的——configKERNEL_INTERRUPT_PRIORITY。但其他定时器的优先级你得自己配。

我的原则是:

  • SysTick:最高优先级(FreeRTOS调度依赖它)
  • PWM相关中断:中等优先级(占空比更新不及时最多波形抖一下)
  • 输入捕获:较高优先级(丢失边沿等于丢失数据)

配置优先级用HAL库的HAL_NVIC_SetPriority(),或者直接操作NVIC寄存器。记住:STM32只使用优先级寄存器的高4位,低4位是保留的。

// 设置TIM3中断优先级为5(0最高,15最低)
HAL_NVIC_SetPriority(TIM3_IRQn, 5, 0);
HAL_NVIC_EnableIRQ(TIM3_IRQn);
重要提醒:FreeRTOS的临界区(taskENTER_CRITICAL)只会屏蔽优先级低于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断。如果你的定时器中断优先级高于这个值,它可以在临界区中执行——这可能导致意外的重入问题。我建议所有「业务相关」的定时器中断优先级都低于configMAX_SYSCALL_INTERRUPT_PRIORITY。