
简介这份资源是一套面向嵌入式初学者的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 COUNTFLAGSYST_RVR重装值bit23:0 RELOAD计数器到 0 后自动装载SYST_CVR当前计数值bit23:0 CURRENT写任意值清零并清 COUNTFLAGSYST_CALIB校准值bit23:0 TENMS10ms 内的计数个数bit31 NOREFSYST_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) | /* CLKSOURCE1使用内核时钟 */ (1u 1) | /* TICKINT1允许触发 SysTick 异常 */ (1u 0); /* ENABLE1启动计数器 */ 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 中断从触发到返回大约 5070 周期而 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 封装成“系统时基”之后事情就变得微妙了。下面是几类常见封装的对比封装初始化入口中断维护的变量典型覆盖风险CMSISSysTick_Config(ticks)不维护全局变量中断处理函数完全交给你写STM32 HALHAL_InitTick()uwTick重写SysTick_Handler后忘调HAL_IncTick()HAL_Delay卡死FreeRTOSvPortSetupTimerInterrupt()xTickCount和 HAL 同时用 SysTick 时基会互相覆盖CMSIS-RTOS2osKernelInitialize()内核内部计数和裸机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±2msSysTick 计数 主循环差值显示刷新50ms±5ms同上通信上报1000ms±20ms同上上报函数内自己加状态机这套结构对裸机工程来说足够简单又不会引入 RTOS 的心跳切换开销。SysTick 在这里只是“心跳源”具体的任务推进逻辑全部留在主循环里这是裸机任务调度最可靠的做法之一。4. 重装值、时钟源与中断优先级SysTick 参数设错的三个后果4.1 重装值公式与 24 位上限1kHz100kHz 周期怎么定SysTick 中断周期由两个参数决定计数器时钟和重装值。公式为中断周期 (RELOAD 1) / SYSTICK_CLOCK在 72MHz 主频下1kHz 中断的重装值是 71,999100kHz 中断的重装值是 719。听起来只是小数点移动代价却完全不同。目标中断频率72MHz 下 RELOAD每次中断固定开销约 60 周期CPU 占比1kHz71999600.08%10kHz7199600.83%50kHz1439604.17%100kHz719608.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 CLKSOURCE0 的“倍率陷阱”内核时钟与参考时钟差多少SYST_CSR的 bit2 是 CLKSOURCE 位。很多例程从低功耗角度喜欢把它配成 0表示使用外部参考时钟但参考时钟在多数 Cortex-M 芯片上不是直接等于内核时钟而是内核时钟的分频值甚至独立振荡器。以 STM32F1 系列为例CLKSOURCE0 时通常取 HCLK/8如果内核时钟是 72MHzSysTick 实际频率只有 9MHz直接把 1ms 时基拉长到 8ms。排查这种倍率问题不要靠猜最直接的方法是让 SysTick 中断翻转一个 GPIOvoid SysTick_Handler(void) { static uint32_t cnt 0u; g_ms_ticks; if ((cnt 1u) 0u) /* 每两次中断翻转一次 */ { GPIO_Toggle(); /* 500Hz 方波周期 2ms */ } }用逻辑分析仪测这个引脚的方波频率如果测量值接近 500Hz说明时基正确。测出来是 62.5Hz 或 4000Hz基本就是时钟源选错了。注意一次翻转发生在中断入口入口延迟本身会引入几十纳秒级别的抖动但这个误差在毫秒级判断下可以忽略。另一个隐蔽问题出现在调试器停住内核时。如果 CLKSOURCE1SysTick 跟随内核时钟断点一停计数器就冻结CLKSOURCE0 使用独立参考时钟时计数器可能在调试暂停期间继续跑。在线仿真时发现延时和“实际时间”对不上先查这个位别急着改重装值。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 的重装值不迟。本文还有配套的精品资源点击获取