4. 系统时钟配置与延时函数:HSE与HSI时钟源、SysTick定时器原理、HAL_Delay与微秒级延时实现

时钟,是单片机的心脏。没有时钟,芯片就是一块死硅片。我刚开始学单片机那会儿,总觉得时钟配置是件麻烦事,能用默认的就行。直到有一次,我调一个ADC采样,数据老是跳,后来才发现是时钟没配好,采样时序全乱了。从那以后,我养成了一个习惯——上电第一件事,先把时钟理清楚。

这一章,我们就来聊聊STM32的时钟系统。说白了,就是搞清楚你的芯片到底跑多快,以及怎么精确地“等”一段时间。

4.1 时钟源的选择:HSE vs HSI

STM32的时钟源有好几个,但最常用的就是两个:HSE(外部高速晶振)和HSI(内部高速RC振荡器)。

特性 HSE(外部晶振) HSI(内部RC)
精度 高(通常±10~30ppm) 低(约±1%~2%)
频率 常用4~16MHz 固定8MHz(STM32F1系列)
启动时间 较慢(ms级) 极快(μs级)
成本 需要外部晶振+电容 无需外部元件
温漂 小 大

我个人习惯:只要对时序有要求,比如串口通信、ADC采样,我一定用HSE。HSI虽然省事,但精度不够,波特率容易跑偏。我见过一个项目,用HSI跑115200波特率,结果丢包率高达10%,换成HSE后问题立刻消失。

不过,HSI也有它的用武之地。比如低功耗模式下,或者系统刚上电需要快速启动时,先用HSI顶着,等HSE稳定了再切过去。这种“双保险”的做法,我在电池供电的产品里用过很多次。

4.2 SysTick定时器原理

SysTick,全称System Tick Timer,是ARM Cortex-M内核自带的一个24位递减计数器。它不依赖外设,所以哪怕你换了个型号的STM32,SysTick的用法基本不变。

它的工作原理很简单:

  1. 从你设定的重装载值开始,每个时钟周期减1。
  2. 减到0时,触发一次中断,同时自动重新装载。
  3. 如此循环,就像一个节拍器。

你想想看,如果我们把SysTick的中断频率设为1kHz,那每次中断就是1ms。HAL库里的HAL_Delay(),就是靠这个实现的。

核心公式:

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

例如:时钟72MHz,重装载值设为71999,则中断频率 = 72MHz / 72000 = 1kHz,即1ms中断一次。

这里有个坑,我踩过。SysTick的时钟源可以选处理器时钟(AHB),也可以选AHB/8。如果你改了系统时钟,忘了调整重装载值,那延时就不准了。我曾经因为这个,让一个电机多转了半圈,差点把机械结构打坏。

4.3 HAL_Delay的实现与局限

HAL库的HAL_Delay(),本质上就是一个基于SysTick的忙等待延时。它的代码大致是这样的:

__weak void HAL_Delay(uint32_t Delay)
{
    uint32_t tickstart = HAL_GetTick();
    while ((HAL_GetTick() - tickstart) < Delay)
    {
        // 空转等待
    }
}

HAL_GetTick()返回的是从系统启动到现在经过的毫秒数,这个值由SysTick中断每1ms加1。

局限很明显:

  • 精度只有1ms,微秒级延时做不到。
  • 是阻塞的,CPU一直在那空转。
  • 如果在中断里调用,SysTick中断优先级不够高时,会导致延时不准。

我记得有一次调试一个I2C传感器,时序要求微秒级。用HAL_Delay(1),结果实际等了1.2ms,传感器直接不响应。后来我不得不自己写微秒延时。

4.4 微秒级延时实现

要实现微秒级延时,有几种方法。我常用的是定时器微秒延时和指令周期延时。

方法一:使用通用定时器

这种方法精度高,不占用CPU(可以用中断),但需要占用一个定时器资源。

// 假设TIM2时钟为72MHz,预分频器设为71,则计数频率为1MHz
// 即每计数一次为1μs
void delay_us(uint32_t us)
{
    __HAL_TIM_SET_COUNTER(&htim2, 0);
    while (__HAL_TIM_GET_COUNTER(&htim2) < us);
}

方法二:指令周期延时(简单粗暴)

这种方法不占用外设,但精度受编译器优化影响,移植性差。

void delay_us(uint32_t us)
{
    uint32_t count = us * (SystemCoreClock / 1000000) / 4;
    while (count--)
    {
        __NOP(); // 空操作指令
    }
}

注意:指令周期延时强烈依赖编译器优化等级。在-O0下可能准确,但-O2下可能被优化掉。我建议只在调试阶段用,正式产品还是用定时器方案。

4.5 时钟树与配置流程

STM32的时钟树,看起来复杂,其实就三步:

  1. 选源:决定用HSE还是HSI。
  2. 走路径:经过PLL倍频,得到系统时钟SYSCLK。
  3. 分频:给AHB、APB1、APB2各总线分配时钟。

下面这张图,是我画的一个简化版时钟配置流程,你看一眼就能明白:

STM32时钟配置流程 HSE 外部晶振 HSI 内部RC 选择 PLL 倍频 SYSCLK AHB 分频 APB1 (低速) APB2 (高速) 注意 APB1最高36MHz APB2最高72MHz ADC时钟≤14MHz

实际配置时,我一般用STM32CubeMX图形化工具。选好HSE频率,填上目标系统时钟,它会自动算好PLL参数。但你要理解背后的逻辑,不然出了问题不知道怎么调。

我的经验:配置时钟时,先确认HSE是否起振。可以在HAL_RCC_ClockConfig()之后,检查__HAL_RCC_GET_FLAG(RCC_FLAG_HSERDY)。如果HSE没起振,系统会自动切回HSI,这时候你以为是72MHz,实际只有8MHz,所有外设时序全乱。

4.6 实战:配置系统时钟为72MHz

下面是一个完整的时钟配置代码,我把它放在SystemClock_Config()函数里。这是HAL库的标准写法:

void SystemClock_Config(void)
{
    RCC_OscInitTypeDef RCC_OscInitStruct = {0};
    RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

    // 1. 配置HSE为8MHz,打开PLL,目标72MHz
    RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
    RCC_OscInitStruct.HSEState = RCC_HSE_ON;
    RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
    RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
    RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
    RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; // 8MHz * 9 = 72MHz
    HAL_RCC_OscConfig(&RCC_OscInitStruct);

    // 2. 配置SYSCLK、AHB、APB1、APB2分频
    RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK
                                | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
    RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
    RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;     // AHB = 72MHz
    RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;      // APB1 = 36MHz
    RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;      // APB2 = 72MHz
    HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2);
}

这段代码,我几乎每个项目都会用到。你把它当成模板,改改PLL倍频系数就能适配不同频率的晶振。

嗯,时钟配置和延时函数,是单片机开发的基石。你把这章吃透了,后面ADC采样、串口通信的时序问题,就都不是问题了。