Jak obsłużyć najazd millis ()?

73

Muszę czytać czujnik co pięć minut, ale ponieważ mój szkic ma również inne zadania do wykonania, nie mogę po prostu delay()między odczytami. Istnieje samouczek „ Błysk” bez zwłoki, sugerujący kodowanie według następujących linii:

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

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

    // Do other stuff...
}

Problem polega na tym, że millis()po około 49,7 dniach nastąpi powrót do zera. Ponieważ mój szkic ma trwać dłużej, muszę upewnić się, że najazd nie spowoduje niepowodzenia mojego szkicu. Mogę łatwo wykryć warunek najazdu ( currentMillis < previousMillis), ale nie jestem pewien, co wtedy zrobić.

Tak więc moje pytanie: jaki byłby właściwy / najprostszy sposób radzenia sobie z millis()najazdem?

Edgar Bonet
źródło
5
Nota redakcyjna: To nie jest dokładnie moje pytanie, a raczej samouczek w formie pytania / odpowiedzi. Byłem świadkiem wielu nieporozumień w Internecie (w tym tutaj) na ten temat, a ta strona wydaje się oczywistym miejscem do szukania odpowiedzi. Właśnie dlatego udostępniam ten samouczek tutaj.
Edgar Bonet
2
Zrobiłbym previousMillis += intervalzamiast tego, previousMillis = currentMillisgdybym chciał określoną częstotliwość wyników.
Jasen
4
@Jasen: Zgadza się! previousMillis += intervaljeśli chcesz mieć stałą częstotliwość i masz pewność, że przetwarzanie zajmie mniej niż interval, ale previousMillis = currentMillisdla zagwarantowania minimalnego opóźnienia wynoszącego interval.
Edgar Bonet,
Naprawdę potrzebujemy FAQ na takie tematy.
Jednym z „trików”, których używam, jest zmniejszenie obciążenia arduino za pomocą najmniejszej liczby int zawierającej interwał. Na przykład, dla maksymalnie 1-minutowych odstępów, piszęuint16_t previousMillis; const uint16_t interval = 45000; ... uint16_t currentMillis = (uint16_t) millis(); if ((currentMillis - previousMillis) >= interval) ...
frarugi87

Odpowiedzi:

95

Krótka odpowiedź: nie próbuj „obsłużyć” najazdu Millis, zamiast tego napisz kod bezpieczny dla najazdu. Twój przykładowy kod z samouczka jest w porządku. Jeśli spróbujesz wykryć najazd w celu wdrożenia działań naprawczych, prawdopodobnie robisz coś złego. Większość programów Arduino musi zarządzać tylko zdarzeniami, które trwają stosunkowo krótko, np. Ogłaszanie przycisku na 50 ms lub włączanie grzejnika na 12 godzin ... Wtedy, nawet jeśli program ma działać przez lata, rollis Millis nie powinien stanowić problemu.

Prawidłowym sposobem zarządzania (a raczej unikania zarządzania) problemem najazdu jest przemyślenie unsigned longliczby zwróconej millis()w postaci arytmetyki modułowej . Dla matematyków pewna znajomość tej koncepcji jest bardzo przydatna podczas programowania. Możesz zobaczyć matematykę w akcji w artykule Millis () przepełnienie artykułu Nicka Gammona ... zła rzecz? . Dla tych, którzy nie chcą przechodzić przez szczegóły obliczeniowe, oferuję tutaj alternatywny (miejmy nadzieję prostszy) sposób myślenia o tym. Opiera się na prostym rozróżnieniu między chwilami i czasem trwania . Tak długo, jak twoje testy polegają jedynie na porównywaniu czasów trwania, wszystko powinno być w porządku.

Uwaga na temat micros () : Wszystko, o czym tu mowa, millis()dotyczy w równym stopniu micros(), z wyjątkiem tego, że micros()przewija się co 71,6 minut, a setMillis()funkcja podana poniżej nie ma wpływu micros().

Instanty, znaczniki czasu i czasy trwania

Kiedy mamy do czynienia z czasem, musimy rozróżnić co najmniej dwa różne pojęcia: momenty i czasy trwania . Chwila to punkt na osi czasu. Czas trwania to długość przedziału czasu, tj. Odległość w czasie między instancjami, które określają początek i koniec przedziału. Rozróżnienie między tymi pojęciami nie zawsze jest bardzo wyraźne w języku potocznym. Na przykład, jeśli powiem „ wrócę za pięć minut ”, to „ pięć minut ” to szacowany czas mojej nieobecności, podczas gdy „ za pięć minut ” to chwila mojego przewidywanego powrotu. Ważne jest, aby pamiętać o tym rozróżnieniu, ponieważ jest to najprostszy sposób, aby całkowicie uniknąć problemu przewrócenia.

