4、系统时钟与定时器:时钟树配置(HSE、PLL)、SysTick定时器实现精准延时、通用定时器PWM输出、输入捕获测量脉宽
时钟,是单片机的「心跳」。没有时钟,芯片就是一块废铁。我刚开始做嵌入式那会儿,总觉得时钟配置就是抄几个寄存器值,直到有一次产品在高温下跑飞了,排查了三天才发现是PLL配置不当导致的。从那以后,我对时钟树的态度就变成了:必须亲手算一遍,绝不信默认值。
这一章,我们重点搞定四件事:时钟树怎么搭、SysTick怎么用、PWM怎么输出、脉宽怎么测。说白了,就是让芯片「知道时间、产生时间、测量时间」。
4.1 时钟树配置:HSE与PLL的「黄金搭档」
STM32的时钟源有好几个:HSI(内部高速)、HSE(外部高速)、LSI(内部低速)、LSE(外部低速)。我个人习惯,主系统时钟首选HSE + PLL。为什么?因为HSI精度不够,温度一漂频率就变,你想想看,做消防报警这种需要可靠性的产品,谁敢用内部RC振荡器?
配置流程其实就三步:
- 打开HSE,等待它稳定
- 配置PLL,选择HSE作为输入,设置倍频系数
- 切换系统时钟到PLL输出
以STM32F103为例,我们目标跑72MHz。外部晶振用8MHz,那么PLL倍频系数就是9倍。代码长这样:
void SystemClock_Config(void)
{
// 1. 使能HSE,等待就绪
RCC->CR |= RCC_CR_HSEON;
while(!(RCC->CR & RCC_CR_HSERDY));
// 2. 配置PLL:HSE作为输入,9倍频
RCC->CFGR |= RCC_CFGR_PLLSRC_HSE;
RCC->CFGR |= RCC_CFGR_PLLMULL9;
// 3. 使能PLL,等待锁定
RCC->CR |= RCC_CR_PLLON;
while(!(RCC->CR & RCC_CR_PLLRDY));
// 4. 切换系统时钟为PLL输出
RCC->CFGR |= RCC_CFGR_SW_PLL;
while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL);
}
嗯,这里要注意:APB1和APB2的总线时钟分频也要配好。APB1最高36MHz,APB2最高72MHz。配错了,外设要么不工作,要么工作异常。
4.2 SysTick定时器:精准延时的「心脏」
SysTick是Cortex-M内核自带的24位递减计数器。说白了,它就是一个硬件定时器,专门用来产生系统节拍。我建议你不要用软件循环做延时,那玩意儿既不精准又浪费CPU。
SysTick的配置极其简单:设置重装载值,启动计数器,等待标志位。重装载值怎么算?
重装载值 = 系统时钟频率 / 目标节拍频率 - 1
比如系统时钟72MHz,想要1ms中断一次,那就是72000-1 = 71999。
void SysTick_Init(void)
{
// 重装载值:72MHz / 1000Hz = 72000
SysTick->LOAD = 72000 - 1;
// 清零当前值
SysTick->VAL = 0;
// 使能SysTick,使能中断,使用系统时钟
SysTick->CTRL = SysTick_CTRL_ENABLE_Msk |
SysTick_CTRL_TICKINT_Msk |
SysTick_CTRL_CLKSOURCE_Msk;
}
// 全局毫秒计数器
volatile uint32_t sysTickUptime = 0;
void SysTick_Handler(void)
{
sysTickUptime++;
}
// 精准延时函数
void delay_ms(uint32_t ms)
{
uint32_t start = sysTickUptime;
while((sysTickUptime - start) < ms);
}
我在项目中遇到过一个问题:中断里做太多事情导致延时不准。后来我把SysTick中断函数里只做计数器累加,其他事情都放到主循环处理。记住:中断服务函数要短小精悍。
4.3 通用定时器PWM输出:让LED呼吸起来
PWM,脉冲宽度调制。说白了就是通过调节高电平占空比来控制输出功率。消防报警主控板上,PWM可以用来驱动蜂鸣器、调节指示灯亮度、甚至控制电磁阀。
以TIM2的通道1为例,配置步骤如下:
- 使能TIM2时钟
- 配置GPIO为复用推挽输出
- 设置TIM2的预分频器和自动重装载值
- 配置输出比较模式为PWM1
- 使能TIM2
void PWM_Init(void)
{
// 1. 使能时钟
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
// 2. 配置PA0为复用推挽输出
GPIOA->CRL &= ~(0xF << 0); // 清零
GPIOA->CRL |= (0xB << 0); // 复用推挽输出50MHz
// 3. 定时器配置:72MHz / 72 = 1MHz, 重装载1000 = 1KHz PWM
TIM2->PSC = 72 - 1; // 预分频
TIM2->ARR = 1000 - 1; // 自动重装载
// 4. 配置通道1为PWM1模式,输出极性高
TIM2->CCMR1 |= (6 << 4); // 输出比较模式:PWM1
TIM2->CCER |= TIM_CCER_CC1E; // 使能输出
// 5. 使能定时器
TIM2->CR1 |= TIM_CR1_CEN;
}
// 设置占空比,duty范围0~999
void PWM_SetDuty(uint16_t duty)
{
TIM2->CCR1 = duty;
}
我曾经犯过一个低级错误:ARR设成了1000,CCR设成了1000,结果输出一直是高电平。后来才反应过来,CCR等于ARR时,PWM输出不会翻转。嗯,这个坑我替你们踩过了。
4.4 输入捕获测量脉宽:给信号「把脉」
输入捕获,就是测量外部信号的脉宽或频率。消防报警系统中,可以用来检测烟雾传感器的脉冲输出、或者解码遥控器信号。
原理很简单:捕获上升沿时记下CNT值,捕获下降沿时再记一次,差值就是高电平脉宽。
以TIM3的通道1为例:
volatile uint32_t cap_start = 0;
volatile uint32_t cap_width = 0;
volatile uint8_t cap_flag = 0;
void InputCapture_Init(void)
{
// 1. 使能时钟
RCC->APB1ENR |= RCC_APB1ENR_TIM3EN;
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN;
// 2. 配置PA6为浮空输入
GPIOA->CRL &= ~(0xF << 24);
GPIOA->CRL |= (0x4 << 24); // 浮空输入
// 3. 定时器配置:72MHz / 72 = 1MHz, 即1us计数一次
TIM3->PSC = 72 - 1;
TIM3->ARR = 0xFFFF; // 最大计数值
// 4. 配置通道1为输入捕获,上升沿触发
TIM3->CCMR1 |= (0x1 << 0); // 通道1映射到TI1
TIM3->CCER |= TIM_CCER_CC1E; // 使能捕获
TIM3->CCER &= ~TIM_CCER_CC1P; // 上升沿捕获
// 5. 使能中断
TIM3->DIER |= TIM_DIER_CC1IE;
NVIC_EnableIRQ(TIM3_IRQn);
// 6. 使能定时器
TIM3->CR1 |= TIM_CR1_CEN;
}
void TIM3_IRQHandler(void)
{
if(TIM3->SR & TIM_SR_CC1IF)
{
TIM3->SR = ~TIM_SR_CC1IF; // 清除标志
if(cap_flag == 0)
{
// 第一次捕获:上升沿,记录开始时间
cap_start = TIM3->CCR1;
// 切换为下降沿捕获
TIM3->CCER |= TIM_CCER_CC1P;
cap_flag = 1;
}
else
{
// 第二次捕获:下降沿,计算脉宽
cap_width = TIM3->CCR1 - cap_start;
// 切换回上升沿捕获
TIM3->CCER &= ~TIM_CCER_CC1P;
cap_flag = 0;
}
}
}
实际项目中,我遇到过输入信号有毛刺导致误捕获。解决方案是:开启数字滤波器。TIMx->CCMRx里的ICF位可以设置采样频率和采样次数,能有效滤除窄脉冲干扰。
4.5 本章小结
时钟和定时器,是嵌入式开发的「基本功」。我个人觉得,能把时钟树讲清楚、能把定时器用明白,才算真正入了嵌入式的大门。
这一章我们覆盖了:
- HSE + PLL搭建72MHz主时钟
- SysTick实现1ms精准延时
- 通用定时器输出1KHz可调占空比PWM
- 输入捕获测量外部信号脉宽
下一章,我们会把这些定时器知识用到实际外设驱动中。到时候你会发现,时钟和定时器就是整个系统的「时间轴」,所有时序逻辑都围着它们转。
嗯,今天就到这里。代码别光看,动手烧录到板子上试试,用示波器看看波形,比读十遍文章都管用。