¿Cómo puedo manejar el rollover millis ()?


73

Necesito leer un sensor cada cinco minutos, pero como mi boceto también tiene otras tareas que hacer, no puedo simplemente delay()entre las lecturas. Existe el tutorial de Blink sin demora que sugiere que codifique a lo largo de estas líneas:

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

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

    // Do other stuff...
}

El problema es que millis()volverá a cero después de aproximadamente 49.7 días. Dado que mi boceto está diseñado para ejecutarse por más tiempo, necesito asegurarme de que el rollover no haga que mi boceto falle. Puedo detectar fácilmente la condición de vuelco ( currentMillis < previousMillis), pero no estoy seguro de qué hacer entonces.

Por lo tanto, mi pregunta: ¿cuál sería la forma correcta / más simple de manejar el millis()rollover?


55
Nota editorial: Esta no es exactamente una pregunta mía, sino un tutorial en formato de pregunta / respuesta. He sido testigo de mucha confusión en Internet (incluido aquí) sobre este tema, y ​​este sitio parece ser el lugar obvio para buscar una respuesta. Es por eso que estoy proporcionando este tutorial aquí.
Edgar Bonet

2
Lo haría en previousMillis += intervallugar de previousMillis = currentMillissi quisiera una cierta frecuencia de resultados.
Jasen

44
@Jasen: ¡Eso es correcto! previousMillis += intervalsi desea una frecuencia constante y está seguro de que su procesamiento tarda menos interval, pero previousMillis = currentMillispara garantizar un retraso mínimo de interval.
Edgar Bonet

Realmente necesitamos preguntas frecuentes para cosas como esta.

Uno de los "trucos" que uso es aligerar la carga en el arduino usando el int más pequeño que contiene el intervalo. Por ejemplo, para intervalos máximos de 1 minuto, escribouint16_t previousMillis; const uint16_t interval = 45000; ... uint16_t currentMillis = (uint16_t) millis(); if ((currentMillis - previousMillis) >= interval) ...
frarugi87

Respuestas:


95

Respuesta corta: no intente "manejar" el rollover millis, en su lugar escriba un código seguro para rollover. Su código de ejemplo del tutorial está bien. Si intenta detectar el vuelco para implementar medidas correctivas, es probable que esté haciendo algo mal. La mayoría de los programas de Arduino solo tienen que gestionar eventos que abarcan duraciones relativamente cortas, como eliminar el botón de un botón durante 50 ms o encender un calentador durante 12 horas ... Entonces, e incluso si el programa está destinado a funcionar durante años, el vuelco del millis no debería ser una preocupación.

La forma correcta de gestionar (o más bien, evitar tener que gestionar) el problema de rollover es pensar en el unsigned longnúmero devuelto millis()en términos de aritmética modular . Para los matemáticamente inclinados, cierta familiaridad con este concepto es muy útil cuando se programa. Puedes ver las matemáticas en acción en el desbordamiento millis () del artículo de Nick Gammon ... ¿algo malo? . Para aquellos que no quieren pasar por los detalles computacionales, les ofrezco aquí una forma alternativa (ojalá más simple) de pensar al respecto. Se basa en la simple distinción entre instantes y duraciones . Siempre que sus pruebas solo incluyan la comparación de duraciones, debería estar bien.

Nota sobre micros () : todo lo que se menciona aquí se millis()aplica igualmente micros(), excepto por el hecho de que se micros()transfiere cada 71.6 minutos, y la setMillis()función proporcionada a continuación no afecta micros().

Instantes, marcas de tiempo y duraciones

Cuando se trata del tiempo, tenemos que hacer una distinción entre al menos dos conceptos diferentes: instantes y duraciones . Un instante es un punto en el eje del tiempo. Una duración es la duración de un intervalo de tiempo, es decir, la distancia en el tiempo entre los instantes que definen el inicio y el final del intervalo. La distinción entre estos conceptos no siempre es muy clara en el lenguaje cotidiano. Por ejemplo, si digo " Volveré en cinco minutos ", entonces " cinco minutos " es la duración estimada de mi ausencia, mientras que " en cinco minutos " es el instante de mi regreso previsto. Es importante tener en cuenta la distinción, ya que es la forma más sencilla de evitar por completo el problema del vuelco.

