4、时钟与定时器:系统时钟树、PLL配置、定时器驱动、Tick中断服务

时钟,说白了就是嵌入式系统的「心跳」。没有时钟,CPU 没法跑,外设没法转,整个系统就是一摊死硅。我见过不少刚入行的工程师,花大把时间调外设驱动,结果系统跑不起来——最后发现是时钟树配错了。嗯,这章咱们就把时钟和定时器彻底讲透。

4.1 系统时钟树——你得先看懂这张图

每颗 SoC 的手册里都有一张时钟树图,看着像地铁线路图。我个人习惯,拿到一个新平台,第一件事不是看 GPIO,而是先找到时钟树。为什么?因为所有外设的时钟源、分频系数、使能开关,全在这张图上。

时钟树的核心逻辑其实不复杂:

  • 时钟源:晶振(HSE/LSE)、内部 RC 振荡器(HSI/LSI)、PLL 输出
  • 时钟路径:源 → 分频/倍频 → 开关 → 外设总线
  • 时钟门控:每个外设模块前都有一个时钟使能位,关了就能省电

核心原则:CPU 核、总线、外设,各自有最高频率限制。配时钟树就是在「性能」和「稳定性」之间找平衡。

我在项目中遇到过一件事:某款工业控制器,量产 100 台后有 3 台上电后串口乱码。查了两周,最后发现是 HSI 校准值没写对,导致 PLL 输出频率漂了 2%。从那以后,我所有时钟配置代码里都加了频率测量和校验逻辑。

4.2 PLL 配置——倍频不是你想乘就能乘

PLL(锁相环)是时钟树里最关键的模块。它的作用是把低频时钟源(比如 8MHz 晶振)倍频到高频(比如 400MHz 给 CPU 核)。

典型的 PLL 配置流程:

  1. 选择输入源:HSE 还是 HSI?我建议优先用 HSE,精度高、温漂小。
  2. 设置分频系数(M):把输入频率降到 PLL 的参考输入范围内(通常是 1-2MHz)。
  3. 设置倍频系数(N):决定输出频率,N × 参考频率 = VCO 频率。
  4. 设置后分频(P/Q/R):把 VCO 频率分频到目标值。
  5. 等待 PLL 锁定:必须检查锁定标志位,否则输出频率不稳定。
/* STM32F4 PLL 配置示例:HSE 8MHz → 系统时钟 168MHz */
void SystemClock_Config(void)
{
    RCC->CR |= RCC_CR_HSEON;                    /* 使能 HSE */
    while(!(RCC->CR & RCC_CR_HSERDY));          /* 等待 HSE 就绪 */

    RCC->PLLCFGR = (8 << 0)  |                  /* PLL_M = 8  */
                   (336 << 6) |                  /* PLL_N = 336 */
                   (2 << 16) |                   /* PLL_P = 4  */
                   (7 << 24);                    /* PLL_Q = 7  */

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

    FLASH->ACR = FLASH_ACR_LATENCY_5WS;         /* 配置 Flash 等待周期 */
    RCC->CFGR |= RCC_CFGR_SW_PLL;               /* 切换系统时钟为 PLL */
    while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}

我曾经踩过的坑:PLL 配置完成后,一定要检查 VCO 频率是否在手册规定的范围内(通常是 100-432MHz)。超了,芯片会随机死机;低了,PLL 锁不住。别问我怎么知道的。

4.3 定时器驱动——不只是数数

嵌入式里的定时器,分三类:

类型 位数 典型用途 我常用的场景
基本定时器 16-bit 定时中断、延时 按键消抖、轮询间隔
通用定时器 16/32-bit PWM、输入捕获、输出比较 电机控制、脉冲计数
高级定时器 16/32-bit 互补PWM、死区插入、刹车 三相逆变器、开关电源

定时器驱动的核心就三件事:时钟源选择、预分频器设置、自动重装载值配置。

你想想看,一个 16 位定时器,最大计数值是 65535。如果时钟频率是 72MHz,不分频的话,一个计数周期才 13.9ns,定时器只能跑 0.9ms 就溢出了。所以必须用预分频器把时钟降下来。

