4、定时器与PWM:系统滴答定时器、通用定时器配置、PWM输出与呼吸灯、输入捕获与频率测量

定时器这东西,可以说是嵌入式系统的「心跳」。没有它,你的单片机就像个没戴手表的程序员——完全不知道时间过了多久。我刚开始学嵌入式那会儿,总觉得定时器不就是个计数器嘛,有啥好学的?直到做第一个带PWM的项目,电机死活转不起来,我才老老实实回去啃手册。

这一章,咱们就把定时器这块硬骨头啃下来。从系统滴答定时器开始,再到通用定时器,最后玩转PWM和输入捕获。嗯,内容不少,但都是实战干货。

4.1 系统滴答定时器(SysTick)

SysTick是Cortex-M内核自带的定时器,说白了就是个24位的递减计数器。它最大的好处是——所有Cortex-M芯片都有,代码移植性极强。

核心特点:

  • 24位递减计数器,从设定值减到0
  • 自动重装载,无需手动干预
  • 可产生中断,用于操作系统的心跳
  • 时钟源可选:内核时钟或外部参考时钟

我个人习惯用SysTick做延时函数和任务调度的时间基准。你想想看,如果每个项目都去配一个通用定时器做延时,那多浪费资源?

// SysTick配置示例——1ms中断一次
void SysTick_Init(uint32_t systemClockHz) {
    // 重装载值 = 系统时钟 / 1000
    uint32_t reload = systemClockHz / 1000;
    
    // 清空当前值
    SysTick->VAL = 0;
    
    // 配置重装载寄存器
    SysTick->LOAD = reload - 1;
    
    // 使能SysTick,使用内核时钟,使能中断
    SysTick->CTRL = (1 << 0) |  // ENABLE
                     (1 << 1) |  // TICKINT
                     (1 << 2);   // CLKSOURCE
}

// 全局滴答计数
volatile uint32_t sysTickUptime = 0;

void SysTick_Handler(void) {
    sysTickUptime++;
}

// 毫秒延时
void delay_ms(uint32_t ms) {
    uint32_t start = sysTickUptime;
    while ((sysTickUptime - start) < ms);
}

避坑指南:我曾经在配置SysTick时忘了减1,结果每次中断都少了一个时钟周期。72MHz下跑一天,误差能到好几秒。记住:LOAD寄存器的值是重装载值减1,因为计数器是从LOAD到0,共LOAD+1个周期。

4.2 通用定时器配置

通用定时器比SysTick复杂多了。以STM32为例,它有TIM2-TIM5这些16位/32位定时器。功能包括:基本定时、PWM输出、输入捕获、编码器接口等。

配置通用定时器,我一般按这个步骤来:

  1. 使能时钟——定时器挂在APB1或APB2总线上
  2. 配置预分频器——决定计数时钟频率
  3. 配置自动重装载值——决定定时周期
  4. 配置计数模式——向上、向下、中央对齐
  5. 使能中断或DMA——如果需要的话
  6. 使能计数器——开始干活
// 通用定时器配置示例——TIM2,1秒中断一次
void TIM2_Init(void) {
    // 1. 使能TIM2时钟
    RCC->APB1ENR |= (1 << 0);
    
    // 2. 配置预分频器:72MHz / 7200 = 10kHz
    TIM2->PSC = 7200 - 1;
    
    // 3. 自动重装载值:10kHz / 10000 = 1Hz
    TIM2->ARR = 10000 - 1;
    
    // 4. 清空计数器
    TIM2->CNT = 0;
    
    // 5. 使能更新中断
    TIM2->DIER |= (1 << 0);
    
    // 6. 配置NVIC
    NVIC_EnableIRQ(TIM2_IRQn);
    NVIC_SetPriority(TIM2_IRQn, 1);
    
    // 7. 使能计数器
    TIM2->CR1 |= (1 << 0);
}

void TIM2_IRQHandler(void) {
    if (TIM2->SR & (1 << 0)) {
        TIM2->SR &= ~(1 << 0);  // 清除中断标志
        // 用户代码:每秒执行一次
    }
}

注意:预分频器和自动重装载值都要减1!这是新手最容易犯的错。另外,修改ARR寄存器时,如果计数器正在运行,要等更新事件发生后新值才生效。我建议在修改前先停止计数器。

4.3 PWM输出与呼吸灯

PWM,脉冲宽度调制,说白了就是通过调节高电平的占空比来控制平均电压。呼吸灯是PWM最经典的入门实验——LED从暗到亮,再从亮到暗,像呼吸一样。

配置PWM输出,核心是设置捕获/比较寄存器(CCR)。CCR的值决定了占空比:

  • CCR = 0 → 占空比0%,LED灭
  • CCR = ARR/2 → 占空比50%,LED半亮
  • CCR = ARR → 占空比100%,LED全亮
// PWM配置示例——TIM3通道1,PA6输出
void PWM_Init(void) {
    // 1. 使能GPIO和定时器时钟
    RCC->APB1ENR |= (1 << 1);   // TIM3
    RCC->APB2ENR |= (1 << 2);   // GPIOA
    
    // 2. 配置PA6为复用推挽输出
    GPIOA->CRL &= ~(0xF << 24);
    GPIOA->CRL |= (0xB << 24);  // 复用推挽输出,50MHz
    
    // 3. 配置TIM3
    TIM3->PSC = 72 - 1;          // 72MHz/72 = 1MHz
    TIM3->ARR = 1000 - 1;        // PWM频率 = 1MHz/1000 = 1kHz
    
    // 4. 配置通道1为PWM模式1
    TIM3->CCMR1 |= (6 << 4);    // OC1M = 110 (PWM模式1)
    TIM3->CCMR1 |= (1 << 3);    // OC1PE = 1 (预装载使能)
    TIM3->CCER |= (1 << 0);     // CC1E = 1 (使能输出)
    
    // 5. 使能自动重装载预装载
    TIM3->CR1 |= (1 << 7);
    
    // 6. 使能计数器
    TIM3->CR1 |= (1 << 0);
}

