3. 系统时钟与定时器:MCU的“心跳”与“节拍器”

时钟,是MCU的“心跳”。没有时钟,芯片就是一滩死硅。定时器,则是基于这个心跳衍生出的“节拍器”,用来精确控制时间、产生波形、测量信号。这一章,咱们就把这两个核心模块彻底讲透。

我刚开始做嵌入式那会儿,总觉得时钟配置就是抄个初始化代码,跑起来就行。直到有一次,产品在高温下频繁死机,查了三天才发现是PLL配置超出了芯片的额定范围。从那以后,我养成了一个习惯:每次拿到新芯片,第一件事就是仔细看时钟树,亲手算一遍频率。

核心要点:时钟树配置决定了MCU的“速度”和“功耗”;定时器则决定了你对“时间”的控制精度。这两者,是MCU开发的基石。

3.1 时钟树配置:HSE、HSI、PLL

MCU内部有一个复杂的时钟树,它像一棵大树的根系,从根时钟源出发,经过分频、倍频,最终供给各个外设。常见的时钟源有:

  • HSE (High Speed External):外部高速晶振,通常为4-16MHz,精度高,是系统时钟的首选。
  • HSI (High Speed Internal):内部高速RC振荡器,通常为8MHz或16MHz,精度一般,但胜在无需外部元件,上电即用。
  • PLL (Phase Locked Loop):锁相环,可以对输入时钟进行倍频,得到更高的系统时钟频率。

我个人习惯,在开发阶段先用HSI,等硬件稳定了再切换到HSE+PLL。为什么?因为HSI不需要外部晶振起振,可以快速验证代码逻辑。你想想看,如果晶振没焊好或者匹配电容不对,代码卡在起振等待里,排查起来多麻烦。

下面是一个典型的时钟树配置流程,我用STM32F1系列举例:

// 1. 使能HSE,并等待其稳定
RCC_HSEConfig(RCC_HSE_ON);
while (RCC_GetFlagStatus(RCC_FLAG_HSERDY) == RESET);

// 2. 配置PLL:HSE 8MHz -> PLLXTPRE(不分频) -> PLLMUL(9倍频) -> SYSCLK 72MHz
RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9);

// 3. 使能PLL,并等待其稳定
RCC_PLLCmd(ENABLE);
while (RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET);

// 4. 配置AHB、APB1、APB2分频器
RCC_HCLKConfig(RCC_SYSCLK_Div1);   // AHB = SYSCLK = 72MHz
RCC_PCLK1Config(RCC_HCLK_Div2);    // APB1 = 36MHz (最高36MHz)
RCC_PCLK2Config(RCC_HCLK_Div1);    // APB2 = 72MHz

// 5. 选择系统时钟源为PLL
RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK);
while (RCC_GetSYSCLKSource() != 0x08); // 0x08表示PLL作为系统时钟

我曾经踩过的坑:配置PLL时,一定要确保输入时钟频率在PLL允许的范围内。比如STM32F1的PLL输入范围是1-16MHz,如果你用8MHz晶振,倍频系数最大只能是9(72MHz)。如果你用25MHz晶振,直接倍频到72MHz?不行,因为25MHz超出了PLL输入范围。必须先分频!

为了让你更直观地理解,我画了一张时钟树配置的流程图:

时钟树配置流程图 (以STM32F1为例) HSE (8MHz) HSI (8MHz) LSE (32.768kHz) PLL时钟源选择 PLL (倍频x9) SYSCLK (72MHz) AHB/APB分频 注意: PLL输入频率必须在 1-16MHz范围内

3.2 SysTick滴答定时器:操作系统的“心跳”

SysTick,是一个24位的递减计数器,它挂在系统时钟上。说白了,它就是一个简单的定时器,每来一个时钟脉冲,计数值就减1,减到0就触发一次中断。

它的主要用途有两个:

  1. 提供操作系统的心跳:FreeRTOS、uC/OS等实时操作系统,都需要一个周期性的中断来切换任务。SysTick就是干这个的。
  2. 提供精确的延时:比如我们常用的HAL_Delay(),底层就是基于SysTick实现的。

配置SysTick非常简单,核心就是设置它的重装载值:

// 假设系统时钟为72MHz,我们希望SysTick每1ms中断一次
// 重装载值 = 72,000,000 / 1000 = 72,000
SysTick_Config(72000);

小技巧:SysTick的时钟源可以选择是系统时钟还是系统时钟的8分频。如果你需要更长的定时周期,可以选择8分频。但要注意,这样定时精度会降低。

在实际项目中,我经常用SysTick来做“软件定时器”。比如,我需要一个100ms的超时检测,但又不想占用一个硬件定时器。我会在SysTick中断里维护一个全局变量uwTick,然后通过比较这个变量的差值来实现超时。

// 在SysTick中断中,uwTick自增
void SysTick_Handler(void)
{
    uwTick++;
}

// 实现超时检测
uint32_t startTick = uwTick;
while ((uwTick - startTick) < 100) // 等待100ms
{
    // 轮询某个标志位
    if (flag == SET) break;
}

if (flag == RESET)
{
    // 超时处理
}

