4、STM32基础外设开发(定时器):SysTick系统滴答、基本定时器与PWM输出、输入捕获测频率、看门狗应用

定时器这东西,说简单也简单,说复杂能写一本书。我做了这么多年嵌入式,几乎每个项目都离不开它。从简单的延时,到复杂的电机控制,定时器就是MCU的「心跳」。今天咱们就把STM32的定时器家族捋一遍,重点讲几个最常用的场景。

4.1 SysTick系统滴答定时器

SysTick是内核自带的24位递减计数器。说白了,它就是给操作系统做心跳用的。但即便你不用RTOS,它也是个宝贝。

我个人的习惯:任何项目上来第一件事,先把SysTick配好,用来做软件延时和超时管理。你想想看,有了它,你就不用再写那种傻等的delay()函数了。

核心要点:SysTick的时钟源可以选择AHB时钟或AHB/8。配置时注意这个细节,不然你的延时时间会差8倍。

配置代码其实很简单:

// 配置SysTick,1ms中断一次
// 假设系统时钟72MHz,选择AHB/8作为时钟源
// 那么计数频率 = 72MHz / 8 = 9MHz
// 重装载值 = 9MHz / 1000 = 9000 - 1

void SysTick_Init(void)
{
    // 关闭SysTick
    SysTick->CTRL = 0;
    
    // 设置重装载值
    SysTick->LOAD = 9000 - 1;
    
    // 清空当前值
    SysTick->VAL = 0;
    
    // 选择AHB/8时钟源,使能中断,使能计数器
    SysTick->CTRL = 0x07;  // 位2:时钟源,位1:中断使能,位0:使能
}

// 全局变量,用于计时
volatile uint32_t SysTick_Count = 0;

// SysTick中断服务函数
void SysTick_Handler(void)
{
    SysTick_Count++;
}

// 毫秒延时函数
void delay_ms(uint32_t ms)
{
    uint32_t start = SysTick_Count;
    while((SysTick_Count - start) < ms);
}

我的经验:用SysTick_Count - start这种方式做延时,可以避免溢出问题。因为无符号数减法在C语言里是循环的,哪怕计数器溢出了,结果也是对的。这个技巧我在很多项目里都用过,百试不爽。

4.2 基本定时器

基本定时器(TIM6、TIM7)是最简单的定时器。它只有向上计数功能,没有输入输出通道。但别小看它,我经常用它来触发ADC采样或者做精确延时。

为什么不用SysTick?因为SysTick被系统占用了,或者你需要多个独立的定时器。这时候基本定时器就派上用场了。

配置步骤:

  1. 使能定时器时钟
  2. 设置预分频器(PSC)和自动重装载值(ARR)
  3. 使能更新中断(如果需要)
  4. 使能计数器
// 配置TIM6,定时100ms
// 假设APB1时钟 = 36MHz
// 预分频:36000 - 1,得到1kHz的计数频率
// 重装载值:100 - 1,得到100ms中断

void TIM6_Init(void)
{
    // 使能TIM6时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM6EN;
    
    // 设置预分频器
    TIM6->PSC = 36000 - 1;  // 36MHz / 36000 = 1kHz
    
    // 设置自动重装载值
    TIM6->ARR = 100 - 1;    // 1kHz / 100 = 10Hz,即100ms
    
    // 清除更新标志
    TIM6->SR = 0;
    
    // 使能更新中断
    TIM6->DIER |= TIM_DIER_UIE;
    
    // 使能计数器
    TIM6->CR1 |= TIM_CR1_CEN;
    
    // 配置NVIC
    NVIC_EnableIRQ(TIM6_IRQn);
    NVIC_SetPriority(TIM6_IRQn, 1);
}

void TIM6_IRQHandler(void)
{
    if(TIM6->SR & TIM_SR_UIF)
    {
        TIM6->SR = ~TIM_SR_UIF;  // 清除中断标志
        // 用户代码:比如翻转LED
    }
}

我曾经踩过的坑:APB1时钟分频系数会影响定时器的时钟。如果APB1预分频系数不是1,那么定时器时钟会是APB1的两倍。这个在手册里有写,但很容易忽略。我当年调试一个PWM输出,频率死活不对,查了半天才发现是这个原因。

4.3 PWM输出

