第4章 定时器中断实战:基本定时器配置、更新中断、PWM中断与捕获中断

定时器中断,是嵌入式开发里最常用的外设中断之一。说实话,我做了这么多年固件,几乎每个项目都离不开它。从简单的延时、到复杂的波形测量,定时器都是核心角色。

这一章,我们聚焦STM32的定时器中断。我会从基本定时器讲起,然后逐步深入到PWM中断和输入捕获中断。嗯,咱们一步步来。

4.1 基本定时器配置与更新中断

基本定时器(TIM6、TIM7)结构最简单。它只有一个功能:计数,然后产生更新事件。说白了,就是内部计数器从0数到自动重装载值,然后溢出,触发中断。

我个人习惯,把基本定时器当作“软件心跳”。比如做系统调度、周期性数据采集,用它最省心。

4.1.1 基本定时器的核心寄存器

寄存器 作用 说明
PSC 预分频器 对时钟源分频,决定计数频率
ARR 自动重装载寄存器 计数值达到ARR时产生更新事件
CNT 计数器当前值 只读,反映当前计数值
SR 状态寄存器 UIF位指示更新中断是否发生

配置流程其实很固定。我刚开始学的时候,总觉得寄存器太多记不住。后来发现,只要抓住PSC和ARR这两个关键参数,其他都是套路。

4.1.2 配置步骤(HAL库方式)

  1. 使能定时器时钟:__HAL_RCC_TIM6_CLK_ENABLE()
  2. 初始化定时器句柄:htim6.Instance = TIM6
  3. 设置PSC和ARR:决定中断频率
  4. 使能更新中断:__HAL_TIM_ENABLE_IT(&htim6, TIM_IT_UPDATE)
  5. 使能定时器:HAL_TIM_Base_Start_IT(&htim6)
  6. 编写中断回调:HAL_TIM_PeriodElapsedCallback()

关键公式:中断频率 = 定时器时钟 / (PSC + 1) / (ARR + 1)

举个例子:时钟72MHz,PSC=7199,ARR=999,中断频率 = 72M / 7200 / 1000 = 10Hz,也就是100ms触发一次。

4.1.3 代码示例:1秒定时器中断

// 定时器句柄定义
TIM_HandleTypeDef htim6;

// 定时器初始化函数
void MX_TIM6_Init(void)
{
    htim6.Instance = TIM6;
    htim6.Init.Prescaler = 7199;      // 72MHz / 7200 = 10kHz
    htim6.Init.Period = 9999;         // 10kHz / 10000 = 1Hz
    htim6.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
    
    HAL_TIM_Base_Init(&htim6);
    
    // 使能更新中断
    HAL_NVIC_SetPriority(TIM6_DAC_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(TIM6_DAC_IRQn);
}

// 启动定时器
HAL_TIM_Base_Start_IT(&htim6);

// 中断回调函数
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM6)
    {
        // 每1秒执行一次
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    }
}

我的经验:回调函数里不要做耗时操作。我曾经在一个项目里,把LCD刷新放在定时器回调里,结果中断执行时间超过了定时周期,导致系统卡死。后来改成用标志位,在主循环里处理,问题就解决了。

4.2 PWM中断:不只是输出波形

很多人以为PWM只是用来输出波形。其实PWM中断也很有用。比如,你想在PWM的每个周期开始或结束时做点事情,就可以用PWM中断。

STM32的通用定时器和高级定时器都支持PWM模式。PWM中断主要有两种:更新中断(周期结束)和比较中断(占空比匹配点)。

4.2.1 PWM中断的应用场景

  • 逐周期调整占空比:比如软件实现呼吸灯,每个周期更新一次CCR值
  • PWM周期同步采样:在PWM周期的特定时刻触发ADC采样
  • 故障检测:在PWM中断里检测过流、过温等异常

4.2.2 配置PWM中断

配置PWM中断,和基本定时器类似,但多了一步:设置比较值(CCR)。

// 定时器2 PWM初始化
TIM_HandleTypeDef htim2;
TIM_OC_InitTypeDef sConfigOC;

void MX_TIM2_PWM_Init(void)
{
    htim2.Instance = TIM2;
    htim2.Init.Prescaler = 71;        // 72MHz / 72 = 1MHz
    htim2.Init.Period = 999;          // 1MHz / 1000 = 1kHz PWM
    htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
    HAL_TIM_PWM_Init(&htim2);
    
    sConfigOC.OCMode = TIM_OCMODE_PWM1;
    sConfigOC.Pulse = 500;            // 50% 占空比
    sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
    sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
    HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
    
    // 使能更新中断和比较中断
    __HAL_TIM_ENABLE_IT(&htim2, TIM_IT_UPDATE | TIM_IT_CC1);
    HAL_NVIC_SetPriority(TIM2_IRQn, 0, 1);
    HAL_NVIC_EnableIRQ(TIM2_IRQn);
}

// 启动PWM输出并开启中断
HAL_TIM_PWM_Start_IT(&htim2, TIM_CHANNEL_1);

注意:PWM中断频率等于PWM频率。如果PWM频率是20kHz,那中断每秒触发20000次。中断服务函数必须非常精简,否则会拖累系统。

我曾经在一个电机控制项目里,PWM频率设到50kHz,中断里还做了浮点运算,结果CPU占用率直接爆表。后来把运算挪到主循环,中断里只做标志位设置,才稳定下来。

4.2.3 中断回调中的占空比调整

