Comment puis-je gérer le basculement millis ()?

73

Je dois lire un capteur toutes les cinq minutes, mais mon dessin ayant également d'autres tâches à accomplir, je ne peux pas me contenter delay()des lectures. Il y a le tutoriel Blink sans délai suggérant que je code dans ce sens:

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

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

    // Do other stuff...
}

Le problème, c’est qu’il millis()va revenir à zéro après environ 49,7 jours. Étant donné que mon dessin doit durer plus longtemps, je dois m'assurer que le basculement ne provoque pas l'échec de mon dessin. Je peux facilement détecter la condition de basculement ( currentMillis < previousMillis), mais je ne sais pas quoi faire alors.

C’est pourquoi ma question est la suivante: quel serait le moyen le plus simple / approprié de gérer le millis()roulement?

Edgar Bonet
la source
5
Note de la rédaction: Ce n’est pas vraiment une question qui m’appartient, c’est plutôt un tutoriel au format question / réponse. J'ai été témoin de beaucoup de confusion sur Internet (y compris ici) sur ce sujet, et ce site semble être l'endroit idéal pour chercher une réponse. C'est pourquoi je fournis ce tutoriel ici.
Edgar Bonet
2
Je le ferais previousMillis += intervalau lieu de previousMillis = currentMillissi je voulais une certaine fréquence de résultats.
Jasen
4
@Jasen: C'est vrai! previousMillis += intervalsi vous voulez une fréquence constante et que vous êtes sûr que votre traitement prend moins de interval, mais previousMillis = currentMillispour vous garantir un délai minimum de interval.
Edgar Bonet
Nous avons vraiment besoin d'une FAQ pour des choses comme ça.
L’une des «astuces» que j’utilise consiste à alléger la charge de l’arduino en utilisant le plus petit int contenant l’intervalle. Par exemple, pendant une minute au maximum, j'écrisuint16_t previousMillis; const uint16_t interval = 45000; ... uint16_t currentMillis = (uint16_t) millis(); if ((currentMillis - previousMillis) >= interval) ...
frarugi87

Réponses:

95

Réponse courte: n'essayez pas de "gérer" le basculement en millis, écrivez plutôt du code protégé contre le basculement. Votre exemple de code du didacticiel est correct. Si vous essayez de détecter le basculement afin de mettre en œuvre des mesures correctives, il y a des chances que vous fassiez quelque chose de mal. La plupart des programmes Arduino ne doivent gérer que des événements s'étendant sur des durées relativement courtes, telles que le rebond d'un bouton pendant 50 ms ou l'allumage d'un chauffage pendant 12 heures ... Ensuite, et même si le programme est conçu pour durer des années, le roulement en millis ne devrait pas être une préoccupation.