PWM输出是通用定时器的拿手好戏。呼吸灯、电机调速、舵机控制,都离不开它。STM32的定时器可以同时输出多路PWM,而且占空比可以独立调节。

配置要点:

  • 选择定时器的某个通道作为PWM输出
  • 配置输出比较模式为PWM1或PWM2
  • 设置占空比(通过CCR寄存器)
  • 使能输出
// 配置TIM2的通道1(PA0)输出PWM
// 频率:1kHz,占空比:50%

void PWM_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    TIM_OCInitTypeDef TIM_OCInitStruct;
    TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
    
    // 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
    
    // 配置PA0为复用推挽输出
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStruct.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 定时器基础配置
    TIM_TimeBaseInitStruct.TIM_Prescaler = 72 - 1;      // 72MHz / 72 = 1MHz
    TIM_TimeBaseInitStruct.TIM_Period = 1000 - 1;       // 1MHz / 1000 = 1kHz
    TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseInitStruct);
    
    // PWM模式配置
    TIM_OCInitStruct.TIM_OCMode = TIM_OCMode_PWM1;
    TIM_OCInitStruct.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OCInitStruct.TIM_Pulse = 500;                   // 占空比50%
    TIM_OCInitStruct.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OC1Init(TIM2, &TIM_OCInitStruct);
    
    // 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

// 动态修改占空比
void PWM_SetDuty(uint16_t duty)
{
    TIM_SetCompare1(TIM2, duty);
}

我建议:做电机控制时,PWM频率选在10kHz-20kHz比较合适。太低会有噪音,太高MOS管开关损耗大。呼吸灯的话,100Hz左右就够用了,人眼看不出闪烁。

4.4 输入捕获测频率

输入捕获可以测量外部信号的频率和脉宽。原理很简单:捕获上升沿或下降沿时,把计数器的值存到CCR寄存器里。两次捕获的差值,就是信号的周期。

测频率的步骤:

  1. 配置定时器的输入捕获通道
  2. 设置捕获边沿(上升沿或下降沿)
  3. 使能捕获中断
  4. 在中断里读取CCR值,计算频率
// 使用TIM3的通道1(PA6)测量输入频率

volatile uint32_t Capture_Value1 = 0;
volatile uint32_t Capture_Value2 = 0;
volatile uint8_t Capture_Complete = 0;

void InputCapture_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct;
    TIM_ICInitTypeDef TIM_ICInitStruct;
    
    // 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
    
    // 配置PA6为浮空输入
    GPIO_InitStruct.GPIO_Pin = GPIO_Pin_6;
    GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 定时器基础配置
    TIM_TimeBaseInitTypeDef TIM_TimeBaseInitStruct;
    TIM_TimeBaseInitStruct.TIM_Prescaler = 72 - 1;      // 1MHz计数频率
    TIM_TimeBaseInitStruct.TIM_Period = 0xFFFF;         // 最大计数值
    TIM_TimeBaseInitStruct.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseInit(TIM3, &TIM_TimeBaseInitStruct);
    
    // 输入捕获配置
    TIM_ICInitStruct.TIM_Channel = TIM_Channel_1;
    TIM_ICInitStruct.TIM_ICPolarity = TIM_ICPolarity_Rising;
    TIM_ICInitStruct.TIM_ICSelection = TIM_ICSelection_DirectTI;
    TIM_ICInitStruct.TIM_ICPrescaler = TIM_ICPSC_DIV1;
    TIM_ICInitStruct.TIM_ICFilter = 0;
    TIM_ICInit(TIM3, &TIM_ICInitStruct);
    
    // 使能捕获中断
    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);
        
        static uint8_t capture_index = 0;
        
        if(capture_index == 0)
        {
            Capture_Value1 = TIM_GetCapture1(TIM3);
            capture_index = 1;
        }
        else
        {
            Capture_Value2 = TIM_GetCapture1(TIM3);
            capture_index = 0;
            Capture_Complete = 1;
        }
    }
}

