深入解析STM32F103定时器(一):从SysTick到时间片轮询,构建高效裸机系统
在嵌入式开发领域,无论是使用C++进行底层驱动开发,还是用Python进行快速原型验证,理解微控制器的核心外设都是通往高手之路的基石。定时器,作为单片机中功能最强大、应用最广泛的片上外设之一,是实现多任务调度、精准延时和复杂时序控制的关键。本文将以经典的STM32F103系列单片机为例,深入剖析其定时器系统,特别是作为系统“心脏”的SysTick定时器,并探讨如何利用它构建高效的裸机多任务架构。
一、定时器:嵌入式系统的节拍器
想象一下,你正在用Go语言编写一个高并发的网络服务,需要协程调度器来管理众多任务。在单片机的裸机世界里,没有操作系统,定时器就扮演着这个“调度者”的角色。它产生的稳定时钟节拍,是协调按键扫描、屏幕刷新、数据通信等多个外设有序工作的基础。无论是简单的LED闪烁,还是复杂的电机控制算法,都离不开定时器的精准计时。因此,掌握定时器是每一位嵌入式开发者,无论你擅长Java、TypeScript还是C++,都必须攻克的核心技能。
二、STM32F103定时器全景概览
在 STM32F103ZET6 这颗经典的芯片内部,意法半导体为其配备了功能丰富的定时器阵列。这些定时器如同一个分工明确的团队,各自承担着不同的职责:
- 基本定时器(TIM6, TIM7):功能纯粹,主要用于产生基础的定时中断或DMA触发。
- 通用定时器(TIM2-TIM5):功能全面,支持输入捕获、输出比较、PWM生成等,是应用中的“多面手”。
- 高级定时器(TIM1, TIM8):装备精良,在通用定时器基础上增加了互补输出、死区控制等高级功能,常用于电机控制和电源领域。
具体的分类和功能总结如下表所示:
| 定时器类别 | 具体型号 | 挂载总线(默认时钟频率) | 核心功能 | 应用场景 |
|---|---|---|---|---|
| 基本定时器 | 、 | () | 16 位递增计数器,没有外部引脚,只能用于内部定时或触发数模转换() | 系统时间基准(Time Base)、触发 数模转换 |
| 通用定时器 | 、、、 | () | 在具备基本定时器功能的基础上,拥有 4 个独立的外部通道,支持输入捕获、输出比较、 输出及编码器接口 | 按键消抖、超声波测距、舵机控制、普通电机调速 |
| 高级定时器 | 、 | () | 包含通用定时器的所有功能,并额外增加了:互补输出、死区时间发生器、刹车输入 | 高精度电机控制 |
然而,在这个家族之上,还有一个更为特殊的成员——系统滴答定时器(SysTick)。它并非ST设计的外设,而是ARM Cortex-M内核的“嫡系部队”,与NVIC紧密耦合,为整个芯片系统提供最基础的心跳。
三、系统心脏:SysTick定时器深度剖析
SysTick是维持整个单片机时间秩序的基石。它的设计简洁而高效,具有几个鲜明特点:
- 24位递减计数器:最大计数值为16,777,215(2^24 - 1),只能从设定值向下计数到0。
- 自动重装载:计数到0后,硬件自动将重装载寄存器(LOAD)的值载入,开始新一轮计数,无需CPU干预,效率极高。
- 固定时钟源:通常直接使用处理器内核时钟(HCLK)或其分频,保证了计时的稳定性和一致性。
我们通过操作三个核心寄存器来控制它:
- CTRL(控制与状态寄存器):总开关,负责启停、选择时钟源和使能中断。
- LOAD(重装载寄存器):存储定时周期的初始值。
- VAL(当前值寄存器):反映计数器的实时值,读取它可以获取精确的时间戳。
在HAL库中,SysTick默认被配置为每1毫秒(1ms)触发一次中断,其工作流程堪称嵌入式世界的“标准时间”:
重装载值为什么要减 1?
需要注意的是,系统滴答定时器是向下计数直到 才结束(包含了 这一拍)。人类习惯从 1 开始计数,而计算机习惯从 0 开始。例如,我们想要延时 3 个节拍,定时器实际经历的状态是 。
因此,在配置重装载值()时,必须将计算出的目标跳动次数减去 1。
举个例子: 假设系统主频为 (即 ),我们希望每 产生一次中断请求(即频率降低为 )。不难得出我们需要让定时器跳动 次才能凑够 。但是,当我们向自动重装载寄存器中写值时,应该写入 。
四、实战:实现高精度微秒延时函数
HAL库提供的HAL_Delay()函数只有毫秒级精度,但在驱动像超声波传感器(HC-SR04)这类需要精确微秒级脉冲的器件时,就显得力不从心。这时,我们可以巧妙地利用SysTick的VAL寄存器,实现一个不依赖中断、精度极高的微秒级延时函数。
其核心思想是:通过连续读取VAL寄存器的递减值,并计算时间差,巧妙地规避了计数器重装载瞬间可能产生的误差。这种方法实现了“忙等待”式的精准延时。具体实现代码如下:
/**
* @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;
}
}
}
}
这个函数是裸机开发中驱动精密外设的利器。[AFFILIATE_SLOT_1]
五、架构升华:基于SysTick的时间片轮询
当系统需要“同时”处理多个任务时(例如:每10ms扫描按键、每500ms闪烁LED、每1秒发送数据),新手可能会写出阻塞式的代码,导致系统实时性丧失。 这时,时间片轮询架构(或称前后台系统)便闪亮登场。它是在不使用RTOS的情况下,实现多任务并发的经典模式。
其核心架构如下:
- 后台(中断服务函数):由SysTick中断驱动。它只做最轻量的工作——更新一系列时间戳或标志位,执行速度极快。
- 前台(主循环):不断轮询检查各个任务的标志位。一旦发现某个任务的“执行时间到了”,就调用对应的非阻塞业务函数。
下面是一个错误(阻塞式)与正确(时间片轮询)的代码对比:
while(1) {
Scan_Key();
HAL_Delay(100);
Toggle_LED();
HAL_Delay(500);
Send_Data();
HAL_Delay(2000);
}
⚠️ 关键约束:在此架构下,所有业务函数都必须是非阻塞的,绝不能出现HAL_Delay()或死循环,否则会破坏整个轮询节奏。
六、细节揭秘:HAL_Delay()的“保守”哲学
我们不仅可以利用SysTick创造延时,还可以用它来测量代码段的执行时间。HAL库中的HAL_Delay()函数正是基于此原理实现的。查看其源码,会发现一个有趣的细节:
/**
* @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)
{
}
}
注意代码中的 tickstart += uwTickFreq; 语句。这里默认uwTickFreq为1,意味着当你调用HAL_Delay(1)时,系统实际会等待约1到2个tick周期。为什么宁可多等也不愿少等?
这是因为函数调用时刻与SysTick中断触发时刻是完全异步的。为了防止在调用瞬间恰逢中断即将到来而导致的“延时不足”,HAL库采取了这种“保守主义”设计哲学:确保延时时间至少满足要求,哪怕可能多出近1ms。这对于需要稳定时序的通信协议等场景至关重要。
七、总结与展望
SysTick定时器,作为ARM Cortex-M内核赐予开发者的礼物,其意义远不止提供一个简单的延时函数。它是裸机系统的时间基石,是性能剖析的精密尺子,更是构建轻量级多任务系统(时间片轮询)的核心引擎。通过深入理解其寄存器操作、中断机制以及像微秒延时这样的高级用法,开发者能够显著提升嵌入式系统的实时性、可靠性和开发效率。
掌握从底层寄存器到上层架构的全链路知识,是区别嵌入式新手与高手的重要标志。无论是面对Python的简洁还是C++的复杂,这种对硬件基础的深刻理解都将让你事半功倍。[AFFILIATE_SLOT_2]
在后续的文章中,我们将继续深入STM32F103的通用定时器和高级定时器,探索PWM、输入捕获等更强大的功能,敬请期待!
TIM6TIM7APB172MHzDACDACTIM2TIM3TIM4TIM5APB172MHzPWMTIM1TIM8APB272MHz002 -> 1 -> 0LOAD72MHz72,000,000 Hz1ms1000Hz720001ms72000 - 1 = 71999
浙公网安备 33010602011771号