La bonne façon de gérer (ou plutôt d'éviter de devoir gérer) le problème du roulement est de penser au unsigned longnombre renvoyé par millis()en termes d' arithmétique modulaire . Pour les mathématiciens, une certaine familiarité avec ce concept est très utile lors de la programmation. Vous pouvez voir le calcul en action dans l'article de Nick Gammon, millis (), débordement ... une mauvaise chose? . Pour ceux qui ne veulent pas passer à travers les détails de calcul, je propose ici une autre façon (espérons-le plus simple) d'y penser. Elle repose sur la simple distinction entre instants et durées . Tant que vos tests ne concernent que la comparaison des durées, ça devrait aller.

Note sur micros () : Tout ce que nous avons dit à propos millis()s'applique également micros(), à l'exception du fait qu'il se micros()déroule toutes les 71,6 minutes et que la setMillis()fonction fournie ci-dessous n'affecte pas micros().

Instants, horodatages et durées

Dans le temps, il faut distinguer au moins deux concepts différents: les instants et les durées . Un instant est un point sur l'axe du temps. Une durée est la durée d'un intervalle de temps, c'est-à-dire la distance dans le temps entre les instants qui définissent le début et la fin de l'intervalle. La distinction entre ces concepts n’est pas toujours très nette dans le langage courant. Par exemple, si je dis « Je serai de retour dans cinq minutes », alors « cinq minutes » est la durée estimée de mon absence, alors que « dans cinq minutes » est l' instant de mon prédit revenir. Il est important de garder la distinction à l’esprit, car c’est le moyen le plus simple d’éviter entièrement le problème du roulement.

La valeur de retour de millis()pourrait être interprétée comme une durée: le temps écoulé depuis le début du programme jusqu'à maintenant. Cette interprétation, cependant, se décompose dès que des millis débordent. Il est généralement beaucoup plus utile de penser millis()à renvoyer un horodatage , c'est-à-dire une "étiquette" identifiant un instant particulier. On pourrait soutenir que cette interprétation est ambiguë car ces étiquettes sont réutilisées tous les 49,7 jours. Cependant, cela pose rarement un problème: dans la plupart des applications intégrées, tout ce qui s’est passé il ya 49,7 jours est une histoire ancienne qui ne nous intéresse pas. Ainsi, le recyclage des anciennes étiquettes ne devrait pas être un problème.

Ne pas comparer les horodatages

Essayer de savoir lequel des deux horodatages est supérieur à l'autre n'a pas de sens. Exemple:

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

Naïvement, on pourrait s’attendre à ce que la condition du if ()soit toujours vraie. Mais ce sera réellement faux si des millis dépassent pendant delay(3000). Considérer t1 et t2 comme des étiquettes recyclables est le moyen le plus simple d'éviter l'erreur: l'étiquette t1 a clairement été affectée à un instant antérieur à t2, mais elle sera réaffectée à un instant ultérieur dans 49,7 jours. Ainsi, t1 se produit à la fois avant et après t2. Cela devrait indiquer clairement que l'expression t2 > t1n'a aucun sens.

Mais si ce ne sont que des étiquettes, la question évidente est: comment pouvons-nous faire des calculs de temps utiles avec eux? La réponse est: en nous limitant aux deux seuls calculs pertinents pour les horodatages:

  1. later_timestamp - earlier_timestampdonne une durée, à savoir la durée écoulée entre l’instant précédent et l’instant précédent. Il s'agit de l'opération arithmétique la plus utile impliquant des horodatages.
  2. timestamp ± durationdonne un horodatage qui est quelque temps après (si vous utilisez +) ou avant (si -) l'horodatage initial. Pas aussi utile que ça en a l'air, puisque l'horodatage résultant ne peut être utilisé que dans deux types de calculs ...

Grâce à l'arithmétique modulaire, il est garanti que ces deux solutions fonctionneront parfaitement tout au long du roulement en millis, du moins tant que les retards impliqués sont inférieurs à 49,7 jours.

Comparer les durées c'est bien

Une durée est simplement la quantité de millisecondes écoulée au cours d'un intervalle de temps. Tant que nous n'avons pas besoin de gérer des durées de plus de 49,7 jours, toute opération ayant un sens physique doit également avoir un sens sur le plan informatique. On peut, par exemple, multiplier une durée par une fréquence pour obtenir un nombre de périodes. Ou nous pouvons comparer deux durées pour savoir laquelle est la plus longue. Par exemple, voici deux implémentations alternatives de delay(). Tout d'abord, le buggy:

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

Et voici la bonne:

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

La plupart des programmeurs en C écrivent les boucles ci-dessus sous forme de test, comme

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

et

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

Bien qu’ils se ressemblent de manière trompeuse, la distinction timestamp / duration devrait indiquer clairement lequel est correct et lequel est correct.

Et si j'ai vraiment besoin de comparer les horodatages?

Mieux vaut essayer d'éviter la situation. Si cela est inévitable, il reste encore de l’espoir si on sait que les instants respectifs sont suffisamment proches: moins de 24,85 jours. Oui, notre délai maximum gérable de 49,7 jours vient d'être réduit de moitié.

La solution évidente consiste à convertir notre problème de comparaison d’horodatage en un problème de comparaison de durée. Supposons que nous ayons besoin de savoir si l'instant t1 est avant ou après t2. Nous choisissons un instant de référence dans leur passé commun et comparons les durées de cette référence jusqu'à t1 et t2. L’instant de référence est obtenu en soustrayant une durée suffisamment longue de t1 ou de 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

Ceci peut être simplifié comme:

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

Il est tentant de simplifier davantage if (t1 - t2 < 0). Évidemment, cela ne fonctionne pas car t1 - t2, calculé comme un nombre non signé, ne peut pas être négatif. Ceci, cependant, bien que non portable, fonctionne:

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

Le mot clé signedci-dessus est redondant (une simple longest toujours signée), mais cela permet de clarifier l'intention. La conversion en une longueur signée équivaut à un réglage LONG_ENOUGH_DURATIONégal à 24,85 jours. L'astuce n'est pas portable car, selon la norme C, le résultat est défini par la mise en œuvre . Mais comme le compilateur gcc promet d'agir correctement , il fonctionne de manière fiable sur Arduino. Si nous souhaitons éviter le comportement défini par l'implémentation, la comparaison signée ci-dessus est mathématiquement équivalente à ceci:

#include <limits.h>

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

avec le seul problème que la comparaison regarde en arrière. Il est également équivalent, dans la mesure où les longueurs sont en 32 bits, à ce test à un seul bit:

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

Les trois derniers tests sont en fait compilés par gcc dans exactement le même code machine.

Comment puis-je tester mon croquis contre le roulement de millis

Si vous suivez les préceptes ci-dessus, vous devriez être tout bon. Si vous souhaitez néanmoins tester, ajoutez cette fonction à votre croquis:

#include <util/atomic.h>

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

et vous pouvez maintenant voyager dans le temps dans votre programme en appelant setMillis(destination). Si vous voulez que les débordements de millis se répètent encore et encore, comme Phil Connors revivant le jour de la marmotte, vous pouvez insérer ceci à l'intérieur loop():

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

L'horodatage négatif ci-dessus (-3000) est converti implicitement par le compilateur en un signe long non signé correspondant à 3 000 millisecondes avant le basculement (il est converti en 4294964296).

Et si j'ai vraiment besoin de suivre de très longues durées?

Si vous devez activer et désactiver un relais trois mois plus tard, vous devez absolument suivre les débordements de millis. Il y a plusieurs façons de le faire. La solution la plus simple peut être simplement d’étendre millis() à 64 bits:

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

Il s’agit essentiellement de compter les événements de substitution et d’utiliser ce compte comme les 32 bits les plus significatifs d’un décompte en millisecondes de 64 bits. Pour que ce comptage fonctionne correctement, la fonction doit être appelée au moins une fois tous les 49,7 jours. Toutefois, s'il n'est appelé qu'une fois tous les 49,7 jours, il est possible que le contrôle (new_low32 < low32)échoue et que le code ne contienne pas le nombre de high32. Utiliser millis () pour décider quand faire le seul appel à ce code en un seul "wrap" de millis (une fenêtre spécifique de 49,7 jours) peut être très dangereux, en fonction de l'alignement des trames horaires. Pour des raisons de sécurité, si vous utilisez millis () pour déterminer quand faire les seuls appels à millis64 (), vous devez avoir au moins deux appels dans une fenêtre de 49,7 jours.

Gardez toutefois à l'esprit que l'arithmétique 64 bits coûte cher sur l'Arduino. Il peut être intéressant de réduire la résolution temporelle afin de rester à 32 bits.

Edgar Bonet
la source
2
Donc, vous dites que le code écrit dans la question fonctionnera correctement?
Jasen
3
@Jasen: Exactement! J'ai plus d'une fois l'impression que des gens essayaient de «résoudre» le problème qui n'existait pas au départ.
Edgar Bonet
2
Je suis content d'avoir trouvé ça. J'ai déjà eu cette question.
Sebastian Freeman
1
Une des meilleures et des plus utiles réponses sur StackExchange! Merci beaucoup! :)
Falko
C'est une réponse incroyable à la question. Je reviens à cette réponse une fois par an, essentiellement, parce que je suis paranoïaque des rouleaux chaotiques.
Jeffrey Cash le
17

