4. 定时器与PWM:系统滴答定时器、通用定时器配置、PWM输出驱动蜂鸣器/电机

定时器,说白了就是嵌入式系统的“心跳”。没有它,你连个精准的延时都做不了。我刚开始做医疗设备时,总觉得定时器就是个计数的玩意儿,后来才发现——它才是整个系统实时性的基石。

这一章,咱们就聊聊三种最常用的定时器:系统滴答定时器、通用定时器,还有PWM输出。我会结合医疗设备里的实际场景,比如驱动蜂鸣器报警、控制微型电机,把配置思路和坑都讲清楚。

本章核心知识点:
  • SysTick 定时器:操作系统的“节拍器”
  • 通用定时器:精准延时与事件计数
  • PWM 输出:用占空比控制蜂鸣器音量和电机转速
  • 实战案例:医疗输液泵报警蜂鸣器驱动
定时器与PWM知识体系 定时器系统 SysTick 滴答定时器 通用定时器 PWM 输出 1ms 系统节拍 / 任务调度 输入捕获 / 输出比较 / 计数 占空比控制 / 频率调节 医疗应用:蜂鸣器报警 / 微型泵电机控制 / 传感器时序

4.1 系统滴答定时器(SysTick)

SysTick 是 Cortex-M 内核自带的 24 位递减计数器。它不依赖外设时钟,所以移植性极好。我习惯把它配置成 1ms 中断一次,作为整个系统的时基。

为什么是 1ms?因为人耳能分辨的延时大约在 10ms 级别,1ms 的精度足够覆盖大部分传感器采样和通信协议的需求。你想想看,心电信号采样率也就 250Hz 到 1000Hz,1ms 的节拍完全够用。

我的习惯:SysTick 只做“心跳”,不做复杂处理。中断服务函数里只置一个标志位,真正的任务调度交给主循环或 RTOS。这样可以避免中断嵌套导致的时序抖动。

配置代码其实很简单,以 STM32 为例:

// SysTick 配置函数,重装载值 = 系统时钟 / 1000
void SysTick_Init(uint32_t sysclk_hz)
{
    // 重装载值:例如 72MHz 时钟下,72 000 000 / 1000 = 72000
    SysTick->LOAD = sysclk_hz / 1000 - 1;
    // 当前值清零
    SysTick->VAL = 0;
    // 使能 SysTick,使能中断,使用处理器时钟
    SysTick->CTRL = SysTick_CTRL_ENABLE_Msk |
                    SysTick_CTRL_TICKINT_Msk |
                    SysTick_CTRL_CLKSOURCE_Msk;
}

// 1ms 中断服务函数
void SysTick_Handler(void)
{
    // 只做一件事:置位 1ms 标志
    systick_flag = 1;
}

我曾经在一个心电监护仪项目里,因为 SysTick 中断里做了太多浮点运算,导致系统响应变慢。后来把所有计算都挪到主循环,问题就解决了。嗯,这里要注意——中断里别干重活。

4.2 通用定时器配置

通用定时器比 SysTick 灵活得多。它可以向上计数、向下计数、或者中心对齐计数。我常用的场景有两个:

  • 精准延时:用定时器做微秒级延时,比软件循环靠谱多了。
  • 事件计数:比如统计单位时间内的心跳脉冲数。

配置通用定时器,核心就是三个参数:预分频器(PSC)、自动重装载值(ARR)、计数模式。它们的关系是:

参数 作用 计算公式
PSC(预分频器) 将时钟源分频 定时器时钟 = 系统时钟 / (PSC + 1)
ARR(自动重装载值) 设定计数上限 溢出时间 = (ARR + 1) / 定时器时钟
计数模式 向上/向下/中心对齐 向上模式最常用

举个例子,我想产生一个 100ms 的定时中断。系统时钟 72MHz,预分频器设为 7200-1,那么定时器时钟就是 72MHz / 7200 = 10kHz。ARR 设为 1000-1,溢出时间就是 1000 / 10kHz = 100ms。

