4. 定时器基础与应用:SysTick、PWM与输入捕获

定时器这东西,可以说是STM32里最常用的外设之一了。我刚开始做嵌入式那会儿,总觉得定时器不就是个计数器嘛,有啥好研究的?后来做项目多了才发现,用得好不好,直接决定了系统的实时性和稳定性。这一章咱们就把三个最核心的定时器应用讲透:SysTick系统滴答、通用定时器的PWM输出、还有输入捕获测频率。

本章核心知识点:
  • SysTick的工作原理与配置方法
  • 通用定时器产生PWM信号的完整流程
  • 输入捕获测量外部信号频率
  • 实际项目中的避坑经验

4.1 SysTick系统滴答定时器

SysTick是Cortex-M内核自带的24位递减计数器。说白了,它就是系统的心跳。你想想看,如果没有这个心跳,操作系统怎么调度任务?延时函数怎么实现?

我个人习惯把SysTick配置成1ms中断一次。为什么是1ms?因为大多数RTOS的时间片轮转都是以ms为单位的,而且1ms的精度对大部分应用场景来说足够了。

配置要点: SysTick的时钟源可以选择内核时钟或内核时钟的8分频。我建议用内核时钟直接驱动,这样精度更高。比如STM32F103主频72MHz,重装载值设成72000-1,就能得到1ms的中断周期。
// SysTick配置示例 - 1ms中断
void SysTick_Init(void)
{
    // 重装载值 = 系统时钟 / 1000 - 1
    // 假设系统时钟72MHz
    SysTick->LOAD = 72000 - 1;
    
    // 选择内核时钟源,使能中断,启动定时器
    SysTick->CTRL |= SysTick_CTRL_CLKSOURCE_Msk |
                     SysTick_CTRL_TICKINT_Msk |
                     SysTick_CTRL_ENABLE_Msk;
}

// SysTick中断服务函数
void SysTick_Handler(void)
{
    // 每1ms执行一次
    // 可以在这里做系统节拍计数
    static uint32_t tick = 0;
    tick++;
}

我曾经在一个项目中遇到过一个坑:SysTick中断里做了太多事情,导致中断执行时间超过了1ms。结果就是系统节拍丢失,延时函数不准,整个系统时序全乱了。嗯,这里要注意:SysTick中断里只做最轻量的事情,比如计数、置标志位,千万别在里面做复杂运算或者调用printf。

4.2 通用定时器PWM输出

PWM输出是通用定时器最常用的功能之一。控制电机转速、调节LED亮度、驱动舵机,都离不开它。STM32的通用定时器可以同时输出多路PWM,而且每路的占空比可以独立调节。

我画了一张图,帮你理清PWM输出的核心逻辑:

通用定时器PWM输出流程 时钟源(CK_INT) 预分频器(PSC) 自动重装载寄存器(ARR) 捕获/比较寄存器(CCR) 输出比较模式 → PWM信号 关键公式 PWM频率 = 时钟 / (PSC+1) / (ARR+1) 占空比 = CCR / (ARR+1) × 100% 例如:72MHz时钟 PSC=71, ARR=999 频率 = 72M/72/1000 = 1kHz CCR=500 → 占空比50%

配置PWM输出其实就几个步骤:

  1. 使能定时器时钟 - 别漏了GPIO的时钟也要使能
  2. 配置GPIO为复用推挽输出 - 注意要选对复用功能号
  3. 设置预分频器和自动重装载值 - 决定PWM频率
  4. 配置输出比较模式 - 常用PWM模式1或模式2
  5. 使能定时器 - 开始输出PWM信号
// TIM2 PWM输出配置示例 - 1kHz, 50%占空比
void TIM2_PWM_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_OCInitTypeDef TIM_OCInitStructure;
    
    // 1. 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    // 2. 配置PA0为TIM2_CH1复用输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 3. 配置定时器时基
    TIM_TimeBaseStructure.TIM_Period = 999;          // ARR
    TIM_TimeBaseStructure.TIM_Prescaler = 71;        // PSC
    TIM_TimeBaseStructure.TIM_ClockDivision = 0;
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
    
    // 4. 配置PWM模式1
    TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
    TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OCInitStructure.TIM_Pulse = 500;             // CCR = 500 → 50%
    TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OC1Init(TIM2, &TIM_OCInitStructure);
    
    // 5. 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}
