Como lidar com a rolagem millis ()?

73

Preciso ler um sensor a cada cinco minutos, mas como meu esboço também tem outras tarefas, não posso apenas delay()entre as leituras. Existe o tutorial Blink sem demora, sugerindo que eu codifique ao longo destas linhas:

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

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

    // Do other stuff...
}

O problema é que millis()ele voltará a zero depois de aproximadamente 49,7 dias. Como meu esboço deve ser executado por mais tempo, preciso garantir que a sobreposição não faça com que meu esboço falhe. Posso detectar facilmente a condição de sobreposição ( currentMillis < previousMillis), mas não tenho certeza do que fazer.

Assim, minha pergunta: qual seria a maneira correta / mais simples de lidar com a millis()rolagem?

Edgar Bonet
fonte
5
Nota editorial: Esta não é exatamente uma questão minha, mas um tutorial em formato de pergunta / resposta. Testemunhei muita confusão na Internet (inclusive aqui) sobre esse tópico, e este site parece ser o lugar óbvio para procurar uma resposta. É por isso que estou fornecendo este tutorial aqui.
Edgar Bonet
2
Eu faria em previousMillis += intervalvez de previousMillis = currentMillisse eu quisesse uma certa frequência de resultados.
Jasen 07/02
4
@ Jason: Isso mesmo! previousMillis += intervalse você deseja frequência constante e tem certeza de que seu processamento leva menos de interval, mas previousMillis = currentMillispara garantir um atraso mínimo de interval.
Edgar Bonet
Nós realmente precisamos de um FAQ para coisas assim.
Um dos "truques" que eu uso é aliviar a carga no arduino usando o menor int que contém o intervalo. Por exemplo, para intervalos máximo 1 minuto, eu escrevouint16_t previousMillis; const uint16_t interval = 45000; ... uint16_t currentMillis = (uint16_t) millis(); if ((currentMillis - previousMillis) >= interval) ...
frarugi87

Respostas:

95

Resposta curta: não tente “manipular” a rolagem em milissegundos, escreva um código seguro contra rolagem. Seu código de exemplo do tutorial está bom. Se você tentar detectar a sobreposição para implementar medidas corretivas, é provável que esteja fazendo algo errado. A maioria dos programas do Arduino precisa apenas gerenciar eventos que duram relativamente curtos períodos, como rebater um botão por 50 ms ou ligar um aquecedor por 12 horas ... Então, e mesmo se o programa for executado por anos, a rolagem em milissegundos não deve ser uma preocupação.

A maneira correta de gerenciar (ou melhor, evitar a necessidade de gerenciar) o problema de sobreposição é pensar no unsigned longnúmero retornado millis()em termos de aritmética modular . Para os inclinados matematicamente, alguma familiaridade com esse conceito é muito útil na programação. Você pode ver a matemática em ação no artigo millis () overflow de Nick Gammon ... uma coisa ruim? . Para aqueles que não querem passar pelos detalhes computacionais, ofereço aqui uma maneira alternativa (talvez mais simples) de pensar sobre isso. É baseado na distinção simples entre instantes e durações . Contanto que seus testes envolvam apenas comparações de durações, você estará bem.

Nota no micros () : tudo o que foi dito aqui millis()se aplica igualmente a micros(), exceto pelo fato de que micros()rola a cada 71,6 minutos e a setMillis()função fornecida abaixo não afeta micros().

Instantes, carimbos de data e hora e durações

Ao lidar com o tempo, precisamos fazer a distinção entre pelo menos dois conceitos diferentes: instantes e durações . Um instante é um ponto no eixo do tempo. Uma duração é a duração de um intervalo de tempo, ou seja, a distância no tempo entre os instantes que definem o início e o fim do intervalo. A distinção entre esses conceitos nem sempre é muito nítida na linguagem cotidiana. Por exemplo, se eu disser " voltarei em cinco minutos ", " cinco minutos " é a duração estimada da minha ausência, enquanto " em cinco minutos " é o instante do meu previsto voltar. Manter a distinção em mente é importante, porque é a maneira mais simples de evitar completamente o problema de rolagem.

