4. 定时器:从SysTick到输入捕获

定时器这东西,可以说是嵌入式开发的基石。我做了这么多年BSP,几乎每个项目都离不开它。从简单的延时,到复杂的电机控制,定时器无处不在。今天咱们就把这四种定时器玩法——SysTick、通用定时器、PWM输出、输入捕获——一次性讲透。

核心观点:定时器不只是用来计时的。它是CPU的“硬件协处理器”,能帮你完成精确的时间控制、波形生成和信号测量。用好定时器,你的系统实时性会上一个台阶。

4.1 SysTick定时器:系统的心跳

SysTick是Cortex-M内核自带的24位递减计数器。说白了,它就是给操作系统提供“心跳”的。就算你不用RTOS,它也是个非常精准的延时工具。

我个人习惯,在裸机开发时也把SysTick用起来。为什么?因为它不占用通用定时器资源,而且所有Cortex-M芯片都有,代码移植性极好。

4.1.1 SysTick的工作原理

SysTick从你设定的重装载值开始,每个时钟周期减1。减到0时,会触发一个中断(如果你使能了的话),然后自动重新装载,继续下一轮计数。

// SysTick配置示例:产生1ms中断
void SysTick_Init(uint32_t systemClockHz) {
    // 重装载值 = 系统时钟频率 / 1000 - 1
    // 假设系统时钟为72MHz
    SysTick->LOAD = 72000 - 1;  // 1ms中断
    SysTick->VAL  = 0;          // 清空计数器
    SysTick->CTRL = 0x07;       // 使能、使能中断、使用系统时钟
}

小技巧:SysTick的LOAD寄存器是24位的,最大值是0xFFFFFF。如果你的系统时钟很高,又想产生较长的定时周期,记得先算一下会不会溢出。我曾经在168MHz的芯片上直接写了个1秒的装载值,结果发现计数器根本装不下——嗯,后来改用分频解决了。

4.1.2 基于SysTick的延时实现

有了1ms的中断,我们就可以实现一个毫秒级的延时函数。这里有个关键点:延时函数里要“忙等”,但中断服务函数里只做计数。

volatile uint32_t sysTickUptime = 0;

void SysTick_Handler(void) {
    sysTickUptime++;  // 每1ms加1
}

void delay_ms(uint32_t ms) {
    uint32_t start = sysTickUptime;
    while ((sysTickUptime - start) < ms) {
        // 等待
    }
}

你想想看,这个实现有什么好处?它不会阻塞中断。延时期间,其他中断照常响应。这就是“基于系统滴答的延时”比纯循环延时高明的地方。

4.2 通用定时器:多功能的时间管理

通用定时器比SysTick复杂得多。它通常有多个通道,支持向上计数、向下计数、中央对齐模式,还能产生PWM、捕获输入信号。每个MCU厂商的实现略有差异,但核心逻辑是相通的。

4.2.1 定时器的核心寄存器

寄存器 作用 我常用的配置
PSC(预分频器) 对系统时钟分频,降低计数频率 通常设为 71(72MHz→1MHz)
ARR(自动重装载) 计数值达到此值后溢出/更新 根据需要的周期计算
CNT(计数器) 当前计数值,可读可写 初始化时清0
SR(状态寄存器) 标志位,如更新中断标志 中断服务函数中清标志

举个例子。你想产生一个1ms的定时中断,系统时钟72MHz。先设置PSC=71,这样计数频率变成1MHz(1微秒一个计数)。再设置ARR=999,这样从0数到999正好是1000微秒,也就是1ms。

// 通用定时器初始化(以TIM2为例)
void TIM2_Init(void) {
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;  // 使能时钟
    
    TIM2->PSC = 71;      // 72MHz / (71+1) = 1MHz
    TIM2->ARR = 999;     // 1MHz / (999+1) = 1kHz = 1ms
    TIM2->DIER |= TIM_DIER_UIE;  // 使能更新中断
    NVIC_EnableIRQ(TIM2_IRQn);    // 使能NVIC中断
    
    TIM2->CR1 |= TIM_CR1_CEN;    // 启动定时器
}

避坑指南:我曾经在配置定时器时,忘了使能RCC时钟。结果定时器寄存器怎么写都不动,查了整整半天。记住:外设时钟使能是第一步,没有时钟,一切都是空谈。

4.3 PWM输出:让硬件帮你干活

PWM(脉冲宽度调制)说白了就是输出一个频率固定、占空比可调的方波。你可能会问:这玩意儿有什么用?用处大了去了——调LED亮度、控制电机速度、驱动舵机角度,都靠它。

用通用定时器产生PWM,核心思路是:定时器工作在输出比较模式,当CNT的值小于某个阈值时,输出高电平;大于阈值时,输出低电平。这个阈值就是“比较值”,通常存在CCR寄存器里。