/* 通用定时器初始化:1ms 中断周期 */
void TIM_Init(TIM_TypeDef* TIMx, uint32_t period_ms)
{
    uint32_t psc, arr;
    uint32_t tim_clk = 72000000;  /* 假设定时器时钟 72MHz */

    /* 计算预分频系数:让计数器时钟为 1MHz(1us 一个 tick) */
    psc = (tim_clk / 1000000) - 1;
    /* 计算自动重装载值:1ms = 1000 个 tick */
    arr = (1000 * period_ms) - 1;

    TIMx->PSC = psc;
    TIMx->ARR = arr;
    TIMx->EGR = TIM_EGR_UG;      /* 产生更新事件,重新加载寄存器 */
    TIMx->DIER |= TIM_DIER_UIE;  /* 使能更新中断 */
    TIMx->CR1  |= TIM_CR1_CEN;   /* 启动定时器 */
}

个人经验:预分频器和自动重装载值,尽量用整数除法算,别用浮点。嵌入式里浮点运算慢,而且容易引入精度误差。我习惯先算好分频系数,再反算验证一下实际频率。

4.4 Tick 中断服务——系统的脉搏

Tick 中断,就是系统定时器(SysTick)每 1ms 触发一次的中断。它是 RTOS 的心跳,也是裸机延时函数的基础。

SysTick 是 Cortex-M 内核自带的 24 位递减计数器。配置起来很简单:

/* SysTick 初始化:1ms 中断 */
void SysTick_Init(void)
{
    uint32_t reload = SystemCoreClock / 1000;  /* 1ms 的计数值 */
    SysTick->LOAD  = reload - 1;
    SysTick->VAL   = 0;
    SysTick->CTRL  = SysTick_CTRL_CLKSOURCE_Msk |
                     SysTick_CTRL_TICKINT_Msk   |
                     SysTick_CTRL_ENABLE_Msk;
}

/* Tick 中断服务函数 */
void SysTick_Handler(void)
{
    static uint32_t tick_count = 0;
    tick_count++;

    /* 裸机延时:每 1ms 减一次 */
    if(delay_ms_counter > 0)
        delay_ms_counter--;

    /* RTOS 的 Tick 钩子 */
    #ifdef USE_RTOS
    xPortSysTickHandler();
    #endif
}

这里有个细节:SysTick 中断里不要做耗时操作。我见过有人直接在中断里打印日志,结果系统卡死。Tick 中断应该只做三件事:

  • 更新系统滴答计数
  • 处理软件定时器
  • 触发 RTOS 调度

我曾经犯过的错:在 Tick 中断里调用了 printf,结果串口还没发完,下一次 Tick 又来了。中断嵌套把栈撑爆了,系统直接 HardFault。从那以后,我所有中断服务函数都遵循「快进快出」原则。

4.5 时钟与定时器知识体系

下面这张图,是我自己总结的时钟与定时器知识结构。你把它记熟了,遇到任何 SoC 都能快速上手。

时钟与定时器知识体系 时钟源 PLL 配置 时钟树 基本定时器 通用定时器 高级定时器 定时器驱动核心:时钟源选择 → 预分频器 → 自动重装载值 → 中断使能 Tick 中断服务:更新滴答计数 → 处理软件定时器 → 触发 RTOS 调度 核心原则:先配时钟树 → 再配 PLL → 最后配定时器。顺序错了,系统必出问题。

这张图把整个知识体系串起来了。你从上往下看:时钟源经过 PLL 倍频后,送入时钟树分配;时钟树给各个定时器提供时钟;定时器驱动配置好预分频和重装载值后,产生中断;Tick 中断服务负责系统心跳。每一步都环环相扣。

总结一下:时钟和定时器是 BSP 里最基础也最容易出错的模块。我的建议是——拿到新板子,先花半天把时钟树吃透,把 PLL 配稳,把 Tick 中断调准。后面所有驱动开发都会顺很多。