Zwracana wartość millis()może być interpretowana jako czas trwania: czas, który upłynął od początku programu do chwili obecnej. Jednak ta interpretacja załamuje się, gdy tylko Millis się przepełni. Na ogół o wiele bardziej przydatne jest millis()zwracanie znacznika czasu , tj. „Etykiety” identyfikującej konkretny moment. Można argumentować, że interpretacja ta ma niejednoznaczny charakter, ponieważ są one ponownie wykorzystywane co 49,7 dni. Jest to jednak rzadko problem: w większości osadzonych aplikacji wszystko, co wydarzyło się 49,7 dni temu, to historia starożytna, na której nam nie zależy. Dlatego recykling starych etykiet nie powinien stanowić problemu.

Nie porównuj znaczników czasu

Próba ustalenia, który z dwóch znaczników czasu jest większy od drugiego, nie ma sensu. Przykład:

unsigned long t1 = millis();
delay(3000);
unsigned long t2 = millis();
if (t2 > t1) { ... }

Naiwnie można oczekiwać, że warunek if ()zawsze będzie prawdziwy. Ale tak naprawdę będzie to fałsz, jeśli Millis się przepełni delay(3000). Myślenie o t1 i t2 jako etykietach nadających się do recyklingu jest najprostszym sposobem uniknięcia błędu: etykieta t1 została wyraźnie przypisana do chwili sprzed t2, ale za 49,7 dni zostanie ona ponownie przypisana do przyszłej chwili. Zatem t1 zachodzi zarówno przed jak i po t2. Powinno to wyjaśnić, że wyrażenie t2 > t1nie ma sensu.

Ale jeśli są to zwykłe etykiety, oczywiste pytanie brzmi: w jaki sposób możemy wykonać z nimi użyteczne obliczenia czasu? Odpowiedź brzmi: ograniczając się do jedynych dwóch obliczeń, które mają sens dla znaczników czasu:

  1. later_timestamp - earlier_timestampdaje czas trwania, a mianowicie czas, który upłynął między wcześniejszą chwilą a późniejszą chwilą. Jest to najbardziej przydatna operacja arytmetyczna obejmująca znaczniki czasu.
  2. timestamp ± durationzwraca znacznik czasu, który jest jakiś czas po (jeśli używasz +) lub przed (jeśli -) początkowy znacznik czasu. Nie jest to tak przydatne, jak się wydaje, ponieważ wynikowy znacznik czasu można wykorzystać tylko w dwóch rodzajach obliczeń ...

Dzięki modułowej arytmetyki gwarantuje się, że obie z nich będą działały poprawnie w przypadku najazdu millis, przynajmniej tak długo, jak długo opóźnienia będą krótsze niż 49,7 dni.

Porównywanie czasów trwania jest w porządku

Czas trwania to po prostu ilość milisekund, które upłynęły w pewnym przedziale czasu. Tak długo, jak nie musimy obsługiwać czasów trwania dłuższych niż 49,7 dni, każda operacja, która fizycznie ma sens, również powinna mieć sens obliczeniowy. Możemy na przykład pomnożyć czas trwania przez częstotliwość, aby uzyskać liczbę okresów. Lub możemy porównać dwa czasy, aby wiedzieć, który jest dłuższy. Na przykład oto dwie alternatywne implementacje delay(). Po pierwsze, błędny:

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;
    }
}

A oto poprawny:

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;
    }
}

Większość programistów C napisałaby powyższe pętle w bardziej zwięzłej formie

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

i

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

Chociaż wyglądają na zwodniczo podobne, rozróżnienie znacznika czasu / czasu trwania powinno jasno wskazywać, który jest błędny, a który poprawny.

Co jeśli naprawdę muszę porównać znaczniki czasu?

Lepiej spróbuj uniknąć sytuacji. Jeśli jest to nieuniknione, nadal istnieje nadzieja, jeśli wiadomo, że odpowiednie momenty są wystarczająco blisko: bliżej niż 24,85 dni. Tak, nasze maksymalne opóźnienie wynoszące 49,7 dni zostało właśnie zmniejszone o połowę.

Oczywistym rozwiązaniem jest konwersja naszego problemu porównywania znaczników czasu na problem porównania czasu trwania. Powiedzmy, że musimy wiedzieć, czy natychmiastowe t1 jest przed czy po t2. Wybieramy chwile odniesienia w ich wspólnej przeszłości i porównujemy czasy trwania od tego odniesienia do obu t1 i t2. Moment odniesienia uzyskuje się, odejmując odpowiednio długi czas od t1 lub 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

Można to uprościć, ponieważ:

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

Kuszące jest dalsze upraszczanie if (t1 - t2 < 0). Oczywiście to nie działa, ponieważ t1 - t2obliczone jako liczba bez znaku, nie może być ujemne. To jednak, choć nie jest przenośne, działa:

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