El valor de retorno de millis()podría interpretarse como una duración: el tiempo transcurrido desde el inicio del programa hasta ahora. Esta interpretación, sin embargo, se rompe tan pronto como se desborda Millis. En general, es mucho más útil pensar millis()que devuelve una marca de tiempo , es decir, una "etiqueta" que identifica un instante en particular. Se podría argumentar que esta interpretación adolece de que estas etiquetas sean ambiguas, ya que se reutilizan cada 49,7 días. Sin embargo, esto rara vez es un problema: en la mayoría de las aplicaciones integradas, cualquier cosa que sucedió hace 49.7 días es una historia antigua que no nos importa. Por lo tanto, reciclar las etiquetas antiguas no debería ser un problema.

No compare las marcas de tiempo

Intentar averiguar cuál de las dos marcas de tiempo es mayor que la otra no tiene sentido. Ejemplo:

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

Ingenuamente, uno esperaría que la condición de la if ()sea ​​siempre cierta. Pero en realidad será falso si Millis se desborda durante delay(3000). Pensar en t1 y t2 como etiquetas reciclables es la forma más sencilla de evitar el error: la etiqueta t1 se ha asignado claramente a un instante anterior a t2, pero en 49.7 días se reasignará a un instante futuro. Por lo tanto, t1 ocurre tanto antes como después de t2. Esto debería dejar en claro que la expresión t2 > t1no tiene sentido.

Pero, si se trata de simples etiquetas, la pregunta obvia es: ¿cómo podemos hacer cálculos de tiempo útiles con ellas? La respuesta es: restringiéndonos a los únicos dos cálculos que tienen sentido para las marcas de tiempo:

  1. later_timestamp - earlier_timestampproduce una duración, es decir, la cantidad de tiempo transcurrido entre el instante anterior y el instante posterior. Esta es la operación aritmética más útil que implica marcas de tiempo.
  2. timestamp ± durationproduce una marca de tiempo que es un tiempo después (si usa +) o antes (si -) de la marca de tiempo inicial. No es tan útil como parece, ya que la marca de tiempo resultante se puede usar en solo dos tipos de cálculos ...

Gracias a la aritmética modular, se garantiza que ambos funcionarán bien en el rollo de Millis, al menos siempre que los retrasos involucrados sean más cortos que 49.7 días.

Comparar duraciones está bien

Una duración es solo la cantidad de milisegundos transcurridos durante un intervalo de tiempo. Mientras no necesitemos manejar duraciones superiores a 49.7 días, cualquier operación que tenga sentido físicamente también debería tener sentido computacionalmente. Podemos, por ejemplo, multiplicar una duración por una frecuencia para obtener varios períodos. O podemos comparar dos duraciones para saber cuál es más largo. Por ejemplo, aquí hay dos implementaciones alternativas de delay(). Primero, el 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;
    }
}

Y aquí está el correcto:

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 mayoría de los programadores de C escribirían los bucles anteriores en forma terser, como

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

y

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

Aunque se ven engañosamente similares, la distinción de marca de tiempo / duración debe dejar en claro cuál tiene errores y cuál es la correcta.

¿Qué sucede si realmente necesito comparar marcas de tiempo?

Mejor trata de evitar la situación. Si es inevitable, todavía hay esperanza si se sabe que los instantes respectivos están lo suficientemente cerca: menos de 24.85 días. Sí, nuestro retraso manejable máximo de 49.7 días se redujo a la mitad.

La solución obvia es convertir nuestro problema de comparación de marca de tiempo en un problema de comparación de duración. Digamos que necesitamos saber si el instante t1 es antes o después de t2. Elegimos algún instante de referencia en su pasado común, y comparamos las duraciones de esta referencia hasta t1 y t2. El instante de referencia se obtiene restando una duración suficientemente larga de t1 o 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

Esto se puede simplificar como:

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