O valor de retorno de millis()pode ser interpretado como uma duração: o tempo decorrido desde o início do programa até agora. Essa interpretação, no entanto, quebra assim que o milissegundo excede. Geralmente é muito mais útil pensar millis()em retornar um carimbo de data / hora , ou seja, um "rótulo" identificando um determinado instante. Pode-se argumentar que essa interpretação é ambígua, pois são reutilizados a cada 49,7 dias. No entanto, isso raramente é um problema: na maioria dos aplicativos incorporados, qualquer coisa que aconteceu 49,7 dias atrás é uma história antiga com a qual não nos importamos. Portanto, reciclar os rótulos antigos não deve ser um problema.

Não compare timestamps

Tentar descobrir qual entre dois registros de data e hora é maior que o outro não faz sentido. Exemplo:

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

Ingenuamente, seria de esperar que a condição do if ()fosse sempre verdadeira. Mas, na verdade, será falso se o millis exceder o limite durante delay(3000). Pensar em t1 e t2 como etiquetas recicláveis ​​é a maneira mais simples de evitar o erro: a etiqueta t1 foi claramente atribuída a um instante anterior a t2, mas em 49,7 dias será reatribuída para um instante futuro. Assim, t1 acontece antes e depois de t2. Isso deve deixar claro que a expressão t2 > t1não faz sentido.

Mas, se esses são meros rótulos, a pergunta óbvia é: como podemos fazer cálculos de tempo úteis com eles? A resposta é: restringindo-nos aos únicos dois cálculos que fazem sentido para os registros de data e hora:

  1. later_timestamp - earlier_timestampproduz uma duração, ou seja, a quantidade de tempo decorrido entre o instante anterior e o instante posterior. Esta é a operação aritmética mais útil que envolve registros de data e hora.
  2. timestamp ± durationgera um registro de data e hora que demora algum tempo após (se usar +) ou antes (se -) do registro de data e hora inicial. Não é tão útil quanto parece, pois o carimbo de data e hora resultante pode ser usado em apenas dois tipos de cálculos ...

Graças à aritmética modular, é garantido que ambos funcionem bem ao longo da rolagem em milissegundos, pelo menos enquanto os atrasos envolvidos forem menores que 49,7 dias.

Comparar durações é bom

Uma duração é apenas a quantidade de milissegundos decorridos durante algum intervalo de tempo. Desde que não precisemos lidar com durações superiores a 49,7 dias, qualquer operação que faça sentido fisicamente também deve fazer sentido computacionalmente. Podemos, por exemplo, multiplicar uma duração por uma frequência para obter vários períodos. Ou podemos comparar duas durações para saber qual é mais longa. Por exemplo, aqui estão duas implementações alternativas de delay(). Primeiro, o de 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;
    }
}

E aqui está o correto:

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

A maioria dos programadores C escreveria os loops acima em uma forma de terser, como

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

e

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

Embora pareçam enganosamente similares, a distinção de carimbo de data / hora deve deixar claro qual é o buggy e qual é o correto.

E se eu realmente precisar comparar registros de data e hora?

Melhor tentar evitar a situação. Se for inevitável, ainda há esperança se se souber que os respectivos instantes estão próximos o suficiente: menos de 24,85 dias. Sim, nosso atraso máximo gerenciável de 49,7 dias foi reduzido pela metade.

A solução óbvia é converter nosso problema de comparação de carimbo de data / hora em um problema de comparação de duração. Digamos que precisamos saber se t1 instantâneo é anterior ou posterior a t2. Escolhemos algum instante de referência em seu passado comum e comparamos as durações dessa referência até t1 e t2. O instante de referência é obtido subtraindo uma duração suficientemente longa de t1 ou 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

Isso pode ser simplificado como:

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

É tentador simplificar ainda mais if (t1 - t2 < 0). Obviamente, isso não funciona, porque t1 - t2, sendo calculado como um número não assinado, não pode ser negativo. Isso, no entanto, embora não seja portátil, funciona:

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