4.3.1 PWM配置步骤

  1. 配置GPIO为复用功能——把引脚交给定时器控制
  2. 设置定时器时基——PSC和ARR决定PWM频率
  3. 配置输出比较通道——选择PWM模式1或模式2
  4. 设置占空比——写CCR寄存器
  5. 使能输出和定时器——开始输出波形
// PWM输出配置(TIM2通道1,PA0)
void PWM_Init(void) {
    // 1. GPIO配置
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    GPIOA->MODER |= GPIO_MODER_MODER0_1;  // 复用功能
    GPIOA->AFR[0] |= 1 << 0;             // AF1 = TIM2_CH1
    
    // 2. 定时器时基
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    TIM2->PSC = 71;       // 1MHz计数频率
    TIM2->ARR = 999;      // PWM频率 = 1MHz/1000 = 1kHz
    
    // 3. 输出比较配置
    TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;  // PWM模式1
    TIM2->CCMR1 |= TIM_CCMR1_OC1PE;   // 预装载使能
    
    // 4. 设置占空比(50%)
    TIM2->CCR1 = 500;
    
    // 5. 使能
    TIM2->CCER |= TIM_CCER_CC1E;      // 使能通道输出
    TIM2->CR1 |= TIM_CR1_CEN;         // 启动定时器
}

// 动态修改占空比
void PWM_SetDuty(uint16_t duty) {
    TIM2->CCR1 = duty;  // 范围:0~ARR
}

经验之谈:修改占空比时,最好在定时器更新事件之后写CCR,否则可能出现一个完整的错误周期。我习惯在PWM初始化时使能预装载功能(OCxPE位),这样CCR的值会在下一个更新事件才生效,避免波形毛刺。

4.4 输入捕获:测量外部信号

输入捕获和PWM正好相反。PWM是输出波形,输入捕获是测量外部波形的频率和占空比。原理很简单:当检测到指定的边沿(上升沿或下降沿)时,定时器会把当前CNT的值“捕获”到CCR寄存器里,同时触发中断。

通过两次捕获的差值,就能算出信号的周期和频率。如果再捕获一次另一个边沿,还能算出高电平宽度,进而得到占空比。

4.4.1 频率测量实现

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

void TIM3_IRQHandler(void) {
    if (TIM3->SR & TIM_SR_CC1IF) {
        static uint8_t edgeCount = 0;
        if (edgeCount == 0) {
            cap1 = TIM3->CCR1;  // 第一次上升沿
            edgeCount = 1;
        } else {
            cap2 = TIM3->CCR1;  // 第二次上升沿
            capDone = 1;
            edgeCount = 0;
        }
        TIM3->SR = ~TIM_SR_CC1IF;  // 清标志
    }
}

// 在主循环中读取结果
if (capDone) {
    uint32_t period;
    if (cap2 > cap1) {
        period = cap2 - cap1;
    } else {
        period = (TIM3->ARR + 1) - cap1 + cap2;  // 处理溢出
    }
    // 频率 = 计数频率 / period
    capDone = 0;
}

注意:输入捕获一定要处理计数器溢出!如果信号周期很长,CNT可能已经翻转了好几次。我遇到过这种情况:测量一个低频信号,结果算出来的频率完全不对,就是因为没考虑溢出。解决方案是开启定时器的更新中断,在中断里记录溢出的次数。

4.5 本章知识体系

下面这张图总结了四种定时器玩法的关系和应用场景。你可以把它当作一个快速参考。

定时器知识体系图 定时器知识体系 定时器 SysTick定时器 通用定时器 PWM输出 输入捕获 应用场景 系统心跳 精确延时 RTOS时基 应用场景 周期性任务 定时中断 时基扩展 应用场景 LED调光 电机控制 舵机驱动 应用场景 频率测量、脉冲宽度测量

4.6 避坑总结

做定时器开发这些年,我踩过的坑不少。挑几个典型的分享给你:

  • 时钟源搞错:APB1和APB2的定时器时钟可能不同。APB1上的定时器通常是系统时钟的一半(如果APB1分频系数不为1)。我吃过这个亏,算出来的时间常数全不对。
  • 中断优先级:定时器中断优先级设置不当,会导致其他中断响应延迟。特别是SysTick,如果优先级设得太低,系统调度会出问题。
  • PWM初始状态:刚使能PWM输出时,引脚电平可能不确定。我习惯在初始化时先设置一个安全的占空比(比如0%或50%),再启动定时器。
  • 输入捕获的噪声:外部信号可能有毛刺。建议开启定时器的数字滤波器功能,或者加一个简单的RC滤波电路。

定时器这部分内容,说难不难,说简单也不简单。关键是要理解它的本质——一个独立于CPU运行的硬件计数器。你配置好参数,它自己就能干活,CPU可以去做别的事。这就是嵌入式系统里“硬件加速”的思想。

嗯,这一章就到这里。代码示例都是基于STM32的,但思路通用。你换成其他芯片,寄存器名字不一样,逻辑是一样的。