4. 定时器与PWM驱动:定时器基础与时钟源选择、PWM输出原理与占空比控制、捕获与比较功能、看门狗定时器(WDT)实现
定时器,可以说是嵌入式系统里最常用的外设之一。我做了这么多年嵌入式,几乎每个项目都离不开它。从简单的延时,到复杂的电机控制,再到系统异常恢复,定时器无处不在。
这一章,我们就来聊聊Rust嵌入式HAL下的定时器驱动开发。我会结合自己的项目经验,把定时器的基础、PWM、捕获比较,还有看门狗这些知识点串起来。
4.1 定时器基础与时钟源选择
说白了,定时器就是一个会数数的计数器。它靠时钟源驱动,每来一个时钟脉冲,计数器就加1(或减1)。当计数值达到我们设定的阈值时,就会触发中断或更新输出信号。
但这里有个关键问题:时钟源从哪里来?
核心概念:定时器的精度和范围,直接取决于时钟源的选择和分频配置。
4.1.1 时钟源类型
我在项目中遇到过好几次因为时钟源选错导致定时不准的坑。常见的时钟源有这几种:
| 时钟源 | 特点 | 典型用途 |
|---|---|---|
| 内部主时钟(如HCLK) | 频率高,精度取决于晶振 | 通用定时、PWM生成 |
| 内部低速时钟(如LSI) | 频率低,功耗小,精度一般 | 看门狗、RTC校准 |
| 外部时钟(如ETR引脚) | 由外部信号驱动 | 外部事件计数、频率测量 |
| 内部专用时钟(如PLL输出) | 可调频率,灵活 | 高精度PWM、电机控制 |
嗯,这里要注意:时钟源的选择会影响定时器的最大计数频率和最小分辨率。你想想看,如果你用低速时钟去生成一个20kHz的PWM,那分辨率可能就不够用了。
4.1.2 分频器与预分频
大多数定时器都带有一个预分频器(Prescaler)。它的作用是把时钟源频率降下来,让我们能灵活控制定时器的计数速度。
计算公式很简单:
定时器计数频率 = 时钟源频率 / (预分频值 + 1)
举个例子,如果时钟源是72MHz,预分频值设为7199,那么计数频率就是10kHz。这样定时器每计数一次就是0.1ms。
个人经验:我习惯把预分频值设置得大一些,让定时器工作在较低频率,这样计数周期更长,适合做较长时间的延时。如果需要高精度PWM,那就把预分频值设小,甚至设为0。
4.1.3 Rust HAL中的定时器配置
在Rust嵌入式HAL中,配置定时器通常需要这几步:
- 获取定时器外设的实例
- 配置时钟源(通常由RCC或时钟树管理)
- 设置预分频值和自动重装载值
- 使能定时器
来看一个具体的代码示例:
use stm32f4xx_hal::{
pac,
prelude::*,
timer::Timer,
};
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(72.MHz()).freeze();
// 获取定时器2,使用内部时钟
let mut timer = Timer::new(dp.TIM2, &clocks);
// 配置为1秒定时
timer.start(1.Hz());
// 阻塞等待定时器溢出
nb::block!(timer.wait()).unwrap();
println!("1秒到了!");
这段代码看起来简单,但背后涉及了时钟树的配置。我个人建议你在实际项目中,先确认好系统时钟频率,再配置定时器参数。
4.2 PWM输出原理与占空比控制
PWM,全称是脉冲宽度调制。说白了,就是通过调节高低电平的占空比,来模拟一个模拟信号。比如控制LED亮度、电机转速,都是PWM的典型应用。
4.2.1 PWM的工作原理
PWM的核心是一个比较器。定时器的计数器不断递增,同时我们设定一个比较值(占空比)。当计数值小于比较值时,输出高电平;当计数值大于等于比较值时,输出低电平。这样,一个周期内高电平的宽度就由比较值决定了。
我画了一张图,帮你理解这个过程:
4.2.2 占空比控制
占空比就是高电平时间占整个周期的比例。比如50%占空比,就是一半时间高,一半时间低。
在Rust HAL中,控制占空比通常通过设置比较寄存器的值来实现:
use stm32f4xx_hal::{
pac,
prelude::*,
timer::{Timer, PwmChannel},
};
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(72.MHz()).freeze();
// 创建PWM实例,使用定时器2的通道1
let mut pwm = Timer::new(dp.TIM2, &clocks).pwm(
dp.PA0, // PWM输出引脚
1000.Hz(), // PWM频率
);
// 使能PWM输出
pwm.enable();
// 设置占空比为50%
pwm.set_duty(500); // 假设周期为1000,则500对应50%
// 动态调整占空比
pwm.set_duty(750); // 75%占空比
注意:我曾经在项目中因为忘记使能PWM输出,调试了半天才发现波形根本没出来。记得调用 enable() 方法。
4.2.3 频率与分辨率的权衡
PWM的频率和分辨率是一对矛盾。频率越高,每个周期的时间越短,能分的档位就越少。举个例子:
| PWM频率 | 定时器时钟 | 最大分辨率 |
|---|---|---|
| 1 kHz | 72 MHz | 72000 级(约16位) |
| 20 kHz | 72 MHz | 3600 级(约12位) |
| 100 kHz | 72 MHz | 720 级(约9位) |
你想想看,如果做电机控制,20kHz的PWM频率已经够用,分辨率3600级也足够平滑。但如果是做音频DAC,可能需要更高的频率,分辨率就得牺牲一些。
4.3 捕获与比较功能
捕获和比较,是定时器的两个高级功能。捕获用来测量外部信号的频率或脉宽,比较用来在特定时刻触发动作。
4.3.1 输入捕获
输入捕获的原理很简单:当外部信号发生跳变(上升沿或下降沿)时,定时器会把当前的计数值“捕获”到一个寄存器里。通过两次捕获的差值,就能算出信号的周期或脉宽。
我在项目中用输入捕获做过红外遥控解码,效果不错。来看代码:
use stm32f4xx_hal::{
pac,
prelude::*,
timer::{Timer, Capture},
};
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(72.MHz()).freeze();
// 配置定时器2的通道1为输入捕获模式
let mut capture = Timer::new(dp.TIM2, &clocks).capture(
dp.PA0, // 捕获输入引脚
);
// 使能捕获
capture.enable();
// 等待捕获事件
let value = nb::block!(capture.wait()).unwrap();
println!("捕获到的计数值:{}", value);
避坑指南:我曾经在捕获高频信号时,因为定时器溢出导致捕获值不准确。解决办法是开启定时器的溢出中断,在中断里记录溢出次数,然后结合捕获值计算完整的时间。
4.3.2 输出比较
输出比较正好反过来。我们设定一个比较值,当定时器计数值达到这个值时,输出引脚会自动翻转或产生中断。这个功能常用于生成精确的时序信号。
比如,我想在1ms后触发一个中断:
use stm32f4xx_hal::{
pac,
prelude::*,
timer::{Timer, Compare},
};
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(72.MHz()).freeze();
// 配置定时器2的通道1为输出比较模式
let mut compare = Timer::new(dp.TIM2, &clocks).compare(
dp.PA0, // 比较输出引脚
);
// 设置比较值(假设计数频率为1MHz,则1000对应1ms)
compare.set_compare_value(1000);
// 使能比较中断
compare.enable_interrupt();
// 在中断服务函数中处理
// ...
4.4 看门狗定时器(WDT)实现
看门狗定时器,说白了就是一个“保命”机制。系统正常运行时,我们定期“喂狗”;如果系统卡死或跑飞,看门狗超时就会触发复位,让系统重新启动。
我刚开始做嵌入式时,总觉得看门狗可有可无。直到有一次产品在现场死机,客户投诉了才知道这玩意儿多重要。
4.4.1 独立看门狗(IWDG)与窗口看门狗(WWDG)
大多数MCU提供两种看门狗:
| 类型 | 时钟源 | 特点 | 适用场景 |
|---|---|---|---|
| 独立看门狗(IWDG) | 内部低速时钟(LSI) | 独立于主时钟,即使主时钟失效也能工作 | 系统死机保护 |
| 窗口看门狗(WWDG) | 系统时钟(APB时钟) | 必须在特定时间窗口内喂狗,过早或过晚都会复位 | 任务超时监控 |
4.4.2 Rust HAL中的看门狗实现
以独立看门狗为例,配置起来很简单:
use stm32f4xx_hal::{
pac,
prelude::*,
watchdog::IndependentWatchdog,
};
let dp = pac::Peripherals::take().unwrap();
// 创建独立看门狗实例,超时时间设为2秒
let mut watchdog = IndependentWatchdog::new(dp.IWDG, 2_000_000u32);
// 启动看门狗
watchdog.start();
// 主循环中定期喂狗
loop {
// 执行任务...
// 喂狗
watchdog.feed();
// 模拟任务耗时
// ...
}
重要提醒:看门狗一旦启动,就不能停止(除非复位)。所以喂狗的位置要选好。我习惯在主循环的末尾喂狗,确保所有关键任务都执行完毕。
4.4.3 窗口看门狗的进阶用法
窗口看门狗更严格一些。它要求喂狗的时间不能太早,也不能太晚。这在任务调度监控中很有用。
use stm32f4xx_hal::{
pac,
prelude::*,
watchdog::WindowWatchdog,
};
let dp = pac::Peripherals::take().unwrap();
let rcc = dp.RCC.constrain();
let clocks = rcc.cfgr.sysclk(72.MHz()).freeze();
// 创建窗口看门狗,窗口上限为64,下限为127
let mut wwdg = WindowWatchdog::new(dp.WWDG, &clocks, 64, 127);
wwdg.start();
loop {
// 必须在窗口内喂狗
// 如果计数值在64到127之间喂狗,否则复位
wwdg.feed();
// 其他任务...
}
个人经验:窗口看门狗特别适合监控实时任务的执行时间。比如一个任务必须在10ms到20ms之间完成,如果太快(可能跳过关键步骤)或太慢(可能卡住),都会触发复位。
4.4.4 看门狗使用的最佳实践
总结一下我多年使用看门狗的经验:
- 喂狗位置要合理:不要在主循环开头喂狗,否则任务卡死在后面也检测不到
- 超时时间要留余量:比最坏情况下的任务执行时间多50%左右
- 调试时可以先禁用:但发布固件前一定要开启
- 配合中断使用:可以在看门狗中断中保存关键数据,再触发复位
嗯,这一章的内容就到这里。定时器和PWM是嵌入式开发的基础功,掌握了这些,后面做电机控制、传感器驱动就会轻松很多。