3. 系统时钟与定时器:时钟树详解、SysTick定时器、模块化封装延时函数、实战:精准闪烁LED

时钟,是单片机的“心跳”。没有时钟,CPU 无法执行指令,外设也无法工作。很多初学者觉得时钟配置很神秘,其实说白了,就是给芯片的各个部分分配一个稳定的“节拍”。

我个人习惯,拿到一块新的 STM32 板子,第一件事就是先把时钟树理清楚。为什么?因为后面所有的定时、通信、PWM 都依赖它。时钟乱了,整个系统就乱了。

3.1 时钟树:STM32 的“心脏”与“血管”

STM32 的时钟源有好几个,它们之间的关系就像一棵树——根是振荡器,干是 PLL,枝是总线时钟。我画了一张图,帮你快速建立整体认知。

STM32时钟树结构图 STM32 时钟树核心结构 HSI (8MHz RC) HSE (外部晶振) LSI (40kHz RC) PLL (锁相环倍频) 输出: 72MHz / 168MHz / 216MHz SYSCLK 选择器 AHB 分频器 APB1 分频器 APB2 分频器 GPIO / DMA / SRAM USART / I2C / SPI GPIO / TIM / ADC 说明:HSI 精度低但启动快;HSE 精度高;PLL 用于倍频到目标主频;AHB/APB 分频后供给不同外设。

这张图里,你重点关注三个东西:时钟源、PLL 倍频、总线分频。我刚开始学的时候,总搞不清 HSI 和 HSE 到底用哪个。后来在项目里吃过亏——用 HSI 做 USART 通信,结果波特率偏差太大,数据全是乱码。从那以后,只要涉及通信,我必用 HSE 外部晶振。

我的建议:开发阶段直接用 HSE + PLL 倍频到芯片最高主频。HSI 留着做备份或低功耗场景。别在时钟源上省事,不然后面排查问题会非常痛苦。

3.2 SysTick 定时器:Cortex-M 内核自带的“节拍器”

SysTick 是 Cortex-M 内核内部的一个 24 位递减计数器。它不属于 STM32 外设,而是 ARM 内核自带的。这意味着,不管你是 STM32F1 还是 F4,SysTick 的用法完全一样。

它的工作原理很简单:

  • 从重装载值开始,每个时钟周期减 1
  • 减到 0 时,触发一次中断,并自动重新装载
  • 24 位计数器,最大计数值 16,777,215

你想想看,如果我们把 SysTick 配置成 1ms 中断一次,那它就是一个精准的“心跳”。延时函数、操作系统时钟节拍,都靠它。

3.2.1 SysTick 寄存器一览

寄存器 地址偏移 功能描述
CTRL 0x00 控制寄存器:使能、时钟源选择、中断使能
LOAD 0x04 重装载值寄存器(24位有效)
VAL 0x08 当前计数值寄存器(读清零)
CALIB 0x0C 校准寄存器(一般不用)
我曾经踩过的坑:VAL 寄存器在读取后会自动清零!如果你在中断里读 VAL 做时间计算,会发现数值永远不对。正确做法是只在 CTRL 的 COUNTFLAG 位来判断是否溢出。

3.3 模块化封装延时函数

裸机开发里,延时函数是最基础的工具。但很多人直接写死循环空转,既不精准,又浪费 CPU。我习惯把延时函数封装成一个独立模块,方便移植和复用。

3.3.1 头文件设计:systick.h

#ifndef __SYSTICK_H
#define __SYSTICK_H

#include "stm32f10x.h"  // 根据芯片型号调整

// 初始化 SysTick,配置为 1ms 中断一次
void SysTick_Init(void);

// 毫秒级延时
void delay_ms(uint32_t ms);

// 微秒级延时(通过循环精细调整)
void delay_us(uint32_t us);

#endif

3.3.2 实现文件:systick.c

#include "systick.h"

// 全局变量,用于记录 SysTick 中断次数
static volatile uint32_t sysTickCounter = 0;

void SysTick_Init(void)
{
    // 1. 关闭 SysTick
    SysTick->CTRL = 0;
    
    // 2. 设置重装载值:72MHz / 1000 = 72000
    //    即每 1ms 产生一次中断
    SysTick->LOAD = 72000 - 1;
    
    // 3. 清空当前计数值
    SysTick->VAL = 0;
    
    // 4. 配置时钟源为 HCLK (72MHz)
    //    使能中断,使能计数器
    SysTick->CTRL = (1 << 2) |  // CLKSOURCE: HCLK
                    (1 << 1) |  // TICKINT: 使能中断
                    (1 << 0);   // ENABLE: 启动
}

void delay_ms(uint32_t ms)
{
    uint32_t start = sysTickCounter;
    while ((sysTickCounter - start) < ms);
}

void delay_us(uint32_t us)
{
    // 微秒级延时:用循环精细控制
    // 72MHz 下,一个空循环约 6 个时钟周期
    // 所以 1us ≈ 12 次循环
    uint32_t count = us * 12;
    while (count--);
}

// SysTick 中断处理函数
void SysTick_Handler(void)
{
    sysTickCounter++;
}
关键点:sysTickCounter 必须用 volatile 修饰!否则编译器优化后,while 循环可能永远跳不出。我见过有人 debug 一整天,最后发现是 volatile 漏了。

3.4 实战:精准闪烁 LED

理论说完了,咱们来点实际的。用刚才封装的延时函数,让 LED 以精准的 500ms 间隔闪烁。

3.4.1 硬件连接

  • LED 正极 → 通过 220Ω 电阻 → PB0
  • LED 负极 → GND

3.4.2 主程序代码

#include "systick.h"
#include "stm32f10x_gpio.h"

void GPIO_Init(void)
{
    // 使能 GPIOB 时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);
    
    // 配置 PB0 为推挽输出,50MHz
    GPIO_InitTypeDef gpio;
    gpio.GPIO_Pin = GPIO_Pin_0;
    gpio.GPIO_Mode = GPIO_Mode_Out_PP;
    gpio.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOB, &gpio);
}

int main(void)
{
    // 初始化 SysTick
    SysTick_Init();
    
    // 初始化 GPIO
    GPIO_Init();
    
    while (1)
    {
        GPIO_SetBits(GPIOB, GPIO_Pin_0);   // LED 亮
        delay_ms(500);                      // 延时 500ms
        
        GPIO_ResetBits(GPIOB, GPIO_Pin_0); // LED 灭
        delay_ms(500);                      // 延时 500ms
    }
}

3.4.3 验证方法

  1. 用示波器测量 PB0 引脚,应该看到 1Hz 的方波(500ms 高,500ms 低)
  2. 用逻辑分析仪抓取,观察高低电平时间是否精准
  3. 肉眼观察 LED,应该以稳定的节奏闪烁
调试技巧:如果 LED 闪烁频率不对,先检查 SysTick_LOAD 的值。72MHz 下,LOAD = 72000-1 对应 1ms。如果你用的是 8MHz HSI,LOAD 应该设为 8000-1。这个值错了,整个时间基准就全偏了。

3.5 本章小结

时钟树是 STM32 的基石,SysTick 是裸机开发的瑞士军刀。把这两样东西封装好,后面的 PWM、输入捕获、通信协议都会轻松很多。

嗯,这里再啰嗦一句:不要跳过时钟配置直接写应用代码。我见过太多人拿着开发板,跑例程没问题,自己写就翻车。原因十有八九是时钟没配对。把时钟树吃透,你就算入门了。