第三章 定时器基础与应用

定时器这东西,说白了就是单片机的「心跳」。我做了这么多年嵌入式开发,可以负责任地告诉你——谁掌握了定时器,谁就掌握了嵌入式系统的节奏。今天咱们就把系统滴答定时器、通用定时器、高级定时器,还有编码器接口模式,一次性讲透。

本章核心脉络:

  • SysTick:操作系统的「节拍器」
  • 通用定时器:PWM输出 + 输入捕获
  • 高级定时器:互补PWM + 死区时间
  • 编码器接口:电机位置反馈
定时器知识体系 定时器外设 系统滴答定时器 通用定时器 高级定时器 编码器接口 操作系统心跳 PWM输出 输入捕获 互补PWM 死区时间 位置/速度检测 电机控制 · 信号测量 · 系统调度

3.1 系统滴答定时器(SysTick)—— 操作系统的「节拍器」

SysTick 是 Cortex-M 内核自带的 24 位递减计数器。你想想看,为什么每个 ARM 芯片都有它?因为它是为操作系统量身定做的。

我个人习惯,不管跑不跑 RTOS,都会先初始化 SysTick。为什么?因为它能提供一个精准的 1ms 时基,做延时、做超时判断都特别方便。

我的经验:在裸机开发中,我经常用 SysTick 做「软件定时器池」。一个中断服务函数里轮询多个标志位,比用硬件定时器挨个配置省事多了。

SysTick 寄存器一览

寄存器功能关键位
SYST_CSR控制与状态ENABLE(0)、TICKINT(1)、CLKSOURCE(2)
SYST_RVR重装载值24位,决定中断周期
SYST_CVR当前计数值读清零,写清0
SYST_CALIB校准值厂家提供,一般不用

配置其实很简单。假设系统时钟 72MHz,想要 1ms 中断一次:

void SysTick_Init(void)
{
    // 1. 关闭SysTick,确保配置期间不产生意外中断
    SYST_CSR = 0x00;
    
    // 2. 设置重装载值:72MHz / 1000 = 72000
    SYST_RVR = 72000 - 1;  // 注意:减1是因为从0开始计数
    
    // 3. 清空当前计数值
    SYST_CVR = 0;
    
    // 4. 使能:使用处理器时钟 + 使能中断 + 启动定时器
    SYST_CSR = 0x07;  // CLKSOURCE=1, TICKINT=1, ENABLE=1
}

我曾经踩过的坑:有次把 SYST_RVR 写成了 72000,结果中断周期变成了 1.0000139ms。虽然误差很小,但在做高精度时间同步时,累积误差会越来越大。记住:重装载值 = 时钟频率 / 目标频率 - 1

中断服务函数怎么写?

volatile uint32_t SysTick_Count = 0;

void SysTick_Handler(void)
{
    SysTick_Count++;
    
    // 这里可以放你的轮询任务
    // 比如:每10ms扫描一次按键
    if (SysTick_Count % 10 == 0) {
        Key_Scan();
    }
}

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

嗯,这里要注意:SysTick_Count 一定要用 volatile 修饰,否则编译器优化后会出大问题。我见过有人 debug 时正常,release 版就死机,查了半天发现是 volatile 丢了。

3.2 通用定时器——PWM输出与输入捕获

通用定时器比 SysTick 灵活得多。它通常有 4 个独立通道,每个通道都可以配置成输出比较或输入捕获。说白了,输出比较就是定时器到了某个值就翻转引脚,输入捕获就是记录外部信号跳变时的计数值

3.2.1 PWM 输出——让 LED 呼吸起来

PWM 的核心就三个参数:频率、占空比、极性。我建议你先把频率定下来,再调占空比。

void TIM_PWM_Init(void)
{
    // 以TIM2为例,输出PA0
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    
    // 配置PA0为复用功能AF1(TIM2_CH1)
    GPIOA->MODER |= GPIO_MODER_MODER0_1;  // 复用模式
    GPIOA->AFR[0] |= 0x01;                // AF1
    
    // 定时器配置
    TIM2->PSC = 72 - 1;     // 72MHz / 72 = 1MHz
    TIM2->ARR = 1000 - 1;   // 1MHz / 1000 = 1kHz PWM频率
    
    // 通道1配置为PWM模式1
    TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;  // PWM模式1
    TIM2->CCMR1 |= TIM_CCMR1_OC1PE;  // 预装载使能
    TIM2->CCER |= TIM_CCER_CC1E;     // 使能输出
    
    // 设置占空比50%
    TIM2->CCR1 = 500;
    
    // 使能定时器
    TIM2->CR1 |= TIM_CR1_CEN;
}

