ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32 SysTick延时实现与卡死排查:Delay_us和Delay_ms全解析

STM32 SysTick延时实现与卡死排查:Delay_us和Delay_ms全解析 做单片机开发的人大概都写过 Delay。刚开始玩 STM32 的时候我直接在延时函数里套了三层 for 循环编译一跑发现灯闪得忽快忽慢换一块板子时间又变了。后来入了门才知道正儿八经的延时应该依赖硬件定时器而 Cortex-M 内核自带的 SysTick 就是最顺手的一把“时间尺”。这篇文章我就围绕 Delay_us 和 Delay_ms 的实现把 SysTick 的原理、两种主流写法、以及我在实际调试中遇到的卡死案例全部捋一遍。无论你是刚把 F103 点亮 LED 的新手还是正在把裸机工程往 FreeRTOS 上迁移的老手这几段经验应该都能帮你少踩几个坑。很多玩过 FPGA 的朋友对set_input_delay这类时序约束不陌生——它本质上是在告诉工具外部信号相对时钟的到达窗口是多少。MCU 里写 Delay 其实也一样你得先确定 SysTick 的输入时钟是多少后面所有乘法因子才对。只盯着延时循环看不管时钟源是很多“延时卡死”问题的根源。下面我从原理开始讲。1. 做时间尺之前先搞清楚 SysTick 是谁在给它“打拍子”1.1 24 位递减计数器的工作模式SysTick 是 Cortex-M 内核自带的一个定时器不是某个 STM32 系列独有的外设。它里面是一颗 24 位递减计数器核心寄存器就四个理解起来非常直接寄存器作用CTRL控制使能、时钟源选择、中断开关、溢出标志LOAD24 位重装载值VAL当前计数值读它看剩余值写任意值则清零CALIB校准值多数场景用不到工作流程是这样的上电后你没有使能它它就不跑。当你写完 LOAD 并在 CTRL 里把 ENABLE 置 1 之后每个系统时钟周期VAL 都会减 1。VAL 减到 0 的时候CTRL 里的 COUNTFLAG 会被硬件置 1如果同时配置了 TICKINT还会触发一次 SysTick 异常。紧接着如果 LOAD 不为 0计数器会自动把 LOAD 的值重新装载进去继续下一轮递减。可以把它想象成一根烟囱你往里面塞了 N 块砖每过一个时钟周期就抽掉一块抽到第 N 块时“啪”一下冒个烟。注意从 LOAD 装载到递减到 0实际需要 LOAD1 个时钟周期这个“1”在写配置的时候很容易忽略。为什么它只有 24 位因为 ARM 当初设计它的时候主要考虑的是给操作系统提供节拍。72MHz 主频下24 位可以数 233ms作为 OS tick 周期完全够用而且 24 位操作在 32 位机上也轻巧。它不是为长时间延时设计的这一点心里要有数。1.2 CLKSOURCE同一个内核两块板子时间尺度完全不同SysTick 有两个时钟源可选具体由 CTRL 寄存器的 CLKSOURCE 位决定为 1 时使用处理器时钟也就是 HCLK为 0 时使用处理器时钟的 8 分频即 HCLK/8。CMSIS 标准库里的SysTick_Config()函数默认会把 CLKSOURCE 置 1直接用 HCLK 驱动。但很多人不从 CMSIS 函数走而是自己写寄存器那就得特别注意这个位。不同 STM32 系列、不同时钟树配置HCLK 可以完全不同。F103 经典 72MHzF407 可以到 168MHzH743 能到 480MHzL 系列可能只有几十兆。哪怕同一颗芯片如果你把 PLL 倍频改了或者把 AHB 预分频改了HCLK 也跟着变。所以我把话说得直接一点任何把延时因子写死的代码都是定时炸弹。你按 72MHz 算好delay_us的因子换到 168MHz 板子延时时间直接缩到原来的一半反过来按 168MHz 写在 72MHz 板子上延时又变得过长某些 I2C、SPI 的时序就直接崩了。正确做法是统一使用SystemCoreClock。标准库和 HAL 库在初始化时钟树之后这个全局变量会跟着 HCLK 刷新你的延时函数只要基于它做乘除就自动适应不同主频。更具体一点如果 CLKSOURCE1SysTick 计数频率就是SystemCoreClock如果 CLKSOURCE0就变成SystemCoreClock / 8。网上不少例程两种方式混着写抄的时候一定要先确认手上板子的实际频率。1.3 把周期翻译成时间的数学公式推导这其实是小学级别的乘法但我要把它写完整因为后面所有代码都从这里来。假设要延时 t 秒SysTick 输入时钟频率是 f那么需要计数的节拍数 N 是N t * f由于硬件是从 LOAD 装载后递减到 0共产生 LOAD1 个节拍所以实际写入 LOAD 的值是 N-1。以 STM32F103 的 72MHz 为例1 微秒N 72 000 000 × 1e-6 72写 LOAD 711 毫秒N 72 000 000 × 1e-3 72000写 LOAD 7199924 位计数器的最大装载值是 2^24 - 1 16777215。在这个前提下72MHz 主频下一次最多能延到16777215 / 72000000 ≈ 0.233 秒这个数字很重要。如果你的delay_ms(1000)直接把 1000 毫秒换算出的节拍数写进 LOAD节拍数远超过 16777215会被硬件截断延时结果乱七八糟。168MHz 时一次最多只能延约 99.8ms480MHz 时只有约 34.9ms。所以大延时必须分段拆开跑后面代码里我会给实现。2. 两种可靠实现查 VAL 寄存器法和 SysTick 中断定时法市面上所谓“可靠”的延时实现归根到底只有两条路线。一个是死等 VAL 寄存器往下减另一个是让 SysTick 溢出中断产生时基再拿时基做差。两者我都用过各有脾气。2.1 查询 VAL 寄存器法适合短延时不依赖中断先说我日常最常用的短延时实现。它的核心思路是记下启动时的 VAL 值然后不断把当前 VAL 和启动值做差差值到达目标节拍数就退出。#include stm32f1xx.h #include stdint.h static void delay_us(uint32_t nus) { uint32_t ticks nus * (SystemCoreClock / 1000000U); uint32_t start SysTick-VAL; while (1) { uint32_t cur SysTick-VAL; uint32_t elapsed start - cur; if (elapsed ticks) { break; } } }这里有个关键点start - cur用的是 32 位无符号减法会自动处理 VAL 从 0 翻到 LOAD 的回绕。为什么因为 VAL 虽然只有 24 位但它赋值给 32 位变量当cur start比如翻过了时减法结果会变成一个很大的正数这个正数一定大于你的ticks于是循环退出。这正是补码模运算的妙处不需要额外写分支判断。但使用查询法有一个前提SysTick 的中断最好是关着的否则每次溢出都会插入一个中断处理过程你的短延时会被强行拉长。初始化时可以这样写void delay_us_init(void) { SysTick-CTRL 0; /* 先彻底关掉 */ SysTick-LOAD 0xFFFFFF; /* 给个大装载值延长翻转周期 */ SysTick-VAL 0; SysTick-CTRL SysTick_CTRL_CLKSOURCE_Msk | SysTick_CTRL_ENABLE_Msk; /* HCLK 驱动使能但不开中断 */ }LOAD 设成 0xFFFFFF 不是因为要数满它而是让 24 位计数器不要过早回绕。反正查询法看的是start - cur的差值和你 LOAD 设多少没直接关系只要 LOAD 够大、不会在单次延时里翻转太多次就行。然后是毫秒级的查询版本。因为上面算过72MHz 下一次最多约 233ms所以函数内部要拆段void delay_ms(uint32_t nms) { const uint32_t max_once (SysTick_LOAD_RELOAD_Msk 1U) / (SystemCoreClock / 1000U); while (nms max_once) { delay_ms(max_once); nms - max_once; } uint32_t ticks nms * (SystemCoreClock / 1000U); uint32_t start SysTick-VAL; while (1) { uint32_t cur SysTick-VAL; if ((start - cur) ticks) { break; } } }SysTick_LOAD_RELOAD_Msk在 CMSIS 头文件里定义值就是 0xFFFFFF。max_once计算出当前主频下一次能扛的最大毫秒数72MHz 时是 233。超过 233ms 的请求就被拆成多次完全避开了 24 位溢出问题。查询法的最大缺点其实是 CPU 空转。1ms 的延时也有成千上万个周期在死等中断来了还得排队。所以它适合做短时序字节级延时不适合做业务级“等多久”的轮询。2.2 SysTick 中断定时法时基累加适合大延时和系统级逻辑如果你要的是毫秒级的大延时而且想顺便维护一个系统时间戳那就走中断累加路线。让 SysTick 固定 1ms 中断一次在中断服务函数里维护一个全局计数器延时函数就是对这个计数器做差。配置部分static volatile uint32_t g_sys_tick 0; void SysTick_Handler(void) { g_sys_tick; } void systick_timer_init(void) { if (SysTick_Config(SystemCoreClock / 1000U)) /* 1ms 一个节拍 */ { while (1) { /* 配置失败通常是主频太高导致 LOAD 超 24 位 */ } } }SysTick_Config是 CMSIS 提供的标准函数它会把 LOAD 设置好、清空 VAL、使能 SysTick、设置 TICKINT、并且把中断优先级设到最低。也就是说你调用它之后SysTick 中断就已经在跑了只是如果你没写SysTick_Handler程序会直接飞进 HardFault。这个坑我在后面排查实录里会专门讲。有了g_sys_tick延时函数就非常简单void delay_ms_int(uint32_t nms) { uint32_t target g_sys_tick nms; while ((int32_t)(g_sys_tick - target) 0) { } }为什么转成int32_t再比较因为g_sys_tick会回绕。用有符号差值判断两个无符号时间点谁先谁后是嵌入式里处理回绕的标准姿势。比如g_sys_tick已经接近 0xFFFFFFFF再加 100ms 后 target 回绕到很小的数如果直接用g_sys_tick target判断就永远不满足了转成有符号差之后就安全。HAL 库里的HAL_Delay其实也是这个思路底层靠HAL_GetTick()从uwTick变量读数而uwTick正是在SysTick_Handler里递增的。理解了这一点后面卡死案例就顺理成章了。2.3 两种方法的边界可重入性、临界区以及不能混用这两种方法看着都简单但混用会出问题。比如你先调用了SysTick_Config把中断打开了然后再用 2.1 的查询法会发生什么SysTick 每 1ms 溢出一次中断服务函数会进进出出你的短延时虽然还在等 VAL 差值但中间被一个 1ms 周期打断短延时反而被拉长而且每次中断执行时间不一定精度变得稀烂。反过来如果你在关中断的环境里使用 2.2 的中断累加延时就彻底卡死。因为g_sys_tick只有中断服务函数会更新中断被禁用后它永远不涨while循环就成了死等。我把关键差异整理成表格方便你选型维度查询 VAL 法中断累加法精度影响仅一个时钟周期误差受中断响应抖动影响通常几十纳秒到几微秒最小可用范围可做几十纳秒到微秒级适合毫秒级及以上最长支持32 位差值足够覆盖大部分短延时约 49.7 天之后回绕但可安全判断CPU 占用调用期间全速空转调用期间全速空转但中断有附加开销关中断环境可用不依赖中断绝对不可用会死等与 RTOS 共存需注意不干扰 OS 的 SysTick 设置需确认 SysTick 是否被 OS 接管一句话结论短时序用查询法业务级大延时用中断法两者不要同时叠在同一个 SysTick 上。3. delay 卡死排查实录三个真实案例的完整链路网上搜“STM32 延时函数 delay 卡死”能搜出一堆提问。我把自己实际跟进过的三个案例写出来每一个都是“现象—排查—根因—修复”的完整链路。排查手法比答案本身更重要。3.1 案例一SysTick_Config 悄悄开了中断程序飞进 HardFault有个朋友移植代码用了我上面说的查询法delay_us然后单步都能过去一全速跑就直接跳进HardFault_Handler。他百思不解因为他没写任何中断服务函数。我让他断住之后开寄存器窗口看SysTick-CTRL结果 TICKINT 位是 1。再往工程里搜SysTick_Config发现标准外设库的某个初始化函数在别处调用了它。问题就在这SysTick_Config会自动把 TICKINT 置 1开启中断源。如果你全工程没有定义SysTick_Handler内核异常一旦触发找不到处理函数直接落进 HardFault。修复有两路。如果你确实只想要查询法那就在SysTick_Config之后再补一句SysTick-CTRL ~SysTick_CTRL_TICKINT_Msk;把中断关掉只留 ENABLE。如果你原本就想要中断累加做系统时基那就老老实实实现SysTick_Handler并在里面递增自己的 tick 计数器。这个案例的教训是SysTick 不只是个定时器它还是内核异常源。配置它的宏不一定只做了你眼睛看到的事。3.2 案例二临界区里调 HAL_Delay延时变成永等另一个案例来自 Flash 参数保存功能。代码大概长这样进入临界区保护写 Flash然后HAL_Delay(10)再退出临界区。程序跑几秒后死机看调用栈正好卡在HAL_Delay的 while 循环里。先验证是不是优先级问题。在HAL_Delay前后读一下 PRIMASK 寄存器发现确实等于 1说明调用者的临界区把全局中断关了。此时 SysTick 中断被屏蔽HAL_GetTick()永远不更新而HAL_Delay内部是while ((HAL_GetTick() - tickstart) delay) { }起始值和当前值永远相等循环永不退出。这种情况不光是 Flash 临界区任何__disable_irq()、__set_PRIMASK(1)、或者其他锁中断的库函数里面都不能调这类依赖中断的延时。修复方式看你实际需求如果只是要“等 Flash 操作完成”应该查状态寄存器而不是傻等固定时间如果非要延时先把临界区退出延完再加锁如果确实必须在一个关中断的上下文里短等那就要用查询 VAL 法因为它根本不看中断。我后来给那位朋友换成查询法问题立刻消失。这个案例说明“能用”和“在哪个上下文都能用”是两码事延时函数的注释里一定要写清楚“是否依赖中断、是否可在中断里调用”。3.3 案例三FreeRTOS 接管 SysTick 后裸机 delay 产生叠加错误第三个案例更隐蔽。一个本来稳定的裸机工程改成 FreeRTOS 之后任务里偶尔出现“一卡一卡”的现象还有一次直接进 HardFault。代码里保留了很多裸机阶段的delay_us它们直接操作SysTick-LOAD、SysTick-VAL。FreeRTOS 默认是把 SysTick 当作心跳源它会接管SysTick_Handler并且按configTICK_RATE_HZ重新配置 SysTick 的节拍。你的裸机延时函数此时再去写 LOAD 和 VAL等于把操作系统的时基给掀了。轻则调度周期错乱重则两个写操作竞争到奇怪状态触发内核异常导致 HardFault。正确姿势是在 RTOS 工程里分清场景任务级延时用vTaskDelay或vTaskDelayUntil别碰 SysTick真正需要微秒级短延时时用通用定时器 TIM而不是 SysTick。我当时给的建议是用 TIM2 做短延时static void delay_tim_us(TIM_TypeDef *TIMx, uint32_t nus) { __HAL_TIM_SET_COUNTER(TIMx, 0); __HAL_TIM_SET_AUTORELOAD(TIMx, nus); __HAL_TIM_ENABLE(TIMx); while (__HAL_TIM_GET_COUNTER(TIMx) nus) { } __HAL_TIM_DISABLE(TIMx); }注意使用前提TIM 的输入时钟必须等于你期望的 1MHz 计数频率也就是 1 个计数值对应 1 微秒。如果总线时钟不是整数 MHz需要先做预分频校准。这个函数在上面例子里只是示意实际工程要事先算好PSC和ARR。这个案例告诉我们进入 RTOS 之后“裸机时代的每一样资源都不再是私有财产”SysTick 尤其如此。3.4 排查 delay 卡死的通用套路除了具体案例我把排查步奏整理成一张“操作清单”下次再遇到延时不对劲可以直接照着做单步执行看 PC 指针最终卡在哪条 while。在工程里搜索所有写SysTick-LOAD、SysTick-VAL、SysTick-CTRL的代码看清楚是谁在配置。开寄存器窗口观察SysTick-CTRL三个关键位ENABLE、TICKINT、CLKSOURCE。如果调用了SysTick_Config注意它是开了中断的确认工程里有没有对应的SysTick_Handler。用示波器或逻辑分析仪测一个本来应该精确翻转的 GPIO直接看延时是否线性变化。确认SystemCoreClock的值和实际 HCLK 一致。不一致的话重新初始化时钟树。如果代码从裸机迁入 RTOS默认考虑 SysTick 已被 OS 接管。排查过程中要注意编译优化等级。优化器虽然不会把一个读取 volatile 寄存器的死循环删掉但它可能改变代码顺序造成断点位置和源码对不上。遇到奇怪现象时先把优化等级调到 O0 再说。4. 工程化进阶把 Delay 做成时间工具库而不是到处裸循环延时的本质不是“拖时间”而是“在系统时基里等待一个时机”。裸循环 delay 到业务逻辑层会被用得很蠢比如轮询外设时每查一次条件就固定延 50ms这中间明明可以做别的事情。真正稳妥的工程方案是把底层延长函数只留最精简的几个原语业务层全部换成时间戳和超时判断。4.1 统一时间基准时间戳和绝对截止时间如果你已经用中断累加维护了g_sys_tick那么业务层最好这样写等待逻辑uint32_t deadline g_sys_tick 100; while ((int32_t)(g_sys_tick - deadline) 0) { if (peripheral_is_ready()) { break; } /* 在这里可以执行其他轻量任务或者至少喂一下看门狗 */ }这种“绝对截止时间”模式比HAL_Delay(100)高明在哪里第一条件提前满足时能立即退出不用白白把 100ms 耗完第二超时上限明确不会因为某次中断抖动进入无限等待第三等待期间你可以在循环里做别的事情系统资源利用率一下子就上来了。裸的阻塞 delay 也不是不能要硬件时序类操作确实需要它比如 DHT11 的读时序、I2C 软件模拟的起始条件这类必须精准卡主频周期。但业务逻辑里的“等一下再查寄存器”还是尽量用超时模式。4.2 上电顺序陷阱时钟稳定是前提再分享一个很多人启动阶段踩过的坑。main 函数刚进来就调用delay_us但这时候系统时钟可能还在内部 HSI 上或者外部晶振还在起振。如果你的延时函数用的是SystemCoreClock计算节拍而这个变量已经被默认值为 72MHz或者某个最终主频硬件实际还在 8MHzLOAD 就会配大延时变成预期的 9 倍。表现是什么程序启动像“卡死”一样等好久才跑起来。这个和虚拟化环境里的“启动延时”思路很像。Proxmox VE 建虚拟机时有 Startup delay 参数目的是错开开机风暴、等依赖服务就绪MCU 上电阶段也一样PLL 没锁定、Flash 没准备好时你按最终主频把 LOAD 灌进去和虚拟机没就绪就启动业务一样迟早出问题。所以延时工具初始化必须放在时钟树初始化之后。更稳妥的做法是在delay_us_init里直接读一次SystemCoreClock并缓存之后所有函数都用缓存值避免运行途中时钟配置被别的库偷偷改掉。当然如果你在运行期间主动切换时钟树需要再次调用初始化函数刷新缓存。4.3 精度对比SysTick、TIM、DWT 谁更合适选延时工具时很多人忽略精度和资源占用的差异。我一般按这个表来决策方法时钟源精度特点典型场景SysTick 查询 VALHCLK 或 HCLK/81 个时钟周期分辨率24 位翻转可处理裸机通用短延时SysTick 中断累加HCLK 或 HCLK/8毫秒级受中断抖动影响系统时基、业务层延时TIM 定时器计数器总线时钟可预分频1 个时钟周期分辨率可独立多路RTOS 环境、独立短延时DWT CYCCNTCPU 周期计数器精确到本周期32 位代码耗时测量、ns 级基准测试HAL_DelaySysTick 中断累加毫秒级最常用依赖 HAL 库的工程DWT 是数据观察与跟踪单元里的一个周期计数器启动代码大概是这样的CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk;然后用DWT-CYCCNT差值乘以主频周期就能得到 1 个周期的精度。但 DWT 在低功耗停止模式、某些调试状态下的行为不够一致所以我不太建议拿它做通用延时更适合做性能分析。SysTick 的最大优势是 Cortex-M 全系列一致不用额外占用通用定时器最大劣势是只有一个RTOS 环境下通常已经被系统的时基占用。TIM 的优势是数量多、互相独立特别适合一个工程里同时需要多路短延时/超时计时。代价是要多配一个外设代码量会变大。4.4 我的个人经验清单最后把这几年写延时相关的检查项列成一份清单每次写完延时功能我都过一遍第一所有延时函数注释里写清楚三件事依赖什么时钟、是否允许关中断环境下调用、最大支持多长延时。这行注释值几块钱能救你几次不抓狂。第二写完延时函数不要靠刷屏肉眼验证用示波器或者逻辑分析仪测一个 GPIO 翻转周期一测便知准不准。我之前在 168MHz 板子上用 72MHz 因子写过一次延时代码从头到尾“看着都对”实测直接露馅。第三一个工程尽量只保留一套延时工具。不要既有标准库的SysTick_Config写法又有裸寄存器操作还混着 TIM 延时。工具多了寄存器状态就乱最后谁也说不清是谁改了谁。第四临界区里绝不使用基于中断的延时。这是硬规则不是建议。如果确认必须在关中断环境里短暂等待就换查询 VAL 法或者干脆把临界区拆开延完再继续。第五定期回头审视延时调用点。很多地方其实不需要“等固定时间”而是需要“在截止时间前循环检查条件”。把阻塞延时替换成带条件的超时循环之后系统响应速度和可读性都会明显不一样。
返回列表