4. 定时器基础与PWM输出:让硬件“动”起来

各位同学,欢迎来到第四章。前几章我们把GPIO、中断、串口这些基础外设都过了一遍。从这一章开始,我们要接触一个在嵌入式世界里无处不在的东西——定时器。

说实话,我刚学STM32那会儿,觉得定时器不就是个“倒计时”嘛,有啥好学的?后来做项目才发现,定时器远比你想象的复杂,也远比你想象的强大。尤其是PWM输出,这几乎是控制类项目的标配——舵机、电机、LED呼吸灯、甚至音频输出,都离不开它。

这一章,我们就从通用定时器入手,一步步把它搞明白,然后亲手配置一个PWM输出,驱动舵机转起来,或者让LED呼吸起来。

4.1 定时器是个啥?——从“心跳”说起

你可以把定时器想象成MCU内部的一个“独立心跳”。它有自己的时钟源,可以独立于CPU运行。你告诉它:“每过1毫秒,你通知我一声。”然后它就会准时给你发中断,或者自动翻转一个引脚电平。

STM32的定时器家族很庞大,但最常用的是这三类:

类型 定时器编号 主要特点 典型用途
基本定时器 TIM6、TIM7 只有基本定时功能,无外部引脚 产生时基、触发DAC
通用定时器 TIM2~TIM5 有4个独立通道,支持输入捕获、输出比较、PWM PWM输出、脉冲计数、编码器接口
高级定时器 TIM1、TIM8 在通用基础上增加互补输出、死区插入、刹车功能 电机控制、三相逆变、开关电源

我们这一章重点讲通用定时器。它功能最均衡,也最常用。我个人习惯,但凡不是特别复杂的电机控制场景,都用通用定时器搞定。

4.2 通用定时器的核心寄存器

要玩转定时器,你得先认识几个关键寄存器。别怕,不多,就四个:

  • PSC(预分频器):把定时器的输入时钟分频。比如72MHz的时钟,你设PSC=71,就变成1MHz。
  • ARR(自动重装载寄存器):决定定时器计数的上限。计数器从0数到ARR,然后归零重新开始。
  • CNT(计数器):当前计数值,只读。
  • CCRx(捕获/比较寄存器):每个通道一个,用于设定比较值。当CNT == CCRx时,触发动作。

嗯,这里要注意:PSC和ARR的值都是实际值减1。比如你想分频72倍,PSC要写71;你想计数1000次,ARR要写999。这个坑我刚开始踩过好几次,后来养成了习惯,每次写之前都心里默念一遍“减一”。

定时周期计算公式:

T = (PSC + 1) × (ARR + 1) / 定时器时钟频率

举个例子:72MHz时钟,PSC=71,ARR=999,则T = 72 × 1000 / 72,000,000 = 0.001秒 = 1ms

4.3 PWM输出原理——说白了就是“占空比”

PWM,全称Pulse Width Modulation,脉冲宽度调制。说白了,就是让一个方波的高电平时间占总周期的比例可调。这个比例就叫占空比。

在通用定时器里,PWM是这样工作的:

  1. 计数器CNT从0开始,向上计数到ARR。
  2. 每个计数周期内,CNT与CCRx比较一次。
  3. 当CNT < CCRx时,输出高电平;当CNT ≥ CCRx时,输出低电平。
  4. 下一个周期开始,重复上述过程。

你想想看,改变CCRx的值,不就改变了高电平的宽度吗?占空比 = CCRx / (ARR + 1)。

我曾经在一个项目中,需要用PWM控制一个微型气泵的转速。当时没注意ARR和CCRx的匹配关系,导致占空比非线性,气泵转速忽快忽慢。后来排查了半天,才发现是ARR设得太小,CCRx的调节范围不够。从那以后,我设计PWM时都会先算好ARR,再定CCRx。

PWM生成原理:计数器与比较器 CNT(计数器值) CCRx ARR PWM输出(高电平宽度随CCRx变化)

4.4 实战:配置PWM驱动舵机

好了,理论讲完了,咱们直接上手。我选一个最常见的场景——舵机控制。舵机一般需要50Hz的PWM信号,也就是周期20ms。占空比在5%~10%之间,对应0°~180°的旋转角度。

先算参数:定时器时钟72MHz,目标频率50Hz,周期20ms。

  • 设PSC=71,得到1MHz的计数频率。
  • 要得到50Hz,ARR+1 = 1,000,000 / 50 = 20,000,所以ARR=19999。
  • 0°对应占空比5%:CCR = 20000 × 5% = 1000。
  • 180°对应占空比10%:CCR = 20000 × 10% = 2000。

下面是完整的初始化代码。我用的是STM32F103系列,TIM2的通道1,引脚PA0。