// 设置占空比
void PWM_SetDuty(uint16_t duty) {
    if (duty > 1000) duty = 1000;
    TIM3->CCR1 = duty;
}

// 呼吸灯效果
void BreathLight(void) {
    static uint8_t direction = 0;
    static uint16_t duty = 0;
    
    if (direction == 0) {
        duty++;
        if (duty >= 1000) direction = 1;
    } else {
        duty--;
        if (duty <= 0) direction = 0;
    }
    
    PWM_SetDuty(duty);
    delay_ms(2);  // 调整呼吸速度
}

实战经验:我在做智能家居面板时,呼吸灯频率选的是1kHz。太低了会闪烁,太高了MOS管开关损耗大。另外,呼吸曲线用线性变化其实不太自然,人眼对亮度的感知是对数的。想做得更逼真?试试指数曲线:duty = (uint16_t)(1000.0f * (1.0f - exp(-t / tau)))。

4.4 输入捕获与频率测量

输入捕获,就是测量外部信号的频率或脉宽。原理很简单:定时器检测到上升沿或下降沿时,把当前计数器的值「捕获」到CCR寄存器里。两次捕获的差值,乘以时钟周期,就是时间间隔。

频率测量的两种常用方法:

方法 原理 适用场景 精度
测频法 固定闸门时间,计脉冲数 高频信号(>1kHz) 闸门时间越长越准
测周法 测量一个周期的时间 低频信号(<1kHz) 周期越长越准

我个人偏爱测周法,因为它响应快,适合实时控制。来看看代码:

// 输入捕获配置——TIM4通道1,测量频率
volatile uint32_t cap1 = 0, cap2 = 0;
volatile uint8_t capFlag = 0;

void TIM4_InputCapture_Init(void) {
    // 1. 时钟使能
    RCC->APB1ENR |= (1 << 2);   // TIM4
    RCC->APB2ENR |= (1 << 3);   // GPIOB
    
    // 2. 配置PB6为浮空输入
    GPIOB->CRL &= ~(0xF << 24);
    GPIOB->CRL |= (0x4 << 24);  // 浮空输入
    
    // 3. 配置TIM4时基
    TIM4->PSC = 72 - 1;          // 1MHz计数时钟
    TIM4->ARR = 0xFFFF;          // 最大重装载
    
    // 4. 配置通道1为输入捕获,上升沿触发
    TIM4->CCMR1 |= (1 << 0);    // CC1S = 01 (输入到TI1)
    TIM4->CCMR1 &= ~(3 << 4);   // IC1F = 000 (无滤波)
    TIM4->CCER |= (1 << 0);     // CC1E = 1, 上升沿
    
    // 5. 使能捕获中断
    TIM4->DIER |= (1 << 1);     // CC1IE
    
    // 6. NVIC配置
    NVIC_EnableIRQ(TIM4_IRQn);
    NVIC_SetPriority(TIM4_IRQn, 2);
    
    // 7. 使能计数器
    TIM4->CR1 |= (1 << 0);
}

void TIM4_IRQHandler(void) {
    if (TIM4->SR & (1 << 1)) {
        TIM4->SR &= ~(1 << 1);
        
        static uint32_t lastCap = 0;
        uint32_t currentCap = TIM4->CCR1;
        
        // 计算周期(考虑溢出)
        uint32_t period;
        if (currentCap >= lastCap) {
            period = currentCap - lastCap;
        } else {
            period = (0xFFFF - lastCap) + currentCap + 1;
        }
        
        lastCap = currentCap;
        
        // 频率 = 1MHz / period
        if (period > 0) {
            uint32_t freq = 1000000 / period;
            // 用户代码:处理频率值
        }
    }
}

踩坑记录:我曾经在测量50Hz交流信号时,发现频率值跳变厉害。查了半天,原来是信号有毛刺,触发了多次捕获。解决办法是开启输入滤波——TIMx_CCMR1里的ICF位。对于50Hz信号,设置ICF=4(8个采样点)基本就能滤掉噪声。另外,别忘了处理计数器溢出,否则低频信号测出来全是错的。

4.5 本章知识体系

说了这么多,咱们用一张图把定时器的知识体系串起来。这张SVG图展示了从系统滴答到高级应用的完整脉络:

定时器与PWM知识体系 定时器系统 系统滴答定时器 24位递减计数 OS心跳/延时 通用定时器 预分频器配置 自动重装载 中断/DMA PWM输出 占空比控制 呼吸灯/电机 输入捕获 测频法(高频) 测周法(低频)

这张图把定时器的四个核心模块串在了一起。SysTick是基础,通用定时器是主力,PWM和输入捕获是两大应用方向。实际项目中,我经常把SysTick做时间基准,通用定时器做PWM驱动电机,再用另一个定时器做输入捕获测转速——一个芯片搞定闭环控制。

核心要点回顾:

  • SysTick:24位递减,适合做延时和OS心跳
  • 通用定时器:16/32位,功能丰富,配置时注意PSC和ARR都要减1
  • PWM:通过CCR控制占空比,呼吸灯用指数曲线更自然
  • 输入捕获:高频用测频法,低频用测周法,别忘了滤波和溢出处理

定时器这块,说白了就是「数数」——数时钟周期,数脉冲个数,数时间间隔。把数数这件事玩明白了,嵌入式系统的时间控制就掌握了七八成。剩下的,就是在项目中不断踩坑、填坑、积累经验。

专注资料整理