3、系统时钟与延时:SysTick定时器原理、实现微秒/毫秒级精确延时、我在项目中遇到的时钟配置坑。

时钟,是单片机的“心跳”。

没有稳定的时钟,一切代码都是空中楼阁。而SysTick定时器,就是Cortex-M内核自带的一个24位倒计时器。说白了,它就是专门用来产生固定时间中断的硬件模块。你不需要额外配置一个TIM外设,就能拿到精准的延时和系统节拍。

我个人习惯,拿到一个新板子,第一件事就是先把SysTick和延时函数调通。这一步走稳了,后面的状态机、任务调度才有根基。

3.1 SysTick定时器的工作原理

SysTick本质上就是一个递减计数器。它从你设定的重装载值开始,每个时钟周期减1,减到0的时候,会触发一次中断,然后自动重新装载,继续下一轮计数。

它的核心寄存器只有4个:

寄存器 功能
CTRL (控制与状态) 使能定时器、使能中断、选择时钟源、读取计数标志
LOAD (重装载值) 设定倒计数的起始值,范围0~0xFFFFFF
VAL (当前值) 读取当前计数值,写任意值会清0并清除COUNTFLAG
CALIB (校准值) 芯片出厂时固化的10ms校准值,一般用不上

时钟源的选择很关键。CTRL寄存器的第2位(CLKSOURCE)决定了SysTick用哪个时钟:

  • 0:使用HCLK/8(AHB总线时钟的8分频)
  • 1:使用HCLK(直接使用AHB总线时钟)

我在项目中遇到过一个问题:用HCLK/8时,延时还算准;切到HCLK后,发现所有延时都翻倍了。排查了半天,原来是重装载值没跟着改。你想想看,时钟频率变了,计数值不变,时间肯定不对。

核心公式:

延时时间 = 重装载值 × (1 / SysTick时钟频率)

例如:HCLK=72MHz,选择HCLK/8=9MHz,想要1ms中断,重装载值 = 0.001 × 9,000,000 = 9000

3.2 实现毫秒级精确延时

毫秒级延时是最常用的。我一般这样配置:

// 初始化SysTick,产生1ms中断
void SysTick_Init(void)
{
    // 1. 关闭SysTick,防止配置过程中产生意外中断
    SysTick->CTRL = 0;
    
    // 2. 设置重装载值
    // 假设系统时钟为72MHz,选择HCLK/8 = 9MHz
    // 1ms需要的计数值 = 9,000,000 / 1000 = 9000
    SysTick->LOAD = 9000 - 1;  // 注意要减1,因为从0开始计数
    
    // 3. 清空当前值
    SysTick->VAL = 0;
    
    // 4. 配置时钟源为HCLK/8,使能中断,使能定时器
    SysTick->CTRL = (1 << 2) |  // CLKSOURCE = 0,选择HCLK/8
                    (1 << 1) |  // TICKINT = 1,使能中断
                    (1 << 0);   // ENABLE = 1,启动定时器
}

中断服务函数里,维护一个全局的毫秒计数器:

volatile uint32_t g_sysTickUptime = 0;

void SysTick_Handler(void)
{
    g_sysTickUptime++;
}

// 毫秒延时函数
void delay_ms(uint32_t ms)
{
    uint32_t start = g_sysTickUptime;
    while ((g_sysTickUptime - start) < ms);
}

这里有个小技巧:用减法判断时间差,而不是直接比较大小。因为g_sysTickUptime会溢出,用减法可以避免溢出带来的逻辑错误。嗯,这个坑我踩过,当时查了好久才发现是溢出判断的问题。

3.3 实现微秒级精确延时

微秒级延时就不能靠中断了。中断响应有延迟,而且频繁进中断会影响系统性能。我的做法是:直接查询VAL寄存器,做阻塞式忙等。

