4. 定时器与计数器:SysTick定时器、通用定时器原理、PWM生成、输入捕获与输出比较

定时器这东西,说白了就是芯片里的「节拍器」。我刚开始做嵌入式那会儿,总觉得定时器不就是数数嘛,有啥好研究的?后来被坑过几次才明白——定时器玩不转,整个系统的时序就全乱套了。

这一章咱们把定时器掰开揉碎了讲。从最简单的SysTick,到通用定时器的各种模式,再到PWM、输入捕获、输出比较这些实战功能。嗯,内容不少,但都是硬通货。

核心认知:定时器 = 计数器 + 时钟源 + 控制逻辑。给它一个稳定的时钟,它就能精确地数时间。反过来,给它一个外部信号,它就能测量外部事件的频率或脉宽。

定时器与计数器知识体系 定时器/计数器 SysTick定时器 通用定时器 PWM生成 输入捕获 / 输出比较 应用场景 操作系统心跳 延时函数 定时中断 计数功能 电机调速 LED调光 频率测量 脉宽测量

4.1 SysTick定时器——操作系统的「心跳」

SysTick是ARM Cortex-M内核自带的24位递减计数器。它不依赖外设总线,内核一上电就能用。我个人习惯在裸机开发中也开着SysTick,用它做精确延时和超时检测。

SysTick的工作原理很简单:

  • 从重装载值开始递减计数
  • 减到0时触发中断(可选),然后自动重装
  • 24位计数器,最大计数值16,777,215

配置SysTick的代码其实就几行:

// SysTick配置示例(STM32)
// 系统时钟72MHz,产生1ms中断
void SysTick_Init(void)
{
    // 重装载值 = 72000000 / 1000 = 72000
    SysTick->LOAD = 72000 - 1;
    SysTick->VAL  = 0;
    SysTick->CTRL = 0x07;  // 使能 + 中断 + 使用系统时钟
}

// 中断服务函数
void SysTick_Handler(void)
{
    static uint32_t tick = 0;
    tick++;
    // 每1ms进入一次
}

我的经验:SysTick的重装载值最好减1再写入。因为计数器从LOAD值减到0需要LOAD+1个时钟周期。我第一次用的时候没注意这个,结果延时总是多了一个周期。虽然一个周期影响不大,但强迫症受不了。

SysTick在RTOS里扮演什么角色?它就是任务调度的「节拍器」。比如FreeRTOS的configTICK_RATE_HZ设为1000,那SysTick就每1ms触发一次,OS在里面做任务切换和延时管理。

4.2 通用定时器——真正的「多面手」

通用定时器比SysTick复杂得多。它通常有16位或32位的计数器,支持向上计数、向下计数、中央对齐计数三种模式。我当年做电机控制项目时,一个通用定时器同时干了三件事:产生PWM、测量编码器脉冲、做定时中断。嗯,一个定时器当三个用。

通用定时器的核心寄存器就这几个:

寄存器 功能 说明
CNT 计数器当前值 可读可写,复位后为0
ARR 自动重装载寄存器 决定计数周期
PSC 预分频器 对时钟源分频,16位
CCR 捕获/比较寄存器 每个通道独立,用于PWM或捕获

定时时间的计算公式:

定时时间 = (PSC + 1) × (ARR + 1) / 定时器时钟频率

举个例子:时钟72MHz,想要1ms中断一次:

// 预分频 7200-1,重装载 10-1
// 定时器时钟 = 72MHz / 7200 = 10kHz
// 中断周期 = 10 / 10kHz = 1ms
TIM_TimeBaseInitTypeDef tim;
tim.TIM_Prescaler = 7200 - 1;
tim.TIM_Period = 10 - 1;
tim.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &tim);
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
TIM_Cmd(TIM2, ENABLE);

注意:预分频器PSC是16位的,最大值65535。如果时钟频率太高、需要长时间定时,光靠PSC可能不够。这时候要么用32位定时器,要么在中断里做软件计数累加。我曾经在一个项目中需要定时1小时,就是用中断里累加的方式实现的。

4.3 PWM生成——让LED呼吸,让电机转动

PWM(脉冲宽度调制),说白了就是通过调节高低电平的占空比来模拟模拟量。LED调光、电机调速、舵机控制,都离不开它。

通用定时器生成PWM的原理:计数器从0递增到ARR,同时CCR里存一个比较值。当CNT小于CCR时输出高电平,大于等于CCR时输出低电平。改变CCR就能改变占空比。

// PWM配置示例(STM32 TIM2_CH1,PA0输出)
void PWM_Init(void)
{
    GPIO_InitTypeDef gpio;
    TIM_OCInitTypeDef oc;
    TIM_TimeBaseInitTypeDef tim;

    // 1. 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);

    // 2. 配置GPIO为复用推挽输出
    gpio.GPIO_Pin = GPIO_Pin_0;
    gpio.GPIO_Mode = GPIO_Mode_AF_PP;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &gpio);

    // 3. 配置定时器时基
    tim.TIM_Prescaler = 72 - 1;      // 72MHz/72 = 1MHz
    tim.TIM_Period = 1000 - 1;       // PWM频率 = 1MHz/1000 = 1kHz
    tim.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &tim);

    // 4. 配置PWM模式1
    oc.TIM_OCMode = TIM_OCMode_PWM1;
    oc.TIM_OutputState = TIM_OutputState_Enable;
    oc.TIM_Pulse = 500;              // 初始占空比50%
    oc.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OC1Init(TIM2, &oc);

    TIM_Cmd(TIM2, ENABLE);
}

