4、定时器基础与应用:SysTick定时器、通用定时器配置、PWM输出、输入捕获

定时器这东西,说白了就是单片机的「心跳」。没有它,很多功能都转不起来。我刚开始学嵌入式那会儿,总觉得定时器不就是个计数器嘛,有啥好学的?后来做项目才发现,这里面的门道可真不少。今天咱们就把定时器这块彻底捋清楚。

4.1 SysTick定时器:系统的心跳

SysTick,全称叫系统滴答定时器。它是 Cortex-M 内核自带的,不需要额外配置外设时钟。我个人习惯,拿到一个新板子,第一件事就是把 SysTick 配好。

为什么?因为操作系统需要它来调度任务,裸机程序也需要它来做精准延时。你想想看,如果没有一个稳定的时间基准,你的程序就像没戴手表的上班族,全凭感觉走。

核心要点:SysTick 是一个 24 位的递减计数器,计数到 0 时自动重装载。它的时钟源可以是系统时钟,也可以是系统时钟的 1/8。

配置 SysTick 其实很简单,就几个寄存器的事。我给大家看一段我常用的初始化代码:

// SysTick 配置函数
// 参数:ticks - 重装载值
void SysTick_Init(uint32_t ticks)
{
    // 先关掉定时器,防止配置过程中产生中断
    SysTick->CTRL = 0;
    
    // 设置重装载值
    SysTick->LOAD = ticks - 1;
    
    // 清空当前计数值
    SysTick->VAL = 0;
    
    // 配置时钟源为系统时钟,使能中断,启动定时器
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
                    SysTick_CTRL_TICKINT_Msk |
                    SysTick_CTRL_ENABLE_Msk;
}

// 1ms 延时函数
void delay_ms(uint32_t ms)
{
    for(uint32_t i = 0; i < ms; i++)
    {
        // 等待 SysTick 计数到 0
        while(!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk));
    }
}

嗯,这里要注意:SysTick 的中断优先级默认是最低的。如果你在中断里做延时,记得先把优先级调高一点。我曾经在一个项目中,因为没注意这个细节,导致中断嵌套出了问题,排查了整整一个下午。

个人经验:SysTick 的重装载值最好用 (系统时钟频率 / 1000) - 1,这样刚好 1ms 中断一次。比如 72MHz 的 STM32,重装载值就是 71999。这样算出来的延时精度最高。

4.2 通用定时器配置:不只是计数

通用定时器比 SysTick 复杂多了。它有自己的时钟源,可以向上计数、向下计数,还能中心对齐计数。我刚开始学的时候,被 TIMx 的寄存器搞得头大。后来发现,其实只要抓住几个关键点就行。

通用定时器的核心配置包括:

  • 时钟源选择:内部时钟、外部触发、编码器模式等
  • 预分频器:决定计数频率,PSC 寄存器
  • 自动重装载:决定计数周期,ARR 寄存器
  • 计数模式:向上、向下、中心对齐

为什么需要预分频器?说白了,就是降频。系统时钟太快了,直接计数的话,1秒钟能计几千万次,根本没法用。预分频器就像减速齿轮,把时钟频率降到你需要的范围。

举个例子,假设系统时钟是 72MHz,你想让定时器 1 秒中断一次:

// 定时器配置示例
void TIM_Config(void)
{
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    
    // 使能定时器时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    
    // 预分频器:7200 - 1,得到 10KHz 的计数频率
    TIM_TimeBaseStructure.TIM_Prescaler = 7200 - 1;
    
    // 自动重装载值:10000 - 1,得到 1Hz 的中断频率
    TIM_TimeBaseStructure.TIM_Period = 10000 - 1;
    
    // 向上计数模式
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    
    // 时钟分频:不分频
    TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
    
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
    
    // 使能中断
    TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
    
    // 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

你看,72MHz / 7200 = 10KHz,也就是每 0.1ms 计一次数。计 10000 次就是 1 秒。这个逻辑其实很简单,就是分频再分频。

避坑指南:我曾经在配置定时器时,忘记使能 APB 总线时钟,结果定时器死活不工作。查了半天才发现是 RCC 时钟没开。记住:任何外设使用前,第一件事就是开时钟!

4.3 PWM输出:让硬件自己干活

PWM,脉冲宽度调制。说白了就是输出一串方波,通过调节高电平的宽度来控制功率。比如调灯光亮度、电机转速、舵机角度,都是靠 PWM。

为什么不用软件延时来做 PWM?你想想看,如果软件里用 delay 来产生 PWM,那 CPU 啥也别干了,全程在那数数。用定时器的硬件 PWM,CPU 只需要设置好参数,剩下的活硬件自己干,多省心。

配置 PWM 的关键是捕获/比较寄存器(CCR)。它决定了占空比:

// PWM 输出配置
void PWM_Config(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    TIM_OCInitTypeDef TIM_OCInitStructure;
    
    // 配置 GPIO 为复用推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;  // TIM2_CH1
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 配置 PWM 模式
    TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
    TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OCInitStructure.TIM_Pulse = 500;  // 初始占空比 50%
    TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
    
    TIM_OC1Init(TIM2, &TIM_OCInitStructure);
    TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable);
    
    // 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

// 设置占空比
void PWM_SetDuty(uint16_t duty)
{
    TIM_SetCompare1(TIM2, duty);
}

这里有个细节:PWM 模式 1 和模式 2 的区别。模式 1 是计数值小于 CCR 时输出有效电平,模式 2 则相反。我个人习惯用模式 1,因为更直观——CCR 越大,占空比越大。

实用技巧:如果你需要控制多个通道的 PWM,可以用同一个定时器的不同通道。比如 TIM2 有 4 个通道,可以同时控制 4 个 LED 或 4 个电机。但要注意,它们的频率必须相同,只有占空比可以独立调节。

4.4 输入捕获:测量外部信号

输入捕获,和 PWM 正好相反。PWM 是输出,输入捕获是输入。它用来测量外部信号的频率、脉宽、占空比等参数。

工作原理很简单:当检测到信号的上升沿或下降沿时,定时器会把当前的计数值「捕获」到 CCR 寄存器里。通过两次捕获的差值,就能算出信号的周期或脉宽。

我给大家看一个测量信号频率的例子:

// 输入捕获配置
void InputCapture_Config(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    TIM_ICInitTypeDef TIM_ICInitStructure;
    
    // 配置 GPIO 为浮空输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1;  // TIM2_CH2
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 配置输入捕获
    TIM_ICInitStructure.TIM_Channel = TIM_Channel_2;
    TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising;  // 上升沿捕获
    TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
    TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1;  // 不分频
    TIM_ICInitStructure.TIM_ICFilter = 0x0;  // 不滤波
    
    TIM_ICInit(TIM2, &TIM_ICInitStructure);
    
    // 使能捕获中断
    TIM_ITConfig(TIM2, TIM_IT_CC2, ENABLE);
    
    // 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

// 中断服务函数中计算频率
uint32_t capture_value = 0;
uint32_t frequency = 0;

void TIM2_IRQHandler(void)
{
    if(TIM_GetITStatus(TIM2, TIM_IT_CC2) != RESET)
    {
        uint32_t current = TIM_GetCapture2(TIM2);
        uint32_t diff = current - capture_value;
        
        // 计算频率:计数频率 / 差值
        frequency = 10000 / diff;  // 假设计数频率是 10KHz
        
        capture_value = current;
        
        TIM_ClearITPendingBit(TIM2, TIM_IT_CC2);
    }
}

嗯,这里有个坑:如果信号频率很低,差值可能会超过 16 位定时器的最大值。这时候要么用 32 位定时器,要么在中断里做溢出处理。我曾经在测量 1Hz 信号时,就遇到了这个问题,后来改用 TIM2 的 32 位模式才解决。

核心总结:定时器的四个核心功能——SysTick 做系统心跳、通用定时器做精准延时、PWM 输出控制功率、输入捕获测量信号。掌握了这些,大部分嵌入式项目的时间控制问题都能搞定。

最后说一句:定时器的学习,光看理论是不够的。我建议你拿一块开发板,把今天讲的每个功能都亲手实现一遍。遇到问题不要怕,调试的过程才是真正学到东西的时候。