void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM2)
    {
        static uint16_t pulse = 0;
        static uint8_t dir = 1;
        
        // 呼吸灯效果:逐周期调整占空比
        if (dir) pulse += 10;
        else     pulse -= 10;
        
        if (pulse >= 1000) dir = 0;
        if (pulse == 0)    dir = 1;
        
        __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse);
    }
}

4.3 输入捕获中断:测量外部信号

输入捕获,说白了就是测量外部信号的频率、周期、脉宽。它的原理很简单:当外部信号发生跳变时,定时器会把当前CNT值“捕获”到CCR寄存器里,同时触发中断。

我在做遥控器解码时,就靠输入捕获来测量红外信号的脉冲宽度。嗯,这个功能真的很实用。

4.3.1 输入捕获的两种模式

模式 触发边沿 典型应用
上升沿捕获 信号从低到高 测量频率、周期开始
下降沿捕获 信号从高到低 测量脉宽、周期结束
双边沿捕获 上升和下降都触发 同时测量高电平和低电平时间

4.3.2 配置输入捕获中断

// 定时器3输入捕获初始化
TIM_HandleTypeDef htim3;
TIM_IC_InitTypeDef sConfigIC;

void MX_TIM3_IC_Init(void)
{
    htim3.Instance = TIM3;
    htim3.Init.Prescaler = 71;        // 1MHz计数频率
    htim3.Init.Period = 0xFFFF;       // 最大计数值
    htim3.Init.CounterMode = TIM_COUNTERMODE_UP;
    HAL_TIM_IC_Init(&htim3);
    
    // 配置通道1:上升沿捕获
    sConfigIC.ICPolarity = TIM_ICPOLARITY_RISING;
    sConfigIC.ICSelection = TIM_ICSELECTION_DIRECTTI;
    sConfigIC.ICPrescaler = TIM_ICPSC_DIV1;
    sConfigIC.ICFilter = 0;
    HAL_TIM_IC_ConfigChannel(&htim3, &sConfigIC, TIM_CHANNEL_1);
    
    // 使能捕获中断
    __HAL_TIM_ENABLE_IT(&htim3, TIM_IT_CC1);
    HAL_NVIC_SetPriority(TIM3_IRQn, 0, 2);
    HAL_NVIC_EnableIRQ(TIM3_IRQn);
}

// 启动输入捕获
HAL_TIM_IC_Start_IT(&htim3, TIM_CHANNEL_1);

4.3.3 测量信号频率

测量频率的经典方法:连续两次上升沿捕获,计算时间差。

volatile uint32_t cap1 = 0, cap2 = 0;
volatile uint8_t cap_flag = 0;

void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM3 && htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1)
    {
        if (cap_flag == 0)
        {
            cap1 = HAL_TIM_ReadCapturedValue(&htim3, TIM_CHANNEL_1);
            cap_flag = 1;
        }
        else
        {
            cap2 = HAL_TIM_ReadCapturedValue(&htim3, TIM_CHANNEL_1);
            cap_flag = 2;
        }
    }
}

// 在主循环中计算频率
if (cap_flag == 2)
{
    uint32_t period_ticks;
    if (cap2 > cap1)
        period_ticks = cap2 - cap1;
    else
        period_ticks = (0xFFFF - cap1) + cap2 + 1;  // 处理溢出
    
    // 频率 = 计数频率 / 周期计数值
    float frequency = 1000000.0f / period_ticks;    // 1MHz计数频率
    cap_flag = 0;
}

避坑指南:处理计数器溢出是输入捕获的难点。当两次捕获之间计数器发生了溢出,直接相减会得到错误结果。我建议用两种方法解决:

  • 方法一:使能更新中断,在更新中断里记录溢出次数
  • 方法二:把ARR设到最大(0xFFFF),用公式处理溢出情况

我个人更推荐方法一,虽然代码复杂点,但精度更高。

4.4 本章知识体系

下面这张图,把三种定时器中断的关系和配置要点串起来了。你可以把它当作一个快速参考。

定时器中断知识体系 定时器中断知识体系 基本定时器 通用/高级定时器 输入捕获 更新中断 (UIF) PSC + ARR 配置 更新中断 (UIF) 比较中断 (CCxIF) CCR 占空比控制 捕获中断 (CCxIF) 边沿选择 (上升/下降) 频率/脉宽测量 核心:中断频率 = 时钟 / (PSC+1) / (ARR+1) 回调函数中避免耗时操作,使用标志位与主循环交互 可升级 可升级

4.5 小结与避坑清单

这一章的内容,说白了就是三个核心点:

  • 基本定时器:PSC+ARR决定中断频率,适合周期性任务
  • PWM中断:更新中断+比较中断,适合逐周期控制
  • 输入捕获:边沿触发捕获,适合信号测量

避坑清单(我踩过的坑):

  1. 中断频率过高:别把中断频率设到几十kHz以上,除非你确认CPU扛得住
  2. 回调函数里做延时:HAL_Delay依赖SysTick中断,在定时器中断里调用会导致死锁
  3. 忘记清除中断标志:HAL库会自动清除,但如果你操作寄存器,别忘了清UIF或CCxIF位
  4. 输入捕获溢出处理:两次捕获之间计数器可能溢出,一定要处理这个情况
  5. PWM启动顺序:先配置好所有参数,再启动PWM输出,否则第一个周期可能异常

嗯,定时器中断的内容就到这里。这些代码和配置方法,都是我在实际项目中反复验证过的。你照着做,基本不会出问题。如果遇到奇怪的现象,先检查时钟配置,再检查中断优先级——这两个地方最容易翻车。