Es tentador simplificar aún más if (t1 - t2 < 0). Obviamente, esto no funciona porque t1 - t2, al ser calculado como un número sin signo, no puede ser negativo. Esto, sin embargo, aunque no es portátil, funciona:

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

La palabra clave signedanterior es redundante (una llanura longsiempre está firmada), pero ayuda a aclarar la intención. La conversión a un largo firmado es equivalente a una configuración LONG_ENOUGH_DURATIONigual a 24.85 días. El truco no es portátil porque, de acuerdo con el estándar C, el resultado es la implementación definida . Pero dado que el compilador gcc promete hacer lo correcto , funciona de manera confiable en Arduino. Si deseamos evitar el comportamiento definido de implementación, la comparación firmada anterior es matemáticamente equivalente a esto:

#include <limits.h>

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

con el único problema de que la comparación se ve al revés. También es equivalente, siempre que los largos sean de 32 bits, a esta prueba de un solo bit:

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

Las tres últimas pruebas son compiladas por gcc en el mismo código de máquina.

¿Cómo pruebo mi boceto contra el rollo de Millis?

Si sigues los preceptos anteriores, deberías estar bien. Sin embargo, si desea probar, agregue esta función a su boceto:

#include <util/atomic.h>

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

y ahora puede viajar en el tiempo su programa llamando setMillis(destination). Si quieres que pase por el desbordamiento de los milis una y otra vez, como Phil Connors reviviendo el Día de la Marmota, puedes poner esto dentro loop():

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

La marca de tiempo negativa anterior (-3000) es implícitamente convertida por el compilador a un largo sin signo correspondiente a 3000 milisegundos antes del rollover (se convierte a 4294964296).

¿Qué sucede si realmente necesito rastrear duraciones muy largas?

Si necesita encender un relé y apagarlo tres meses después, entonces realmente necesita rastrear los desbordamientos del millis. Hay muchas maneras de hacerlo. La solución más sencilla puede ser simplemente extender millis() a 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;
}

Esto es esencialmente contar los eventos de reinversión y usar este recuento como los 32 bits más significativos de un recuento de milisegundos de 64 bits. Para que este conteo funcione correctamente, la función debe llamarse al menos una vez cada 49.7 días. Sin embargo, si solo se llama una vez cada 49.7 días, en algunos casos es posible que la verificación (new_low32 < low32)falle y el código no cuente high32. Usar millis () para decidir cuándo hacer la única llamada a este código en una sola "envoltura" de millis (una ventana específica de 49.7 días) podría ser muy peligroso, dependiendo de cómo se alineen los plazos. Por seguridad, si usa millis () para determinar cuándo hacer las únicas llamadas a millis64 (), debe haber al menos dos llamadas en cada ventana de 49.7 días.

Sin embargo, tenga en cuenta que la aritmética de 64 bits es costosa en el Arduino. Puede valer la pena reducir la resolución de tiempo para permanecer en 32 bits.


2
Entonces, ¿estás diciendo que el código escrito en la pregunta realmente funcionará correctamente?
Jasen

3
@Jasen: ¡Exactamente! Parece que más de una vez las personas intentaron "arreglar" el problema que no existía en primer lugar.
Edgar Bonet

2
Me alegro de haber encontrado esto. He tenido esta pregunta antes.
Sebastian Freeman

1
¡Una de las mejores y más útiles respuestas en StackExchange! ¡Muchas gracias! :)
Falko

Esta es una respuesta increíble a la pregunta. Vuelvo a esta respuesta básicamente una vez al año porque soy paranoico de los traspasos de desorden.
Jeffrey Cash

17

TL; DR Versión corta:

An unsigned longes de 0 a 4,294,967,295 (2 ^ 32 - 1).

Entonces, digamos que previousMillises 4,294,967,290 (5 ms antes del rollover), y currentMillises 10 (10ms después del rollover). Entonces currentMillis - previousMillises real 16 (no -4,294,967,280) ya que el resultado se calculará como un largo sin signo (que no puede ser negativo, por lo que se rodará). Puede verificar esto simplemente:

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

Entonces el código anterior funcionará perfectamente bien. El truco consiste en calcular siempre la diferencia de tiempo y no comparar los dos valores de tiempo.


