STM32F103系列单片机定时器介绍(一)
引言
定时器作为单片机中功能最强大且实用性最高的片上外设之一,发挥着无可替代的作用。之前我们在状态机编程中就已经感受到了定时器的重要性。在单片机的裸机开发中,我们不可能让芯片死守着单个任务,系统通常需要同时控制多个外设(如按键检测、屏幕刷新、数据接收等)并让它们协同工作。为了保证配合能够有条不紊地进行,我们必须依赖时钟节拍 来调度不同的外设。因此,如果你需要使用单片机同时控制多个任务并让它们有条不紊地运行,定时器就是你绕不开的核心组件。接下来我会介绍常见的 STM32F1 系列单片机上配备的定时器,以此来理解整体的定时器结构框架。本系列中讲解的芯片型号为经典的 STM32F103ZET6
片上定时器总览
STM32F103ZET6 芯片上总共有 8 个核心定时器,它们的配置与分类如下:
| 定时器类别 | 具体型号 | 挂载总线(默认时钟频率) | 核心功能 | 应用场景 |
|---|---|---|---|---|
| 基本定时器 | TIM6、TIM7 |
APB1(72MHz) |
16 位递增计数器,没有外部引脚,只能用于内部定时或触发数模转换(DAC) |
系统时间基准(Time Base)、触发 DAC 数模转换 |
| 通用定时器 | TIM2、TIM3、TIM4、TIM5 |
APB1(72MHz) |
在具备基本定时器功能的基础上,拥有 4 个独立的外部通道,支持输入捕获、输出比较、PWM 输出及编码器接口 |
按键消抖、超声波测距、舵机控制、普通电机调速 |
| 高级定时器 | TIM1、TIM8 |
APB2(72MHz) |
包含通用定时器的所有功能,并额外增加了:互补输出、死区时间发生器、刹车输入 | 高精度电机控制 |
除此之外,系统还存在一个特殊的系统滴答定时器(SysTick) 。之所以没有将它归入上述表格,主要原因是:SysTick 是直接捆绑在 ARM Cortex-M 内核里的核心组件,与 NVIC(嵌套向量中断控制器)紧密结合在一起,属于处理器的"内脏";而上述 TIMx 定时器是意法半导体(ST)自行设计并挂载到芯片内部外设总线上的外设。不同厂商、不同型号的芯片所包含的外设定时器配置各不相同,但 SysTick 却是所有同内核芯片标配的。
系统滴答定时器 (SysTick)
简介
系统滴答定时器是单片机系统里的心脏。无论外面的代码如何变化,它都在底层以固定的频率进行跳动,以此维持着整个系统的时间秩序。
特点
系统滴答定时器具有以下显著特点:
- 24 位向下计数: 它是一个 24 位的计数器,这意味着它的最大装载值为
0xFFFFFF(对应十进制的16,777,215)。并且它是递减计数的,只能从大往小进行倒数。 - 时钟源单一: 它通常直接使用系统内核时钟(
SystemCoreClock,例如72MHz),或经过分频后的时钟。 - 自动重装载: 纯硬件机制。当计数器数到
0的瞬间,它会自动刷新内容(从重装载寄存器搬运初始值)然后继续开始倒数,完全不需要CPU干预。
相关寄存器
虽然 SysTick 是内核级外设,但我们仍可以通过操作寄存器来控制它。在 CMSIS 的核心头文件中,SysTick 的寄存器被封装得非常简洁,最常用的是以下三个:
SysTick->CTRL(控制与状态寄存器): 这是SysTick的总开关。在这里可以配置开启/关闭定时器、选择时钟源,以及决定当计数器数到0的时候要不要向 CPU 发送中断请求。同时它包含一个标志位,用来判断计数器是否刚刚数到了0。SysTick->LOAD(重装载寄存器): 这里存储的是自动重装载的值。如果你希望它每次都从71999开始进行向下计数,就可以直接将71999写入当前寄存器中。当VAL倒数到0的时候,硬件会自动把LOAD里的值重新填入VAL中。SysTick->VAL(当前值寄存器): 此寄存器中存放着当前正在跳动的实时数值。如果我们通过代码手动向这个寄存器中写任意值,它会被强制清零,并且会清除CTRL寄存器里的到零标志位。我们在实现微秒级纯软件延时的时候,频繁读取的就是这个寄存器。
重装载值为什么要减 1?
需要注意的是,系统滴答定时器是向下计数直到
0才结束(包含了0这一拍)。人类习惯从 1 开始计数,而计算机习惯从 0 开始。例如,我们想要延时 3 个节拍,定时器实际经历的状态是2 -> 1 -> 0。因此,在配置重装载值(
LOAD)时,必须将计算出的目标跳动次数减去 1。举个例子: 假设系统主频为
72MHz(即72,000,000 Hz),我们希望每1ms产生一次中断请求(即频率降低为1000Hz)。不难得出我们需要让定时器跳动72000次才能凑够1ms。但是,当我们向自动重装载寄存器中写值时,应该写入72000 - 1 = 71999。
在 STM32 的 HAL 库中,SysTick 的默认配置是每 1ms 触发一次中断,其具体底层流转流程如下:
HAL库初始化时将SysTick的周期配置为1ms,并开启全局中断。SysTick->VAL开始以72MHz的速度从71999向下飞速递减。- 减到
0时触发中断,程序会强制跳转执行对应的中断服务函数(更新系统运行时间戳)。 - 随后
VAL中的值会自动被LOAD寄存器中的值覆盖填满,紧接着继续下一轮的倒数。
实现微秒级精准延时函数
HAL 库默认只提供了毫秒级延时函数(HAL_Delay),但在实际的硬件驱动应用中,我们往往需要使用到精度更高的微秒级延时。例如,在使用 HC-SR04 超声波传感器时,我们需要向模块的 TRIG 引脚输出大于 10 微秒的高电平脉冲作为触发信号。
接下来,我们就巧妙借助系统滴答定时器向下递减的特性,来实现一个绝对安全且高精度的微秒级延时函数。具体代码如下:
c
/**
* @brief 微秒级延时函数 (基于 SysTick 计数值差值实现)
* @note 此函数通过纯软件轮询 SysTick->VAL 寄存器实现,不修改任何硬件配置,
* 因此不会干扰 HAL_Delay() 或 FreeRTOS 等操作系统的正常运行。
* @param nus: 需要延时的微秒数 (建议尽量控制在 1000us 以内以防影响系统实时性)
* @retval 无
*/
void delay_us(uint32_t nus)
{
uint32_t ticks; // 需要等待的总系统时钟滴答数
uint32_t told, tnow, tcnt = 0; // told:上一次记录的计数值; tnow:当前读到的计数值; tcnt:已累计流逝的滴答数
// 1. 获取系统滴答定时器的重装载值
uint32_t reload = SysTick->LOAD;
/* 2. 计算目标滴答数:
* SystemCoreClock 为系统主频 (如 72,000,000)
* SystemCoreClock / 1000000 就是 1微秒 内 CPU 会跳动的次数 (如 72次)
*/
ticks = nus * (SystemCoreClock / 1000000);
// 3. 记录刚进函数时的 SysTick 当前计数值,作为后续比较的基准快照
told = SysTick->VAL;
// 4. 进入死循环,不断轮询实时计数值
while (1)
{
// 读取此时此刻的 SysTick 计数值
tnow = SysTick->VAL;
// 如果两次读到的值不一样,说明时间往前流逝了
if (tnow != told)
{
/* 5. 计算这段时间流逝的滴答数
* 由于系统滴答定时器是向下计数的 (例如从 71999 减到 0),
* 因此正常情况下应该是:旧值 (told) > 新值 (tnow)
*/
if (tnow < told)
{
// 正常递减情况:直接相减得出流逝的滴答数
tcnt += told - tnow;
}
else
{
// 异常情况 (跨越了零点):tnow > told
// 说明在此期间,SysTick 减到了 0,并自动从 reload 寄存器重新装载了最大值往下减。
// 此时流逝的时间 = (旧值减到0的距离) + (从最大值减到新值的距离)
tcnt += reload - tnow + told;
}
// 6. 更新基准快照,把现在的值作为下一次比较的旧值
told = tnow;
// 7. 判断退出条件:如果累计流逝的滴答数已经达到了目标值,跳出死循环,延时结束
if (tcnt >= ticks)
{
break;
}
}
}
}
当前函数通过不断读取 SysTick->VAL 寄存器中的实时跳动值,并巧妙运用差值运算化解了定时器重装载带来的跨周期误差,从而实现了高精度的死等延时。
时间片轮询
在单片机的裸机开发中,时间片轮询架构(有时也被称为基于定时器的前后台系统)是一种及其经典且高效的软件设计模式,它是介于无限轮询和实时操作系统之间的一座桥梁,它的核心目的是:在不使用操作系统的情况下实现多个任务同时运行。
假设你现在要控制单片机同时干三件事:
- 每
100ms扫描一次按键 - 每
500ms闪烁一次LED灯 - 每
2000ms向串口发送一次数据
如果是新手可能会这样写:
c
while(1) {
Scan_Key();
HAL_Delay(100);
Toggle_LED();
HAL_Delay(500);
Send_Data();
HAL_Delay(2000);
}
代码看似没有什么问题,但是当程序执行HAL_Delay(2000)时,就是在那里原地死等2秒钟,在此期间无论你怎么按下按键单片机都不会有任何反应,整个系统毫无实时性可言
此时我们就可以引入系统滴答定时器来实现时间片轮询,其中系统滴答定时器就像闹钟一般定期产生中断来提醒系统去做相应的事情,在当前的架构中前台和后台的作用如下:
- 后台(系统滴答定时器中断服务函数):只负责倒计时和做标记,操作极快不会占用过多时间
- 前台(
while主循环):只负责不断轮询,一旦发现对应的标志位发生改变了就回去执行对应的操作
当然时间片轮询架构主要的缺点就是不能够在业务函数中进行延时操作,也就是说所有的业务函数都必须是非阻塞的,不能够出现HAL_Delay()和死循环
总结
如果我们希望评估一段代码的执行效率,可以在目标代码前后分别读取一次 SysTick->VAL 中的值并将两者相减,结合当前程序的主频即可得出极其精确的执行时间(时钟周期)其中HAL库中默认的毫秒级延时函数就是通过这种方式实现的,在HAL库的stm32f1xx_hal.c文件中我们可以找到HAL_Delay函数的具体实现,具体实现代码如下:
c
/**
* @brief This function provides minimum delay (in milliseconds) based
* on variable incremented.
* @note In the default implementation , SysTick timer is the source of time base.
* It is used to generate interrupts at regular time intervals where uwTick
* is incremented.
* @note This function is declared as __weak to be overwritten in case of other
* implementations in user file.
* @param Delay specifies the delay time length, in milliseconds.
* @retval None
*/
__weak void HAL_Delay(uint32_t Delay)
{
uint32_t tickstart = HAL_GetTick();
uint32_t wait = Delay;
/* Add a freq to guarantee minimum wait */
if (wait < HAL_MAX_DELAY)
{
wait += (uint32_t)(uwTickFreq);
}
while ((HAL_GetTick() - tickstart) < wait)
{
}
}
函数的底层实现逻辑就是不停的读取 SysTick->VAL 中的值来判断从函数开始到现在经历了多少时间,不过这里需要注意的一点是代码中的wait += (uint32_t)(uwTickFreq);语句,这条语句的作用是在我们目标的延时时间基础上再增加一毫秒,uwTickFreq的值默认是1,这里可能会产生疑惑的点是:为什么为我想要延时10ms但是系统非要改成让我延时11ms?原因是SysTick的中断发生和我们调用HAL_Delay()函数的时机是完全异步的,假设在程序中此时我们想要调用HAL_Delay(1)来延时1ms:
- 如果不加一:当我们调用函数的那一瞬间,距离下一次
SysTick中断触发可能就剩下0.1ms了,0.1ms过后全局变量uwTick就加了1,此时(HAL_GetTick() - tickstart) < 1的条件就被打破了,延时结束。我们希望延时1ms但是实际只延时了0.1ms - 加一之后:系统强制要求等待 2 个 Tick 的跳变。这样算下来,实际的延时时间会在 1.000001 毫秒 到 1.999999 毫秒 之间。
由此不难看出HAL 库的设计哲学是------宁可多延时将近 1 个周期,也绝对不能少延时。
同时,开发者还可以利用系统滴答定时器固定的1ms中断,搭建出高效的"时间片轮询"架构,让裸机系统也能应对复杂的多任务并发场景。