4. 定时器中断:定时器的工作原理、定时器中断的配置、定时器中断实现精确延时

各位同学,今天我们来聊聊单片机开发里最常用的外设之一——定时器。说它常用,是因为几乎每个嵌入式项目都离不开它。我刚开始做项目时,总觉得定时器就是“数数”,后来踩了不少坑才明白,这里面的门道其实挺深的。

4.1 定时器的工作原理

定时器,说白了就是一个会自己加1的计数器。它内部有一个寄存器,每来一个时钟脉冲,这个寄存器的值就加1。当它加到最大值(比如16位定时器就是65535)时,再+1就会溢出,回到0重新开始。

这个“溢出”事件,就是定时器中断的触发源。

核心概念:定时器 = 计数器 + 时钟源 + 自动重装载机制

我画了一张图,帮你理清定时器的工作流程:

定时器中断工作流程 时钟源 (晶振/内部RC) 预分频器 (PSC) 计数器 (CNT) 自动重装载 (ARR) 中断标志 (UIF) 中断控制器 (NVIC) CPU (执行中断服务) f_clk f_ck_cnt 溢出 重装载 中断请求 响应 关键参数 • 时钟频率 • 预分频系数 • 自动重装载值 • 计数模式 • 中断优先级 公式: T_溢出 = (ARR+1) × (PSC+1) ÷ f_clk

定时器的核心公式就一个:

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

其中:

  • ARR:自动重装载寄存器,决定计数上限
  • PSC:预分频器,把时钟频率降下来
  • 定时器时钟频率:通常来自系统时钟或专门的定时器时钟

举个例子,如果系统时钟是72MHz,你想产生1ms的定时中断:

  • 先设置PSC=71,把72MHz分频成1MHz(每1us计数一次)
  • 再设置ARR=999,计数1000次就是1ms

嗯,这里要注意:预分频器是16位的,最大只能到65535。如果你需要更长的定时,就得用软件计数器再累加。

4.2 定时器中断的配置

配置定时器中断,我习惯按下面这几步来走。每一步都有它的道理,别跳步骤。

步骤 操作 说明
1 使能定时器时钟 RCC_APB1PeriphClockCmd() 或类似函数
2 配置预分频器 (PSC) 决定计数时钟频率
3 配置自动重装载值 (ARR) 决定溢出周期
4 配置计数模式 向上/向下/中央对齐
5 使能更新中断 TIM_IT_Update
6 配置NVIC优先级 设置抢占优先级和子优先级
7 使能定时器 TIM_Cmd() 启动计数
8 编写中断服务函数 处理溢出事件

下面是一个完整的配置代码示例,基于STM32标准库:

// 定时器中断配置 - 1ms中断一次
void TIM2_Init(void)
{
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    NVIC_InitTypeDef NVIC_InitStructure;

    // 1. 使能定时器时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

    // 2. 配置定时器基础参数
    TIM_TimeBaseStructure.TIM_Prescaler = 71;        // PSC = 71
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;  // 向上计数
    TIM_TimeBaseStructure.TIM_Period = 999;          // ARR = 999
    TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
    TIM_TimeBaseStructure.TIM_RepetitionCounter = 0; // 高级定时器才用
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

    // 3. 使能更新中断
    TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);

    // 4. 配置NVIC
    NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);

    // 5. 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

// 中断服务函数
void TIM2_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
    {
        // 清除中断标志位
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);

        // 用户代码:每1ms执行一次
        // 比如:软件计数器累加、LED闪烁、ADC采样等
    }
}

小技巧:中断服务函数里要尽量少做事。我一般只放一个标志位,真正的处理放在主循环里。否则中断嵌套多了,系统响应会变慢。

4.3 定时器中断实现精确延时

用定时器中断做延时,比软件延时(比如for循环空转)靠谱多了。软件延时受编译器优化、中断干扰影响很大,而定时器是硬件在跑,不受这些因素干扰。

我常用的方法有两种:

方法一:查询标志位法

这种方法适合短延时,不需要中断服务函数:

// 微秒级延时 - 查询方式
void delay_us(uint32_t us)
{
    TIM_SetCounter(TIM2, 0);          // 计数器清零
    TIM_Cmd(TIM2, ENABLE);            // 启动定时器

    while (TIM_GetCounter(TIM2) < us); // 等待计数到目标值

    TIM_Cmd(TIM2, DISABLE);           // 停止定时器
}

不过要注意,这种方法会阻塞CPU,不适合在中断里调用。

方法二:中断累加法

这种方法适合长延时,不阻塞CPU:

volatile uint32_t g_tick_ms = 0;  // 全局毫秒计数器

// 1ms中断服务函数
void TIM2_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
        g_tick_ms++;  // 每1ms加1
    }
}

// 毫秒级延时函数
void delay_ms(uint32_t ms)
{
    uint32_t start = g_tick_ms;
    while ((g_tick_ms - start) < ms);  // 非阻塞等待
}

我曾经踩过的坑:g_tick_ms这个变量一定要加volatile关键字!否则编译器优化后,while循环里可能永远读不到更新后的值。我有个项目因为这个bug排查了整整两天。

你想想看,用这种方法,主程序可以同时做其他事情,延时到了自然就知道。这就是“非阻塞延时”的思想。

4.4 常见问题与避坑指南

做定时器中断开发,有几个地方特别容易出问题:

  • 中断标志位没清除:会导致一直进中断,系统卡死。每次进中断第一件事就是清标志。
  • 预分频器配置错误:PSC和ARR都是16位的,别超范围。我见过有人把PSC设成100000,结果定时器根本不工作。
  • 中断优先级冲突:多个中断共用优先级时,可能出现响应延迟。建议把定时器中断优先级设高一些。
  • 变量类型溢出:g_tick_ms用uint32_t,能跑49天不溢出。如果项目需要长期运行,记得处理溢出情况。

总结一下:定时器中断的核心就是“硬件计数+软件处理”。配置时注意时钟源、预分频、重装载值这三个参数;使用时注意中断服务函数的效率;调试时注意变量声明和标志位清除。掌握了这些,你就能做出精确到微秒级的延时和定时任务。