这个话题对其他单片机也适用,就是用来计时的变量万一溢出了该怎么整,类似那个经典的千年虫问题。实际上这个问题在日常生活中也很常见,比如,时钟上的小时最大值为23,从0 开始,每过24 小时归零一次,只按时钟上的小时数来记录时间,最多只能计24 小时。时间超过最大值后归零就是所谓的溢出问题。
一种常见的设计是用比较时间戳的方法来预约一个延时任务,比如在1 点整的时候开始一个2 小时的延时任务,1 + 2 = 3,所以任务预约在3 点执行,当前时间大于3 时这个任务就该执行了。但由于计时会溢出,如果在22 点想开始2 小时的延时任务,22 + 2 = 0 (溢出后归零),22 + 2 的结果反而比22 小,任务立即就执行了,发生BUG。最简单万能的处理方法自然是增大计时变量的尺寸,就像IPV4 扩展成IPV6,二进制数值每增加一位,最大值就增加一倍,按这个指数增长,很快就能把溢出周期增大到天文数字。但在单片机上,32 位变量全面增大到64 位的话还是有一些代价的,尤其是乘除法的处理。以下介绍两种常见的处理思路,顺便摘录一个Arduino 社区关于这个问题的回答。
1. 只比较时间间隔
原理很简单,虽然22 + 2 变成了0,但是按无符号整数的算法,0 - 22 还等于2,这也是加减法运算律的要求。当然实际的计算不是这样的,只是拿24 小时制简单说明一下,可以用如下C 代码测试:
#include <stdio.h>
#include <stdint.h>
int main()
{
uint8_t a = 255;
uint8_t b = a + 5;
uint8_t c = b - a;
printf("a = %u, b = %u, c = %u", a, b, c);
return 0;
}
结果:a = 255, b = 4, c = 5
。所以规避时间溢出BUG 的方法就是对时间戳做减法,把预约延时任务时的时间戳T0 = 22
和延时间隔ΔT = 2
都保存下来,然后持续获取当前时间T
。延时过程中,当前时间T
会从T0
一直增加,直到两小时后,T
溢出变成0。此时做减法T - T0 = 0 - 22 = 2
,和延时间隔ΔT
相等,预约完成。用这种思路实现的延时函数大概是下面这样:
void delay(int Δt) {
int t0 = get_time();
int t = t0;
while((t - t0) < Δt) {
t = get_time();
}
}
只要算出来当前时间和T0
的间隔小于延时时间就持续死循环等待。这种方法必须额外保存一个t0 ,只是一个延时倒还好,如果要同时处理多个延时任务就会比较麻烦。比如,想实现定时执行任务的功能,每个任务在指定时间后调用一个回调函数,一般用结构体保存任务的相关信息,类似这样:
struct Task {
int (*callback)();
int t0;
int Δt;
};
然后对每个任务都用上面延时函数的方式处理,每次更新任务状态时,用当前时间减去t0
,再和Δt
比较。可以用,只不过有多个任务时,每个结构体里都多一个t0
,会多占用一些RAM,效率不算高。
2. 倒计时器
想提高任务结构体的存储效率,只能把t0
去掉,然后用其他方式处理溢出问题。比如,可以把结构体里存储的延时时间换成一个倒计时器,结构体改写成这样:
struct Task {
int (*callback)();
int 倒计时;
};
每次更新任务状态时,把所有任务的倒计时变量减去一段时间,倒计时减完了就到执行任务的时候了。要减去的时间是两次更新任务状态的时间间隔,更新任务的函数大概是这样:
void update() {
static int 第一次运行 = 1;
static int 上次时间 = 0;
int 这次时间 = get_time();
int 间隔 = 上次时间 - 这次时间;
上次时间 = 这次时间;
if(第一次运行) {
间隔 = 0;
第一次运行 = 0;
}
// 遍历所有任务
for( /* ... */ ) {
if(task.倒计时 <= 间隔) {
// 说明倒计时再减一次就变成0 或负数,那就不用减了,直接执行回调函数
task.callback();
continue;
}
task.倒计时 -= 间隔;
}
}
整体第一次运行时,计算出来的两次时间间隔是没有意义的,可以把间隔清零,或者额外设计别的初始化机制,比如,任务列表为空就不需要计时,所以不妨把第一个任务添加进来的时间作为计时初始值。只用一个倒计时变量的方式提高了存储效率,相比上一种方式,也没降低运行效率,因为上一种方式也要每个任务做一次减法然后比较时间。缺点在于,倒计时运行后,添加任务时的初始延时参数就没了,如果想实现能自动重复运行的任务,还得额外准备一个变量存储初始参数,这就和上一种方式没区别了。
摘录 - 如何处理millis() 函数归零问题
源地址:How can I handle the millis() rollover?
注:millis() 是Arduino 框架里返回时间毫秒值的函数,这个应该足够众所周知。下面把时间的溢出问题称作“翻转“ 问题(rollover)
时刻,时间戳和持续时间
在处理时间时,我们必须至少区分两个不同的概念:时刻和持续时间。时刻是时间轴上的一个点。持续时间是时间间隔的长度,即定义间隔开始和结束的时刻之间的时间距离。在日常语言中,这些概念之间的区别并不总是非常明显。例如,如果我说“我五分钟后回来”,那么“五分钟”是我缺席的预计持续时间,而“五分钟后”是我预计回来的时刻。牢记这种区别很重要,因为这是完全避免翻转问题的最简单方法。
millis()
的返回值可以解释为持续时间:从程序开始到现在经过的时间。然而,一旦 millis()
溢出,这种解释就失效了。通常更有用的定义是认为 millis()
返回一个时间戳,即标识特定时刻的“标签”。这种解释的缺点是,因为时间戳每 49.7 天就会被重用(millis() 的溢出周期是49.7天),因此时间戳并不具有唯一性。然而,这很少是一个问题:在大多数嵌入式应用程序中,49.7 天前发生的任何事情都是我们不关心的古老历史。因此,回收旧标签不应该是一个问题。
不要比较时间戳
试图找出两个时间戳中哪一个大于另一个是没有意义的。例如:
unsigned long t1 = millis();
delay(3000);
unsigned long t2 = millis();
if (t2 > t1) { ... }
天真的人们会认为 if ()
的条件总是为真。但实际上,如果 millis()
在 delay(3000)
期间溢出,它实际上会是假的。将 t1
和 t2
视为可回收的标签是避免错误的最简单方法:时间戳 t1 明显被分配给在 t2 之前的某个时刻,但在 49.7 天后它将被重新分配给未来的时刻。因此,t2 的之前和之后都存在t1。这应该清楚地表明了表达式 t2 > t1
没有意义。
但是,如果这些只是标签,那么显然的问题是:我们如何用它们进行有用的时间计算?答案是:仅使用对时间戳有意义的两种计算:
后来的时间戳 - 较早的时间戳
等于一段持续时间,即较早时刻和较晚时刻之间经过的时间量。这是涉及时间戳的最有用的算术运算。时间戳 ± 持续时间
产生一个时间戳,该时间戳在初始时间戳之后(如果使用 +)或之前(如果 −)。听起来不像那么有用,因为结果时间戳只能用于两种计算中的一种…
由于取模运算的原理,这两者都保证在millis()
翻转时正常工作,只要涉及的延迟不超过 49.7 天。
注:这里提到取模运算是因为时间溢出和取模是相似的,若取溢出周期为T,取线性无限增长的真实时间为t,那么计时变量的实际值 v = t % T,也就是每过T 时间,计时变量就回归初值
比较持续时间是可以的
持续时间只是在某个时间间隔内经过的毫秒数。只要我们不需要处理超过 49.7 天的持续时间,任何在物理上有意义的操作在计算上也应该有意义。例如,我们可以将持续时间乘以频率得到周期数。或者我们可以比较两个持续时间,以了解哪一个更长。例如,这里是 delay()
的两种实现。首先,有缺陷的版本:
void myDelay(unsigned long ms) { // ms: 持续时间
unsigned long start = millis(); // start: 时间戳
unsigned long finished = start + ms; // finished: 时间戳
for (;;) {
unsigned long now = millis(); // now: 时间戳
if (now >= finished) // 比较时间戳:错误!
return;
}
}
这是正确的版本:
void myDelay(unsigned long ms) { // ms: 持续时间
unsigned long start = millis(); // start: 时间戳
for (;;) {
unsigned long now = millis(); // now: 时间戳
unsigned long elapsed = now - start; // elapsed: 持续时间
if (elapsed >= ms) // 比较持续时间:正确
return;
}
}
大多数 C 程序员会以更简洁的形式编写上面的循环,例如
while (millis() < start + ms) ; // 有缺陷的版本
和
while (millis() - start < ms) ; // 正确的版本
尽管它们看起来骗人地相似,但时间戳/持续时间区别应该清楚地表明哪一个有缺陷,哪一个是正确的。