如何处理millis()转换?


73

我需要每五分钟读取一次传感器,但是由于我的草图还需要执行其他任务,因此我不能只delay()在两次读数之间进行。有一个“ 无延迟闪烁”教程,建议我按照以下方式进行编码:

void loop()
{
    unsigned long currentMillis = millis();

    // Read the sensor when needed.
    if (currentMillis - previousMillis >= interval) {
        previousMillis = currentMillis;
        readSensor();
    }

    // Do other stuff...
}

问题在于,millis()大约49.7天后,该值将回滚为零。由于我的草图打算运行更长的时间,因此我需要确保翻转不会使我的草图失败。我可以轻松检测到翻转条件(currentMillis < previousMillis),但不确定该怎么做。

因此,我的问题是:处理millis()过渡的正确/最简单方法是 什么?


5
编者注:这不完全是我的问题,而是问题/答案格式的教程。我已经在Internet(包括此处)上目睹了很多与此主题相关的混乱情况,并且该站点似乎是寻找答案的显而易见的地方。这就是为什么我在这里提供本教程的原因。
Edgar Bonet 2015年

2
我会这样做,previousMillis += interval而不是previousMillis = currentMillis希望获得一定频率的结果。
杰森

4
@Jasen:是的!previousMillis += interval如果您要保持恒定的频率,并确保处理所需的时间少于interval,则可确保previousMillis = currentMillis最小延迟为interval
Edgar Bonet

我们真的需要像这样的常见问题解答。

我使用的“技巧”之一是通过使用包含间隔的最小int减轻arduino的负担。例如,对于最多1分钟的间隔,我写uint16_t previousMillis; const uint16_t interval = 45000; ... uint16_t currentMillis = (uint16_t) millis(); if ((currentMillis - previousMillis) >= interval) ...
frarugi87 '18

Answers:


95

简短的答案:不要试图“处理”毫秒转换,而是编写安全转换代码。您的教程示例代码很好。如果尝试检测翻转以实施纠正措施,则可能是您做错了什么。大多数Arduino程序仅需要管理持续时间相对较短的事件,例如将按钮弹跳50毫秒或打开加热器12个小时...然后,即使该程序打算一次运行数年,毫不担心侧翻。

管理(或避免管理)过渡问题的正确方法是根据模块化算法考虑unsigned long返回的数字 。对于数学上的偏爱,在编程时熟悉此概念非常有用。您可以在尼克· 加蒙( Nick Gammon)的文章millis()溢出中看到运行中的数学……一件坏事吗?。对于那些不想了解计算细节的人,我在这里提供了一种替代的方法(希望更简单)。它基于瞬时持续时间之间的简单区别。只要您的测试仅涉及比较持续时间,就可以了。millis()

关于micros()的注释:此处所说的所有内容millis()同样适用micros(),除了micros()每71.6分钟翻转一次,并且setMillis()下面提供的功能不影响micros()

瞬间,时间戳和持续时间

在处理时间时,我们必须区分至少两个不同的概念:瞬间持续时间。瞬间是时间轴上的一个点。持续时间是时间间隔的长度,即定义间隔开始和结束的瞬间之间的时间距离。在日常语言中,这些概念之间的区别并不总是很明显。例如,如果我说“ 我将在五分钟之内回来 ”,则“ 五分钟 ”是我缺席的估计 持续时间,而“ 五分钟 ”是瞬间 我预测的回来。记住区别很重要,因为这是完全避免过渡问题的最简单方法。

的返回值millis()可以解释为持续时间:从程序开始到现在的时间。但是,这种解释一旦毫力士溢出就会中断。通常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天内它将被重新分配给将来的一个瞬间。因此,t1发生 t2 之前之后。这应该明确表示该表达式t2 > t1没有任何意义。

但是,如果这些仅仅是标签,那么显而易见的问题是:我们如何使用它们进行任何有用的时间计算?答案是:将自己限制在只有两个对时间戳有意义的计算中:

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

由于采用了模块化算法,因此至少在涉及的延迟短于49.7天的情况下,这两种方法都可以保证在毫秒转换中正常工作。

比较持续时间很好

持续时间只是在某个时间间隔内经过的毫秒数。只要我们不需要处理超过49.7天的持续时间,任何在物理上有意义的操作都应该在计算上有意义。例如,我们可以将持续时间乘以频率来获得多个周期。或者我们可以比较两个持续时间,以了解哪个更长。例如,这是的两个替代实现delay()。首先,越野车:

void myDelay(unsigned long ms) {          // ms: duration
    unsigned long start = millis();       // start: timestamp
    unsigned long finished = start + ms;  // finished: timestamp
    for (;;) {
        unsigned long now = millis();     // now: timestamp
        if (now >= finished)              // comparing timestamps: BUG!
            return;
    }
}

这是正确的:

void myDelay(unsigned long ms) {              // ms: duration
    unsigned long start = millis();           // start: timestamp
    for (;;) {
        unsigned long now = millis();         // now: timestamp
        unsigned long elapsed = now - start;  // elapsed: duration
        if (elapsed >= ms)                    // comparing durations: OK
            return;
    }
}

大多数C程序员会以简短的形式编写上述循环,例如

while (millis() < start + ms) ;  // BUGGY version

while (millis() - start < ms) ;  // CORRECT version

尽管它们看起来看似相似,但时间戳/持续时间的区别应该明确哪个是错误的,哪个是正确的。

如果我真的需要比较时间戳怎么办?

最好尽量避免这种情况。如果不可避免,仍然有希望知道相应时刻是否足够接近:比24.85天短。是的,我们最大的可管理延迟时间为49.7天,减少了一半。

显而易见的解决方案是将时间戳比较问题转换为持续时间比较问题。假设我们需要知道时刻t1是在t2之前还是之后。我们选择它们共同的过去中的某个参考时刻,并比较从该参考直到t1和t2的持续时间。通过从t1或t2中减去足够长的持续时间来获得参考时刻:

unsigned long reference_instant = t2 - LONG_ENOUGH_DURATION;
unsigned long from_reference_until_t1 = t1 - reference_instant;
unsigned long from_reference_until_t2 = t2 - reference_instant;
if (from_reference_until_t1 < from_reference_until_t2)
    // t1 is before t2

可以简化为:

if (t1 - t2 + LONG_ENOUGH_DURATION < LONG_ENOUGH_DURATION)
    // t1 is before t2

倾向于进一步简化为if (t1 - t2 < 0)。显然,这是行不通的,因为t1 - t2作为无符号数计算的不能为负。但是,这虽然不是便携式的,但确实可以工作:

if ((signed long)(t1 - t2) < 0)  // works with gcc
    // t1 is before t2

signed上面的关键字是多余的(平原long总是带符号的),但是它有助于使意图更清晰。转换为有符号长等于设置为LONG_ENOUGH_DURATION等于24.85天。该技巧不可移植,因为根据C标准,结果是实现定义的。但是由于gcc编译器承诺会做正确的事,因此它可以在Arduino上可靠地工作。如果我们希望避免实现定义的行为,则上述签名的比较在数学上等效于此:

#include <limits.h>

if (t1 - t2 > LONG_MAX)  // too big to be believed
    // t1 is before t2

唯一的问题是比较向后看。只要long为32位,它也等同于此一位测试:

if ((t1 - t2) & 0x80000000)  // test the "sign" bit
    // t1 is before t2

最后三个测试实际上是由gcc编译成完全相同的机器代码。

我如何针对米利斯翻转测试我的草图

如果您遵循上述规则,那么您应该一切都很好。如果仍然要测试,请将此功能添加到草图中:

#include <util/atomic.h>

void setMillis(unsigned long ms)
{
    extern unsigned long timer0_millis;
    ATOMIC_BLOCK (ATOMIC_RESTORESTATE) {
        timer0_millis = ms;
    }
}

现在,您可以通过调用来遍历您的程序 setMillis(destination)。如果您希望它一遍又一遍地经历毫氏溢出,就像Phil Connors放荡土拨鼠日一样,可以将其放入其中loop()

// 6-second time loop starting at rollover - 3 seconds
if (millis() - (-3000) >= 6000)
    setMillis(-3000);

上面的负时间戳(-3000)由编译器隐式转换为无符号长,对应于翻转前的3000毫秒(转换为4294964296)。

如果我真的需要追踪很长的时间怎么办?

如果您需要在三个月后打开并关闭继电器,那么您确实需要跟踪毫秒的溢出。有很多方法可以做到这一点。最直接的解决方案可能是简单地扩展millis() 到64位:

uint64_t millis64() {
    static uint32_t low32, high32;
    uint32_t new_low32 = millis();
    if (new_low32 < low32) high32++;
    low32 = new_low32;
    return (uint64_t) high32 << 32 | low32;
}