TL; DR version courte:

An unsigned longvaut 0 à 4 294 967 295 (2 ^ 32 - 1).

Disons previousMillisdonc 4 294 967 290 (5 ms avant le basculement) et currentMillis10 (10 ms après le basculement). Vient ensuite currentMillis - previousMillis16 (pas -4 294 967 280), puisque le résultat sera calculé comme un long non signé (ce qui ne peut pas être négatif, donc il roulera tout seul). Vous pouvez vérifier ceci simplement en:

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

Donc, le code ci-dessus fonctionnera parfaitement. L'astuce consiste à toujours calculer la différence de temps et non à comparer les deux valeurs de temps.

Gerben
la source
Que diriez-vous de 15 ms avant le renversement et de 10 ms après le renversement (soit 49,7 jours après ). 15> 10 , mais le timbre de 15ms a presque un mois et demi. 15-10> 0 et 10-15> 0 unsigned logique, de sorte que cela ne sert à rien ici!
ps95
@ prakharsingh95 10ms-15ms deviendra ~ 49,7 jours - 5ms, ce qui est la différence correcte. Le calcul fonctionne jusqu'à ce qu'il se millis()répète deux fois, mais il est très peu probable que cela se produise pour le code en question.
BrettAM
Permettez-moi de reformuler. Supposons que vous ayez deux horodatages 200 ms et 10 ms. Comment dites-vous qui est (est) retourné?
ps95
@ prakharsingh95 Celui qui previousMillisa été stocké doit avoir été mesuré auparavant currentMillis, donc si currentMillisest plus petit qu'un previousMillisretournement s'est produit. Il se trouve que, à moins que deux roulements ne surviennent, il n’est même pas nécessaire d’y penser.
BrettAM
1
Ah ok. Si vous le faites t2-t1, et si vous pouvez garantir que le résultat t1est mesuré avant, t2il est équivalent à signé (t2-t1)% 4,294,967,295 , d’où le bouclage automatique. Agréable!. Mais que se passe-t-il s'il y a deux roulements, ou si interval> 4 294 967 295?
ps95
1

