Arduino 单片机程序中处理时间戳、时间溢出和延时问题

这个话题对其他单片机也适用,就是用来计时的变量万一溢出了该怎么整,类似那个经典的千年虫问题。实际上这个问题在日常生活中也很常见,比如,时钟上的小时最大值为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) 期间溢出,它实际上会是假的。将 t1t2 视为可回收的标签是避免错误的最简单方法:时间戳 t1 明显被分配给在 t2 之前的某个时刻,但在 49.7 天后它将被重新分配给未来的时刻。因此,t2 的之前之后都存在t1。这应该清楚地表明了表达式 t2 > t1 没有意义。

但是,如果这些只是标签,那么显然的问题是:我们如何用它们进行有用的时间计算?答案是:仅使用对时间戳有意义的两种计算:

  1. 后来的时间戳 - 较早的时间戳 等于一段持续时间,即较早时刻和较晚时刻之间经过的时间量。这是涉及时间戳的最有用的算术运算。
  2. 时间戳 ± 持续时间 产生一个时间戳,该时间戳在初始时间戳之后(如果使用 +)或之前(如果 −)。听起来不像那么有用,因为结果时间戳只能用于两种计算中的一种…

由于取模运算的原理,这两者都保证在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) ;  // 正确的版本

尽管它们看起来骗人地相似,但时间戳/持续时间区别应该清楚地表明哪一个有缺陷,哪一个是正确的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值