// 通用定时器 TIM3 初始化,100ms 中断
void TIM3_Init(void)
{
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;  // 使能 TIM3 时钟
    
    TIM3->PSC = 7200 - 1;   // 预分频,得到 10kHz
    TIM3->ARR = 1000 - 1;   // 自动重装载值,100ms 溢出
    TIM3->CR1 |= TIM_CR1_CEN;  // 使能计数器
    
    // 使能更新中断
    TIM3->DIER |= TIM_DIER_UIE;
    NVIC_EnableIRQ(TIM3_IRQn);
}
注意:预分频器和重装载值的范围取决于定时器位数。16 位定时器的 PSC 和 ARR 最大都是 65535。如果需要更长的定时时间,可以考虑使用 32 位定时器,或者级联两个定时器。

4.3 PWM 输出:驱动蜂鸣器与电机

PWM,说白了就是通过调节占空比来模拟模拟量。占空比越高,蜂鸣器越响,电机转得越快。我做过一个输液泵项目,就是用 PWM 控制微型直流电机,实现精准的输液速度调节。

配置 PWM 的关键步骤:

  1. 选择定时器通道:每个通用定时器有 4 个通道,可以独立输出 PWM。
  2. 配置 GPIO 复用功能:把引脚设为定时器输出模式。
  3. 设置 PWM 模式:常用模式 1 或模式 2,区别在于有效电平的极性。
  4. 设定频率和占空比:频率由 ARR 决定,占空比由 CCRx 决定。

来看一个驱动蜂鸣器的例子。蜂鸣器频率一般设在 2kHz 到 4kHz 之间,人耳最敏感。我习惯用 3kHz:

// TIM2 通道 1 输出 PWM,驱动蜂鸣器
// 系统时钟 72MHz,目标频率 3kHz,占空比 50%
void Buzzer_PWM_Init(void)
{
    // 1. 使能 TIM2 时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    
    // 2. 配置 GPIOA0 为复用推挽输出
    RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
    GPIOA->MODER |= GPIO_MODER_MODER0_1;   // 复用功能
    GPIOA->AFR[0] |= 1 << 0;               // AF1 = TIM2_CH1
    
    // 3. 计算参数:PSC=0, ARR=24000-1, 频率=72MHz/24000=3kHz
    TIM2->PSC = 0;
    TIM2->ARR = 24000 - 1;
    
    // 4. 配置通道 1 为 PWM 模式 1,输出极性高
    TIM2->CCMR1 |= (6 << TIM_CCMR1_OC1M_Pos);  // PWM 模式 1
    TIM2->CCMR1 |= TIM_CCMR1_OC1PE;             // 预装载使能
    TIM2->CCER |= TIM_CCER_CC1E;                // 使能输出
    
    // 5. 设置占空比 50%:CCR1 = ARR/2 = 12000
    TIM2->CCR1 = 12000;
    
    // 6. 使能计数器
    TIM2->CR1 |= TIM_CR1_CEN;
}

// 调节蜂鸣器音量:占空比 0~100
void Buzzer_SetVolume(uint8_t percent)
{
    if(percent > 100) percent = 100;
    TIM2->CCR1 = (uint32_t)(TIM2->ARR) * percent / 100;
}
实战经验:驱动电机时,PWM 频率不能太低,否则电机会发出刺耳的啸叫声。我一般选 20kHz 以上,人耳听不到。另外,电机启动时占空比要缓慢增加,避免冲击电流烧坏驱动管。

4.4 避坑指南

做定时器和 PWM 开发,有几个坑我踩过,分享给你:

  • 预分频器更新时机:修改 PSC 后,要等下一个更新事件才会生效。我习惯在修改后手动产生一次软件更新。
  • 占空比突变:电机或蜂鸣器的占空比不要从 0 直接跳到 100,容易产生电流尖峰。加一个渐变函数,比如每次增加 5%。
  • 中断优先级:SysTick 中断优先级不要设得太高,否则会阻塞其他外设中断。我一般设成最低优先级。
  • PWM 输出引脚:确认引脚是否支持定时器复用功能。我见过有人把 PWM 配置到普通 GPIO 上,折腾了半天才发现。
调试技巧:用示波器看 PWM 波形时,记得把探头的地线夹在 GND 上,不要悬空。否则测出来的波形全是噪声,你根本看不出占空比对不对。

好了,定时器和 PWM 的内容就这些。记住一个原则:定时器是系统的骨架,PWM 是执行器的语言。把这两样玩熟了,医疗设备里的时序控制和驱动问题,基本就解决了一大半。