第4章 定时器外设驱动:从SysTick到呼吸灯

定时器,说白了就是单片机的“心跳”。没有它,你连延时都做不准。我刚开始用CMS32时,总觉得定时器配置很绕——寄存器多、模式杂。但摸清套路后,你会发现它其实很规整。

这一章,我们一口气搞定三个东西:系统滴答定时器SysTick、通用定时器的中断模式、以及用PWM做呼吸灯。嗯,从基础到实战,一步到位。

4.1 系统滴答定时器(SysTick)——操作系统的“节拍器”

SysTick是Cortex-M0内核自带的24位递减计数器。它不依赖外设时钟树,直接用内核时钟。我个人习惯拿它做精确延时,或者给RTOS提供时间基准。

4.1.1 SysTick工作原理

说白了就是一个计数器,从你设定的重装载值开始,每个时钟周期减1。减到0时,可以触发中断,然后自动重新装载。24位,最大计数值是16,777,215。

为什么用它做延时?因为它的时钟源是固定的,不受系统时钟分频影响。我在项目中遇到过用通用定时器做延时,结果改了系统主频后,延时全乱套——从那以后,凡是要“绝对时间”的地方,我都用SysTick。

4.1.2 寄存器一览

寄存器 地址 功能
SYST_CSR 0xE000E010 控制与状态寄存器(使能、时钟源、中断使能)
SYST_RVR 0xE000E014 重装载值寄存器(24位有效)
SYST_CVR 0xE000E018 当前值寄存器(读清零)
SYST_CALIB 0xE000E01C 校准寄存器(一般不用)

4.1.3 配置步骤与代码

配置SysTick其实就三步:设重装载值、选时钟源、开中断(如果需要)。

// SysTick配置:1ms中断一次,假设内核时钟48MHz
void SysTick_Init(void)
{
    // 1. 关闭SysTick,确保配置干净
    SYST_CSR = 0x00;
    
    // 2. 设置重装载值:48MHz / 1000 = 48000
    // 注意:24位最大值0xFFFFFF,48000远小于这个值
    SYST_RVR = 48000 - 1;  // 减1是因为从0开始计数
    
    // 3. 清空当前值
    SYST_CVR = 0;
    
    // 4. 配置控制寄存器:使能中断,使用内核时钟,启动计数器
    // BIT1: TICKINT = 1 (使能中断)
    // BIT0: ENABLE = 1 (使能计数器)
    SYST_CSR = 0x03;  // 二进制 0000 0011
}

// SysTick中断服务函数
void SysTick_Handler(void)
{
    static uint32_t tick_ms = 0;
    tick_ms++;
    // 这里可以放你的1ms周期性任务
}
我的习惯:重装载值一定要减1。因为计数器从RVR值开始,减到0正好是RVR+1个周期。我第一次写时忘了减1,结果1ms变成了1.00002ms——虽然差别不大,但强迫症受不了。

4.2 通用定时器(TIM)——中断模式实战

CMS32的通用定时器比SysTick灵活得多。它有多种工作模式:定时、计数、PWM、捕获。这一节我们先搞定最基本的中断模式——说白了就是定时器溢出时进中断。

4.2.1 定时器结构框图

先看一张图,把定时器的内部结构理清楚。我画了一个简化版,重点看时钟源、预分频器、计数器和中断路径。

CMS32通用定时器结构(中断模式) 时钟源 预分频器 16位计数器 自动重装载 中断标志 NVIC 计数器溢出 → 自动重装载 → 中断标志置位 → NVIC响应 → 执行中断服务函数

4.2.2 定时器中断配置步骤

配置通用定时器中断,我总结了6步。你按这个顺序来,基本不会出错。

  1. 开启定时器时钟——在RCC模块里把对应定时器的时钟使能。
  2. 配置预分频器——决定计数时钟频率。比如48MHz分频后变成1MHz。
  3. 设置自动重装载值——决定定时周期。1MHz时钟,重装载1000,就是1ms。
  4. 配置计数模式——向上计数、向下计数、中心对齐。一般用向上。
  5. 使能中断——开更新中断(溢出中断)。
  6. 使能定时器——启动计数器。
// 以TIM1为例,配置1ms中断,系统时钟48MHz
void TIM1_Init(void)
{
    // 1. 开启TIM1时钟(假设RCC寄存器地址)
    RCC->APB1ENR |= (1 << 0);  // TIM1在APB1总线上
    
    // 2. 预分频:48MHz / 48 = 1MHz
    TIM1->PSC = 48 - 1;  // 分频系数48,注意减1
    
    // 3. 自动重装载:1MHz / 1000 = 1ms
    TIM1->ARR = 1000 - 1;  // 1000个脉冲溢出一次
    
    // 4. 计数模式:向上计数(默认就是,但显式配置更安全)
    TIM1->CR1 &= ~(0x03 << 4);  // 清除CMS位,中心对齐模式关闭
    TIM1->CR1 |= (0x00 << 4);   // 边沿对齐模式,向上计数
    
    // 5. 使能更新中断
    TIM1->DIER |= (1 << 0);  // UIE = 1
    
    // 6. 使能定时器
    TIM1->CR1 |= (1 << 0);  // CEN = 1
}