Enveloppez le millis()dans une classe!

Logique:

  1. Utilisez les identifiants au lieu de millis()directement.
  2. Comparez les inversions en utilisant les identifiants. C'est propre et indépendant du roulement.
  3. Pour des applications spécifiques, pour calculer la différence exacte entre deux identifiants, gardez une trace des annulations et des tampons. Calculez la différence.

Garder une trace des renversements:

  1. Mettre à jour un tampon local périodiquement plus rapidement que millis(). Cela vous aidera à savoir si millis()a survolé.
  2. La période du minuteur détermine la précision
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
}

Crédits de minuterie .

ps95
la source
9
J'ai édité le code pour supprimer les erreurs maaaaany qui l'empêchaient de se compiler. Cela vous coûtera environ 232 octets de RAM et deux canaux PWM. Il va également commencer à corrompre la mémoire après get_stamp()51 fois. Comparer les retards au lieu des horodatages sera certainement plus efficace.
Edgar Bonet
1

J'ai adoré cette question et les bonnes réponses qu'elle a générées. Tout d’abord un bref commentaire sur une réponse précédente (je sais, je sais, mais je n’ai pas encore le représentant à commenter. :-).

La réponse d'Edgar Bonet était incroyable. Je code depuis 35 ans et j'ai appris quelque chose de nouveau aujourd'hui. Je vous remercie. Cela dit, je crois que le code pour "Et si je dois vraiment suivre de très longues durées?" sauf si vous appelez millis64 () au moins une fois par période de substitution. Vraiment tatillon et peu susceptible de poser problème dans une implémentation réelle, mais voilà.

Maintenant, si vous voulez vraiment que les horodatages couvrent un intervalle de temps raisonnable (64 bits de millisecondes représentent environ un demi-milliard d'années), il semble simple d'étendre l'implémentation existante de millis () à 64 bits.

Ces modifications apportées à attinycore / câblage.c (je travaille avec ATTiny85) semblent fonctionner (je suppose que le code des autres AVR est très similaire). Voir les lignes avec les commentaires // BFB et la nouvelle fonction millis64 (). Clairement, cela va être à la fois plus volumineux (98 octets de code, 4 octets de données) et plus lent, et comme Edgar l'a souligné, vous pouvez certainement atteindre vos objectifs avec une meilleure compréhension du calcul des entiers non signés, mais c'était un exercice intéressant. .

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
la source
1
Vous avez raison, mon millis64()seul travail s’il est appelé plus fréquemment que la période de roulement. J'ai édité ma réponse pour souligner cette limitation. Votre version ne présente pas ce problème, mais elle présente un autre inconvénient: elle utilise l'arithmétique 64 bits dans un contexte d'interruption , ce qui augmente parfois le temps de latence dans la réponse à d'autres interruptions.
Edgar Bonet