// 动态调整占空比
void PWM_SetDuty(uint16_t duty)
{
    if (duty > 1000) duty = 1000;
    TIM2->CCR1 = duty;
}

我的习惯:PWM 频率选 1kHz 以上,人眼就看不到闪烁了。做电机控制时,我一般用 10kHz~20kHz,既能避免噪音,又能保证控制精度。

3.2.2 输入捕获——测量脉冲宽度

输入捕获说白了就是「守株待兔」。定时器一直在跑,当外部信号跳变时,硬件自动把当前计数值锁存到捕获寄存器里。

volatile uint32_t IC_Value1, IC_Value2;
volatile uint8_t  IC_Complete = 0;

void TIM_IC_Init(void)
{
    // 配置TIM3_CH1 (PA6) 为输入捕获
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
    
    TIM3->PSC = 72 - 1;     // 1MHz计数
    TIM3->ARR = 0xFFFF;     // 最大计数值
    
    // 通道1配置为输入捕获,上升沿触发
    TIM3->CCMR1 |= TIM_CCMR1_CC1S_0;  // 映射到TI1
    TIM3->CCER |= TIM_CCER_CC1E;      // 使能捕获
    TIM3->CCER &= ~TIM_CCER_CC1P;     // 上升沿
    
    // 使能中断
    TIM3->DIER |= TIM_DIER_CC1IE;
    NVIC_EnableIRQ(TIM3_IRQn);
    
    TIM3->CR1 |= TIM_CR1_CEN;
}

void TIM3_IRQHandler(void)
{
    if (TIM3->SR & TIM_SR_CC1IF) {
        TIM3->SR &= ~TIM_SR_CC1IF;
        
        if (!IC_Complete) {
            IC_Value1 = TIM3->CCR1;  // 第一次捕获
            // 切换为下降沿
            TIM3->CCER |= TIM_CCER_CC1P;
            IC_Complete = 1;
        } else {
            IC_Value2 = TIM3->CCR1;  // 第二次捕获
            // 切换回上升沿
            TIM3->CCER &= ~TIM_CCER_CC1P;
            
            // 计算脉宽
            uint32_t pulse = (IC_Value2 > IC_Value1) ? 
                             (IC_Value2 - IC_Value1) : 
                             (0xFFFF - IC_Value1 + IC_Value2);
            // pulse 的单位是 1us
            IC_Complete = 0;
        }
    }
}

我曾经遇到的坑:测量低频信号时,如果计数值溢出了怎么办?我的做法是开启定时器的更新中断(UIE),在溢出中断里记录溢出次数。这样就能测量任意宽度的脉冲了。

3.3 高级定时器——互补PWM与死区时间

高级定时器是通用定时器的「Pro Max」版本。它多了什么?互补输出、死区插入、刹车功能。这些在电机控制、逆变器、开关电源里是标配。

3.3.1 互补PWM——H桥驱动的灵魂

你想想看,驱动一个 H 桥,上管和下管不能同时导通,否则就是短路。互补 PWM 就是让上下管交替导通,中间留一个「死区时间」。

void TIM1_ComplementaryPWM_Init(void)
{
    // TIM1是高级定时器,挂在APB2上
    RCC->APB2ENR |= RCC_APB2ENR_TIM1EN;
    
    TIM1->PSC = 0;           // 不分频
    TIM1->ARR = 7200 - 1;    // 10kHz PWM (72MHz/7200)
    
    // 通道1配置为PWM模式1
    TIM1->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;
    TIM1->CCMR1 |= TIM_CCMR1_OC1PE;
    
    // 使能主输出和互补输出
    TIM1->CCER |= TIM_CCER_CC1E;   // CH1输出
    TIM1->CCER |= TIM_CCER_CC1NE;  // CH1N互补输出
    
    // 设置死区时间
    // BDTR寄存器的DTG[7:0]控制死区
    // 假设系统时钟72MHz,死区时间 = DTG * 1/72MHz
    TIM1->BDTR |= 72;  // 死区时间 = 1us
    
    // 使能刹车和死区寄存器
    TIM1->BDTR |= TIM_BDTR_MOE;  // 主输出使能
    
    TIM1->CR1 |= TIM_CR1_CEN;
}

死区时间怎么选?

  • MOSFET 驱动:一般 100ns ~ 500ns
  • IGBT 驱动:需要 1us ~ 5us
  • 继电器驱动:可以到 10ms

选小了会烧管子,选大了效率低。我一般留 20% 的余量。

