3. 系统时钟与定时器基础:STM32时钟树详解

时钟,是单片机的心脏。没有时钟,芯片里所有逻辑单元都动不了。我刚开始学STM32时,总觉得时钟配置就是抄几个寄存器值,后来才发现——时钟树要是没理清楚,整个系统跑起来都是歪的。

这一章,咱们把STM32的时钟树掰开揉碎,从HSE、HSI到PLL,再到SysTick和基本定时器,一条路走通。

3.1 时钟树概览:谁给谁供时钟?

STM32的时钟源有好几个,它们不是随便接的,而是通过一个叫“时钟树”的结构,逐级分配。说白了,就是先选一个“根时钟”,然后经过分频、倍频,再分给各个外设。

核心原则:系统时钟(SYSCLK)是所有外设时钟的源头。SYSCLK最高72MHz,超过这个值,芯片会不稳定甚至锁死。

我画了一张时钟树的简化图,帮你快速建立整体认知:

STM32时钟树简化结构图 HSE (外部晶振) HSI (内部RC) LSE (32.768kHz) LSI (40kHz) PLL (倍频至72MHz) SW选择器 (SYSCLK) AHB分频 APB1/APB2 GPIO/定时器等 说明: • HSE/HSI → PLL → SW选择器 → SYSCLK → AHB分频 → APB1/APB2 → 外设 • LSE/LSI主要用于RTC和独立看门狗,不参与系统时钟路径 • 实际配置时,需要依次使能时钟源、配置PLL、选择SYSCLK、配置分频器

3.2 时钟源详解:HSE、HSI、PLL

3.2.1 HSE(外部高速晶振)

HSE是外部晶振,通常接4~16MHz的晶体。我个人习惯用8MHz,因为后面PLL倍频到72MHz刚好是整数倍(×9)。

  • 优点:精度高,温漂小,适合对时序敏感的场景(如串口通信、USB)。
  • 缺点:需要外部电路,占PCB面积,成本略高。
  • 我踩过的坑:曾经在画板子时,晶振离芯片太远,走线太长,结果起振不稳定。后来把晶振紧贴OSC_IN/OUT引脚,问题就解决了。

3.2.2 HSI(内部高速RC振荡器)

HSI是芯片内部自带的RC振荡器,频率8MHz。上电后默认就是HSI作为系统时钟。

  • 优点:无需外部元件,上电即用,省成本。
  • 缺点:精度差(约±1%),温漂大。做串口通信时,波特率容易偏。

我的建议:如果项目对成本敏感、对时序要求不高(比如只控制LED、读取按键),直接用HSI就够了。但如果你要用串口、CAN、USB,老老实实上HSE。

3.2.3 PLL(锁相环倍频)

PLL的作用是把HSE或HSI的频率“翻倍”。STM32F103的PLL最高输出72MHz。

配置PLL时,有几个参数要搞清楚:

参数说明典型值
PLLSRCPLL输入源选择(HSE或HSI/2)HSE
PLLMUL倍频系数(2~16)9(8MHz×9=72MHz)
PLLXTPREHSE预分频(1或2)1

注意:PLL输出频率不能超过72MHz。我曾经见过有人把PLLMUL设成16,8MHz×16=128MHz,结果芯片直接死机,电流飙升。嗯,那次差点烧了开发板。

3.3 配置系统时钟(72MHz)

下面是一段标准的系统时钟配置代码,把SYSCLK设为72MHz。我习惯在工程初始化时直接调用这个函数。

void SystemClock_Config(void)
{
    // 1. 使能HSE,等待就绪
    RCC->CR |= RCC_CR_HSEON;
    while(!(RCC->CR & RCC_CR_HSERDY));
    
    // 2. 配置FLASH预取指、等待周期(72MHz需要2个等待周期)
    FLASH->ACR = FLASH_ACR_PRFTBE | FLASH_ACR_LATENCY_2;
    
    // 3. 配置AHB、APB1、APB2分频
    RCC->CFGR |= RCC_CFGR_HPRE_DIV1;   // AHB不分频 = 72MHz
    RCC->CFGR |= RCC_CFGR_PPRE2_DIV1;  // APB2不分频 = 72MHz
    RCC->CFGR |= RCC_CFGR_PPRE1_DIV2;  // APB1二分频 = 36MHz
    
    // 4. 配置PLL:HSE作为源,倍频系数9
    RCC->CFGR |= RCC_CFGR_PLLSRC_HSE | RCC_CFGR_PLLMULL9;
    
    // 5. 使能PLL,等待就绪
    RCC->CR |= RCC_CR_PLLON;
    while(!(RCC->CR & RCC_CR_PLLRDY));
    
    // 6. 选择PLL作为系统时钟
    RCC->CFGR |= RCC_CFGR_SW_PLL;
    while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}