Powyższe słowo kluczowe signedjest zbędne (zwykły znak longjest zawsze podpisany), ale pomaga wyjaśnić zamiar. Konwersja na podpisany długi jest równoważny ustawieniu LONG_ENOUGH_DURATIONrównemu 24,85 dni. Sztuczka nie jest przenośna, ponieważ zgodnie ze standardem C wynik jest zdefiniowany jako implementacja . Ale ponieważ kompilator gcc obiecuje zrobić coś dobrego , działa niezawodnie na Arduino. Jeśli chcemy uniknąć zachowania zdefiniowanego w implementacji, powyższe porównanie jest matematycznie równoważne z tym:

#include <limits.h>

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

z jedynym problemem, że porównanie wygląda wstecz. Jest również równoważny, o ile długości są 32-bitowe, z tym testem jednobitowym:

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

Ostatnie trzy testy są w rzeczywistości kompilowane przez gcc do dokładnie tego samego kodu maszynowego.

Jak przetestować mój szkic w stosunku do najazdu Millis

Jeśli będziesz przestrzegać powyższych zasad, powinieneś być dobry. Jeśli mimo to chcesz przetestować, dodaj tę funkcję do swojego szkicu:

#include <util/atomic.h>

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

i możesz teraz podróżować w czasie po swoim programie, dzwoniąc setMillis(destination). Jeśli chcesz, aby przepełniało go millis w kółko, tak jak Phil Connors przeżywający Dzień Świstaka, możesz umieścić to w środku loop():

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

Ujemny znacznik czasu powyżej (-3000) jest domyślnie konwertowany przez kompilator na niepodpisaną długość odpowiadającą 3000 milisekund przed najazdem (jest konwertowany na 4294964296).

Co jeśli naprawdę muszę śledzić bardzo długie czasy?

Jeśli potrzebujesz włączyć przekaźnik i wyłączyć go trzy miesiące później, naprawdę musisz wyśledzić przepełnienie millis. Można to zrobić na wiele sposobów. Najprostszym rozwiązaniem może być po prostu rozszerzenie millis() do 64 bitów:

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;
}

Zasadniczo zlicza to zdarzenia najazdu i wykorzystuje tę liczbę jako 32 najbardziej znaczące bity z 64-bitowej liczby milisekund. Aby to zliczanie działało poprawnie, funkcja musi być wywoływana co najmniej raz na 49,7 dni. Jeśli jednak jest wywoływany tylko raz na 49,7 dni, w niektórych przypadkach może się zdarzyć, że sprawdzenie się (new_low32 < low32)nie powiedzie i kod nie będzie mógł zostać policzony high32. Użycie millis () do podjęcia decyzji, kiedy wykonać jedyne wywołanie tego kodu w pojedynczym „zawinięciu” millis (specyficzne okno 49,7 dni), może być bardzo niebezpieczne, w zależności od tego, jak ustawione są ramy czasowe. Dla bezpieczeństwa, jeśli używasz millis () do określenia, kiedy wykonać jedyne wywołanie do millis64 (), powinny być co najmniej dwa wywołania w każdym oknie 49,7 dnia.

Pamiętaj jednak, że 64-bitowa arytmetyka jest droga na Arduino. Warto obniżyć rozdzielczość czasową, aby pozostać na 32 bitach.

Edgar Bonet
źródło
2
Mówisz więc, że kod napisany w pytaniu faktycznie będzie działał poprawnie?
Jasen
3
@Jasen: Dokładnie! Wydaje mi się, że nieraz ludzie próbują „naprawić” problem, który w ogóle nie istniał.
Edgar Bonet,
2
Cieszę się, że to znalazłem. Miałem już to pytanie.
Sebastian Freeman,
1
Jedna z najlepszych i najbardziej przydatnych odpowiedzi na StackExchange! Wielkie dzięki! :)
Falko,
To niesamowita odpowiedź na pytanie. Wracam do tej odpowiedzi w zasadzie raz w roku, ponieważ jestem paranoikiem bałaganu.
Jeffrey Cash
17

TL; DR Wersja skrócona:

An unsigned longwynosi od 0 do 4 294 967 295 (2 ^ 32 - 1).

Powiedzmy, że previousMilliswynosi 4 294 967 290 (5 ms przed najazdem), a currentMilliswynosi 10 (10 ms po przejściu). Wtedy currentMillis - previousMillisrzeczywista liczba 16 (nie -4 294 967 280), ponieważ wynik zostanie obliczony jako długi bez znaku (który nie może być ujemny, więc sam się przewróci). Możesz to sprawdzić po prostu:

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

Tak więc powyższy kod będzie działał idealnie. Sztuką jest zawsze obliczyć różnicę czasu i nie porównywać dwóch wartości czasu.