// 定时器PWM初始化函数
void TIM2_PWM_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
    TIM_OCInitTypeDef TIM_OCInitStructure;
    
    // 1. 使能时钟
    RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
    
    // 2. 配置PA0为复用推挽输出
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOA, &GPIO_InitStructure);
    
    // 3. 配置定时器时基
    TIM_TimeBaseStructure.TIM_Prescaler = 71;        // 72MHz / 72 = 1MHz
    TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
    TIM_TimeBaseStructure.TIM_Period = 19999;        // 1MHz / 20000 = 50Hz
    TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
    TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
    
    // 4. 配置PWM模式1
    TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1;
    TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
    TIM_OCInitStructure.TIM_Pulse = 1500;            // 初始占空比:7.5%,对应90°
    TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
    TIM_OC1Init(TIM2, &TIM_OCInitStructure);
    
    // 5. 使能定时器
    TIM_Cmd(TIM2, ENABLE);
}

// 设置舵机角度函数
void Servo_SetAngle(uint16_t angle)
{
    // angle: 0~180
    // 映射到CCR值:1000~2000
    uint16_t pulse = 1000 + (uint16_t)((uint32_t)angle * 1000 / 180);
    TIM_SetCompare1(TIM2, pulse);
}

小技巧:舵机的PWM频率不一定是严格的50Hz,45Hz~55Hz通常都能正常工作。但占空比的上下限一定要根据舵机手册来,超出范围可能会损坏舵机内部的电位器。我一般会在代码里做限幅保护。

4.5 进阶:用PWM实现LED呼吸灯

舵机搞定了,咱们再来个更直观的——LED呼吸灯。原理很简单:让PWM的占空比从0%逐渐增加到100%,再逐渐减小到0%,循环往复。

这里有个关键点:人眼对亮度的感知不是线性的。如果你让占空比线性变化,看起来会感觉亮得很快、暗得很慢。所以,我一般会用指数曲线或者查表法来映射。

下面是一个简单的呼吸灯实现,用定时器中断来更新占空比:

// 呼吸灯参数
#define BREATHE_PERIOD 2000    // 一个完整呼吸周期2秒
#define PWM_MAX 999            // ARR值

uint16_t breathe_step = 0;
uint8_t breathe_dir = 1;       // 1: 变亮, 0: 变暗

// 定时器中断服务函数(假设TIM3每1ms触发一次)
void TIM3_IRQHandler(void)
{
    if(TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET)
    {
        TIM_ClearITPendingBit(TIM3, TIM_IT_Update);
        
        breathe_step++;
        if(breathe_step >= BREATHE_PERIOD)
        {
            breathe_step = 0;
            breathe_dir = !breathe_dir;
        }
        
        // 计算占空比,使用正弦曲线使呼吸更自然
        float rad = (float)breathe_step / BREATHE_PERIOD * 3.14159f;
        uint16_t pulse;
        if(breathe_dir)
            pulse = (uint16_t)(sinf(rad) * PWM_MAX);
        else
            pulse = (uint16_t)(sinf(rad + 3.14159f) * PWM_MAX);
        
        TIM_SetCompare1(TIM3, pulse);
    }
}

注意:在中断里做浮点运算要谨慎。上面的代码只是为了演示原理,实际项目中建议用查表法代替sinf()计算,否则中断响应时间会变长,影响系统实时性。我曾经在一个多任务系统里这么干过,结果导致其他中断丢失,排查了好久才发现是浮点运算惹的祸。

4.6 常见问题与避坑指南

做PWM项目时,有几个坑我几乎每次都会遇到,分享给大家:

  • 引脚复用功能没开:GPIO配置成复用推挽输出后,还要确保AFIO时钟使能,并且引脚重映射正确。我见过有人配了半天没波形,最后发现是引脚映射错了。
  • 预装载寄存器没使能:TIM_OC1PreloadConfig()这个函数容易被忽略。如果不使能预装载,CCRx的更新可能会在计数周期的任意时刻生效,导致输出波形出现毛刺。
  • ARR和CCRx的匹配:CCRx的值不能超过ARR,否则PWM会一直输出高电平或低电平。建议在代码里加一个限幅判断。
  • 高级定时器的刹车功能:如果你用的是TIM1或TIM8,默认刹车输入引脚可能是高电平有效,如果不小心拉高了,PWM输出会被强制关闭。我第一次用TIM1时就被这个坑了半小时。

4.7 本章小结

这一章我们干了三件事:理解了通用定时器的工作原理,学会了PWM输出的配置方法,然后用两个实战案例——舵机和呼吸灯——把知识落地了。

说实话,定时器和PWM是嵌入式开发里非常基础但又极其重要的技能。你后面做电机控制、电源管理、音频播放,甚至RTOS的时钟节拍,都离不开它。所以,我建议你花点时间把这一章的代码亲手敲一遍,用示波器或者逻辑分析仪看看波形,感受一下占空比变化时波形的实际变化。

嗯,这一章就到这里。代码我已经放在课程配套的例程包里了,大家去下载练习。有问题随时在群里问我。