3.3 通用定时器PWM输出:控制电机、调光、发声

PWM,脉冲宽度调制。说白了,就是通过调节一个方波中高电平的占空比,来控制输出功率。比如,让LED灯变暗,让电机转得慢一点。

通用定时器产生PWM的原理,是基于一个自动重装载的计数器和一个比较寄存器。计数器从0开始递增,当计数值小于比较值时,输出高电平;当计数值大于等于比较值时,输出低电平。这样,就产生了一个周期和占空比都可调的PWM波。

下面是一个配置PWM输出的代码示例,我以TIM2的通道1为例:

// 1. 使能TIM2时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);

// 2. 配置GPIO为复用推挽输出
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // PA0是TIM2_CH1
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(GPIOA, &GPIO_InitStructure);

// 3. 配置定时器
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_TimeBaseStructure.TIM_Period = 999;      // 自动重装载值,决定PWM周期
TIM_TimeBaseStructure.TIM_Prescaler = 71;    // 预分频器,72MHz / (71+1) = 1MHz
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);

// 4. 配置PWM模式
TIM_OCInitTypeDef TIM_OCInitStructure;
TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; // PWM模式1
TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable;
TIM_OCInitStructure.TIM_Pulse = 500;              // 比较值,决定占空比 (50%)
TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High;
TIM_OC1Init(TIM2, &TIM_OCInitStructure);

// 5. 使能定时器
TIM_Cmd(TIM2, ENABLE);

你看,PWM周期 = (Prescaler + 1) * (Period + 1) / 时钟频率 = 72 * 1000 / 72MHz = 1ms,也就是1kHz。占空比 = Pulse / (Period + 1) = 500 / 1000 = 50%。

我曾经踩过的坑:在配置PWM时,一定要先配置定时器的时基,再配置输出比较通道。顺序反了,可能会导致输出波形异常。另外,别忘了使能定时器的输出比较通道,很多人只配置了模式,忘了使能输出。

3.4 输入捕获测量频率:精准测量外部信号

输入捕获,是定时器的另一个重要功能。它可以精确测量外部信号的频率、周期、脉宽等参数。

原理很简单:当外部信号发生跳变(上升沿或下降沿)时,定时器会“捕获”当前计数器的值,并存入捕获寄存器。通过两次捕获值的差值,就可以计算出信号的周期或脉宽。

测量频率的步骤:

  1. 配置定时器的输入捕获通道,选择上升沿触发。
  2. 使能捕获中断。
  3. 在中断服务函数中,读取捕获值。
  4. 连续捕获两次,计算差值,再根据定时器的时钟频率,换算出信号的频率。
// 假设定时器时钟为1MHz,捕获到两次上升沿的计数值分别为1000和2000
// 那么信号周期 = (2000 - 1000) * 1us = 1000us = 1ms
// 信号频率 = 1 / 1ms = 1kHz

小技巧:如果信号频率很低,周期很长,可能会导致计数器溢出。这时候,你需要在中断里记录溢出的次数,然后综合计算。或者,你可以降低定时器的时钟频率,让计数器跑得慢一点。

在实际项目中,我经常用输入捕获来测量电机转速(通过霍尔传感器或编码器),或者测量遥控器信号的脉宽。比如,有一次我需要解析一个红外遥控器的协议,就是用输入捕获来测量每个脉冲的宽度,然后解码出按键值。

下面是一个简单的输入捕获配置代码:

// 1. 使能TIM3时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE);

// 2. 配置GPIO为输入
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0; // PA0是TIM3_CH1
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);

// 3. 配置定时器时基
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_TimeBaseStructure.TIM_Period = 0xFFFF; // 最大计数值
TIM_TimeBaseStructure.TIM_Prescaler = 71;  // 1MHz
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure);

// 4. 配置输入捕获
TIM_ICInitTypeDef TIM_ICInitStructure;
TIM_ICInitStructure.TIM_Channel = TIM_Channel_1;
TIM_ICInitStructure.TIM_ICPolarity = TIM_ICPolarity_Rising; // 上升沿捕获
TIM_ICInitStructure.TIM_ICSelection = TIM_ICSelection_DirectTI;
TIM_ICInitStructure.TIM_ICPrescaler = TIM_ICPSC_DIV1; // 不分频
TIM_ICInitStructure.TIM_ICFilter = 0x0; // 不滤波
TIM_ICInit(TIM3, &TIM_ICInitStructure);

// 5. 使能捕获中断
TIM_ITConfig(TIM3, TIM_IT_CC1, ENABLE);
NVIC_EnableIRQ(TIM3_IRQn);

// 6. 使能定时器
TIM_Cmd(TIM3, ENABLE);

在中断服务函数中,读取捕获值:

void TIM3_IRQHandler(void)
{
    if (TIM_GetITStatus(TIM3, TIM_IT_CC1) != RESET)
    {
        uint16_t capture = TIM_GetCapture1(TIM3);
        // 处理捕获值...
        TIM_ClearITPendingBit(TIM3, TIM_IT_CC1);
    }
}

好了,这一章的内容就到这里。时钟和定时器是MCU开发中最基础也最重要的部分,希望你能通过实践,真正掌握它们。