¿Qué tal unos 15 ms antes de la reinversión y 10 ms después de la reinversión (es decir, 49.7 días después )? 15> 10 , pero el sello de 15 ms tiene casi un mes y medio de antigüedad. 15-10> 0 y 10-15> 0 unsigned lógica, por lo que no sirve de nada aquí!
ps95

@ prakharsingh95 10ms-15ms se convertirán en ~ 49.7 días - 5ms, que es la diferencia correcta. Las matemáticas funcionan hasta que se millis()vuelcan dos veces, pero es muy poco probable que ocurra con el código en cuestión.
BrettAM

Déjame reformular. Supongamos que tiene dos marcas de tiempo de 200 ms y 10 ms. ¿Cómo se sabe cuál es (se) volcó?
ps95

@ prakharsingh95 El que está almacenado previousMillistiene que haberse medido antes currentMillis, por lo que si currentMillises más pequeño que se previousMillisprodujo un vuelco. Lo matemático resulta que, a menos que se hayan producido dos vuelcos, ni siquiera necesita pensar en ello.
BrettAM

1
Ah ok si lo hace t2-t1, y si puede garantizar que t1se mide antes, t2entonces es equivalente a firmado (t2-t1)% 4,294,967,295 , de ahí el ajuste automático. ¡Agradable!. Pero, ¿qué intervalpasa si hay dos vuelcos, o es> 4,294,967,295?
ps95

1

Envuelva el millis()en una clase!

Lógica:

  1. Use id's en lugar de millis()directamente.
  2. Compare las reversiones usando id. Esto es limpio e independiente de vuelco.
  3. Para aplicaciones específicas, para calcular la diferencia exacta entre dos ID, realice un seguimiento de las reversiones y los sellos. Calcula la diferencia.

Seguimiento de las reversiones:

  1. Actualice un sello local periódicamente más rápido que millis(). Esto lo ayudará a descubrir si se millis()ha desbordado.
  2. El período del temporizador determina la precisión.
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
}

Temporizador de créditos .


9
Edité el código para eliminar los errores maaaaany que impidieron su compilación. Esto le costará unos 232 bytes de RAM y dos canales PWM. También comenzará a corromper la memoria después de get_stamp()51 veces. Comparar demoras en lugar de marcas de tiempo ciertamente será más eficiente.
Edgar Bonet

1

Me encantó esta pregunta y las excelentes respuestas que generó. Primero, un comentario rápido sobre una respuesta anterior (lo sé, lo sé, pero todavía no tengo el representante para comentar. :-).

La respuesta de Edgar Bonet fue asombrosa. Llevo 35 años codificando, y hoy aprendí algo nuevo. Gracias. Dicho esto, creo que el código para "¿Qué pasa si realmente necesito rastrear duraciones muy largas?" se rompe a menos que llame a millis64 () al menos una vez por período de reinversión. Realmente quisquilloso, y es poco probable que sea un problema en una implementación del mundo real, pero ahí lo tienes.

Ahora, si realmente quería marcas de tiempo que cubrieran cualquier rango de tiempo razonable (64 bits de milisegundos es aproximadamente medio billón de años según mi cálculo), parece simple extender la implementación de millis () existente a 64 bits.

Parece que estos cambios en attinycore / cableado.c (estoy trabajando con el ATTiny85) funcionan (supongo que el código para otros AVR es muy similar). Vea las líneas con los comentarios // BFB y la nueva función millis64 (). Claramente será más grande (98 bytes de código, 4 bytes de datos) y más lento, y como señaló Edgar, es casi seguro que puede lograr sus objetivos con una mejor comprensión de las matemáticas de enteros sin signo, pero fue un ejercicio interesante .

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
Tiene razón, mi millis64()solo funciona si se llama con más frecuencia que el período de reinversión. Edité mi respuesta para señalar esta limitación. Su versión no tiene este problema, pero tiene otro inconveniente: hace aritmética de 64 bits en el contexto de interrupción , lo que ocasionalmente aumenta la latencia para responder a otras interrupciones.
Edgar Bonet