资讯动态

STM32 SysTick全解析:从原理到蓝桥杯实战,避开这4个致命坑

发布时间:2026/10/3 17:22:46 来源:尧图企业网站定制
距离第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 后触发连发屏幕显示刷新率的节拍控制非阻塞延时 / 超时判断比如等待串口接收某个应答最多等500msCubeMX 生成的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实际产生的定时周期是 N1 个时钟周期。这一点在你自己算重装载值的时候要留个心眼不过用 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-CTRLENABLE置1启动计数器SysTick-CTRLTICKINT置1使能中断减到0时触发SysTick_HandlerSysTick-CTRLCLKSOURCE选择计数时钟源1为处理器时钟0为外部参考时钟通常HCLK/8SysTick-CTRLCOUNTFLAG计数器减到0时置1读此寄存器会清零SysTick-LOAD低24位重装载值SysTick-VAL低24位当前计数值写入任意值会清空并清除COUNTFLAGSysTick-CALIBTENMS校准值表示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不影响HAL4. 实战模板用 SysTick 改造按键消抖和任务轮询4.1 例15ms按键扫描的消抖状态机按键消抖在蓝桥杯赛题里几乎是必考的。最笨的写法是检测到按键变化后阻塞延时 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 坑1SysTick_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 中断里干重活整个程序卡成 PPTSysTick 是 1ms 触发一次。如果你在中断里做了耗时操作比如printf、大段字符串拼接、调用HAL_Delay那么每 1ms 就会卡一次整个程序运行速度被拖垮。你用示波器观察某个 GPIO 翻转频率会发现周期被拉长很多而且不稳定。这个现象在串口调试时特别常见。有人想在 SysTick 中断里通过串口打印调试信息结果printf要发几十个字节UART 波特率 115200 时发一个字符大约 87us30 个字符就要 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 相关配置而不是去翻业务代码。这个习惯帮我省了无数次排查时间备赛的同学可以直接抄走。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