简介:SysTick(系统滴答定时器)是ARM Cortex-M系列处理器内置的24位递减硬件定时器,本资源围绕其原理、配置、中断处理与RTOS应用展开,面向需要实现精确延时、周期调度或OS时间基准的嵌入式开发者。压缩包内含完整工程与示例代码,覆盖重载值设置、分频因子选择、SysTick_Config等编程接口说明,并附有寄存器调试与性能优化思路,有助于从底层理解定时机制并快速集成到实际项目中。包体共94个文件,以h头文件和c源文件为主,另含uvproj工程文件、hex烧录文件、map映射文件及汇编、依赖等辅助文件,整体约667KB,目录按FWlib、CMSIS、USER等模块划分,便于对照学习。目前已有426人学习下载,适合正在学习Cortex-M定时器或需要SysTick做延时与任务调度的开发人员参考实践。
1. 先把SysTick从“找个定时器”里区别出来
拿到一块STM32F103最小系统板,想把LED变成1Hz闪烁,很多人的第一反应是写个for循环空转延时。当主频从8MHz换到72MHz,之前调的延时参数立刻失效;再换成内部RC振荡器,同一套参数又跑出不同闪烁速度。SysTick出现的意义,就是把时间基准从CPU主频的随意变化中剥离开。SysTick是ARM Cortex-M内核内置的24位递减计数器,自带独立中断,不需要占用通用定时器TIM资源,也不依赖具体芯片型号。裸机程序里可以做延时节拍,RTOS里它承担任务调度的心跳。适合刚开始从51转Cortex-M的开发者,也适合在RTOS里需要理解时间片机制的从业者。
2. SysTick硬件结构与寄存器配置
2.1 24位递减计数器的工作流程
SysTick本质上是一个从LOAD寄存器加载初值后向下计数的计数器。使能后每个系统时钟周期数值减1,减到0时产生计数标志,如果中断使能位被置1,就触发一次SysTick异常。计数归零的同时,硬件自动把LOAD里的重载值重新装入VAL,因此只要不关闭定时器,中断会周期性产生。
这个流程与普通定时器的PWM溢出中断非常像,但SysTick有一个特殊之处:它挂在内核私有外设总线(System Control Space)上,不属于某颗芯片的APB总线设备。结果是,它不会像TIM3、TIM4那样被RCC分频影响,启动文件和CMSIS头文件已经把它定义成内核标准外设,换芯片型号时几乎不用改驱动代码,只要重配重载值和时钟源。
2.2 CTRL、LOAD、VAL三个核心寄存器位含义
Cortex-M3/M4的SysTick由三个32位寄存器控制,其中仅低24位有效。
| 寄存器 | 位段 | 名称 | 含义 |
|---|---|---|---|
| CTRL | 0 | ENABLE | 置1启动计数器 |
| CTRL | 1 | TICKINT | 置1使能递减到0时触发中断 |
| CTRL | 2 | CLKSOURCE | 1选内核时钟HCLK,0选HCLK/8 |
| CTRL | 16 | COUNTFLAG | 上次读取后是否已计数到0 |
| LOAD | 23:0 | RELOAD | 重载值,计数到0后自动装入 |
| VAL | 23:0 | CURRENT | 当前计数值,写入任意值清零 |
一位一位说清楚要点。COUNTFLAG是只读位,读取CTRL寄存器会自动清掉它,所以调试时不要先读了CTRL再判断,容易读到旧状态。VAL寄存器写任意值都会清零,这个操作同时会把COUNTFLAG清掉,初始化时写VAL清零比等它自己递减更可靠。CLKSOURCE是新手最容易忽略的位,它以HCLK还是HCLK/8为时钟源,直接决定LOAD值的计算方式。
2.2.1 时钟源分频怎么选
不同Cortex-M内核的可选分频不一样。STM32F1/F4系列通常只开放内核时钟HCLK和HCLK/8两种,你会在摘要里看到的1、2、4、8、16、32等更多分频档位,是另一些MCU厂商对SysTick时钟域扩展后的选项。STM32上标准库只提供SysTick_CLKSource_HCLK_Div8和SysTick_CLKSource_HCLK两个枚举值。
选择核心原则是看系统主频和想要的节拍周期。主频72MHz时,如果选HCLK/8得到9MHz计数频率,要产生1ms中断,LOAD就是9000-1=8999;24位计数器最大能装16777215,9MHz下最长溢出周期约1.86秒。如果直接选HCLK=72MHz,同一LOAD值产生的节拍是0.125ms,分辨率更高,但中断频率也高了8倍。
提示:SysTick_Config这个CMSIS函数默认选择HCLK不经过分频,并开启中断。用标准外设库时注意先不要重复调用,优先使用库函数配置。
2.2.2 重载值和当前值的配合
LOAD寄存器决定中断周期,VAL寄存器决定当前剩余计数值。初始化时应该先给LOAD写入目标值,再写VAL清零,最后配置CTRL使能。顺序反了会出现一次不确定的短超时,尤其在复用的初始化代码里,可能导致首个中断提前到来。
24位计数器的上限决定了单次最大延时时间。若系统主频72MHz、时钟源HCLK/8,最大LOAD=16777215,对应约1.86秒;如果时钟源直接用HCLK,同样LOAD只对应0.23秒。需要延时超过这个范围,必须在中断服务程序里维护软件计数器,而不是试图修改LOAD。
2.3 标准外设库与CMSIS提供的初始化入口
项目中FWlib文件夹里的stm32f10x_syscfg.c是对系统配置控制器的封装,SysTick本身由CMSIS的core_cm3.h直接支持。常用初始化代码有两种写法。
CMSIS标准写法:
#include "stm32f10x.h" void SysTick_Init(uint32_t us) { // 传入微秒数,系统主频来自system_stm32f10x.c中的SystemCoreClock uint32_t ticks = (SystemCoreClock / 1000000) * us; // 固定使用HCLK作为SysTick时钟源,普通裸机延时用够了 if (SysTick_Config(ticks)) { while (1); } }标准外设库的时钟源切换写法:
void SysTick_Init_WithDiv8(void) { // 选择HCLK/8作为计数时钟,降低中断频率,减少主循环被抢占的比例 SysTick_CLKSourceConfig(SysTick_CLKSource_HCLK_Div8); // 9MHz时钟源,自动重载9000-1,得到1ms周期 SysTick->LOAD = SystemCoreClock / 8 / 1000 - 1; // 清除当前计数值 SysTick->VAL = 0; // 使能计数器,不使能溢出中断,供轮询式延时使用 SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; }说明里需要补一句:SysTick_Config返回1表示寄存器无效,原因是传入的tick值超过24位,或小于1。它会把CLKSOURCE固定为1,即HCLK不分频,如果对中断频率敏感,建议改用标准外设库方式手动设置。两段代码区别在于:前者适合简单固定延时,后者把时钟源和中断使能拆开,方便后续单独做轮询检测。
3. 例程工程拆解:从FWlib到USER
3.1 工程目录里每个文件夹的职责
压缩包内的工程结构是典型的STM32标准外设库模板:
| 目录 | 主要文件 | 职责 |
|---|---|---|
| USER | main.c、stm32f10x_it.c | 用户代码、中断服务函数、系统时钟初始化 |
| CMSIS | core_cm3.h、startup_stm32f10x_hd.s | 内核寄存器定义、启动向量表 |
| FWlib | stm32f10x_rcc.c、stm32f10x_gpio.c | 外设驱动库源码与头文件,按预处理器宏裁剪 |
| Listing | 编译列表文件 | Keil生成的可阅读汇编/符号文件 |
| Output | 编译产物 | hex/bin/axf文件,用于烧录与调试 |
对着这个目录能看到一条清晰的依赖链。CMSIS提供Systick寄存器结构体与SysTick_Config函数,FWlib中的misc模块负责NVIC中断优先级配置,USER里的stm32f10x_it.c承接SysTick_Handler。许多人在新建工程时只关心main.c而漏掉misc.c,结果SysTick中断始终进不去,问题往往出现在FWlib源码没有被正确加入编译列表。
3.2 编写一个1ms节拍
假定外部晶振8MHz,PLL倍频到72MHz。先在main函数里配置时钟树,再初始化SysTick。
#include "stm32f10x_gpio.h" #include "stm32f10x_rcc.h" #include "misc.h" static __IO uint32_t g_TickCount = 0; void SysTick_Handler(void) { g_TickCount++; } int main(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_2MHz; GPIO_Init(GPIOC, &GPIO_InitStructure); // 1ms tick,默认使用HCLK=72MHz if (SysTick_Config(SystemCoreClock / 1000)) { while (1); } while (1) { // 每500个tick翻转一次,即500ms if (g_TickCount >= 500) { g_TickCount = 0; GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); } } }这段代码展示的是最简tick计数器模式。逻辑说明:SysTick_Config(72000000/1000)传入72000,表示计数72000次后归零,72MHz下正好1ms。SysTick_Handler每毫秒把g_TickCount加1,主循环不断轮询它。__IO是CMSIS定义的volatile修饰宏,防止编译器把变量优化进寄存器缓存。
这样写的问题在于SysTick_Handler只做了简单自增,如果主循环同时要响应按键、串口、传感器,轮询g_TickCount会出现无法精确整倍数的抖动。更稳的做法是在中断里直接驱动状态机,或者用下面要讲的查询式延时。
3.3 查询式与中断式delay的取舍
有些资料用轮询COUNTFLAG实现延时,类似这样:
void SysTick_DelayUs(uint32_t us) { uint32_t i; for (i = 0; i < us; i++) { SysTick->LOAD = SystemCoreClock / 1000000 - 1; SysTick->VAL = 0; SysTick->CTRL = SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0); } }这种写法实现了微秒级延时,但每次调用都要重新初始化LOAD和VAL,中断也没开。它的优点是HCLK不分频时,COUNTFLAG在1微秒内置位,实测精度在几百纳秒级。缺点也很明显:若在中断服务程序里调用它,SysTick计数器在裸机延时期间无法产生节拍中断,所有基于tick的调度都会暂停。
中断式延时则以SysTick_Handler自增为基础,延时实现如下:
void SysTick_DelayMs(uint32_t ms) { uint32_t start = g_TickCount; while ((uint32_t)(g_TickCount - start) < ms); }这里通过无符号整数减法避免tick回绕问题。假如start=4294967290,当前tick回绕到3,差值等于9,循环依然能正确判断。查询式适合阻塞延时,中断式适合需要同时响应中断的场合。实际例程中两种模式都有,建议裸机时只用一种,避免在SysTick_Handler里既维护g_TickCount又依赖查询延时而卡死。
4. 中断处理与RTOS tick机制
4.1 SysTick_Handler中该写什么
中断服务函数应当尽量短,只做累加和标志位更新。如果一定要在中断里执行回调函数,使用函数指针表维护,避免在每个tick里处理耗时任务。
void SysTick_Handler(void) { static uint16_t scheduler_counter = 0; g_TickCount++; // 每10个tick执行一次周期任务 scheduler_counter++; if (scheduler_counter >= 10) { scheduler_counter = 0; PeriodicTask_10ms(); } }这个例子将10ms周期任务从主循环摘出。参数说明:scheduler_counter是无符号16位静态变量,周期10个tick;如果想扩展到多个不同周期任务,可以增加多个计数器。注意理解一下当前工程中断服务函数的命名规则,在STM32标准外设库用SysTick_Handler,在旧版Keil示例里可能写成SysTick_Handler或stm32f10x_it.c中的同名函数。
4.2 中断优先级与临界区
SysTick是内核异常,优先级通过NVIC设置。在工程中常见处理方式是把它设为最低优先级,这样外部硬件中断可以抢占它,保证紧急事件及时响应。设置示例:
NVIC_SetPriority(SysTick_IRQn, 0x0F);Cortex-M3的NVIC用高四位表达优先级,0x0F表示最低可编程优先级。若把SysTick设成最高优先级,任意一次tick都能打断所有中断的尾部处理,外部中断响应时间会受影响。更重要的是,调度器的临界区保护必须考虑SysTick所在优先级。
移植RTOS时,任务切换通常在PendSV里做,SysTick负责产生节拍。大多数RTOS的策略是:SysTick中断里只加tick计数并检查当前任务的延时是否到期,不直接在中断里切换任务,而是触发PendSV让它在退出中断的瞬间切换。这种设计避免在SysTick上下文中修改寄存器状态,大幅降低临界区复杂度。
4.3 从tick到任务调度的时间片模型
RTOS的时间片调度依赖SysTick周期。假设configTICK_RATE_HZ=1000,即1ms一个tick,每个任务分配5个tick作为时间片,那么在SysTick_Handler中每计数5次就让出CPU。时间片计算的底层依赖如下关系:
| 参数 | 数值 | 说明 |
|---|---|---|
| HCLK | 72MHz | 系统主频 |
| SysTick时钟源 | HCLK | 72MHz |
| LOAD值 | 71999 | 1ms周期 |
| tick频率 | 1kHz | 每秒中断次数 |
| 时间片 | 5ms | 5个tick执行权 |
理解这个之后,你再看RTOS的延时API。它是把毫秒数除以tick周期得到要跳过的tick数,再在SysTick中断里对每个任务剩余等待时间做递减。这也是延时粒度只能精确到tick级别的原因。
5. 调试技巧与边界:验证SysTick配置是否准确
5.1 用GPIO翻转法验证tick精度
配置一个GPIO在SysTick_Handler中翻转,用示波器测量波形周期,是最直观的验证方法。
void SysTick_Handler(void) { GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET); }不过直接在中断里做两次GPIO写入会引入一定延迟。有条件的话,可以配合逻辑分析仪测量间隔,正常波形是两个相邻上升沿间隔等于设定的周期。使用内部8MHz RC时波形会有约1%的最大偏差,外部晶振时应稳定在±0.1%以内。如果波形周期正常倍率不匹配,优先检查SysTick_CLKSource位是否被其他初始化覆盖。
5.2 Keil调试器查看寄存器
Keil调试模式下,打开Peripherals菜单找到Core Peripherals里的SysTick窗口,能看到当前CTRL、LOAD、VAL值。在中断中设置断点,观察COUNTFLAG与当前值,确认中断周期是否符合预期。实操时常见问题是看到LOAD无误而VAL永远不递减,这通常是调试器在暂停期间SysTick被同步冻结,并非代码错误。
| 检查项 | 期望值 | 异常时的处理 |
|---|---|---|
| CTRL的CLKSOURCE | 0或1 | 注意SysTick_Config固定置1,标准库方式可配置 |
| LOAD | 目标时钟周期减1 | 写入大于0xFFFFFF时返回配置失败 |
| VAL | 每次中断前跳变 | 停止时恒定是正常冻结 |
| COUNTFLAG | 读取后自动清除 | 读取两次看到0且再次变1说明新周期发生 |
5.3 三个容易翻车的细节
SysTick_Config传入0或负数会立即断言失败,延时初始化前需要检查主频是否已正确设置。我在实际排错中遇到过一次换晶振后SystemCoreClock还停留在旧值,结果所有延时都偏快。另外请勿在SysTick_Handler与主循环同时操作同一个全局计数器而不加volatile,优化级别一高,循环判断会被缓存。还有一个边界是微秒级延时依赖关闭中断后的HCLK不分频模式,这样用到RTOS里会产生优先级反转,建议在极端延迟需求下使用TIM定时器而非SysTick。
SysTick_Config默认使能中断,如果你只想拿它做纯硬件延时,需要手动清零TICKINT位。关闭中断后依然可以轮询COUNTFLAG,只是无法驱动RTOS节拍。最后一招是调试时单步执行进入SysTick_Handler,查看栈里的LR值,确认异常返回模式和PSP/MSP,这能定位是中断优先级设置还是错误地使用了OPTIMIZE编译选项导致的问题。
本文还有配套的精品资源,点击获取