// 在主循环中计算频率
uint32_t Get_Frequency(void)
{
    uint32_t freq = 0;
    
    if(Capture_Complete)
    {
        uint32_t period = 0;
        
        if(Capture_Value2 > Capture_Value1)
        {
            period = Capture_Value2 - Capture_Value1;
        }
        else
        {
            // 处理溢出情况
            period = (0xFFFF - Capture_Value1) + Capture_Value2;
        }
        
        // 频率 = 计数频率 / 周期
        // 计数频率 = 72MHz / 72 = 1MHz
        freq = 1000000 / period;
        
        Capture_Complete = 0;
    }
    
    return freq;
}

我曾经遇到的问题:测量低频信号时,计数器可能会溢出。比如测量50Hz的工频信号,周期是20ms,而16位计数器在1MHz下只能计65ms。如果信号周期超过这个值,就得用32位定时器或者用软件扩展计数范围。我有个项目测心跳信号,频率只有1Hz左右,当时就吃了这个亏。

4.5 看门狗应用

看门狗是嵌入式系统的最后一道防线。程序跑飞了?死循环了?看门狗会帮你复位。STM32有两种看门狗:独立看门狗(IWDG)和窗口看门狗(WWDG)。

独立看门狗:由独立的40kHz RC振荡器驱动,即使主时钟挂了也能工作。适合做系统级保护。

窗口看门狗:基于系统时钟,可以在特定时间窗口内喂狗。早了不行,晚了也不行。适合对时序要求严格的场景。

// 独立看门狗配置
// 超时时间约1秒(40kHz / 64 / 625)

void IWDG_Init(void)
{
    // 写入0x5555,允许修改预分频器和重装载值
    IWDG->KR = 0x5555;
    
    // 设置预分频器为64分频
    IWDG->PR = IWDG_Prescaler_64;
    
    // 设置重装载值
    IWDG->RLR = 625;  // 40kHz / 64 = 625Hz, 625 / 625 = 1秒
    
    // 等待寄存器更新
    while(IWDG->SR);
    
    // 启动看门狗
    IWDG->KR = 0xCCCC;
}

// 喂狗函数
void IWDG_Feed(void)
{
    IWDG->KR = 0xAAAA;
}

// 窗口看门狗配置
void WWDG_Init(void)
{
    // 使能WWDG时钟
    RCC->APB1ENR |= RCC_APB1ENR_WWDGEN;
    
    // 配置WWDG
    // 计数器值:0x7F,窗口值:0x5F
    // 超时时间 = (4096 * 8 * (0x7F - 0x3F)) / 36MHz ≈ 5.8ms
    WWDG->CFR = (0x5F << 8) | WWDG_CFR_WDGTB1;  // 窗口值,分频系数8
    WWDG->CR = 0x7F | WWDG_CR_WDGA;              // 计数器值,使能看门狗
    
    // 使能提前唤醒中断
    WWDG->CFR |= WWDG_CFR_EWI;
    NVIC_EnableIRQ(WWDG_IRQn);
}

void WWDG_Feed(void)
{
    WWDG->CR = 0x7F;  // 喂狗,计数器回到0x7F
}

void WWDG_IRQHandler(void)
{
    // 提前唤醒中断,在复位前做紧急处理
    WWDG->SR = 0;  // 清除中断标志
    // 比如保存重要数据到Flash
}

我的经验:看门狗的喂狗位置很有讲究。我一般会在主循环的末尾喂狗,确保所有任务都执行了一遍。不要在中断里喂狗,因为如果主循环卡死了,中断可能还在正常运行,看门狗就形同虚设了。

4.6 实际项目中的选择建议

应用场景 推荐定时器 理由
系统心跳、软件延时 SysTick 内核自带,配置简单
触发ADC采样 基本定时器 独立运行,不影响其他功能
电机控制、呼吸灯 通用定时器PWM 多通道输出,占空比可调
测量外部信号频率 通用定时器输入捕获 硬件捕获,精度高
系统异常保护 独立看门狗 独立时钟,可靠性高
时序严格的应用 窗口看门狗 可检测程序执行时序异常

嗯,定时器这部分内容确实不少。但说白了,你只要把SysTick、PWM输出、输入捕获和看门狗这四个场景吃透了,大部分项目都能应付。我当年刚开始做物联网终端时,就靠这几个功能撑起了整个项目。记住一点:定时器配置前,先搞清楚时钟树,不然你算出来的时间全是错的。

下一章咱们开始讲通信接口,UART、I2C、SPI这些,都是物联网终端和外界打交道的「嘴巴」,到时候见。