Gerben
źródło
Co powiesz na 15 ms przed rolowaniem i 10 ms po rolowaniu (tj. 49,7 dni po ). 15> 10 , ale znaczek 15ms ma prawie półtora miesiąca. Logika 15-10> 0 i 10-15> 0 unsigned , więc tutaj nie ma sensu!
ps95,
@ prakharsingh95 10ms-15ms wyniesie ~ 49,7 dni - 5ms, co jest poprawną różnicą. Matematyka działa, dopóki nie millis()przewróci się dwukrotnie, ale jest bardzo mało prawdopodobne, że wystąpi w danym kodzie.
BrettAM,
Pozwól mi sformułować. Załóżmy, że masz dwa znaczniki czasu 200 ms i 10 ms. Jak rozpoznać, które są (są) przeniesione?
ps95,
@ prakharsingh95 Pamięć przechowywana w previousMillismusiała zostać zmierzona wcześniej currentMillis, więc jeśli currentMillisjest mniejsza niż previousMillisrolowanie, to miało miejsce. Zdarza się matematyka, że ​​jeśli nie wystąpiły dwa najazdy, nie musisz nawet o tym myśleć.
BrettAM,
1
Ach, okej jeśli to zrobisz t2-t1i jeśli możesz zagwarantować, że t1jest mierzony wcześniej, t2jest to równoważne z podpisem (t2-t1)% 4,294,967,295 , stąd automatyczne zawijanie. Miły!. Ale co jeśli są dwa najazdy, czy intervaljest to> 4 294 967 295?
ps95,
1

Zawinąć millis()w klasie!

Logika:

  1. Użyj id zamiast millis()bezpośrednio.
  2. Porównaj zmiany za pomocą identyfikatorów. Jest to czyste i niezależne od najazdu.
  3. W przypadku konkretnych aplikacji, aby dokładnie obliczyć różnicę między dwoma identyfikatorami, śledź odwrócenia i znaczki. Oblicz różnicę.

Śledzenie cofnięć:

  1. Aktualizuj pieczęć lokalną okresowo szybciej niż millis(). Pomoże to dowiedzieć się, czy millis()się przepełniło.
  2. Okres timera określa dokładność
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
}

Kredyty czasowe .

ps95
źródło
9
Zedytowałem kod, aby usunąć błędy maaaaany, które uniemożliwiły jego kompilację. Te rzeczy będą cię kosztować około 232 bajtów pamięci RAM i dwa kanały PWM. Zacznie również uszkadzać pamięć po tobie get_stamp()51 razy. Porównywanie opóźnień zamiast znaczników czasu z pewnością będzie bardziej wydajne.
Edgar Bonet
1

Podobało mi się to pytanie i wspaniałe odpowiedzi, które wygenerował. Najpierw krótki komentarz do poprzedniej odpowiedzi (wiem, wiem, ale nie mam jeszcze przedstawiciela, aby skomentować :-).

Odpowiedź Edgara Boneta była niesamowita. Koduję od 35 lat i dziś nauczyłem się czegoś nowego. Dziękuję Ci. To powiedziawszy, wierzę kodowi „Co, jeśli naprawdę muszę śledzić bardzo długie czasy trwania?” psuje się, chyba że wywołasz millis64 () przynajmniej raz na okres najazdu. Naprawdę dziwaczne i mało prawdopodobne, aby stanowiło problem we wdrażaniu w świecie rzeczywistym, ale proszę bardzo.

Teraz, jeśli naprawdę chciałeś znaczników czasu obejmujących dowolny rozsądny zakres czasu (64 bity milisekund to około pół miliarda lat, moim zdaniem), proste wydaje się rozszerzenie istniejącej implementacji millis () na 64 bity.

Te zmiany w attinycore / wiring.c (pracuję z ATTiny85) wydają się działać (zakładam, że kod dla innych AVR jest bardzo podobny). Zobacz wiersze z komentarzami // BFB i nową funkcją millis64 (). Oczywiście będzie on zarówno większy (98 bajtów kodu, 4 bajty danych), jak i wolniejszy, i jak zauważył Edgar, prawie na pewno możesz osiągnąć swoje cele, po prostu lepiej rozumiejąc matematykę całkowitą bez znaku, ale było to interesujące ćwiczenie .

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;
}
brainbarker
źródło
1
Masz rację, moje millis64()działa tylko wtedy, gdy jest wywoływane częściej niż okres odnowienia. Zredagowałem swoją odpowiedź, aby wskazać to ograniczenie. Twoja wersja nie ma tego problemu, ale ma jeszcze jedną wadę: wykonuje 64-bitową arytmetykę w kontekście przerwań , co czasami zwiększa opóźnienie w odpowiedzi na inne przerwania.
Edgar Bonet