这实际上是对翻转事件进行计数,并将此计数用作64位毫秒计数中的32个最高有效位。为了使此计数正常运行,需要至少每49.7天调用一次该函数。但是,如果每49.7天仅调用一次,则在某些情况下,检查可能会(new_low32 < low32)失败,并且代码会丢失计数high32。根据时间框架的排列方式,使用millis()决定何时仅在一次“包装” millis(特定的49.7天窗口)中仅对该代码进行调用是非常危险的。为了安全起见,如果使用millis()确定何时仅对millis64()进行调用,则每个49.7天的窗口中应该至少有两个调用。

但是请记住,在Arduino上64位算术运算昂贵。降低时间分辨率以保持在32位可能是值得的。


2
那么,您是说问题中编写的代码实际上可以正常工作吗?
杰森

3
@Jasen:是的!人们似乎似乎不止一次尝试“解决”最初不存在的问题。
Edgar Bonet

2
我很高兴发现了这个。我以前有这个问题。
塞巴斯蒂安·弗里曼

1
StackExchange上最好和最有用的答案之一!非常感谢!:)
法尔科

这个问题的答案如此惊人。我基本上每年一次回到这个答案,因为我对混乱的过渡感到偏执。
杰弗里·卡什

17

TL; DR简版:

An unsigned long是0到4,294,967,295(2 ^ 32-1)。

因此,可以说previousMillis是4,294,967,290(翻转前5毫秒),currentMillis是10(翻转后10毫秒)。然后currentMillis - previousMillis是实际的16(而不是-4,294,967,280),因为结果将被计算为无符号长整数(不能为负,因此它本身会四舍五入)。您可以通过以下方式简单地进行检查:

Serial.println( ( unsigned long ) ( 10 - 4294967290 ) ); // 16

因此,上面的代码可以正常工作。诀窍是始终计算时差,而不是比较两个时间值。


怎么样15ms的侧翻前和10ms的侧翻后(即49.7天)。15> 10,但15ms的戳记已使用了将近一个半月。15-10> 0和10-15> 0 unsigned逻辑,因此此处无用!
ps95

@ prakharsingh95 10ms-15ms将变为〜49.7天-5ms,这是正确的差值。数学运算直到millis()翻转两次为止,但是所讨论的代码极不可能发生这种情况。
BrettAM 2015年

让我改一下。假设您有两个时间戳200ms和10ms。您如何知道哪个被结转了?
ps95 2015年

@ prakharsingh95 previousMillis必须先测量其中存储的一个currentMillis,所以如果currentMillis小于previousMillis一个发生了翻转。通过数学计算得出,除非发生两次翻转,否则您甚至都无需考虑。
BrettAM 2015年

1
喔好吧。如果这样做t2-t1,并且如果可以保证t1之前已进行过测量,t2则它等效于signed (t2-t1)% 4,294,967,295,因此会自动环绕。很好!但是,如果有两次翻转,或者interval> 4,294,967,295,该怎么办?
ps95 2015年

1

millis()课程包起来!

逻辑:

  1. 使用ID代替millis()直接使用。
  2. 使用ID比较冲销。这是干净且无翻转的。
  3. 对于特定的应用程序,要计算两个id之间的确切差异,请跟踪冲销和标记。计算差异。

跟踪冲销:

  1. 定期更新本地戳的速度比快millis()。这将帮助您找出是否millis()溢出。
  2. 计时器的周期决定精度
class Timer {

public:
    static long last_stamp;
    static long *stamps;
    static int *reversals;
    static int count;
    static int reversal_count;

    static void setup_timer() {
        // Setup Timer2 overflow to fire every 8ms (125Hz)
        //   period [sec] = (1 / f_clock [sec]) * prescale * (255-count)
        //                  (1/16000000)  * 1024 * (255-130) = .008 sec


        TCCR2B = 0x00;        // Disable Timer2 while we set it up

        TCNT2  = 130;         // Reset Timer Count  (255-130) = execute ev 125-th T/C clock
        TIFR2  = 0x00;        // Timer2 INT Flag Reg: Clear Timer Overflow Flag
        TIMSK2 = 0x01;        // Timer2 INT Reg: Timer2 Overflow Interrupt Enable
        TCCR2A = 0x00;        // Timer2 Control Reg A: Wave Gen Mode normal
        TCCR2B = 0x07;        // Timer2 Control Reg B: Timer Prescaler set to 1024

        count = 0;
        stamps = new long[50];
        reversals = new int [10];
        reversal_count =0;
    }