常见坑点:
  • 高级定时器(TIM1/TIM8)需要额外使能刹车和死区寄存器
  • 修改占空比时,直接写CCR寄存器即可,不用重新初始化
  • PWM频率不要超过GPIO的翻转速度,一般50MHz的GPIO能输出几MHz的PWM

4.3 输入捕获测量频率

输入捕获是定时器的另一个重要功能。它可以测量外部信号的频率、周期、脉宽。原理其实很简单:当检测到指定的边沿(上升沿或下降沿)时,定时器会把当前的计数值锁存到捕获寄存器里。

我做过一个项目,需要测量一个传感器的输出频率。当时用了输入捕获,配合DMA,实现了连续采样。效果还不错,精度能达到0.1Hz。

测量频率有两种常用方法:

方法 原理 适用场景 精度
周期测量法 测量两个上升沿之间的计数值差 低频信号(<100kHz) 高(低频时)
频率测量法 固定时间内计数脉冲个数 高频信号(>100kHz) 高(高频时)

周期测量法的实现步骤:

  1. 配置定时器的输入捕获通道,选择上升沿触发
  2. 使能捕获中断
  3. 在中断里读取CCR值,计算两次捕获的差值
  4. 频率 = 定时器时钟频率 / (差值 × 预分频系数)
// 输入捕获配置 - 测量信号频率
uint32_t g_cap1 = 0, g_cap2 = 0;
uint8_t g_cap_flag = 0;

void TIM3_Cap_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_ICInitTypeDef TIM_ICInitStructure;
    
    // 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    // 配置PA6为输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 定时器时基配置
    TIM_TimeBaseStructure.TIM_Period = 0xFFFF;
    TIM_TimeBaseStructure.TIM_Prescaler = 71;  // 1MHz计数频率
    TIM_TimeBaseStructure.TIM_ClockDivision = 0;
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);
    
    // 输入捕获配置 - 通道1,上升沿
    TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
    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 = 0;
    TIM_ICInit(TIM3, &TIM_ICInitStructure);
    
    // 使能捕获中断
    TIM_ITConfig(TIM3, TIM_IT_CC1, ENABLE);
    NVIC_EnableIRQ(TIM3_IRQn);
    
    TIM_Cmd(TIM3, ENABLE);
}

// 中断服务函数
void TIM3_IRQHandler(void)
{
    if(TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET)
    {
        TIM_ClearITPendingBit(TIM3, TIM_IT_CC1);
        
        if(g_cap_flag == 0)
        {
            g_cap1 = TIM_GetCapture1(TIM3);
            g_cap_flag = 1;
        }
        else
        {
            g_cap2 = TIM_GetCapture1(TIM3);
            g_cap_flag = 0;
            
            // 计算频率
            uint32_t diff;
            if(g_cap2 > g_cap1)
                diff = g_cap2 - g_cap1;
            else
                diff = (0xFFFF - g_cap1) + g_cap2;
            
            // 频率 = 1MHz / diff
            uint32_t freq = 1000000 / diff;
            // 这里可以处理freq值
        }
    }
}
经验之谈:
  • 输入捕获的滤波器可以滤除毛刺,但会增加延迟。我一般设成4-8个采样点
  • 如果信号频率很低(比如几Hz),定时器可能会溢出。这时候要配合溢出中断来处理
  • 测量高频信号时,预分频器要设小一点,不然计数精度不够

4.4 实战中的避坑指南

做定时器相关的项目,有几个坑我踩过,分享给你:

  • 定时器时钟源搞错 - APB1和APB2的时钟频率可能不同。APB1上的定时器最高36MHz,APB2上的可以到72MHz。我刚开始就吃过这个亏,算出来的频率总是不对。
  • PWM输出极性搞反 - 高电平有效还是低电平有效?这取决于你的外设。驱动LED的话,高电平有效没问题。但驱动某些电机驱动器,可能需要低电平有效。
  • 输入捕获的边沿选择 - 测量脉宽需要同时捕获上升沿和下降沿。可以用两个通道分别捕获,也可以用同一个通道切换边沿。
  • 中断优先级 - 如果系统里同时有多个中断,定时器中断的优先级要设好。我一般把SysTick设成最高优先级,其他定时器中断根据实时性要求来排。
特别提醒: 调试PWM输出时,最好先用示波器看波形。别光靠代码里算出来的频率和占空比。我曾经有一次把预分频器写成了72,结果输出频率只有1Hz,LED一闪一闪的,我还以为是程序跑飞了。用示波器一看,波形完全正确,就是频率设错了。