引子
先看一段代码:
static void led_loop(void)
{
static uint32_t next_tick = 0;
if(HAL_GetTick() >= next_tick)
{
next_tick = HAL_GetTick() + 100;
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
}
int main(void)
{
// 此处省略一万字
while (1)
{
// other loop
led_loop();
}
}
这是stm32裸机平台的LED闪烁代码,每100毫秒翻转一次LED电平。笔者用它来演示定时任务的实现,即每隔一段时间执行一个任务。
问题
这段代码有一个小问题,不知各位同学有没有发现。如果没有,那你看这篇文章就看对了,哈哈。
HAL_GetTick返回的是从系统启动到现在所经过的时间uwTick,单位是1毫秒。uwTick是32位无符号整型变量,它最大能记录多少天的时间呢?
2^32 / 1000 / 3600 / 24 = 49.71.....
uwTick在系统一直运行49天后将溢出,溢出后重新从0开始累加。在即将溢出时,上面这个定时程序将出现问题。为了方便描述,要使用到一个宏UINT32_MAX。UINT32_MAX表示32位无符号整型变量的最大值,即0xffffffff。
时间溢出会引发两种问题:
- 如果HAL_GetTick()溢出而next_tick尚未溢出,则长时间内HAL_GetTick()<next_tick,定时任务“永”不执行。为什么HAL_GetTick()会先溢出呢?比如next_tick为UINT32_MAX。当HAL_GetTick()为UINT32_MAX-1时调用了一次led_loop,此时HAL_GetTick()<next_tick,不执行任务。而之后执行其他任务时,花费了一些时间,当再调用led_loop时,经过了2ms的时间。此时HAL_GetTick()为
UINT32_MAX - 1 + 2 = UINT32_MAX + 1 = 0
,发生了溢出。 - 如果HAL_GetTick()尚未溢出而next_tick溢出,则在HAL_GetTick()溢出之前,HAL_GetTick()>next_tick,定时任务将不停执行。
问题1不是必现的,如果led_loop每毫秒都执行一次,就没问题。问题2是必现的,next_tick不断累加,49天后必然溢出。
解决
49天是一个并不算短的时间,对于一个一直运转的设备来说,溢出都是必然的。所以我们得想办法解决这个问题。
修改如下:
static void led_loop(void)
{
static uint32_t next_tick = 0;
if(HAL_GetTick() - next_tick < UINT32_MAX / 2)
{
next_tick = HAL_GetTick() + 100;
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}
}
只是把判断条件改了下
if(HAL_GetTick() - next_tick < UINT32_MAX / 2)
HAL_GetTick()与next_tick的差值与UINT32_MAX / 2有什么关系呢?我们知道,它的功能就是if (HAL_GetTick() >= next_tick)
,只不过它不怕时间溢出,无论是HAL_GetTick()还是next_tick。我们先用几个具体的数字,来验证一下这个功能。
在举例前,需要明确一点。HAL_GetTick()和next_tick都是无符号的,无符号减无符号的结果还是无符号,uint32减uint32还是uint32。如果(int32_t)HAL_GetTick() - (int32_t)next_tick = -1,则HAL_GetTick() - next_tick = UINT32_MAX。int32类型的负数对应的uint32的正数有如下的关系:
int32_t类型的变量x为负数,(uint32_t)x = UINT32_MAX + 1 - |x|。
- HAL_GetTick() = 1, next_tick = 2, HAL_GetTick() - next_tick = 1 - 2 = -1 = UINT32_MAX > UINT32_MAX / 2,if判断不成立。
- HAL_GetTick() = 2, next_tick = 1, HAL_GetTick() - next_tick = 2 - 1 = 1 < UINT32_MAX / 2,if判断成立。
- HAL_GetTick() = UINT32_MAX, next_tick = 1(next_tick溢出,所以实际上当时时间HAL_GetTick()还没达到超时时间next_tick ), HAL_GetTick() - next_tick = UINT32_MAX - 1 > UINT32_MAX / 2, if判断不成立。
- HAL_GetTick() = 1, next_tick = UINT32_MAX(这描述的场景是,HAL_GetTick()原先是接近UINT32_MAX的,只不过由于检查的间隔过长,再次检查时HAL_GetTick()溢出了,),HAL_GetTick() - next_tick = 1 - UINT32_MAX = UINT32_MAX + 1 - |1 - UINT32_MAX| = 2 < UINT32_MAX / 2, if判断成立。
例1和例2是普通场景,其结果与if (HAL_GetTick() >= next_tick)
一致。例3和例4是我们之前所讨论的两种时间溢出场景,例3是next_tick溢出,例4是HAL_GetTick()溢出。例3和例4的判断结果也满足实际的情况,比如例3中,next_tick溢出,HAL_GetTick()尚未溢出,所以尚未满足超时条件,例3的结果也是if判断不成立。
原理
if ((HAL_GetTick() - next_tick) < UINT32_MAX / 2)
为什么能实现超时判断而又不怕溢出呢?
原理还是在无符号整型的减法运算上面。之前提到无符号减无符号的结果还是无符号,即还是正数(或0),并且提了正负数转换的规律。有的同学可能看的云里雾里,那么现在,笔者将告诉你两个无符号数相减的终极含义:两个数的距离。准确说,是被减数在坐标轴上向左走到减数所经过的距离。下面画图说明B-A的两种情况:
- B1 > A1,B1向左直接能走到A1。
- B2 < A2,B2向左走到A2得分为三个步骤:B2向左走到0,从0跳到UINT32_MAX(算走1步),从UINT32_MAX走到A2。
现在再想想“解决”那节里面的例4,HAL_GetTick() = 1, next_tick = UINT32_MAX,从HAL_GetTick()走到next_tick的距离就是1 + 1 + 0 = 2
。
将A和B替换为代表时间的next_tick和HAL_GetTick(),则向右代表未来,而向左代表过去。HAL_GetTick() - next_tick
就是HAL_GetTick()向过去走到next_tick的距离。由前面B-A的叙述可知,无论HAL_GetTick()是不是在next_tick前面(前面表未来),HAL_GetTick()向左总能走到next_tick,因为这是一个环路,从0向左会走到UINT32_MAX。
我们可以通过比较HAL_GetTick() - next_tick
这个距离的大小,从而判断HAL_GetTick()是否能从过去走到next_tick。
我们细想下实际的使用场景的特点:
- 规划的肯定是未来的任务,而不是过去的任务。
- 会定期去检查有没有超时,检测的间隔不会很长,比如每次时间更新时都检查。
基于如上特点,我们可以得出结论:如果检查超时的频率比较高,当检查到HAL_GetTick()超过next_tick时,HAL_GetTick()不会超太多。可能超了1,10,100,反正是一个较小的值。进一步推论得出:若HAL_GetTick()确实在next_tick前面的话,那么HAL_GetTick()往回走到next_tick的距离不会太长。如果HAL_GetTick()要走很长的距离才能到达next_tick,那它到达的应该是未来的next_tick,HAL_GetTick()自己穿越时空了。什么样的距离算很长的距离呢,uint32的最大值为UINT32_MAX,那就以一半为界,因此就有了之前看到的判断式:
if ((HAL_GetTick() - next_tick) < UINT32_MAX / 2)
再重复叙述下:
- 如果HAL_GetTick()向左走较短的距离(小于UINT32_MAX / 2)就到了next_tick,说明HAL_GetTick()往过去走能达到next_tick,从而判断HAL_GetTick()比next_tick更前,即达到了超时条件。
- 如果HAL_GetTick()得走很长距离(大于等于UINT32_MAX / 2)才能走到next_tick,说明HAL_GetTick()往过去走不到next_tick,它走到了未来,从而判断next_tick比HAL_GetTick()更前,即尚未超时。
最后,让我们再用图来演示下4种场景:
为作图方便,用current表示HAL_GetTick()即当前时间,timeout表示next_tick即下次任务的时间。
这里用<<表示远小于,比如current << timeout表示:current < timeout - UINT32_MAX / 2。同理,用>>表示远大于。
- current< timeout,两者均未溢出,即未超时。
- current > timeout,两者均未溢出,即已超时。
- current << timeout,current溢出,即已超时。
- current >> timeout, timeout溢出,即未超时。
质疑
最后再让我们看一眼这个判断式,它是万能的吗,有没有限制条件呢?
if ((HAL_GetTick() - next_tick) < UINT32_MAX / 2)
其实是有的,那就是定时任务的周期必须小于UINT32_MAX / 2,否则就会得出相反的判断结果:
- 实际未超时时认为超时。
- 实际超时时认为尚未超时。
前面提到,UINT32_MAX对应的是49天,所以UINT32_MAX / 2是24天。长周期的定时任务,比如以周、月、季度、年为周期,本就不适合用基于Tick的机制,因为:
- UINT32_MAX也就49天,极限状态下也不满足季度周期。
- 月、季、年的天数是会变的,2月可能有28天也可能有29天,其他月有30天和31天。
对于长周期的定时任务,可以使用RTC模块,或者使用timer.h中的相关函数。