4. 时钟系统与定时器:芯片的“心跳”与“秒表”

时钟,是嵌入式系统的绝对命脉。没有时钟,CPU 就是一块死寂的硅片。我刚开始做 BSP 时,总觉得时钟配置就是填几个寄存器,没什么技术含量。直到有一次,我调了三天三夜,板子就是跑不起来,最后发现是 PLL 的锁定时间没给够。嗯,从那以后,我再也不敢小看时钟树了。

这一章,我们就把时钟系统和定时器彻底讲透。从时钟树的源头,到 SysTick 的精准延时,再到通用定时器的 PWM 和输入捕获,一步步带你掌握。

4.1 系统时钟树解析

说白了,时钟树就是芯片内部所有时钟信号的“供水管网”。源头是几个振荡器,中间经过 PLL 倍频、分频器分频,最后送到各个外设。你想想看,如果水管压力不稳,后面的设备肯定出问题。

4.1.1 时钟源:HSI、HSE、LSI、LSE

主流 MCU 通常有四个时钟源,我整理了一个表格,方便你对比:

时钟源 全称 典型频率 精度 典型用途
HSI 高速内部振荡器 8 MHz / 16 MHz ±1% ~ ±2% 上电默认时钟,低成本方案
HSE 高速外部振荡器 4 ~ 26 MHz ±50 ppm(晶振) PLL 输入,高精度系统时钟
LSI 低速内部振荡器 32 kHz / 40 kHz ±5% ~ ±10% 独立看门狗,RTC 备用
LSE 低速外部振荡器 32.768 kHz ±20 ppm 高精度 RTC 时钟

这里有个关键点:HSI 虽然启动快,但精度差。我在一个需要 USB 通信的项目里,偷懒用了 HSI,结果 USB 枚举死活过不去。后来换成 HSE + PLL,一次通过。所以,涉及通信的场景,老老实实用外部晶振。

4.1.2 PLL 倍频与系统时钟路径

PLL(锁相环)是时钟树的核心。它能把低频输入(比如 8 MHz HSE)倍频到几百 MHz。但 PLL 不是万能的,它有输入频率范围、倍频系数限制、VCO 频率范围等约束。

我画了一张典型的时钟树路径图,你看一眼就明白了:

典型 MCU 系统时钟树路径 HSE (8 MHz) HSI (16 MHz) 时钟选择 PLL ×N / M SYSCLK AHB 分频器 APB1 (≤ 36 MHz) APB2 (≤ 72 MHz) 注意 PLL 输出频率 不能超过 芯片最大主频

从图里你能看到,HSE 或 HSI 经过时钟选择器后,进入 PLL 倍频,然后成为 SYSCLK。SYSCLK 再经过 AHB 分频器,分给 APB1 和 APB2 总线。APB1 通常跑低速外设(如 UART、I2C),APB2 跑高速外设(如 SPI、TIM)。

我曾经踩过的坑: 配置 PLL 时,忘了检查 VCO 频率范围。芯片手册写 VCO 必须在 64~432 MHz 之间,我配了个 48 MHz,结果 PLL 输出完全乱掉。所以,配置 PLL 前,一定先算好 VCO 频率。

4.2 时钟配置驱动开发

时钟配置的代码,说白了就是操作 RCC(Reset and Clock Control)寄存器。我个人习惯把时钟配置封装成一个函数,放在 bsp_clk.c 里。

4.2.1 标准配置流程

以 STM32F103 为例,配置 72 MHz 系统时钟的步骤:

  1. 使能 HSE,等待 HSE 就绪。
  2. 配置 Flash 预取缓冲,设置等待周期(因为主频高了,Flash 跟不上)。
  3. 配置 PLL:设置 PLL 源为 HSE,设置倍频系数。
  4. 使能 PLL,等待 PLL 锁定。
  5. 切换系统时钟为 PLL 输出。
  6. 配置 AHB、APB1、APB2 分频器。

代码示例:

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

    // 2. 配置 Flash 等待周期
    FLASH->ACR = FLASH_ACR_LATENCY_2 | FLASH_ACR_PRFTBE;

    // 3. 配置 PLL: HSE 8 MHz × 9 = 72 MHz
    RCC->CFGR |= RCC_CFGR_PLLSRC_HSE | RCC_CFGR_PLLMULL9;

    // 4. 使能 PLL
    RCC->CR |= RCC_CR_PLLON;
    while (!(RCC->CR & RCC_CR_PLLRDY));

    // 5. 切换系统时钟
    RCC->CFGR &= ~RCC_CFGR_SW;
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while ((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);

    // 6. 配置总线分频
    RCC->CFGR |= RCC_CFGR_HPRE_DIV1;   // AHB = 72 MHz
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV2;  // APB1 = 36 MHz
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV1;  // APB2 = 72 MHz
}
我的小技巧: 调试时钟配置时,可以用示波器看 MCO(Microcontroller Clock Output)引脚。把系统时钟分频后输出到 MCO,用示波器一量就知道频率对不对。比看寄存器快多了。

4.3 SysTick 定时器与延时实现

