第四章 定时器与PWM输出:用硬件定时器产生精确延时,输出PWM波控制屏幕背光或舵机
说到定时器,我脑子里第一个蹦出来的词就是「心脏」。没有定时器,单片机就是个瘫子,啥也干不了。你想想看,不管是延时、计数、产生波形,还是做任务调度,哪样离得开它?
这一章咱们就聊聊定时器的两个核心应用:精确延时和PWM输出。这两个东西,说白了就是嵌入式开发里最常用的「时间管理术」。屏幕背光要调亮度?舵机要转角度?电机要调速?统统离不开PWM。
4.1 硬件定时器 vs 软件延时
很多初学者喜欢用 delay() 或者 HAL_Delay() 来做延时。嗯,我刚开始做项目时也这么干,直到有一次踩了个大坑。
硬件定时器和软件延时有什么区别?我列个表你就明白了:
| 特性 | 软件延时 | 硬件定时器 |
|---|---|---|
| 精度 | 受中断影响,不稳定 | 硬件计数,精确到时钟周期 |
| CPU占用 | 死循环,CPU被占死 | 后台运行,不阻塞CPU |
| 可移植性 | 依赖主频,换芯片要重算 | 配置寄存器,通用性强 |
| 多任务能力 | 只能做一件事 | 可同时管理多个定时器 |
说白了,软件延时就是「傻等」,硬件定时器是「让硬件帮你等」。你想想看,CPU 几百兆的主频,让它在那空转,多浪费啊。
4.2 定时器的基本配置
以 STM32 为例,我习惯用 TIM2 来做通用定时器。配置其实不复杂,核心就三步:
- 时钟源选择——内部时钟还是外部触发
- 预分频器设置——把高频时钟降到你需要的频率
- 自动重装载值——决定定时周期
举个例子,我想产生一个 1ms 的定时中断。系统时钟是 72MHz,预分频器设成 72-1,这样定时器时钟就是 1MHz。自动重装载值设成 1000-1,那么每计数 1000 次就是 1ms。
// 定时器配置示例(STM32 HAL库)
void MX_TIM2_Init(void)
{
TIM_ClockConfigTypeDef sClockSourceConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71; // 72MHz / (71+1) = 1MHz
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 999; // 1MHz / (999+1) = 1kHz = 1ms
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE;
HAL_TIM_Base_Init(&htim2);
HAL_TIM_Base_Start_IT(&htim2); // 启动定时器并开启中断
}
4.3 PWM 输出原理
PWM,脉冲宽度调制。说白了就是让一个方波的高电平时间占比(占空比)可调。你想想看,如果这个方波频率足够高,人眼就看不出来闪烁,LED 的亮度就由占空比决定了。
PWM 的核心参数就两个:
- 频率——方波每秒重复的次数。控制舵机用 50Hz,控制背光用 1kHz 以上
- 占空比——高电平时间占整个周期的比例。0% 全灭,100% 全亮
硬件产生 PWM 的原理其实很简单:定时器从 0 开始计数,到达「比较值」时翻转电平,到达「重装载值」时归零。这个比较值,就是占空比的控制点。
// PWM 输出配置示例
void MX_TIM2_PWM_Init(void)
{
TIM_OC_InitTypeDef sConfigOC = {0};
htim2.Instance = TIM2;
htim2.Init.Prescaler = 71; // 1MHz
htim2.Init.Period = 999; // 1kHz PWM频率
HAL_TIM_PWM_Init(&htim2);
sConfigOC.OCMode = TIM_OCMODE_PWM1;
sConfigOC.Pulse = 500; // 初始占空比 50%
sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH;
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
HAL_TIM_PWM_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1);
HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1);
}
// 动态调整占空比
void Set_PWM_Duty(uint16_t duty)
{
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, duty);
}
4.4 实战:屏幕背光控制
我在做车载仪表盘时,屏幕背光控制就是个典型应用。白天要亮,晚上要暗,还得支持无级调光。
背光 PWM 的几个要点:
- 频率选 1kHz 左右——太低会闪烁,太高驱动芯片跟不上
- 占空比范围 0%~100%——但实际使用时建议留 5%~95%,避免完全熄灭或全亮
- 用指数曲线调光——人眼对亮度的感知是非线性的,线性调光会感觉「前面没变化,后面突然变亮」
// 指数调光曲线
uint16_t Brightness_Curve[100];
void Init_Brightness_Curve(void)
{
for (int i = 0; i < 100; i++)
{
// 指数映射:输入 0~99,输出 0~999
float normalized = i / 99.0f;
Brightness_Curve[i] = (uint16_t)(999.0f * normalized * normalized);
}
}
void Set_Brightness(int percent)
{
if (percent < 0) percent = 0;
if (percent > 99) percent = 99;
Set_PWM_Duty(Brightness_Curve[percent]);
}
4.5 实战:舵机控制
舵机控制是另一个经典应用。标准舵机的控制信号是 50Hz 的 PWM,占空比 5%~10% 对应 0°~180°。
具体来说:
- 周期 20ms(50Hz)
- 0.5ms 高电平 → 0°
- 1.0ms 高电平 → 45°
- 1.5ms 高电平 → 90°
- 2.0ms 高电平 → 135°
- 2.5ms 高电平 → 180°
// 舵机角度控制
#define SERVO_PERIOD 19999 // 20ms @ 1MHz定时器
#define SERVO_MIN 500 // 0.5ms
#define SERVO_MAX 2500 // 2.5ms
void Servo_SetAngle(uint8_t angle)
{
if (angle > 180) angle = 180;
// 线性映射:0°→500, 180°→2500
uint16_t pulse = SERVO_MIN + (uint16_t)((float)(SERVO_MAX - SERVO_MIN) * angle / 180.0f);
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, pulse);
}
// 使用示例:让舵机从 0° 转到 180°
for (uint8_t i = 0; i <= 180; i++)
{
Servo_SetAngle(i);
HAL_Delay(10); // 每步延时 10ms,让舵机跟上
}
4.6 多通道 PWM 管理
一个定时器通常有 4 个通道,可以同时输出 4 路 PWM。但要注意,所有通道共享同一个频率,只有占空比可以独立设置。
我在项目中常用这样的分配方案:
| 定时器 | 通道 | 用途 | 频率 |
|---|---|---|---|
| TIM2 | CH1 | 屏幕背光 | 1kHz |
| TIM2 | CH2 | 舵机 | 50Hz |
| TIM3 | CH1 | 电机调速 | 20kHz |
| TIM3 | CH2 | 蜂鸣器 | 4kHz |
你想想看,如果背光和舵机用同一个定时器,频率冲突怎么办?我的做法是:不同频率的需求,用不同的定时器。TIM2 做低频(50Hz~1kHz),TIM3 做高频(10kHz 以上),各管各的,互不干扰。
4.7 调试技巧与常见问题
做 PWM 调试时,我建议你准备一个逻辑分析仪。别光靠眼睛看,眼睛看不出来 1% 的占空比误差。
几个我踩过的坑:
- 预分频器算错——72MHz 分频到 1MHz,预分频器要设 71,不是 72。这个 +1 和 -1 的坑,我至少栽过三次
- 重装载值溢出——16位定时器最大 65535,如果周期太长会溢出。这时候要用 32位定时器或者级联
- 占空比突变——在中断里直接改比较值,可能导致当前周期异常。建议用影子寄存器或者等周期结束再更新
嗯,这一章的内容就到这里。定时器和 PWM 说难不难,说简单也不简单。关键是要理解硬件的工作原理,然后多动手试。下一章咱们聊聊中断系统,那可是嵌入式开发的另一个核心技能。