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是这样工作的:
- 计数器CNT从0开始,向上计数到ARR。
- 每个计数周期内,CNT与CCRx比较一次。
- 当CNT < CCRx时,输出高电平;当CNT ≥ CCRx时,输出低电平。
- 下一个周期开始,重复上述过程。
你想想看,改变CCRx的值,不就改变了高电平的宽度吗?占空比 = CCRx / (ARR + 1)。
我曾经在一个项目中,需要用PWM控制一个微型气泵的转速。当时没注意ARR和CCRx的匹配关系,导致占空比非线性,气泵转速忽快忽慢。后来排查了半天,才发现是ARR设得太小,CCRx的调节范围不够。从那以后,我设计PWM时都会先算好ARR,再定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的时钟节拍,都离不开它。所以,我建议你花点时间把这一章的代码亲手敲一遍,用示波器或者逻辑分析仪看看波形,感受一下占空比变化时波形的实际变化。
嗯,这一章就到这里。代码我已经放在课程配套的例程包里了,大家去下载练习。有问题随时在群里问我。