    static long get_stamp () {
        stamps[count++] = millis();
        return count-1;
    }

    static bool compare_stamps_by_id(int s1, int s2) {
        return s1 > s2;
    }

    static long long get_stamp_difference(int s1, int s2) {
        int no_of_reversals = 0;
        for(int j=0; j < reversal_count; j++)
        if(reversals[j] < s2 && reversals[j] > s1)
            no_of_reversals++;
        return stamps[s2]-stamps[s1] + 49.7 * 86400 * 1000;       
    }

};

long Timer::last_stamp;
long *Timer::stamps;
int *Timer::reversals;
int Timer::count;
int Timer::reversal_count;

ISR(TIMER2_OVF_vect) {

    long stamp = millis();
    if(stamp < Timer::last_stamp) // reversal
        Timer::reversals[Timer::reversal_count++] = Timer::count;
    else 
        ; // no reversal
    Timer::last_stamp = stamp;    
    TCNT2 = 130;     // reset timer ct to 130 out of 255
    TIFR2 = 0x00;    // timer2 int flag reg: clear timer overflow flag
};

// Usage

void setup () {
    Timer::setup_timer();

    long s1 = Timer::get_stamp();
    delay(3000);
    long s2 = Timer::get_stamp();

    Timer::compare_stamps_by_id(s1, s2); // true

    Timer::get_stamp_difference(s1, s2); // return true difference, taking into account reversals
}

计时器学分


9
我编辑了代码,以消除阻止其编译的maaaaany错误。这些东西将花费您大约232字节的RAM和两个PWM通道。get_stamp()51次后,它也会开始损坏内存。比较延迟而不是时间戳比较肯定会更有效率。
Edgar Bonet 2015年

1

我喜欢这个问题,它产生了很好的答案。首先,对先前的答案进行快速评论(我知道,我知道,但是我还没有代表发表评论。:-)。

埃德加·博内特(Edgar Bonet)的答案令人惊讶。我已经编码35年了,今天我学到了一些新东西。谢谢。就是说,我相信代码“如果我真的需要跟踪很长的持续时间怎么办?” 会中断,除非您在每个过渡期至少调用一次millis64()。真的很挑剔,在现实世界中不太可能成为问题,但是您就可以了。

现在,如果您确实想要覆盖任何合理时间范围的时间戳(按我的估算,64位毫秒大约是十亿年),那么将现有的millis()实现扩展到64位看起来很简单。

这些对attinycore / wiring.c的更改(我正在使用ATTiny85)似乎可以正常工作(我假设其他AVR的代码非常相似)。请参阅带有// BFB注释的行以及新的millis64()函数。显然,它会变得更大(98字节代码,4字节数据),并且变得更慢,并且正如Edgar指出的那样,几乎可以肯定,只要对无符号整数数学有更好的了解就可以实现目标,但这是一个有趣的练习。

volatile unsigned long long timer0_millis = 0;      // BFB: need 64-bit resolution

#if defined(__AVR_ATtiny24__) || defined(__AVR_ATtiny44__) || defined(__AVR_ATtiny84__)
ISR(TIM0_OVF_vect)
#else
ISR(TIMER0_OVF_vect)
#endif
{
    // copy these to local variables so they can be stored in registers
    // (volatile variables must be read from memory on every access)
    unsigned long long m = timer0_millis;       // BFB: need 64-bit resolution
    unsigned char f = timer0_fract;

    m += MILLIS_INC;
    f += FRACT_INC;
    if (f >= FRACT_MAX) {
        f -= FRACT_MAX;
        m += 1;
    }

    timer0_fract = f;
    timer0_millis = m;
    timer0_overflow_count++;
}

// BFB: 64-bit version
unsigned long long millis64()
{
    unsigned long long m;
    uint8_t oldSREG = SREG;

    // disable interrupts while we read timer0_millis or we might get an
    // inconsistent value (e.g. in the middle of a write to timer0_millis)
    cli();
    m = timer0_millis;
    SREG = oldSREG;

    return m;
}

1
您是对的,millis64()只有在调用时间比过渡期更频繁的情况下,my 才有效。我编辑了答案以指出此限制。您的版本没有这个问题,但是它还有另一个缺点:它在中断上下文中执行64位算术运算,这有时会增加响应其他中断的延迟。
Edgar Bonet
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.