4、系统时钟与定时器:时钟树配置(HSE、PLL)、SysTick定时器实现精准延时、通用定时器PWM输出、输入捕获测量脉宽

时钟,是单片机的「心跳」。没有时钟,芯片就是一块废铁。我刚开始做嵌入式那会儿,总觉得时钟配置就是抄几个寄存器值,直到有一次产品在高温下跑飞了,排查了三天才发现是PLL配置不当导致的。从那以后,我对时钟树的态度就变成了:必须亲手算一遍,绝不信默认值

这一章,我们重点搞定四件事:时钟树怎么搭、SysTick怎么用、PWM怎么输出、脉宽怎么测。说白了,就是让芯片「知道时间、产生时间、测量时间」。

4.1 时钟树配置:HSE与PLL的「黄金搭档」

STM32的时钟源有好几个:HSI(内部高速)、HSE(外部高速)、LSI(内部低速)、LSE(外部低速)。我个人习惯,主系统时钟首选HSE + PLL。为什么?因为HSI精度不够,温度一漂频率就变,你想想看,做消防报警这种需要可靠性的产品,谁敢用内部RC振荡器?

配置流程其实就三步:

  1. 打开HSE,等待它稳定
  2. 配置PLL,选择HSE作为输入,设置倍频系数
  3. 切换系统时钟到PLL输出

以STM32F103为例,我们目标跑72MHz。外部晶振用8MHz,那么PLL倍频系数就是9倍。代码长这样:

void SystemClock_Config(void)
{
    // 1. 使能HSE,等待就绪
    RCC->CR |= RCC_CR_HSEON;
    while(!(RCC->CR & RCC_CR_HSERDY));

    // 2. 配置PLL:HSE作为输入,9倍频
    RCC->CFGR |= RCC_CFGR_PLLSRC_HSE;
    RCC->CFGR |= RCC_CFGR_PLLMULL9;

    // 3. 使能PLL,等待锁定
    RCC->CR |= RCC_CR_PLLON;
    while(!(RCC->CR & RCC_CR_PLLRDY));

    // 4. 切换系统时钟为PLL输出
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}
⚠️ 我曾经踩过的坑: 切换时钟源后,一定要等待状态位确认。有一次我偷懒没等SWS标志位,结果代码跑得时快时慢,查了两天才发现是时钟还没切过去就往下执行了。

嗯,这里要注意:APB1和APB2的总线时钟分频也要配好。APB1最高36MHz,APB2最高72MHz。配错了,外设要么不工作,要么工作异常。

4.2 SysTick定时器:精准延时的「心脏」

SysTick是Cortex-M内核自带的24位递减计数器。说白了,它就是一个硬件定时器,专门用来产生系统节拍。我建议你不要用软件循环做延时,那玩意儿既不精准又浪费CPU。

SysTick的配置极其简单:设置重装载值,启动计数器,等待标志位。重装载值怎么算?

重装载值 = 系统时钟频率 / 目标节拍频率 - 1

比如系统时钟72MHz,想要1ms中断一次,那就是72000-1 = 71999。

void SysTick_Init(void)
{
    // 重装载值:72MHz / 1000Hz = 72000
    SysTick->LOAD = 72000 - 1;
    // 清零当前值
    SysTick->VAL = 0;
    // 使能SysTick,使能中断,使用系统时钟
    SysTick->CTRL = SysTick_CTRL_ENABLE_Msk |
                    SysTick_CTRL_TICKINT_Msk |
                    SysTick_CTRL_CLKSOURCE_Msk;
}

// 全局毫秒计数器
volatile uint32_t sysTickUptime = 0;

void SysTick_Handler(void)
{
    sysTickUptime++;
}

// 精准延时函数
void delay_ms(uint32_t ms)
{
    uint32_t start = sysTickUptime;
    while((sysTickUptime - start) < ms);
}
💡 个人经验: 用减法判断而不是加法比较,可以避免溢出问题。你想想看,如果sysTickUptime从0xFFFFFFFF翻回0,用减法依然能得到正确的时间差。

我在项目中遇到过一个问题:中断里做太多事情导致延时不准。后来我把SysTick中断函数里只做计数器累加,其他事情都放到主循环处理。记住:中断服务函数要短小精悍

4.3 通用定时器PWM输出:让LED呼吸起来

PWM,脉冲宽度调制。说白了就是通过调节高电平占空比来控制输出功率。消防报警主控板上,PWM可以用来驱动蜂鸣器、调节指示灯亮度、甚至控制电磁阀。

以TIM2的通道1为例,配置步骤如下:

  1. 使能TIM2时钟
  2. 配置GPIO为复用推挽输出
  3. 设置TIM2的预分频器和自动重装载值
  4. 配置输出比较模式为PWM1
  5. 使能TIM2