3.3.2 刹车功能——紧急停机的保障

高级定时器还有一个很贴心的功能:刹车输入(Break Input)。当检测到故障信号时,硬件自动把 PWM 输出强制拉到安全电平。

// 配置刹车功能
TIM1->BDTR |= TIM_BDTR_BKE;    // 使能刹车输入
TIM1->BDTR |= TIM_BDTR_BKP;    // 刹车输入高电平有效
TIM1->BDTR |= TIM_BDTR_AOE;    // 自动输出使能(刹车解除后恢复)

// 刹车时的输出状态
// 可以配置为:高阻态、强制高、强制低
TIM1->BDTR |= TIM_BDTR_OSSI;   // 空闲时输出使能
TIM1->BDTR |= TIM_BDTR_OSSR;   // 运行时空闲状态

我的经验:刹车功能一定要接硬件故障信号,比如过流保护、过温保护。不要只依赖软件判断,硬件刹车比软件中断快至少一个数量级。

3.4 编码器接口模式——电机的位置「眼睛」

编码器接口模式,说白了就是让定时器自动处理编码器的 A、B 相信号。你不需要写代码去判断正反转,硬件帮你算好了。

工作原理

编码器接口模式使用 TI1 和 TI2 两个输入通道。定时器根据两路信号的相位差,自动决定是加计数还是减计数。

信号状态计数方向说明
A 领先 B 90°递增正转
B 领先 A 90°递减反转
A 和 B 同时变化×2 或 ×4 计数分辨率翻倍
void TIM_Encoder_Init(void)
{
    // 以TIM4为例,PB6(TIM4_CH1), PB7(TIM4_CH2)
    RCC->APB1ENR |= RCC_APB1ENR_TIM4EN;
    
    TIM4->PSC = 0;  // 不分频,直接计数
    
    // 配置编码器模式:TI1和TI2都计数(×4模式)
    TIM4->SMCR |= TIM_SMCR_SMS_0 | TIM_SMCR_SMS_1;  // 编码器模式3
    
    // 配置输入滤波
    TIM4->CCMR1 |= TIM_CCMR1_IC1F_0 | TIM_CCMR1_IC1F_1;  // 8个采样点滤波
    TIM4->CCMR1 |= TIM_CCMR1_IC2F_0 | TIM_CCMR1_IC2F_1;
    
    // 使能输入
    TIM4->CCER |= TIM_CCER_CC1E;
    TIM4->CCER |= TIM_CCER_CC2E;
    
    // 设置自动重装载为最大值
    TIM4->ARR = 0xFFFF;
    
    TIM4->CR1 |= TIM_CR1_CEN;
}

// 读取编码器位置
int16_t Encoder_GetPosition(void)
{
    return (int16_t)TIM4->CNT;
}

// 清零位置
void Encoder_ResetPosition(void)
{
    TIM4->CNT = 0;
}

我曾经踩过的坑:编码器线太长,信号有毛刺,导致计数不准。后来加了硬件滤波(RC 低通)和软件滤波(连续采样三次取中值),问题才解决。另外,编码器接口的 CNT 寄存器是 16 位的,记得用 int16_t 读取,否则正反转会出问题。

速度测量——M法 vs T法

测速度有两种常用方法:

  • M法(测频法):固定时间窗口内计数。适合高速场景。
  • T法(测周法):测量两个脉冲的时间间隔。适合低速场景。
// M法测速:每100ms读取一次计数值
uint16_t last_cnt = 0;
uint16_t speed = 0;

void Speed_Measure_M_Method(void)
{
    uint16_t current_cnt = TIM4->CNT;
    uint16_t delta = current_cnt - last_cnt;
    last_cnt = current_cnt;
    
    // 速度 = 脉冲数 / 时间 / 编码器线数 × 60 (RPM)
    // 假设编码器1000线,时间窗口100ms
    speed = (delta * 600) / 1000;  // 单位:RPM
}

我的建议:实际项目中,我通常把 M 法和 T 法结合起来用。高速用 M 法,低速自动切换为 T 法,这样全速域都能保证精度。


好了,定时器的核心内容就这些。从 SysTick 的系统节拍,到通用定时器的 PWM 和捕获,再到高级定时器的互补输出和编码器接口,每个模块都是工业控制里的「基本功」。你把这些吃透了,大部分电机控制、信号测量的场景都能应对。

记住我的一句话:定时器不是万能的,但没有定时器是万万不能的。多动手写代码,多拿示波器看波形,比看十遍文档都管用。

公众号:蓝海资料掘金营,微信deep3321