A palavra-chave signedacima é redundante (um simples longsempre é assinado), mas ajuda a tornar clara a intenção. A conversão para um longo assinado é equivalente a uma configuração LONG_ENOUGH_DURATIONigual a 24,85 dias. O truque não é portátil porque, de acordo com o padrão C, o resultado é a implementação definida . Mas como o compilador gcc promete fazer a coisa certa , ele funciona de maneira confiável no Arduino. Se desejamos evitar o comportamento definido pela implementação, a comparação assinada acima é matematicamente equivalente a isso:

#include <limits.h>

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

com o único problema que a comparação olha para trás. Também é equivalente, desde que os longos tenham 32 bits, a este teste de bit único:

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

Os últimos três testes são realmente compilados pelo gcc no mesmo código de máquina.

Como testo meu esboço em relação à sobreposição de milis

Se você seguir os preceitos acima, deve ser bom. Se você deseja testar, adicione esta função ao seu sketch:

#include <util/atomic.h>

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

e agora você pode viajar no tempo com seu programa ligando para setMillis(destination). Se você deseja que ele passe pelo excesso de milésimos repetidamente, como Phil Connors revivendo o Dia da Marmota, você pode colocar isso dentro loop():

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

O carimbo de data / hora negativo acima (-3000) é implicitamente convertido pelo compilador em um comprimento não assinado correspondente a 3000 milissegundos antes do rollover (é convertido em 4294964296).

E se eu realmente precisar rastrear durações muito longas?

Se você precisar ativar um revezamento e desativá-lo três meses depois, será necessário rastrear os estouros de milissegundos. Existem muitas maneiras de fazer isso. A solução mais direta pode ser simplesmente estender millis() para 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;
}

Isso basicamente conta os eventos de rolagem e usa essa contagem como os 32 bits mais significativos de uma contagem de milissegundos de 64 bits. Para que essa contagem funcione corretamente, a função precisa ser chamada pelo menos uma vez a cada 49,7 dias. No entanto, se for chamado apenas uma vez a cada 49,7 dias, em alguns casos, é possível que a verificação (new_low32 < low32)falhe e o código perca uma contagem high32. Usar millis () para decidir quando fazer a única chamada para esse código em um único "agrupamento" de millis (uma janela específica de 49,7 dias) pode ser muito perigoso, dependendo de como os prazos se alinham. Por segurança, se estiver usando millis () para determinar quando fazer as únicas chamadas para millis64 (), deve haver pelo menos duas chamadas em cada janela de 49,7 dias.

Lembre-se, porém, que a aritmética de 64 bits é cara no Arduino. Pode valer a pena reduzir a resolução do tempo para permanecer em 32 bits.

Edgar Bonet
fonte
2
Então, você está dizendo que o código escrito na pergunta realmente funcionará corretamente?
Jasen 07/02
3
@ Jasen: Exatamente! Eu pareço mais de uma vez pessoas tentando "consertar" o problema que não existia em primeiro lugar.
Edgar Bonet
2
Estou feliz por ter encontrado isso. Eu já tive essa pergunta antes.
Sebastian Freeman
11
Uma das melhores e mais úteis respostas no StackExchange! Muito obrigado! :)
Falko
Esta é uma resposta tão surpreendente para a pergunta. Volto a esta resposta basicamente uma vez por ano, porque sou paranóico de rolagens bagunçadas.
Jeffrey Cash
17

TL; DR Versão curta:

An unsigned longé de 0 a 4.294.967.295 (2 ^ 32 - 1).

Digamos que previousMillisseja 4.294.967.290 (5 ms antes da sobreposição) e currentMillisé 10 (10ms após a sobreposição). Em seguida, currentMillis - previousMillisé 16 real (não -4.294.967.280), pois o resultado será calculado como um longo sem sinal (que não pode ser negativo, portanto, será revertido). Você pode verificar isso simplesmente por:

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

Portanto, o código acima funcionará perfeitamente bem. O truque é sempre calcular a diferença de tempo e não comparar os dois valores de tempo.