// 动态调整占空比
void PWM_SetDuty(uint16_t duty)
{
    TIM_SetCompare1(TIM2, duty);  // duty范围0~999
}

你想想看,改变占空比就能控制LED亮度或电机转速,这背后的原理其实就是定时器在不停地比较CNT和CCR的值。硬件自动完成的,不占CPU。

避坑指南:我曾经在调PWM驱动舵机时,发现舵机抖得厉害。查了半天,原来是PWM频率不对。舵机标准是50Hz(周期20ms),而我设成了1kHz。记住:不同负载对PWM频率要求不同——LED调光要高于人眼闪烁频率(通常100Hz以上),电机驱动要看MOS管开关损耗,舵机必须50Hz。

4.4 输入捕获——测量外部信号的「频率计」

输入捕获用来测量外部信号的频率、周期、脉宽。原理是:当检测到指定边沿(上升沿或下降沿)时,把当前CNT的值锁存到CCR里,同时触发中断。

测量脉宽的典型步骤:

  1. 配置捕获通道检测上升沿
  2. 捕获到上升沿时,记录CCR值T1
  3. 切换为下降沿捕获
  4. 捕获到下降沿时,记录CCR值T2
  5. 脉宽 = (T2 - T1) × 定时器时钟周期
// 输入捕获配置(测量高电平脉宽)
void InputCapture_Init(void)
{
    TIM_ICInitTypeDef ic;

    // 通道1:上升沿捕获
    ic.TIM_Channel = TIM_Channel_1;
    ic.TIM_ICPolarity = TIM_ICPolarity_Rising;
    ic.TIM_ICSelection = TIM_ICSelection_DirectTI;
    ic.TIM_ICPrescaler = TIM_ICPSC_DIV1;
    ic.TIM_ICFilter = 0;
    TIM_ICInit(TIM3, &ic);

    // 使能捕获中断
    TIM_ITConfig(TIM3, TIM_IT_CC1, ENABLE);
    TIM_Cmd(TIM3, ENABLE);
}

// 中断中切换边沿
void TIM3_IRQHandler(void)
{
    if(TIM_GetITStatus(TIM3, TIM_IT_CC1))
    {
        static uint8_t edge = 0;
        uint16_t cap = TIM_GetCapture1(TIM3);

        if(edge == 0)  // 上升沿
        {
            // 记录上升沿时间
            // 切换为下降沿
            TIM_OC1PolarityConfig(TIM3, TIM_ICPolarity_Falling);
            edge = 1;
        }
        else  // 下降沿
        {
            // 计算脉宽 = cap - last_cap
            // 切换回上升沿
            TIM_OC1PolarityConfig(TIM3, TIM_ICPolarity_Rising);
            edge = 0;
        }
        TIM_ClearITPendingBit(TIM3, TIM_IT_CC1);
    }
}

注意:输入捕获的精度取决于定时器时钟频率。比如定时器时钟72MHz,一个计数周期约13.9ns。如果被测信号频率太高,可能一个周期内计数器就溢出了。这时候需要开启定时器的从模式——用外部信号复位计数器,或者用32位定时器。

4.5 输出比较——精确控制输出时刻

输出比较和PWM有点像,但思路不同。PWM是持续输出可调占空比的方波,而输出比较是在「指定时刻」改变输出电平。你可以把它理解成「定时开关」。

应用场景举例:

  • 产生特定宽度的脉冲
  • 步进电机的精确脉冲控制
  • 红外遥控信号的编码发送

输出比较的工作模式:

模式 行为 典型用途
冻结 CNT=CCR时无动作 仅用于软件查询
翻转 CNT=CCR时电平翻转 产生对称方波
强制低/高 强制输出固定电平 调试用
PWM模式 CNT<CCR输出有效电平 PWM生成

输出比较和输入捕获其实是同一套硬件——捕获/比较通道。区别在于:捕获是「外部信号来了,我记下时间」;比较是「时间到了,我改变输出」。一个往里收,一个往外发。

总结一下:

  • SysTick:内核自带,24位递减,适合做OS心跳和精确延时
  • 通用定时器:功能最全,支持多种计数模式,是PWM和捕获的基础
  • PWM:通过比较CNT和CCR产生可调占空比方波,驱动LED、电机、舵机
  • 输入捕获:锁存边沿时刻,测量频率、周期、脉宽
  • 输出比较:在指定时刻改变输出电平,产生精确脉冲

这些功能说到底都是围绕「计数+比较」这两个核心操作展开的。理解了这个本质,不管换什么芯片,定时器部分你都能快速上手。