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输出频率完全不对。正确的顺序是:
- 配置PLL时钟源和分频系数
- 使能PLL
- 等待PLL锁定标志
- 切换系统时钟到PLL
坑三:APB1/APB2时钟频率超限
STM32的APB1最高36MHz,APB2最高72MHz。我有个同事,把APB1的TIM定时器时钟配到了72MHz,结果TIM寄存器写不进去,定时器完全不工作。查了两天才发现是时钟超限了。
我的建议:每次配置完时钟,都去读一下RCC寄存器,确认实际配置值。不要想当然地认为“我配了72MHz它就一定是72MHz”。用调试器看寄存器,比猜靠谱得多。
3.5 知识结构总览
下面这张图,把SysTick和时钟配置的核心逻辑串起来了:
3.6 总结一下
时钟配置是嵌入式开发的“第一道坎”。SysTick虽然简单,但用好了,能给整个系统提供稳定的时间基准。我个人的经验是:
- 先确认时钟树:打开参考手册的时钟树图,一条一条路径跟下来,确保每个分频系数都算对了
- 再写延时函数:毫秒用中断,微秒用查询,各司其职
- 最后做验证:用逻辑分析仪或者示波器,实测一下延时精度。别信代码里的注释,信波形
一句话记住:时钟配不对,后面全白费。花30分钟把时钟搞明白,能省下后面3天的调试时间。