4. 时钟系统与定时器:系统时钟树解析、PLL配置、SysTick定时器、通用定时器PWM输出
时钟,说白了就是芯片的心脏。没有时钟,CPU没法跑,外设也动不了。我刚开始接触ARM裸机时,总觉得时钟配置就是改几个寄存器的事,后来才发现——时钟树要是没理清楚,整个系统跑起来都是乱的。
这一章,咱们就好好捋一捋ARM芯片的时钟系统。从时钟树怎么走,到PLL怎么配,再到SysTick和通用定时器的实战用法。嗯,内容不少,但都是硬货。
4.1 系统时钟树解析
先看一张图,把时钟树的整体结构搞清楚。
这张图我画了好几次才满意。你看,时钟源有好几个:HSI(内部高速)、HSE(外部晶振)、LSI(内部低速)、LSE(外部32.768kHz)。它们经过PLL倍频,再通过选择器决定SYSCLK,最后分频给AHB、APB1、APB2总线。
关键点:APB1最高36MHz,APB2最高72MHz。定时器时钟可能还要再翻倍——这个坑我踩过,后面细说。
4.2 PLL配置实战
PLL配置,说白了就是算乘法。输入8MHz,想要72MHz,那就得×9。但实际没这么简单,还得考虑分频系数。
我习惯用STM32F103来举例。它的PLL公式是这样的:
PLL输出 = (HSE / M) × N / P
其中:
- M:预分频系数,通常设为HSE频率值(比如8MHz就设8)
- N:倍频系数,范围192~432
- P:主分频系数,可选2、4、6、8
举个例子:外部晶振8MHz,目标SYSCLK 72MHz。
PLL输出 = (8MHz / 8) × 360 / 2 = 180MHz // 不对!超了!
PLL输出 = (8MHz / 8) × 144 / 2 = 72MHz // 正确
我曾经在一款产品上直接把N设成360,结果芯片发热严重,跑一会儿就死机。后来查手册才发现——PLL输出不能超过72MHz,N也有范围限制。嗯,从那以后我配PLL都先看数据手册的“PLL characteristics”表格。
实际代码怎么写?我一般这样配:
// 配置PLL,HSE=8MHz,目标SYSCLK=72MHz
RCC->CR |= RCC_CR_HSEON; // 开启HSE
while(!(RCC->CR & RCC_CR_HSERDY)); // 等待HSE稳定
RCC->CFGR |= RCC_CFGR_PLLSRC_HSE; // PLL源选HSE
RCC->CFGR |= RCC_CFGR_PLLMULL9; // 倍频×9 → 72MHz
RCC->CR |= RCC_CR_PLLON; // 开启PLL
while(!(RCC->CR & RCC_CR_PLLRDY)); // 等待PLL锁定
RCC->CFGR |= RCC_CFGR_SW_PLL; // 切换SYSCLK到PLL
while((RCC->CFGR & RCC_CFGR_SWS) != RCC_CFGR_SWS_PLL); // 等待切换完成
小技巧:切换时钟源后,一定要等状态位确认。我见过有人没等就往下跑,结果外设配置全乱套。
4.3 SysTick定时器
SysTick是ARM Cortex-M内核自带的24位递减定时器。说白了,它就是给操作系统做时间片轮转用的,但裸机里我们常用它做精确延时。
它的工作原理很简单:从重装载值开始递减,减到0就触发中断,然后自动重装。24位,最大计数值2^24-1。
我一般这样初始化SysTick做1ms中断:
// 系统时钟72MHz,配置SysTick为1ms中断
void SysTick_Init(void)
{
// 重装载值 = 72000000 / 1000 = 72000
SysTick->LOAD = 72000 - 1;
SysTick->VAL = 0;
SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | // 使用系统时钟
SysTick_CTRL_TICKINT_Msk | // 使能中断
SysTick_CTRL_ENABLE_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);
}
注意:sysTickUptime要用volatile修饰,否则编译器优化后可能读不到最新值。这个坑我踩过,调试了一下午才发现。
4.4 通用定时器PWM输出
通用定时器比SysTick复杂多了。它有多种模式:定时、输入捕获、输出比较、PWM生成。咱们重点说PWM。
PWM的核心参数就两个:频率和占空比。频率由定时器时钟和自动重装载值决定,占空比由比较值决定。
看个例子,用TIM2输出1kHz、50%占空比的PWM:
// 假设APB1时钟=36MHz,定时器时钟=72MHz(APB1预分频≠1时翻倍)
void TIM2_PWM_Init(void)
{
// 1. 使能TIM2时钟
RCC->APB1ENR |= RCC_APB1ENR_TIM2EN;
// 2. 配置GPIO为复用推挽输出(PA0为TIM2_CH1)
GPIOA->CRL &= ~GPIO_CRL_CNF0_Msk;
GPIOA->CRL |= GPIO_CRL_CNF0_1; // 复用推挽输出
// 3. 配置定时器
TIM2->PSC = 72 - 1; // 预分频:72MHz/72 = 1MHz
TIM2->ARR = 1000 - 1; // 自动重装:1MHz/1000 = 1kHz
TIM2->CCR1 = 500; // 比较值:50%占空比
// 4. 配置PWM模式
TIM2->CCMR1 |= TIM_CCMR1_OC1M_1 | TIM_CCMR1_OC1M_2; // PWM模式1
TIM2->CCMR1 |= TIM_CCMR1_OC1PE; // 预装载使能
TIM2->CCER |= TIM_CCER_CC1E; // 使能输出
// 5. 使能定时器
TIM2->CR1 |= TIM_CR1_CEN;
}
我曾经在调试PWM时,输出死活没波形。查了半天,发现是GPIO模式没配对——复用推挽输出,不是普通推挽输出。你想想看,GPIO模式错了,定时器配得再好也没用。
4.5 定时器时钟的坑
这里有个容易忽略的点:定时器时钟可能比APB总线快一倍。为什么?
看STM32的时钟树:如果APB1预分频系数不是1,那么定时器时钟 = APB1时钟 × 2。比如APB1=36MHz(预分频=2),定时器时钟就是72MHz。
我整理了个表格,方便对照:
| APB1预分频 | APB1时钟 | 定时器时钟 |
|---|---|---|
| 1 | 72MHz | 72MHz |
| 2 | 36MHz | 72MHz |
| 4 | 18MHz | 36MHz |
| 8 | 9MHz | 18MHz |
我的习惯:配定时器前,先确认APB预分频系数,再算定时器时钟。别想当然地以为定时器时钟就等于APB时钟。
4.6 实战:呼吸灯效果
把前面学的串起来,做个呼吸灯。原理很简单:不断改变PWM占空比,从0到100再到0,循环。
void breath_led(void)
{
static uint8_t direction = 0; // 0:增加, 1:减少
static uint16_t duty = 0;
TIM2->CCR1 = duty;
if(direction == 0) {
duty += 10;
if(duty >= 1000) direction = 1;
} else {
duty -= 10;
if(duty <= 0) direction = 0;
}
}
把这个函数放在SysTick中断里,每10ms调用一次,就能看到LED慢慢变亮再慢慢变暗。效果很直观,调试也方便。
总结一下:时钟系统是ARM芯片的基础,PLL配置要算清楚,SysTick适合做系统心跳,通用定时器适合做PWM和精确控制。每个模块都有它的脾气,摸透了就好办了。