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中,配置定时器通常需要这几步:

  1. 获取定时器外设的实例
  2. 配置时钟源(通常由RCC或时钟树管理)
  3. 设置预分频值和自动重装载值
  4. 使能定时器

来看一个具体的代码示例:

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的核心是一个比较器。定时器的计数器不断递增,同时我们设定一个比较值(占空比)。当计数值小于比较值时,输出高电平;当计数值大于等于比较值时,输出低电平。这样,一个周期内高电平的宽度就由比较值决定了。

我画了一张图,帮你理解这个过程:

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是嵌入式开发的基础功,掌握了这些,后面做电机控制、传感器驱动就会轻松很多。