4. 定时器基础与PWM输出:定时器工作原理、HAL库定时器配置、PWM输出驱动舵机/电机实战
定时器,说白了就是单片机的“心跳”。没有它,很多时序相关的操作都得靠CPU死等,效率低得可怜。我刚开始做嵌入式那会儿,总觉得定时器就是个计数的玩意儿,后来踩的坑多了才明白——用好定时器,你的系统就成功了一半。
4.1 STM32定时器家族:你手里到底有几把刷子?
STM32的定时器不是只有一种。我见过不少新手,上来就用高级定时器干基础活,结果资源浪费严重。咱们先理清楚这家族谱系:
| 定时器类型 | 位数 | 主要特性 | 典型应用场景 |
|---|---|---|---|
| 基本定时器(TIM6/TIM7) | 16位 | 仅基础计数,无PWM/捕获 | 定时中断、DMA触发 |
| 通用定时器(TIM2~TIM5) | 16/32位 | PWM输出、输入捕获、编码器 | 舵机控制、电机调速、测频 |
| 高级定时器(TIM1/TIM8) | 16位 | 互补PWM、刹车功能、死区插入 | 电机FOC、三相逆变、电源控制 |
我个人习惯:做舵机控制用通用定时器就够了,别拿高级定时器当普通用。高级定时器的刹车功能和死区插入,那是给电机驱动准备的硬货。
4.2 定时器核心工作原理:从计数器到PWM
定时器工作的本质,就是一个计数器在时钟驱动下不断累加。当计数值达到某个设定值时,产生事件或中断。PWM输出,就是在这个基础上加了个比较器。
核心公式:
- PWM频率 = 定时器时钟 / (预分频器值 + 1) / (自动重装载值 + 1)
- PWM占空比 = 比较值 / (自动重装载值 + 1) × 100%
举个例子:系统时钟72MHz,预分频器设为71(即72分频),自动重装载值设为999,那么PWM频率就是 72MHz / 72 / 1000 = 1kHz。占空比50%的话,比较值设为500就行。
我的经验:预分频器值和自动重装载值的选择,直接影响PWM的分辨率。想要精细调节占空比,就把自动重装载值设大一些。但注意,频率和分辨率是矛盾的——频率越高,分辨率越低。
4.3 HAL库定时器配置:三步走,不迷路
用HAL库配置定时器,其实就三步。我在项目里反复用这套流程,从来没出过问题。
- 初始化定时器句柄:设置预分频器、计数模式、自动重装载值
- 配置PWM通道:设置极性、比较值、使能输出
- 启动定时器:调用HAL_TIM_PWM_Start()
来看一段实际代码,这是我做舵机控制时用的配置:
// 定时器句柄定义
TIM_HandleTypeDef htim2;
// 定时器初始化函数
void MX_TIM2_Init(void)
{
TIM_OC_InitTypeDef sConfigOC = {0};
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71; // 72MHz / 72 = 1MHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 19999; // 1MHz / 20000 = 50Hz
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_PWM_Init(&htim2);
// 配置PWM通道1
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 1500; // 初始占空比:1.5ms脉宽
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
// 启动PWM输出
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}
我曾经踩过的坑:启动PWM之前,一定要确保GPIO引脚已经配置为复用功能模式。否则,你看到的PWM波形就是一条直线。另外,别忘了使能定时器的时钟——HAL_TIM_PWM_Init()内部会做,但如果你手动改了时钟,记得重新初始化。
4.4 PWM输出驱动舵机:角度控制实战
舵机控制,说白了就是控制PWM的脉宽。标准舵机的工作频率是50Hz(周期20ms),脉宽范围0.5ms~2.5ms,对应0°~180°。
我常用的映射关系:
| 舵机角度 | 脉宽(ms) | 比较值(50Hz, 1MHz时钟) |
|---|---|---|
| 0° | 0.5 | 500 |
| 45° | 1.0 | 1000 |
| 90° | 1.5 | 1500 |
| 135° | 2.0 | 2000 |
| 180° | 2.5 | 2500 |
写一个角度设置函数,其实就一行代码的事:
void Servo_SetAngle(uint16_t angle)
{
// 角度范围0~180,映射到脉宽500~2500
uint16_t pulse = 500 + (angle * 2000 / 180);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse);
}
注意:不同品牌的舵机,脉宽范围可能略有差异。我建议你在实际使用前,用示波器确认一下舵机的响应范围。有些廉价舵机,脉宽超过2.3ms就可能烧毁。
4.5 PWM输出驱动直流电机:速度与方向控制
直流电机控制比舵机复杂一些。你需要两个信号:一个控制方向(GPIO高低电平),一个控制速度(PWM占空比)。
我常用的电机驱动方案是L298N或TB6612,控制逻辑如下:
| 电机状态 | IN1 | IN2 | PWM |
|---|---|---|---|
| 正转 | 高 | 低 | 占空比可调 |
| 反转 | 低 | 高 | 占空比可调 |
| 刹车 | 高 | 高 | 任意 |
| 停止 | 低 | 低 | 任意 |
代码实现也很直接:
void Motor_Control(uint8_t direction, uint16_t speed)
{
// speed范围0~1000,对应占空比0%~100%
switch(direction)
{
case MOTOR_FORWARD:
HAL_GPIO_WritePin(MOTOR_IN1_GPIO_Port, MOTOR_IN1_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(MOTOR_IN2_GPIO_Port, MOTOR_IN2_Pin, GPIO_PIN_RESET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, speed);
break;
case MOTOR_BACKWARD:
HAL_GPIO_WritePin(MOTOR_IN1_GPIO_Port, MOTOR_IN1_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(MOTOR_IN2_GPIO_Port, MOTOR_IN2_Pin, GPIO_PIN_SET);
__HAL_TIM_SET_COMPARE(&htim3, TIM_CHANNEL_2, speed);
break;
case MOTOR_STOP:
HAL_GPIO_WritePin(MOTOR_IN1_GPIO_Port, MOTOR_IN1_Pin, GPIO_PIN_RESET);
HAL_GPIO_WritePin(MOTOR_IN2_GPIO_Port, MOTOR_IN2_Pin, GPIO_PIN_RESET);
break;
case MOTOR_BRAKE:
HAL_GPIO_WritePin(MOTOR_IN1_GPIO_Port, MOTOR_IN1_Pin, GPIO_PIN_SET);
HAL_GPIO_WritePin(MOTOR_IN2_GPIO_Port, MOTOR_IN2_Pin, GPIO_PIN_SET);
break;
}
}
我曾经踩过的坑:电机启动瞬间电流很大,如果电源供电不足,会导致单片机复位。我的建议是:电机电源和单片机电源分开,共地即可。另外,PWM频率不要设得太低,否则电机会发出刺耳的啸叫声。1kHz~20kHz是比较合适的范围。
4.6 定时器PWM输出知识体系
下面这张图,是我自己总结的定时器PWM输出知识体系。你把它理清了,后面做电机控制、舵机控制、LED调光,基本就是套模板的事。
4.7 实战中的避坑指南
做PWM驱动,有几个坑我反复踩过,今天一并告诉你:
- GPIO复用功能没配置:PWM输出引脚必须配置为复用推挽输出,否则没波形。我见过有人查了半天,结果是GPIO模式设成了输出。
- 定时器时钟没使能:虽然HAL_TIM_PWM_Init()内部会处理,但如果你在初始化之前手动操作了RCC,记得检查时钟是否正常。
- 自动重装载预加载没使能:如果不使能,修改比较值时可能产生不完整的PWM周期,导致电机抖动。
- 舵机供电不足:舵机启动瞬间电流可达1A以上,直接从单片机3.3V取电必死。用外部5V电源,共地即可。
最后说一句:定时器PWM输出,是嵌入式开发中最基础也最实用的技能之一。你把它吃透了,后面做电机控制、舵机控制、甚至音频输出,都会轻松很多。我当年做第一个平衡车项目时,就是靠这几个定时器撑起了整个控制逻辑。嗯,动手试试吧,代码跑起来才是硬道理。