Gerben
fonte
Que tal 15ms antes da rolagem e 10ms após a rolagem (ou seja, 49,7 dias depois ). 15> 10 , mas o carimbo de 15ms tem quase um mês e meio de idade. 15-10> 0 e 10-15> 0 unsigned lógica, de modo que não adianta aqui!
ps95 12/06
@ prakharsingh95 10ms-15ms se tornará ~ 49,7 dias - 5ms, que é a diferença correta. A matemática funciona até millis()rolar duas vezes, mas é muito improvável que isso ocorra no código em questão.
BrettAM
Deixe-me reformular. Suponha que você tenha dois registros de data e hora de 200 ms e 10 ms. Como você sabe o que é rolado?
ps95 12/06/15
@ prakharsingh95 O item armazenado previousMillisdeve ter sido medido antes currentMillis, portanto, se currentMillisfor menor do que previousMillisocorreu uma sobreposição. A matemática acontece que, a menos que duas rolagens ocorram, você nem precisa pensar nisso.
BrettAM
11
Ah ok. se o fizer t2-t1, e se puder garantir que a t1medição é feita antes t2, é equivalente a assinado (t2-t1)% 4,294,967,295 , daí a envolvente automática. Agradável!. Mas e se houver duas rolagens ou intervalhouver> 4.294.967.295?
ps95 12/06
1

Envolva o millis()em uma classe!

Lógica:

  1. Use IDs em vez de millis()diretamente.
  2. Compare reversões usando IDs. Isso é limpo e independente de sobreposição.
  3. Para aplicações específicas, para calcular a diferença exata entre dois IDs, acompanhe as reversões e os carimbos. Calcule a diferença.

Acompanhamento de reversões:

  1. Atualize um carimbo local periodicamente mais rápido que millis(). Isso ajudará você a descobrir se o millis()excesso foi excedido.
  2. O período do temporizador determina a precisão
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éditos do temporizador .

ps95
fonte
9
Editei o código para remover os muitos erros que impediam sua compilação. Este material custará cerca de 232 bytes de RAM e dois canais PWM. Ele também começará a corromper a memória depois de get_stamp()51 vezes. Comparar atrasos em vez de timestamps certamente será mais eficiente.
Edgar Bonet
1

Adorei essa pergunta e as ótimas respostas que ela gerou. Primeiro, um comentário rápido sobre uma resposta anterior (eu sei, eu sei, mas ainda não tenho o representante para comentar. :-).

A resposta de Edgar Bonet foi incrível. Eu codigo há 35 anos e aprendi algo novo hoje. Obrigado. Dito isto, acredito no código para "E se eu realmente precisar rastrear durações muito longas?" a menos que você chame millis64 () pelo menos uma vez por período de rolagem. Realmente exigente e improvável que seja um problema em uma implementação do mundo real, mas aí está.

Agora, se você realmente deseja carimbos de data / hora cobrindo qualquer intervalo de tempo são (64 bits de milissegundos é de cerca de meio bilhão de anos, pelo meu acerto de contas), parece simples estender a implementação millis () existente para 64 bits.

Essas alterações no attinycore / fiação.c (estou trabalhando com o ATTiny85) parecem funcionar (estou assumindo que o código para outros AVRs seja muito semelhante). Veja as linhas com os comentários // BFB e a nova função millis64 (). Claramente, será tanto maior (98 bytes de código, 4 bytes de dados) quanto mais lento, e, como Edgar apontou, você quase certamente pode alcançar seus objetivos com apenas uma melhor compreensão da matemática inteira não assinada, mas foi um exercício interessante .

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
fonte
11
Você está certo, o meu millis64()só funciona se for chamado com mais frequência do que o período de rolagem. Eu editei minha resposta para apontar essa limitação. Sua versão não possui esse problema, mas apresenta outra desvantagem: faz aritmética de 64 bits no contexto de interrupção , o que ocasionalmente aumenta a latência na resposta a outras interrupções.
Edgar Bonet