距离第17届蓝桥杯嵌入式省赛还有几周的时候,学弟把工程发给我,说按键怎么按都没反应,逻辑翻来覆去看了好几遍也没发现问题。我打开代码一看,main函数里第一件事就是HAL_Delay(800),他说屏幕要卡很久才亮起来,后来想加个长按功能,结果整个程序就乱套了。
问题就出在 SysTick 上。很多人把它当成 CubeMX 自动生成的一行配置,从来没认真看过,但它其实是整个工程的“心跳”。你屏幕刷新、按键消抖、状态机切换、超时判断,甚至HAL_Delay本身,底层全靠这个24位的递减计数器在扛。这篇文章不打算讲大而全的理论,就围绕蓝桥杯嵌入式赛题里最常踩的 SysTick 用法和坑,把背后逻辑一次说透。
1. 先搞清楚:SysTick 在蓝桥杯 G431 工程里到底是什么角色
1.1 一个“隐藏的定时器”在 main 之前就已经开始工作
很多同学觉得 SysTick 是自己代码里某个外设,想用的时候配置一下就行。实际上,当你用 STM32CubeMX 生成了一个 STM32G431RBT6 的工程,HAL_Init()在main函数真正进入 while 循环之前,就已经调用HAL_InitTick()把 SysTick 配置成了 1ms 中断一次。也就是说,你的程序还没跑到用户代码最前面,SysTick 已经在后台工作了。
Cortex-M4 内核自带的 SysTick 和普通的 TIM 定时器不一样,它不占用外设资源,是内核级的外设。STM32G431RBT6 用的正是 Cortex-M4F 内核,所以这块芯片天然就有这个部件,不需要在 CubeMX 的 Timers 分类里去勾选它。你要在 CubeMX 里找 SysTick 是找不到的,它被 HAL 库当作“时基”悄悄用起来了。
这也是很多备赛选手的第一个盲区:以为 SysTick 是某个可选的定时器外设,觉得自己不用它就行。其实你每一句HAL_Delay都在用它,而且它还在持续维护一个全局变量uwTick。这个变量是个 32 位无符号整数,从芯片上电开始每 1ms 加一次,大概 49.7 天才会回绕一次,比赛那点时间完全不用担心溢出问题。
1.2 赛题里哪些功能表面看不出来,实际依赖 SysTick
蓝桥杯嵌入式赛题基本围绕 LED、按键、LCD 显示、ADC 采集、PWM 输出、串口通信这几个模块出题。表面上看,这些功能好像都跟 SysTick 没关系,但只要你用到下面这些写法,就绕不开它:
HAL_Delay(),最基础的阻塞延时;HAL_GetTick(),获取系统当前运行毫秒数;- 状态机里基于时间的切换条件,比如按键按下 500ms 后触发连发;
- 屏幕显示刷新率的节拍控制;
- 非阻塞延时 / 超时判断,比如等待串口接收某个应答,最多等500ms;
- CubeMX 生成的
HAL_UART_Receive_IT这类中断回调里,如果你想做超时保护,也要靠HAL_GetTick()来算时间。
换句话说,只要赛题里有时序逻辑,SysTick 就一定是幕后功臣。甚至可以说,一个工程写得好不好,很大程度就看你对这个“心跳”的运用是否熟练。
2. SysTick 底层机制:一个24位递减计数器如何撑起所有延时
2.1 从装载到归零的完整过程
SysTick 的工作方式可以简单理解成一个秒表倒计时。有一个 24 位的向下递减计数器,你把目标时间换算成计数次数,写进重装载寄存器SysTick->LOAD,计数器就从那里开始每收到一个时钟脉冲减 1。减到 0 的时候,会做两件事:一是把COUNTFLAG置 1,二是如果中断使能了,就会触发一次 SysTick 异常,进入SysTick_Handler中断服务函数。
在这个过程里,设计上有个容易被忽略的细节:计数器从 1 减到 0,算一个完整的计数周期。所以如果重装载值是 N,实际产生的定时周期是 N+1 个时钟周期。这一点在你自己算重装载值的时候要留个心眼,不过用 HAL 库的话它已经处理好了,你只需要关心毫秒数就行。
24 位计数器能装的最大值是 16777215。如果在 170MHz 的系统时钟下,1ms 需要 170000 个计数周期,这个值远小于上限,所以 1ms 节拍没有任何压力。但如果你想一次性让计数器跑更长时间,就有上限限制了,后面会专门算给你看。
2.2 重装载值的数学:170MHz 下1ms是多少
STM32G431RBT6 的最高主频是 170MHz。CubeMX 生成的工程在SystemClock_Config()里会把系统时钟配置到这个频率。假如 HCLK = 170MHz,那么 SysTick 选择处理器时钟作为计数源时,1ms 的重装载值就是:
170000000 Hz / 1000 = 170000因为前面说的“从1减到0”的问题,实际写入SysTick->LOAD的值是 170000 - 1 = 169999。
那 24 位上限能撑住多长的一次性延时呢?16777215 / 170000 ≈ 98.7ms。所以在 G431 上,如果你想通过“设置一个大的重装载值,等 COUNTFLAG”这种方式做延时,单次最大的延时空间不到 100ms。如果赛题要求延时几秒钟,用这种方式做会很别扭,这也是为什么我更推荐用HAL_GetTick()来做长时间延时,它是在中断里对 32 位变量累加,没有这个上限问题。
2.3 几个容易被忽略的寄存器位
SysTick 有四个寄存器,比赛不需要全背,但下面这几个位很重要:
| 寄存器 | 关键位 | 作用 |
|---|---|---|
SysTick->CTRL | ENABLE | 置1启动计数器 |
SysTick->CTRL | TICKINT | 置1使能中断,减到0时触发SysTick_Handler |
SysTick->CTRL | CLKSOURCE | 选择计数时钟源,1为处理器时钟,0为外部参考时钟(通常HCLK/8) |
SysTick->CTRL | COUNTFLAG | 计数器减到0时置1,读此寄存器会清零 |
SysTick->LOAD | 低24位 | 重装载值 |
SysTick->VAL | 低24位 | 当前计数值,写入任意值会清空并清除COUNTFLAG |
SysTick->CALIB | TENMS | 校准值,表示10ms的计数值 |
很多同学在看资料时会发现 SysTick 还有一个 “CLKSOURCE 为 0 时使用外部参考时钟”的说法。在 G431 上这个参考时钟通常是 HCLK/8。HAL 库默认用的是处理器时钟,也就是 170MHz,这样重装载值精度高,而且不会被其他总线时钟分频影响。平时写代码不用去动CLKSOURCE,但客观题和面试里喜欢考这个知识点,意思要知道。
3. 三种用法怎么选:HAL_Delay、HAL_GetTick 和裸寄存器配置
3.1 HAL_Delay:最简单的阻塞延时,但别在需要响应性的地方用
HAL_Delay(ms)是大家接触最多的接口。它的实现逻辑很简单:记录当前的uwTick值,然后死循环等着uwTick增长到目标值。这个延时是阻塞型的,在延时期间 CPU 就在那空转,干不了别的事。
按键消抖、状态机切换、短暂等待外设稳定,用HAL_Delay都可以。但有几个地方尽量不要用:
- 需要实时响应按键的界面逻辑。比如你在
HAL_Delay(500)期间按下按键,程序要等这500ms跑完才能响应,体感很差。 - 长延时 + 多任务轮询。比如一边延时一边要让 LED 闪烁、屏幕刷新,这时用阻塞延时整个程序会卡住。
- 中断服务函数里。中断里调用
HAL_Delay会让中断长时间占着 CPU,如果这个中断优先级比 SysTick 还高,甚至可能导致uwTick无法增长,系统直接死等。
我之前调试过一个现象:按键长按切换页面时,屏幕要过一两秒才有反应,最后发现是在某个中断服务函数里写了HAL_Delay(50)。改成非阻塞写法后,问题立刻消失。
3.2 HAL_GetTick():比你想的更有用
HAL_GetTick()返回的是uwTick的值,也就是从系统初始化到现在经过了多少毫秒。它最大的价值在于,你可以用时间戳的方式实现“非阻塞延时”。
典型的写法是把“等待某个条件成立”改成“等待某个时间点到达”:
uint32_t start = HAL_GetTick(); while (HAL_GetTick() - start < 500) { // 在这里可以顺便处理其它事情 // 比如检测按键、刷新小区域显示 }这段代码的效果是“至少等500ms,但不阻塞其它轻量任务”。注意我用的是HAL_GetTick() - start < 500,不是HAL_GetTick() < start + 500。前者在uwTick发生回绕时依然是安全的,后者的start + 500可能会溢出。虽然比赛里 32 位回绕很难碰到,但养成好习惯总没坏处。
这个时间戳思想还可以做超时判断:
uint32_t wait_start = HAL_GetTick(); while (rx_status == 0) { if (HAL_GetTick() - wait_start > 200) { // 超时,做错误处理 break; } }串口接收、等待传感器就绪这类场景非常实用。赛题里如果要求“等待某事件发生,但超过一定时间要恢复默认状态”,用这套写法比单纯延时要优雅得多。
3.3 有没有必要自己操作 SysTick 寄存器
我一直跟备赛的同学说,比赛里不要轻易去动SysTick->LOAD这些寄存器,因为你一旦改了它,HAL 库维护的uwTick节拍就乱了,HAL_Delay和HAL_GetTick都会跟着失灵。
但是,理解裸寄存器操作用来排查问题是很有价值的。比如你知道 SysTick 的机制,就能明白为什么“HAL_Init()里启动 SysTick 时,系统主频还没切到 170MHz,但最终延时时间却是准的”——因为 HAL 库在处理完时钟配置后,会按新的 HCLK 重新初始化 SysTick 的装载值。这个细节是 CubeMX 帮你做的,不是凭空变准的。
如果赛题真的需要微秒级延时,我建议也别去抢 SysTick,而是用 DWT 或者 TIM。这里贴一个基于 DWT 的微秒延时模板,很多学长都在用:
void DWT_Delay_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } void DWT_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks); }DWT 的 CYCCNT 也是按内核时钟周期递增,但它是 32 位的,精度和灵活度都比 SysTick 好,而且不影响 HAL 时基。比赛里如果要用到类似 1-wire 传感器这种需要微秒级时序的协议,直接上 DWT 即可,别在 SysTick 上硬改。
这里顺便对比一下三种常见用法:
| 用法 | 类型 | 典型场景 | 注意点 |
|---|---|---|---|
HAL_Delay(ms) | 阻塞 | 上电等待、模块复位、短时确认 | 中断里禁用,不能做多任务 |
HAL_GetTick() | 非阻塞 | 超时判断、状态机计时、周期轮询 | 注意无符号相减写法 |
| 直接操作SysTick寄存器 | 底层 | 学习原理、自定义时基 | 会破坏HAL时基,慎用 |
| DWT延时 | 非阻塞 | 微秒级时序 | 需要先使能CYCCNT,不影响HAL |
4. 实战模板:用 SysTick 改造按键消抖和任务轮询
4.1 例1:5ms按键扫描的消抖状态机
按键消抖在蓝桥杯赛题里几乎是必考的。最笨的写法是检测到按键变化后,阻塞延时 10ms 再读一次。这种方式的问题在于,延时期间程序什么都做不了,按下、释放、长按三种状态在一起时逻辑非常乱。
我建议用 SysTick 做周期扫描式的状态机。原理很简单:每 5ms 扫描一次按键引脚,如果连续多次读到相同电平,才认为按键状态真正改变。
uint32_t key_tick = 0; uint8_t key_filter_cnt = 0; uint8_t key_last = 0; uint8_t key_state = 0; // 0未按下,1按下 void Key_Scan_5ms(void) { // 假设按键低电平有效 uint8_t key_level = (HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) == GPIO_PIN_RESET) ? 1 : 0; if (key_level == key_last) { key_filter_cnt++; if (key_filter_cnt >= 3 && key_state != key_level) // 连续3次一致,约15ms消抖 { key_state = key_level; if (key_state == 1) { Handle_Key1_Press(); // 触发按下事件 } } } else { key_filter_cnt = 0; } key_last = key_level; } // 在 main 的 while(1) 中: if (HAL_GetTick() - key_tick >= 5) { key_tick = HAL_GetTick(); Key_Scan_5ms(); }之所以选 5ms 作为扫描周期,是因为大部分机械按键的抖动时间在 5~10ms 之间,连续 3 次检测到同一状态,相当于过了 15ms 的稳定期,既能有效滤掉抖动,又不会让按键响应显得迟钝。这个数值不是拍脑袋定的,是机械按键的典型参数,比赛时无需反复试。
这个方案还有个隐形的好处:因为扫描是周期性的,你可以很自然地扩展“长按”和“连发”逻辑。比如想在按键按下超过 500ms 后进入连发状态,只需要在扫描函数里对key_state == 1的时间累计计数即可,逻辑非常清晰。
4.2 例2:三个任务互不阻塞的轮询框架
SysTick 在比赛里另一大用途是驱动“伪多任务”轮询。所谓伪多任务,就是在一个 while 循环里,根据时间戳判断哪个任务该执行了。这样按键扫描、LED 闪烁、LCD 刷新可以各跑各的,互不阻塞。
我经常建议学生把主循环写成这样:
uint32_t task_key_tick = 0; uint32_t task_led_tick = 0; uint32_t task_display_tick = 0; #define TASK_KEY_PERIOD_MS 5 #define TASK_LED_PERIOD_MS 100 #define TASK_DISPLAY_PERIOD_MS 200 while (1) { if (HAL_GetTick() - task_key_tick >= TASK_KEY_PERIOD_MS) { task_key_tick = HAL_GetTick(); Key_Scan_5ms(); } if (HAL_GetTick() - task_led_tick >= TASK_LED_PERIOD_MS) { task_led_tick = HAL_GetTick(); HAL_GPIO_TogglePin(LED1_GPIO_Port, LED1_Pin); } if (HAL_GetTick() - task_display_tick >= TASK_DISPLAY_PERIOD_MS) { task_display_tick = HAL_GetTick(); Update_Display(); } }这个框架的精髓在于:每个任务都只在“该轮到它”的时候才执行,不会因为某个任务耗时较长而拖死其它任务。有些同学会问:那Update_Display()如果执行时间超过 200ms 怎么办?我的经验是,LCD 刷屏如果是一整屏全刷,确实可能花不少时间,这时候你应该把刷新逻辑拆成小块,比如只刷新变化区域,或者把一次完整刷屏拆到多次周期里完成,而不是把整个函数堵在 while 里。
我还见过一种写法,直接在一个任务里写死HAL_Delay(200)来当刷新周期,结果按键扫描被阻塞,按键怎么按都没反应。用上面的时间戳框架后,两者并行得很好。这种“由 SysTick 派生时间片”的意识,在省赛和国赛的复杂题里都是加分项。
5. 实测最容易把 SysTick“玩坏”的四个坑
5.1 坑1:SysTick_Handler 被无意覆盖,HAL_Delay 当场失效
有些同学为了让 LED 以 1ms 为单位做呼吸灯效果,会去改写SysTick_Handler,在里面直接操作HAL_GPIO_TogglePin,但忘了调用HAL_IncTick()。
SysTick_Handler是 HAL 库维护uwTick的地方,你不调HAL_IncTick(),就相当于把心电监护仪的电池拔了,uwTick永远停在某个值。此时的症状非常迷惑:HAL_Delay()要么瞬间返回,要么永远卡死,HAL_GetTick()也不涨,程序看起来像“卡死”了。
排查这类问题,先看中断服务函数有没有被改动。CubeMX 生成的stm32g4xx_it.c里,正常的SysTick_Handler应该长这样:
void SysTick_Handler(void) { HAL_IncTick(); }如果你要加自己的周期任务,请在它下面追加,但务必保留HAL_IncTick()。最好的做法是不要在 SysTick 中断里写用户逻辑,而是只在中断里维护uwTick,用户逻辑放主循环去轮询。
5.2 坑2:在 SysTick 中断里干重活,整个程序卡成 PPT
SysTick 是 1ms 触发一次。如果你在中断里做了耗时操作,比如printf、大段字符串拼接、调用HAL_Delay,那么每 1ms 就会卡一次,整个程序运行速度被拖垮。你用示波器观察某个 GPIO 翻转频率,会发现周期被拉长很多,而且不稳定。
这个现象在串口调试时特别常见。有人想在 SysTick 中断里通过串口打印调试信息,结果printf要发几十个字节,UART 波特率 115200 时发一个字符大约 87us,30 个字符就要 2.6ms,这已经超过了 1ms 的周期,中断还没处理完下一个就来了。
正确思路是“中断里置标志位,主循环里办事”:
volatile uint8_t g_tick_event = 0; void SysTick_Handler(void) { HAL_IncTick(); g_tick_event = 1; // 只是打个标记,不要做重活 }然后在主循环里检测这个标志位,真正耗时的操作放主循环。这是嵌入式开发最常见的“中断服务函数要短小精悍”原则,SysTick 这个 1ms 高频中断尤其要遵守。
5.3 坑3:超时判断写成HAL_GetTick() < start + timeout
从数学上看,如果start + timeout不超过UINT32_MAX,这种写法没问题。但嵌入式开发有个铁律:不要依赖“某个值恰好不溢出”。万一系统长时间运行,uwTick回绕到 0 附近,这个判断会失败,程序可能在某个时刻突然出现异常超时。
规范写法是计算差值:
// 推荐写法,回绕安全 if (HAL_GetTick() - start > timeout) { } // 看起来直观但存在隐患的写法 if (HAL_GetTick() > start + timeout) { }原因很简单:HAL_GetTick() - start是在 32 位无符号数上取差值,即使回绕了,只要实际时间差小于 2^32 毫秒,结果依然正确。这是嵌入式里“时间差用减不用加”的典型例子,面试和客观题都有可能会遇到。
5.4 坑4:软件仿真的断点让你怀疑人生
我亲眼见过备赛同学用 Keil 的软件仿真跑 SysTick,发现HAL_GetTick()数字乱跳,以为是代码有问题。其实软件仿真下,断点暂停时 CPU 停止,但 SysTick 外设的行为不一定按真实硬件来模拟,有的仿真器配置不当,断点一停,调试器会尝试保持外设状态,一旦恢复运行,计数器状态可能已经错乱。
比赛实测用的都是真机,建议尽量在开发板上验证时序。如果实在要用仿真器单步调试,记得关注“暂停时是否还继续跑外设”的配置,同时别靠HAL_GetTick()的值来验证延时精度。正确做法是用 GPIO 翻转电平,接示波器测实际脉冲宽度。
另外提一句,SysTick 产生的是内核异常而非普通外设中断,它的优先级受System handler priority寄存器控制。HAL 默认把 SysTick 的优先级配得比较高。若你在某个外设中断里使用HAL_Delay,而这个外设中断优先级低于 SysTick 还好,反之 SysTick 无法抢占它,HAL_Delay会在中断里失效。这个问题在初学阶段容易碰到,我在比赛现场就见过有人在中断回调里延时,导致系统卡死的。
6. 备赛清单:出发比赛前 SysTick 要确认的事
6.1 赛前把这几项过一遍
比赛前熬夜调程序时,最容易忽略的就是时基相关的“小问题”。我整理了一份自查清单,赛前可以对着过一遍:
| 检查项 | 正确状态 |
|---|---|
SysTick_Handler是否还包含HAL_IncTick() | 必须包含 |
是否在中断里放了耗时的printf/HAL_Delay | 不允许 |
HAL_Delay()在主循环是否正常工作 | 用 LED 翻转肉眼确认 |
HAL_GetTick()是否持续增长 | 调试窗口或串口观察 |
| 自定义任务周期是否错开了耗时任务 | 任务调度不能互相阻塞 |
| 微秒级延时是否占了 SysTick | 应该用 DWT 或 TIM |
| 长按 / 连发逻辑是否基于时间戳 | 避免用阻塞延时做 |
这份清单看起来简单,但很多“按键失灵”“屏幕刷新慢”的表面现象,追根溯源都是表里某一条被破坏了。事先过一遍,比赛时能少掉不少头发。
6.2 关于 SysTick 的客观题知识点
蓝桥杯的客观题也会考定时器相关概念,SysTick 是常客。以下几个点值得背一下:
- SysTick 是内核自带的 24 位递减计数器。
- 重装载值范围是 0x000000 ~ 0xFFFFFF,最大 16777215。
- 计数器减到 0 时,COUNTFLAG 置 1;读 CTRL 寄存器会清除该标志。
- 写 VAL 寄存器会清空当前计数值,同时清除 COUNTFLAG。
- SysTick 不只属于某个型号,几乎所有 Cortex-M 内核芯片都有。
- 在 STM32G4 上通常选择处理器时钟作为计数源,即 HCLK。
- 若使用外部参考时钟,通常是 HCLK/8。
这些知识点不会单独出一道大题,但在概念题、判断题里出现概率不低。原理懂了,就算题目换个姿势问“SysTick 是递增还是递减”“最大计数值是多少”,你也能快速答出来。
7. 一周内把 SysTick 练成肌肉记忆
离比赛还有一周的时候,与其把精力放在新模块的驱动上,不如先把 SysTick 的常用套路练到不用思考就能写出来。我建议按下面这个顺序刷一遍:
第一天,不看源码,用自己的话讲清楚HAL_Delay、HAL_GetTick、SysTick_Handler三者的关系。讲不清楚就去看stm32g4xx_hal.c里的实现,看完了再复述一遍。
第二天,把工程的按键扫描改成 5ms 状态机,实现短按、长按、松开三种事件。不要用任何阻塞延时,全部用时间戳判断。
第三天,写一个 4 个任务的轮询框架,模拟赛题里“LED 每秒翻转 + 按键触发蜂鸣器 + LCD 每 200ms 刷新 + 串口每 1s 发送一次”的场景,调整任务周期,观察各任务之间是否互相影响。
第四天,专门练超时判断。写一个串口接收程序,要求 200ms 内没收到下一个字节就判定超时,把超时后的处理逻辑补完整。
第五天,复习 SysTick 相关的概念题,和同学互相抽问寄存器位的含义。
第六天,拿两道近年省赛真题,把按键、显示、ADC 采集整合到一个程序里,要求所有时序逻辑都不使用阻塞延时,强制自己用HAL_GetTick()时间戳实现。
第七天,不要再大改代码了,只做检查清单和硬件实测。把HAL_Delay从可能的危险位置全部移除,保证时基稳定。
之所以强调这套训练,是因为我发现很多同学比赛时不是不会配置外设,而是在功能整合阶段被“时间关系”搞崩了——按键扫描和屏幕刷新互相影响,延时一长就响应迟钝,状态机因为阻塞延时跳变丢状态。这些问题几乎都能通过熟练掌握 SysTick 时间戳来解决。
最后分享一个我自己的习惯:调试时永远先写一个 500ms 翻转的 LED 来确认时基正常,再开始写业务逻辑。一旦发现 LED 闪烁频率不对,优先查 SysTick 相关配置,而不是去翻业务代码。这个习惯帮我省了无数次排查时间,备赛的同学可以直接抄走。