关键点:配置顺序不能乱。先使能时钟源,再配置分频和PLL,最后切换系统时钟。跳步会导致时钟切换失败,系统跑在默认的HSI上。

3.4 SysTick定时器(系统滴答)

SysTick是Cortex-M3内核自带的24位递减计数器。它不依赖外设定时器,所以哪怕你换了芯片型号,SysTick的用法基本不变。

SysTick的作用有两个:

  • 给操作系统(如FreeRTOS)提供心跳节拍。
  • 实现精确的软件延时。

它的工作原理很简单:从重装载值开始递减,减到0时触发中断,然后自动重新装载。中断频率由你设定的重装载值决定。

计算公式:

SysTick中断频率 = 系统时钟频率 / (重装载值 + 1)

比如系统时钟72MHz,想要1ms中断一次:

重装载值 = 72000000 / 1000 - 1 = 71999

3.5 使用SysTick实现精确延时

我项目中常用的延时函数,就是基于SysTick的。下面给出一套完整的实现:

// 全局变量,记录SysTick计数
static volatile uint32_t uwTick;

// SysTick中断服务函数
void SysTick_Handler(void)
{
    uwTick++;
}

// 初始化SysTick,1ms中断一次
void SysTick_Init(void)
{
    // 重装载值 = 72MHz / 1000 - 1
    SysTick->LOAD = 72000 - 1;
    // 使能SysTick中断,使用处理器时钟
    SysTick->VAL = 0;
    SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk |
                     SysTick_CTRL_TICKINT_Msk |
                     SysTick_CTRL_ENABLE_Msk;
}

// 获取当前tick值
uint32_t GetTick(void)
{
    return uwTick;
}

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

避坑指南:我曾经在中断里直接调用Delay_ms(),结果死锁了。因为SysTick中断优先级比当前中断低,导致uwTick一直不更新。解决办法:要么不用阻塞延时,要么把SysTick优先级设到最高。

3.6 基本定时器TIM6/TIM7

TIM6和TIM7是STM32里最“纯粹”的定时器。它们只有基本的计数功能,没有输入捕获、输出比较、PWM等花哨功能。但正因为简单,所以稳定可靠。

特性TIM6/TIM7通用定时器(TIM2~TIM5)
计数方向仅向上向上/向下/中心对齐
输入捕获无有
输出比较/PWM无有
DMA支持有有
主要用途定时中断、触发DACPWM、脉冲测量、编码器

TIM6/TIM7挂在APB1总线上。如果APB1分频系数不是1,那么定时器的时钟频率会是APB1的两倍。这个细节容易忽略,我当初调试时发现定时器跑得比预期快一倍,查了半天才发现是APB1分频导致的。

配置TIM6产生1秒中断的示例:

void TIM6_Config(void)
{
    // 使能TIM6时钟(APB1)
    RCC->APB1ENR |= RCC_APB1ENR_TIM6EN;
    
    // 预分频器:72MHz / 7200 = 10kHz
    TIM6->PSC = 7200 - 1;
    
    // 自动重装载值:10kHz / 10000 = 1Hz
    TIM6->ARR = 10000 - 1;
    
    // 更新中断使能
    TIM6->DIER |= TIM_DIER_UIE;
    
    // 使能计数器
    TIM6->CR1 |= TIM_CR1_CEN;
    
    // 配置NVIC
    NVIC_EnableIRQ(TIM6_IRQn);
    NVIC_SetPriority(TIM6_IRQn, 1);
}

// 中断服务函数
void TIM6_IRQHandler(void)
{
    if(TIM6->SR & TIM_SR_UIF)
    {
        TIM6->SR = ~TIM_SR_UIF;  // 清除标志
        // 用户代码:每秒执行一次
    }
}

重要提醒:定时器中断服务函数里,一定要先清除中断标志位,再执行用户代码。否则会反复进入中断,导致程序卡死。我刚开始写STM32时,就因为这个bug排查了一下午。

3.7 本章小结

时钟和定时器,是STM32开发的基石。你想想看,如果时钟配错了,后面所有外设的时序都是错的。SysTick和TIM6/TIM7虽然简单,但用好了,能解决大部分定时需求。

我个人建议:新项目上手,先把系统时钟配到72MHz,然后用SysTick做一个1ms的延时函数,再跑一个LED闪烁。这一步走通了,后面就顺了。