简介:这份资源是一套面向嵌入式初学者的SysTick(系统滴答定时器)操作例程,基于ARM Cortex-M系列微控制器,适合学习STM32等处理器底层定时器配置、中断处理及RTOS时钟基础。压缩包共94个文件,以C源文件(28个)和头文件(29个)为主,包含完整工程配置文件(uvproj、uvopt)、编译生成的hex/axf/map文件,以及文本说明与备份,整体大小约667KB,可直接导入Keil等开发环境运行。已有426人学习参考。内容涵盖SysTick定时器工作原理、重载值与分频配置、中断服务例程编写、在RTOS任务调度及延时函数中的典型应用,并提供HAL库接口调用示例。通过该工程可快速掌握系统滴答定时器的初始化、中断触发与调度技巧,理解Cortex-M内核的实时定时机制,适合入门与进阶调试实践。
1. SysTick 操作:24 位递减计数器的唯一任务
嵌入式的例程包里,“基本例程—SysTick”这类目录名经常被当成“点灯前的热身”跳过。实际上,SysTick 是 ARM Cortex-M 内核自带的 24 位递减计数器,满打满算不过四个寄存器,Cortex-M0 到 M4 的寄存器布局都一致。换一句话说,芯片可以换,操作思路不用换。
它解决的是最底层的一类问题:系统需要一个固定、不占用通用定时器外设的时基。操作系统的时基心跳、毫秒计时、周期任务轮询、看门狗喂狗间隔,全都可以挂在它上面。适合刚接触 Cortex-M 的工程师按步骤复现,也适合被 HAL_Delay“按住”多年的老朋友回来看看寄存器底下的边界——重装值、时钟源选择和中断优先级设置,比例程第一眼看起来更有讲究。
2. 从寄存器到 SysTick_Handler:最小可调时基的落地过程
2.1 操作 SysTick 只需四组寄存器:CSR/RVR/CVR/CALIB 的位段梳理
SysTick 每组寄存器都是 32 位,真正参与计数的是低 24 位。例程里最常见到的一行配置是库函数,里面一次性把控制、重装、中断全部写好。但库函数隐藏了三个重要状态位:ENABLE、TICKINT、CLKSOURCE。我一般会先打开参考手册的手册页,把下表核对一遍再去看库实现。
| 寄存器 | 作用 | 关键位 |
|---|---|---|
| SYST_CSR | 控制与状态 | bit0 ENABLE、bit1 TICKINT、bit2 CLKSOURCE、bit16 COUNTFLAG |
| SYST_RVR | 重装值 | bit23:0 RELOAD,计数器到 0 后自动装载 |
| SYST_CVR | 当前计数值 | bit23:0 CURRENT,写任意值清零并清 COUNTFLAG |
| SYST_CALIB | 校准值 | bit23:0 TENMS,10ms 内的计数个数;bit31 NOREF |
SYST_CSR 的 bit16 COUNTFLAG 会在计数器从 1 递减到 0 时置位,读 CSR 后自动清除。用它做软件延时可以不进中断,很多 51 单片机转过来的工程师喜欢这种风格。SYST_RVR 的 RELOAD 决定每个周期的长度,写到 0 时定时器不会计数,这是不少例程在某次改动后忽然停摆的原因。SYST_CVR 写任意数都会把它清零,在线调试时手动改这个寄存器要留神。
还有一个容易被忽略的细节:写 SYST_CVR 会把 COUNTFLAG 也顺带清掉。如果程序依靠 COUNTFLAG 做测量,先写 VAL 再等标志,和先读标志再操作 VAL,结果会不一样。后面第 5 章测量时还会碰到这一点。
2.2 最小延时函数:不用库、只靠寄存器完成 1ms 周期
先不看厂商封装,直接操作寄存器,这样后面替换库、移植 RTOS 都不会发虚。下面这个函数把 SysTick 配置成 1ms 中断一次:
uint32_t systick_config_1ms(uint32_t system_clock) { uint32_t ticks = system_clock / 1000u; /* 1ms 对应的时钟周期个数 */ if (ticks == 0u || ticks > 0x00FFFFFFu) { return 1u; /* 重装值不能为 0,也不能超过 24 位 */ } SysTick->CTRL = 0u; /* 先停掉定时器,避免配置过程中乱触发 */ SysTick->LOAD = ticks - 1u; /* 重装值写入 RELOAD */ SysTick->VAL = 0u; /* 清零当前值,同时清掉残留的 COUNTFLAG */ NVIC_SetPriority(SysTick_IRQn, 15); /* 内核外设,优先级越低占的有效位越低 */ SysTick->CTRL = (1u << 2) | /* CLKSOURCE=1:使用内核时钟 */ (1u << 1) | /* TICKINT=1:允许触发 SysTick 异常 */ (1u << 0); /* ENABLE=1:启动计数器 */ return 0u; }这段代码做的事情是:先关定时器,再写重装值,清当前值,最后一次性打开时钟源、中断和使能。这里有个关键点是ticks = system_clock / 1000u后面的-1u。递减计数器需要ticks个时钟边沿才能走完一个周期,比如 72MHz 时钟下,1ms 对应 72000 个计数点,RELOAD 填 71999,从 71999 递减到 0 正好 72000 拍。
NVIC_SetPriority里的 15 不是绝对数字。Cortex-M3/M4 的优先级位宽可能只有 3 位或 4 位,CMSIS 内部会把传入值左移或截断到有效区域。写 15 是为了让 SysTick 处在整个中断系统里较低的位置,避免一个内部时基反复打断串口、DMA 这类对响应时间敏感的外设。如果你的外设中断全是最高优先级,这里写 0 也行,但要意识到 SysTick 会不断抢优先级。
2.3 SysTick_Handler 回调里只做标记:中断处理与上下文开销
配置完成后,中断回调函数只需要做一件事:
volatile uint32_t g_ms_ticks = 0u; void SysTick_Handler(void) { ++g_ms_ticks; /* 只做计数,不在这里处理业务 */ }SysTick_Handler 在启动文件里是弱定义,用户写同名函数会自动替换,不需要手动改启动向量表。但要注意两个习惯。
第一,中断里不要放打印、阻塞延时、浮点运算。串口打印的耗时可能远超 1ms,放在 SysTick_Handler 里会让时基漏洞百出。第二,中断入口有固定的压栈开销和现场恢复开销。以 72MHz 主频为例,一次 SysTick 中断从触发到返回大约 50~70 周期,而 1ms 等于 72000 周期,CPU 占用不到 0.1%。但如果把中断周期压到 10us,一次中断的固定开销占比就接近 7%,这时候就要重新评估是否适合用 SysTick 中断做高频时基。
需要阻塞延时的时候,可以在主循环里等计数器差值:
void delay_ms_ticks(uint32_t ms) { volatile uint32_t start = g_ms_ticks; while ((uint32_t)(g_ms_ticks - start) < ms) { __WFI(); /* 等待中断唤醒,降低空转功耗 */ } }这比传统的连续读 COUNTFLAG 忙等更省电。g_ms_ticks - start用无符号减法,即使计数器绕回 0,差值依然正确,这是嵌入式里处理时钟绕回的标准写法。
3. 在 HAL/标准库约束下操作 SysTick:避免被封装带偏
3.1 芯片厂商库对 SysTick 的封装差异:HAL_Delay 依赖的全局时基
厂商库把 SysTick 封装成“系统时基”之后,事情就变得微妙了。下面是几类常见封装的对比:
| 封装 | 初始化入口 | 中断维护的变量 | 典型覆盖风险 |
|---|---|---|---|
| CMSIS | SysTick_Config(ticks) | 不维护全局变量 | 中断处理函数完全交给你写 |
| STM32 HAL | HAL_InitTick() | uwTick | 重写SysTick_Handler后忘调HAL_IncTick(),HAL_Delay卡死 |
| FreeRTOS | vPortSetupTimerInterrupt() | xTickCount | 和 HAL 同时用 SysTick 时基会互相覆盖 |
| CMSIS-RTOS2 | osKernelInitialize() | 内核内部计数 | 和裸机delay_ms_ticks共用同一中断时两个计数互相干扰 |
最常见的事故是 HAL 工程里重写SysTick_Handler,想实现自己的周期任务,却忘了调用HAL_IncTick()。现象很直接:所有HAL_Delay()永久卡死,因为 HAL 层认为时间从没走过。反过来,如果例程是以 FreeRTOS 为背景,FreeRTOS 接管 SysTick 后 HAL 的HAL_Delay又可能失效,此时应该把 HAL 的时基换到普通定时器上,让 SysTick 专门伺候 RTOS。
判断方式也简单:在一个工程里搜索SysTick_Handler的定义,如果是 HAL 库,应当能看到HAL_IncTick();如果没有,说明时基维护链已经断掉了。
3.2 通用时基计数器:把 SysTick 中断转换为 32 位毫秒时间戳
在裸机例程里,我一般维护一个 32 位毫秒计数器作为全局时间戳,而不是直接暴露g_ms_ticks给所有模块。读取时做一次防撕裂处理:
static volatile uint32_t s_sys_ticks = 0u; void SysTick_Handler(void) { ++s_sys_ticks; } uint32_t get_tick_ms(void) { uint32_t now; do { now = s_sys_ticks; } while (now != s_sys_ticks); /* 读两次,防止读的过程中被中断改写 */ return now; }do-while的一致性检查看起来多余,但在 8 位或 16 位总线上,32 位变量读法可能被拆成两次操作。中断恰好发生在两次读之间,就会拿到一个高字节和低字节对不上的时间戳。Cortex-M 的 32 位单次读理论上是原子的,可多写这一层检查成本极低,还能防止将来把代码移植到低端 MCU 时的隐蔽问题。
有了时间戳,阻塞延时和超时判断就好写了:
uint32_t timeout = get_tick_ms() + 500u; /* 500ms 超时 */ while (get_tick_ms() < timeout) { if (irq_flag_ready) { break; /* 提前完成 */ } }注意这里用不等号判断,而不是直接比较get_tick_ms() == timeout。++计数可能跳过某个中间值,标志位也可能被重复清零,等值判断是裸机超时逻辑里最常见的 bug 来源。
3.3 周期任务轮询:用 tick 差值和“到点标记”拆三种任务节奏
SysTick 最常见的落地场景不是延时,而是周期任务调度。第一次做例程时常用取模:if (g_ms_ticks % 10 == 0)。这个写法在主循环匀速时能用,一旦某次循环卡在里面超过 10ms,后面的任务会连续补跑,出现“任务堆积”。
更好的做法是在中断里只维护计数器,在主循环里用差值判断到点:
typedef struct { uint32_t now_ms; uint32_t last_key_ms; uint32_t last_display_ms; uint32_t last_report_ms; } sched_t; void sched_run(sched_t *s) { s->now_ms = get_tick_ms(); if ((uint32_t)(s->now_ms - s->last_key_ms) >= 10u) { s->last_key_ms = s->now_ms; key_scan_task(); /* 每 10ms 扫一次按键 */ } if ((uint32_t)(s->now_ms - s->last_display_ms) >= 50u) { s->last_display_ms = s->now_ms; display_refresh(); /* 每 50ms 刷一次显示 */ } if ((uint32_t)(s->now_ms - s->last_report_ms) >= 1000u) { s->last_report_ms = s->now_ms; report_upload(); /* 每 1s 上报一次状态 */ } }这种做法把三个周期任务放在同一个主循环里,每个任务记录自己的last_xxx_ms。即使某次report_upload()占用了 20ms,按键任务下一轮发现差值已经是 20ms,大于等于 10ms,会立刻补一次,但不会像取模那样把错过的所有周期都执行一遍。
| 任务 | 目标周期 | 允许抖动 | 推荐实现 |
|---|---|---|---|
| 按键扫描 | 10ms | ±2ms | SysTick 计数 + 主循环差值 |
| 显示刷新 | 50ms | ±5ms | 同上 |
| 通信上报 | 1000ms | ±20ms | 同上,上报函数内自己加状态机 |
这套结构对裸机工程来说足够简单,又不会引入 RTOS 的心跳切换开销。SysTick 在这里只是“心跳源”,具体的任务推进逻辑全部留在主循环里,这是裸机任务调度最可靠的做法之一。
4. 重装值、时钟源与中断优先级:SysTick 参数设错的三个后果
4.1 重装值公式与 24 位上限:1kHz~100kHz 周期怎么定
SysTick 中断周期由两个参数决定:计数器时钟和重装值。公式为:
中断周期 = (RELOAD + 1) / SYSTICK_CLOCK在 72MHz 主频下,1kHz 中断的重装值是 71,999,100kHz 中断的重装值是 719。听起来只是小数点移动,代价却完全不同。
| 目标中断频率 | 72MHz 下 RELOAD | 每次中断固定开销约 60 周期 | CPU 占比 |
|---|---|---|---|
| 1kHz | 71999 | 60 | 0.08% |
| 10kHz | 7199 | 60 | 0.83% |
| 50kHz | 1439 | 60 | 4.17% |
| 100kHz | 719 | 60 | 8.33% |
例程里默认值大多是 1kHz,原因就是它几乎不占用 CPU。如果想把 SysTick 用作微秒级延时,比如 1us 中断一次,每次进中断的压栈、比较、跳转本身就要几百纳秒,这个方案在 72MHz 下得不偿失。
配置前先做一次保险检查:
uint32_t reload = (SystemCoreClock / 1000u) - 1u; if (reload > 0x00FFFFFFu) { while (1); /* RELOAD 超过 24 位,当前主频下无法配置 1ms */ }这个判断在低主频 MCU 上特别重要。比如内部 RC 只有 8MHz,此时 1ms 重装值是 7999,没问题。但如果主频没有正确初始化,SystemCoreClock变量仍是复位默认值,SysTick 实际挂在内核时钟上,两者差出几倍,延时就会成倍失真。
4.2 CLKSOURCE=0 的“倍率陷阱”:内核时钟与参考时钟差多少
SYST_CSR的 bit2 是 CLKSOURCE 位。很多例程从低功耗角度喜欢把它配成 0,表示使用外部参考时钟,但参考时钟在多数 Cortex-M 芯片上不是直接等于内核时钟,而是内核时钟的分频值甚至独立振荡器。以 STM32F1 系列为例,CLKSOURCE=0 时通常取 HCLK/8,如果内核时钟是 72MHz,SysTick 实际频率只有 9MHz,直接把 1ms 时基拉长到 8ms。
排查这种倍率问题不要靠猜,最直接的方法是让 SysTick 中断翻转一个 GPIO:
void SysTick_Handler(void) { static uint32_t cnt = 0u; ++g_ms_ticks; if ((++cnt & 1u) == 0u) /* 每两次中断翻转一次 */ { GPIO_Toggle(); /* 500Hz 方波,周期 2ms */ } }用逻辑分析仪测这个引脚的方波频率,如果测量值接近 500Hz,说明时基正确。测出来是 62.5Hz 或 4000Hz,基本就是时钟源选错了。注意一次翻转发生在中断入口,入口延迟本身会引入几十纳秒级别的抖动,但这个误差在毫秒级判断下可以忽略。
另一个隐蔽问题出现在调试器停住内核时。如果 CLKSOURCE=1,SysTick 跟随内核时钟,断点一停计数器就冻结;CLKSOURCE=0 使用独立参考时钟时,计数器可能在调试暂停期间继续跑。在线仿真时发现延时和“实际时间”对不上,先查这个位,别急着改重装值。
4.3 中断优先级与临界区:SysTick 被长关闭窗口延后的代价
SysTick 的中断优先级写在系统异常优先级寄存器里,不参与 STM32 的 NVIC 分组。最常用的配置方式是:
NVIC_SetPriority(SysTick_IRQn, (1UL << __NVIC_PRIO_BITS) - 1UL);__NVIC_PRIO_BITS是编译器根据芯片型号自动定义的,Cortex-M3/M4 上常见值是 4 或 3。这个写法取出当前实现支持的最低优先级,用来保证 SysTick 不打断关键外设中断。如果代码里用__disable_irq()像这样长时间关中断:
__disable_irq(); /* 这里如果耗时超过 1ms,期间 SysTick 中断全部被延后 */ flash_write_sector(); __enable_irq();SysTick 计数器本身不会停止,但中断标志和g_ms_ticks的累加会延后。关中断结束后,SysTick 会立即进入中断”补一次“,再之后才会继续正常周期。结果表现为时间戳在某一点突然跳变,而不是丢失一段时间。对不依赖时间戳严苛连续性的业务问题不大,但对电机控制、通信超时这类场景,跳变就是事故。
解决思路是缩短临界区长度,而不是把 SysTick 优先级提到最高。因为__disable_irq()会屏蔽所有可屏蔽中断,优先级再高也进不来。正确做法是先用下一节的方法测出临界区真实耗时,再去决定时基方案。
5. 用 CALIB 和 DWT 把 SysTick 例程调准:周期验证与开销测量
5.1 读 TENMS 校准字段:校验 SysTick 是否跑在预期频率
Cortex-M 内核在出厂时会把 10ms 内的计数个数写进SYST_CALIB的 TENMS 字段,用它可以做个快速自检:
uint32_t systick_get_calib_10ms(void) { return SysTick->CALIB & 0x00FFFFFFu; /* 只取低 24 位 */ }假设内核时钟 72MHz,校准值应为 720000 附近。读出值和理论值差太多,说明 SysTick 的时钟源不是预期频率。注意 NOREF 位,置 1 时表示芯片没有提供独立参考时钟,TENMS 值只能按内部时钟推导,不能当作外部标准。
5.2 用 DWT_CYCCNT 测中断关闭窗口
SysTick 的频率准不准,除了用逻辑分析仪看方波,还可以用 DWT 的周期计数器直接测临界区耗时。Cortex-M3/M4 的 DWT 单元有一个 32 位自由运行计数器:
void dwt_cyccnt_enable(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 先使能跟踪单元 */ DWT->CYCCNT = 0u; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; /* 启动周期计数 */ }测量一段代码的耗时:
uint32_t dwt_measure_us(uint32_t start_ccnt) { uint32_t elapsed = DWT->CYCCNT - start_ccnt; return elapsed / (SystemCoreClock / 1000000u); /* 周期数转 us */ }调用时先记录DWT->CYCCNT,再执行被测量代码,最后调用转换函数。这个方式可以量化某段关中断代码到底抄走了多少微秒,比用 SysTick 的 CVR 差值更准确,因为 CVR 在重装边界上会跳变。
提示:Cortex-M0/M0+ 没有 DWT,只能用
SysTick->VAL前后差值粗测,但跨重装边界时结果会偏小,只能做量级判断。
5.3 把 SysTick 做成可观测的时间戳源
例程跑起来后,最实用的验证是让时间戳“可见”:在任务开始时记录get_tick_ms(),任务结束再记录一次,打印差值。连续运行几十次后取平均值,基本就能看出主循环的最坏处理耗时。配合前面 GPIO 翻转的方法,还能量出 SysTick 中断本身的入口抖动。
如果追求更细腻的参考,可以把 SysTick 周期固定为 1ms,然后在主循环里用SysTick->VAL反向推算当前周期的剩余时间。这种方法适合做微秒级软件延时,但受中断优先级影响,只推荐在裸机简单场景使用。
最后留一个实操建议:把测量代码封装成两个接口,time_measure_start()和time_measure_stop(),放到公共模块里。后续调优调度周期、缩减临界区长度,都用同一套工具看数字,比靠感觉改参数可靠得多。如果后面把主频从 72MHz 换到更高主频的 M7 内核,也先把这条测量路径走通,再回头动 SysTick 的重装值不迟。
本文还有配套的精品资源,点击获取