4. 时钟系统与定时器:系统时钟树解析、PLL配置、SysTick定时器、通用定时器PWM输出

时钟,说白了就是芯片的心脏。没有时钟,CPU没法跑,外设也动不了。我刚开始接触ARM裸机时,总觉得时钟配置就是改几个寄存器的事,后来才发现——时钟树要是没理清楚,整个系统跑起来都是乱的。

这一章,咱们就好好捋一捋ARM芯片的时钟系统。从时钟树怎么走,到PLL怎么配,再到SysTick和通用定时器的实战用法。嗯,内容不少,但都是硬货。

4.1 系统时钟树解析

先看一张图,把时钟树的整体结构搞清楚。

ARM系统时钟树结构图 HSI (8MHz) HSE (4-16MHz) LSI (32kHz) LSE (32.768kHz) PLL (锁相环) ×N / M / P / Q SYSCLK 选择器 AHB 预分频器 HCLK (CPU/总线) APB1 (低速外设) APB2 (高速外设) 定时器时钟 注意 PLL输出频率 = 输入频率 × N / (M × P)

这张图我画了好几次才满意。你看,时钟源有好几个:HSI(内部高速)、HSE(外部晶振)、LSI(内部低速)、LSE(外部32.768kHz)。它们经过PLL倍频,再通过选择器决定SYSCLK,最后分频给AHB、APB1、APB2总线。

关键点:APB1最高36MHz,APB2最高72MHz。定时器时钟可能还要再翻倍——这个坑我踩过,后面细说。

4.2 PLL配置实战

PLL配置,说白了就是算乘法。输入8MHz,想要72MHz,那就得×9。但实际没这么简单,还得考虑分频系数。

我习惯用STM32F103来举例。它的PLL公式是这样的:

PLL输出 = (HSE / M) × N / P

其中:

  • M:预分频系数,通常设为HSE频率值(比如8MHz就设8)
  • N:倍频系数,范围192~432
  • P:主分频系数,可选2、4、6、8

举个例子:外部晶振8MHz,目标SYSCLK 72MHz。

PLL输出 = (8MHz / 8) × 360 / 2 = 180MHz  // 不对!超了!
PLL输出 = (8MHz / 8) × 144 / 2 = 72MHz   // 正确

我曾经在一款产品上直接把N设成360,结果芯片发热严重,跑一会儿就死机。后来查手册才发现——PLL输出不能超过72MHz,N也有范围限制。嗯,从那以后我配PLL都先看数据手册的“PLL characteristics”表格。

实际代码怎么写?我一般这样配:

// 配置PLL,HSE=8MHz,目标SYSCLK=72MHz
RCC->CR |= RCC_CR_HSEON;                    // 开启HSE
while(!(RCC->CR & RCC_CR_HSERDY));          // 等待HSE稳定

RCC->CFGR |= RCC_CFGR_PLLSRC_HSE;           // PLL源选HSE
RCC->CFGR |= RCC_CFGR_PLLMULL9;             // 倍频×9 → 72MHz

RCC->CR |= RCC_CR_PLLON;                    // 开启PLL
while(!(RCC->CR & RCC_CR_PLLRDY));          // 等待PLL锁定

RCC->CFGR |= RCC_CFGR_SW_PLL;               // 切换SYSCLK到PLL
while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL); // 等待切换完成

小技巧:切换时钟源后,一定要等状态位确认。我见过有人没等就往下跑,结果外设配置全乱套。

4.3 SysTick定时器

SysTick是ARM Cortex-M内核自带的24位递减定时器。说白了,它就是给操作系统做时间片轮转用的,但裸机里我们常用它做精确延时。

它的工作原理很简单:从重装载值开始递减,减到0就触发中断,然后自动重装。24位,最大计数值2^24-1。

我一般这样初始化SysTick做1ms中断:

// 系统时钟72MHz,配置SysTick为1ms中断
void SysTick_Init(void)
{
    // 重装载值 = 72000000 / 1000 = 72000
    SysTick->LOAD = 72000 - 1;
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |  // 使用系统时钟
                     SysTick_CTRL_TICKINT_Msk |    // 使能中断
                     SysTick_CTRL_ENABLE_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要用volatile修饰,否则编译器优化后可能读不到最新值。这个坑我踩过,调试了一下午才发现。

4.4 通用定时器PWM输出

通用定时器比SysTick复杂多了。它有多种模式:定时、输入捕获、输出比较、PWM生成。咱们重点说PWM。

PWM的核心参数就两个:频率和占空比。频率由定时器时钟和自动重装载值决定,占空比由比较值决定。

看个例子,用TIM2输出1kHz、50%占空比的PWM:

// 假设APB1时钟=36MHz,定时器时钟=72MHz(APB1预分频≠1时翻倍)
void TIM2_PWM_Init(void)
{
    // 1. 使能TIM2时钟
    RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
    
    // 2. 配置GPIO为复用推挽输出(PA0为TIM2_CH1)
    GPIOA->CRL &= ~GPIO_CRL_CNF0_Msk;
    GPIOA->CRL |= GPIO_CRL_CNF0_1;  // 复用推挽输出
    
    // 3. 配置定时器
    TIM2->PSC = 72 - 1;             // 预分频:72MHz/72 = 1MHz
    TIM2->ARR = 1000 - 1;           // 自动重装:1MHz/1000 = 1kHz
    TIM2->CCR1 = 500;               // 比较值:50%占空比
    
    // 4. 配置PWM模式
    TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2;  // PWM模式1
    TIM2->CCMR1 |= TIM_CCMR1_OC1PE;                       // 预装载使能
    TIM2->CCER |= TIM_CCER_CC1E;                          // 使能输出
    
    // 5. 使能定时器
    TIM2->CR1 |= TIM_CR1_CEN;
}

我曾经在调试PWM时,输出死活没波形。查了半天,发现是GPIO模式没配对——复用推挽输出,不是普通推挽输出。你想想看,GPIO模式错了,定时器配得再好也没用。

4.5 定时器时钟的坑

这里有个容易忽略的点:定时器时钟可能比APB总线快一倍。为什么?

看STM32的时钟树:如果APB1预分频系数不是1,那么定时器时钟 = APB1时钟 × 2。比如APB1=36MHz(预分频=2),定时器时钟就是72MHz。

我整理了个表格,方便对照:

APB1预分频 APB1时钟 定时器时钟
1 72MHz 72MHz
2 36MHz 72MHz
4 18MHz 36MHz
8 9MHz 18MHz

我的习惯:配定时器前,先确认APB预分频系数,再算定时器时钟。别想当然地以为定时器时钟就等于APB时钟。

4.6 实战:呼吸灯效果

把前面学的串起来,做个呼吸灯。原理很简单:不断改变PWM占空比,从0到100再到0,循环。

void breath_led(void)
{
    static uint8_t direction = 0;  // 0:增加, 1:减少
    static uint16_t duty = 0;
    
    TIM2->CCR1 = duty;
    
    if(direction == 0) {
        duty += 10;
        if(duty >= 1000) direction = 1;
    } else {
        duty -= 10;
        if(duty <= 0) direction = 0;
    }
}

把这个函数放在SysTick中断里,每10ms调用一次,就能看到LED慢慢变亮再慢慢变暗。效果很直观,调试也方便。

总结一下:时钟系统是ARM芯片的基础,PLL配置要算清楚,SysTick适合做系统心跳,通用定时器适合做PWM和精确控制。每个模块都有它的脾气,摸透了就好办了。