void PWM_Init(void)
{
    // 1. 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

    // 2. 配置PA0为复用推挽输出
    GPIOA->CRL &= ~(0xF << 0);  // 清零
    GPIOA->CRL |= (0xB << 0);   // 复用推挽输出50MHz

    // 3. 定时器配置:72MHz / 72 = 1MHz, 重装载1000 = 1KHz PWM
    TIM2->PSC = 72 - 1;      // 预分频
    TIM2->ARR = 1000 - 1;    // 自动重装载

    // 4. 配置通道1为PWM1模式,输出极性高
    TIM2->CCMR1 |= (6 << 4);  // 输出比较模式:PWM1
    TIM2->CCER |= TIM_CCER_CC1E;  // 使能输出

    // 5. 使能定时器
    TIM2->CR1 |= TIM_CR1_CEN;
}

// 设置占空比,duty范围0~999
void PWM_SetDuty(uint16_t duty)
{
    TIM2->CCR1 = duty;
}
🔑 关键点: PWM频率 = 系统时钟 / (预分频器+1) / (自动重装载值+1)。占空比 = CCR / ARR。这两个公式一定要刻在脑子里。

我曾经犯过一个低级错误:ARR设成了1000,CCR设成了1000,结果输出一直是高电平。后来才反应过来,CCR等于ARR时,PWM输出不会翻转。嗯,这个坑我替你们踩过了。

4.4 输入捕获测量脉宽:给信号「把脉」

输入捕获,就是测量外部信号的脉宽或频率。消防报警系统中,可以用来检测烟雾传感器的脉冲输出、或者解码遥控器信号。

原理很简单:捕获上升沿时记下CNT值,捕获下降沿时再记一次,差值就是高电平脉宽

以TIM3的通道1为例:

volatile uint32_t cap_start = 0;
volatile uint32_t cap_width = 0;
volatile uint8_t  cap_flag = 0;

void InputCapture_Init(void)
{
    // 1. 使能时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
    RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;

    // 2. 配置PA6为浮空输入
    GPIOA->CRL &= ~(0xF << 24);
    GPIOA->CRL |= (0x4 << 24);  // 浮空输入

    // 3. 定时器配置:72MHz / 72 = 1MHz, 即1us计数一次
    TIM3->PSC = 72 - 1;
    TIM3->ARR = 0xFFFF;  // 最大计数值

    // 4. 配置通道1为输入捕获,上升沿触发
    TIM3->CCMR1 |= (0x1 << 0);  // 通道1映射到TI1
    TIM3->CCER |= TIM_CCER_CC1E;  // 使能捕获
    TIM3->CCER &= ~TIM_CCER_CC1P; // 上升沿捕获

    // 5. 使能中断
    TIM3->DIER |= TIM_DIER_CC1IE;
    NVIC_EnableIRQ(TIM3_IRQn);

    // 6. 使能定时器
    TIM3->CR1 |= TIM_CR1_CEN;
}

void TIM3_IRQHandler(void)
{
    if(TIM3->SR & TIM_SR_CC1IF)
    {
        TIM3->SR = ~TIM_SR_CC1IF;  // 清除标志

        if(cap_flag == 0)
        {
            // 第一次捕获:上升沿,记录开始时间
            cap_start = TIM3->CCR1;
            // 切换为下降沿捕获
            TIM3->CCER |= TIM_CCER_CC1P;
            cap_flag = 1;
        }
        else
        {
            // 第二次捕获:下降沿,计算脉宽
            cap_width = TIM3->CCR1 - cap_start;
            // 切换回上升沿捕获
            TIM3->CCER &= ~TIM_CCER_CC1P;
            cap_flag = 0;
        }
    }
}
⚠️ 注意: 如果脉宽超过定时器的溢出周期,就需要处理溢出中断。我一般用溢出次数 * ARR + 当前捕获值来计算总脉宽。否则测出来的值会「回绕」,结果完全不对。

实际项目中,我遇到过输入信号有毛刺导致误捕获。解决方案是:开启数字滤波器。TIMx->CCMRx里的ICF位可以设置采样频率和采样次数,能有效滤除窄脉冲干扰。

4.5 本章小结

时钟和定时器,是嵌入式开发的「基本功」。我个人觉得,能把时钟树讲清楚、能把定时器用明白,才算真正入了嵌入式的大门。

这一章我们覆盖了:

  • HSE + PLL搭建72MHz主时钟
  • SysTick实现1ms精准延时
  • 通用定时器输出1KHz可调占空比PWM
  • 输入捕获测量外部信号脉宽

下一章,我们会把这些定时器知识用到实际外设驱动中。到时候你会发现,时钟和定时器就是整个系统的「时间轴」,所有时序逻辑都围着它们转。

嗯,今天就到这里。代码别光看,动手烧录到板子上试试,用示波器看看波形,比读十遍文章都管用。