SysTick 是 Cortex-M 内核自带的 24 位递减计数器。它不依赖外设,所以移植性极好。你想想看,不管换什么 MCU,只要内核是 Cortex-M,SysTick 的代码基本不用改。

4.3.1 工作原理

SysTick 从重装载值开始递减,减到 0 时触发中断,然后自动重装。它的时钟源通常是 HCLK/8 或 HCLK。我个人习惯用 HCLK/8,这样计数范围更大。

实现微秒级延时,核心思路是:

  • 先计算 SysTick 每减一次需要多少微秒。
  • 然后设置重装载值,启动计数。
  • 等待计数到 0。

代码示例:

static volatile uint32_t us_tick;

void SysTick_Init(void)
{
    // 配置 SysTick 时钟源为 HCLK/8
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_0;
}

void delay_us(uint32_t us)
{
    uint32_t reload = SystemCoreClock / 8000000;  // 每微秒的计数次数
    SysTick->LOAD = us * reload - 1;
    SysTick->VAL = 0;
    SysTick->CTRL |= SysTick_CTRL_ENABLE;

    while (!(SysTick->CTRL & SysTick_CTRL_COUNTFLAG));

    SysTick->CTRL &= ~SysTick_CTRL_ENABLE;
}
注意: 这个延时函数是阻塞的,不能在中断里调用。我在一个项目中,在中断里调了 delay_us,结果系统直接卡死。因为 SysTick 中断优先级比当前中断低,永远等不到 COUNTFLAG 置位。

4.4 通用定时器:PWM 输出与输入捕获

通用定时器(TIM)是 MCU 里最灵活的外设之一。它既能输出 PWM,又能测量外部信号的频率和脉宽。说白了,它就是一块多功能“秒表”。

4.4.1 PWM 输出

PWM 输出的核心是:自动重装载寄存器(ARR)决定周期,捕获/比较寄存器(CCR)决定占空比。

配置步骤:

  1. 使能 TIM 时钟,配置 GPIO 为复用推挽输出。
  2. 设置 ARR 和 PSC(预分频器),确定 PWM 频率。
  3. 设置 CCR,确定占空比。
  4. 配置输出比较模式为 PWM 模式 1 或 2。
  5. 使能 TIM 的捕获/比较输出,使能 TIM。

代码示例(TIM2,通道 1,PA0):

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

    // 配置 PA0 为复用推挽输出
    GPIOA->CRL &= ~GPIO_CRL_CNF0;
    GPIOA->CRL |= GPIO_CRL_CNF0_1;   // 复用推挽
    GPIOA->CRL |= GPIO_CRL_MODE0;    // 50 MHz 输出

    // 2. 设置频率: 72 MHz / (71+1) / (999+1) = 1 kHz
    TIM2->PSC = 71;
    TIM2->ARR = 999;

    // 3. 设置占空比 50%
    TIM2->CCR1 = 500;

    // 4. 配置 PWM 模式 1
    TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;
    TIM2->CCMR1 |= TIM_CCMR1_OC1PE;   // 预装载使能

    // 5. 使能输出和定时器
    TIM2->CCER |= TIM_CCER_CC1E;
    TIM2->CR1 |= TIM_CR1_CEN;
}

void PWM_SetDuty(uint16_t duty)
{
    TIM2->CCR1 = duty;
}

4.4.2 输入捕获

输入捕获用来测量外部信号的频率或脉宽。原理是:检测到指定边沿时,把当前计数器的值锁存到 CCR 寄存器。

测量脉宽的步骤:

  1. 配置通道为输入模式,设置触发边沿(上升沿或下降沿)。
  2. 使能捕获中断。
  3. 在中断里,第一次捕获到上升沿时,记录 CCR 值。
  4. 第二次捕获到下降沿时,计算差值,得到高电平脉宽。

代码片段:

volatile uint32_t cap_start, cap_end, pulse_width;

void TIM_IRQHandler(void)
{
    if (TIM2->SR & TIM_SR_CC1IF)
    {
        if (TIM2->CCER & TIM_CCER_CC1P)  // 下降沿
        {
            cap_end = TIM2->CCR1;
            pulse_width = cap_end - cap_start;
            TIM2->CCER |= TIM_CCER_CC1P;  // 切换回上升沿
        }
        else  // 上升沿
        {
            cap_start = TIM2->CCR1;
            TIM2->CCER &= ~TIM_CCER_CC1P; // 切换为下降沿
        }
        TIM2->SR &= ~TIM_SR_CC1IF;
    }
}
避坑指南: 输入捕获时,如果信号频率很低,计数器可能会溢出。我曾经测一个 10 Hz 的信号,忘了处理溢出,结果脉宽数据全是错的。解决方案是:开启定时器的更新中断,在溢出中断里记录溢出次数,计算时把溢出次数也加进去。

4.5 本章小结

时钟系统和定时器,是 BSP 开发的基础。你掌握了时钟树,就能理解芯片的功耗和性能边界;你掌握了 SysTick,就能实现精准延时;你掌握了通用定时器,就能驱动电机、测量传感器信号。

嗯,这一章的内容就到这里。记住,时钟配置是“一票否决”项——配错了,整个系统都跑不起来。所以,每次改时钟配置,我都会用示波器确认一下。这个习惯,救了我很多次。