// 微秒延时函数(阻塞式)
void delay_us(uint32_t us)
{
    uint32_t ticks;
    uint32_t reload;
    uint32_t start, current, elapsed;
    
    // 获取当前重装载值
    reload = SysTick->LOAD;
    
    // 计算需要的计数值
    // 假设HCLK=72MHz,选择HCLK/8=9MHz
    // 1us需要的计数值 = 9,000,000 / 1,000,000 = 9
    ticks = us * 9;
    
    // 读取当前VAL值作为起始点
    start = SysTick->VAL;
    
    while (1)
    {
        current = SysTick->VAL;
        
        // 计算已经走过的计数值
        if (current <= start)
        {
            elapsed = start - current;
        }
        else
        {
            // 发生了溢出,重新装载了一次
            elapsed = start + (reload - current);
        }
        
        if (elapsed >= ticks)
        {
            break;
        }
    }
}

注意:微秒延时是阻塞式的,会占用CPU。在中断服务函数或实时性要求高的场景下,慎用。我曾经在一个电机控制项目里,中断里调了delay_us,结果导致其他中断响应不及时,电机出现了抖动。

3.4 我在项目中遇到的时钟配置坑

做嵌入式这么多年,时钟配置的坑我踩过不少。挑几个典型的说说:

坑一:HSE起振失败,系统卡死

有一次做产品,换了批晶振,结果部分板子死活起不来。查了半天,发现是晶振负载电容不匹配,导致HSE起振超时。后来我学乖了,在HSE配置里加了超时重试机制,起振失败就自动切到HSI。

// HSE超时检测示例
uint8_t HSE_Startup_With_Timeout(uint32_t timeout)
{
    uint32_t i;
    
    // 使能HSE
    RCC->CR |= (1 << 16);
    
    // 等待HSE就绪,带超时
    for (i = 0; i < timeout; i++)
    {
        if (RCC->CR & (1 << 17))
        {
            return 1;  // HSE就绪
        }
    }
    
    // 超时,关闭HSE,返回失败
    RCC->CR &= ~(1 << 16);
    return 0;
}

坑二:PLL配置顺序搞反

配置PLL时,必须先配置好分频系数,再使能PLL,最后等待PLL锁定。我见过有人先使能PLL再配分频,结果PLL输出频率完全不对。正确的顺序是:

  1. 配置PLL时钟源和分频系数
  2. 使能PLL
  3. 等待PLL锁定标志
  4. 切换系统时钟到PLL

坑三:APB1/APB2时钟频率超限

STM32的APB1最高36MHz,APB2最高72MHz。我有个同事,把APB1的TIM定时器时钟配到了72MHz,结果TIM寄存器写不进去,定时器完全不工作。查了两天才发现是时钟超限了。

我的建议:每次配置完时钟,都去读一下RCC寄存器,确认实际配置值。不要想当然地认为“我配了72MHz它就一定是72MHz”。用调试器看寄存器,比猜靠谱得多。

3.5 知识结构总览

下面这张图,把SysTick和时钟配置的核心逻辑串起来了:

系统时钟与SysTick知识结构图 系统时钟与SysTick知识结构 时钟源选择 HSE (外部晶振) HSI (内部RC) PLL (锁相环) SYSCLK (系统时钟) HCLK (AHB总线) APB1/APB2 (外设时钟) SysTick定时器 其他外设 (TIM/USART等) SysTick时钟源可选 HCLK/8 或 HCLK,通过CTRL寄存器的CLKSOURCE位控制 重装载值 = 目标延时时间 × SysTick时钟频率

3.6 总结一下

时钟配置是嵌入式开发的“第一道坎”。SysTick虽然简单,但用好了,能给整个系统提供稳定的时间基准。我个人的经验是:

  • 先确认时钟树:打开参考手册的时钟树图,一条一条路径跟下来,确保每个分频系数都算对了
  • 再写延时函数:毫秒用中断,微秒用查询,各司其职
  • 最后做验证:用逻辑分析仪或者示波器,实测一下延时精度。别信代码里的注释,信波形

一句话记住:时钟配不对,后面全白费。花30分钟把时钟搞明白,能省下后面3天的调试时间。