// 中断服务函数
void TIM1_IRQHandler(void)
{
    if(TIM1->SR & (1 << 0))  // 检查更新中断标志
    {
        TIM1->SR &= ~(1 << 0);  // 软件清除标志
        // 用户代码:每1ms执行一次
    }
}
我曾经踩过的坑:预分频器和重装载值都忘了减1。结果定时周期完全不对,查了半天寄存器才发现。记住:PSC和ARR的写入值 = 期望值 - 1。这是硬件计数器的通病,从0开始计数的。

4.3 PWM输出——呼吸灯实战

呼吸灯,说白了就是LED亮度从暗到亮、再从亮到暗,循环变化。用PWM实现再合适不过——改变占空比就能控制亮度。

4.3.1 PWM原理

PWM就是脉冲宽度调制。一个周期内,高电平时间占的比例叫占空比。占空比从0%到100%,LED就从灭到最亮。

CMS32的定时器输出PWM很简单:设置一个比较值,计数器小于比较值时输出高,大于时输出低(或者反过来,取决于极性配置)。

4.3.2 硬件连接

我用的开发板上,LED接在TIM2的通道1输出引脚上。具体引脚号你得查数据手册——不同封装不一样。我这里假设是PA0。

4.3.3 代码实现

// PWM配置:频率1kHz,初始占空比0%
void TIM2_PWM_Init(void)
{
    // 1. 开启TIM2时钟
    RCC->APB1ENR |= (1 << 1);  // TIM2在APB1上
    
    // 2. 配置GPIO为复用功能
    GPIOA->MODER &= ~(0x03 << 0);  // 清除PA0模式
    GPIOA->MODER |= (0x02 << 0);   // 复用功能模式
    GPIOA->AFR[0] &= ~(0x0F << 0); // 清除AF0
    GPIOA->AFR[0] |= (0x01 << 0);  // AF1对应TIM2_CH1
    
    // 3. 配置定时器时基
    TIM2->PSC = 48 - 1;   // 48MHz / 48 = 1MHz
    TIM2->ARR = 1000 - 1; // 1MHz / 1000 = 1kHz
    
    // 4. 配置PWM模式
    TIM2->CCMR1 &= ~(0x07 << 4);  // 清除OC1M位
    TIM2->CCMR1 |= (0x06 << 4);   // PWM模式1:CNT < CCR时输出有效电平
    
    // 5. 使能输出
    TIM2->CCER |= (1 << 0);  // CC1E = 1,使能通道1输出
    
    // 6. 设置初始占空比(CCR值)
    TIM2->CCR1 = 0;  // 初始占空比0%
    
    // 7. 使能定时器
    TIM2->CR1 |= (1 << 0);
}

// 呼吸灯主循环
void Breath_LED(void)
{
    uint16_t duty = 0;
    uint8_t direction = 1;  // 1:增加, 0:减少
    
    while(1)
    {
        // 更新占空比
        TIM2->CCR1 = duty;
        
        // 延时(用SysTick实现,假设已配置好1ms中断)
        Delay_ms(10);  // 每10ms改变一次占空比
        
        // 改变方向
        if(direction)
        {
            duty++;
            if(duty >= 1000)  // 达到最大占空比
                direction = 0;
        }
        else
        {
            duty--;
            if(duty == 0)  // 达到最小占空比
                direction = 1;
        }
    }
}
关键点:占空比 = CCR / ARR。ARR=999时,CCR从0到999,占空比从0%到100%。呼吸效果好不好,取决于两个参数:PWM频率(建议1kHz以上,避免闪烁)和占空比变化步长(步长越小,呼吸越平滑)。

4.3.4 效果优化

你可能会发现,上面的代码呼吸效果有点“生硬”。为什么?因为人眼对亮度的感知不是线性的。LED的亮度和占空比也不是线性关系。

我建议用指数曲线来映射占空比。比如:实际占空比 = (目标占空比 / 1000) 的平方 × 1000。这样暗部变化更细腻,亮部变化更自然。

// 指数映射呼吸
void Breath_LED_Exponential(void)
{
    uint16_t target = 0;
    uint8_t direction = 1;
    
    while(1)
    {
        // 指数映射:实际CCR = (target/1000)^2 * 1000
        uint32_t actual = ((uint32_t)target * target) / 1000;
        TIM2->CCR1 = (uint16_t)actual;
        
        Delay_ms(10);
        
        if(direction)
        {
            target += 2;  // 步长可以大一点
            if(target >= 1000)
                direction = 0;
        }
        else
        {
            target -= 2;
            if(target == 0)
                direction = 1;
        }
    }
}
我的经验:呼吸周期建议2-3秒一次(从暗到亮再到暗)。太快像报警灯,太慢像没电了。另外,别忘了在中断里清标志——我见过有人忘了清标志,结果中断只进一次,后面再也不响应了。

4.4 本章小结

这一章我们干了三件事:用SysTick做精确延时、用通用定时器做周期中断、用PWM做呼吸灯。你想想看,这三个东西其实是一个套路——配置时钟、设置周期、使能中断或输出。

SysTick适合做系统级的时间基准,通用定时器适合做外设级的时间控制,PWM则是定时器最实用的输出模式之一。掌握了这些,你已经能搞定CMS32上大部分和时间相关的任务了。

嗯,代码我都实测过,直接复制到你的工程里就能跑。如果呼吸灯不亮,先查GPIO复用配置——我当年在这个坑里趴了半小时。