rollover, il nemico che trama nell’ombra

..è paziente, ha un’ottima memoria, ed è lì fuori che ci aspetta tutti al varco.

Prima di iniziare:

  • questa lettura è indirizzata a chi realizza progetti con Arduino o MCU simili e ha bisogno di stabilità di funzionamento nel tempo
  • se non ti interessa la sbrodolata, in fondo trovi il TLDR;
  • non serve attrezzatura, ma un Arduino a portata di mano potrebbe essere comodo

Quindi, ‘sto rollover, cos’è?

una variabile ha una dimensione, fisica, in memoria, che le viene assegnata al momento della dichiarazione. E non è che quando cresce, se ha bisogno di più byte, se li prenda. Tsè, C++ è un linguaggio da veri duri, non è mica PHP. E fin qui, siamo tutti d’accordo.

Ora, l’ATMega ha un bel meccanismo, che si chiama rollover: quando aggiungo +1 ad una variabile che è piena fino all’orlo (ovvero contiene il massimo valore rappresentabile per i byte che le sono stati allocati), invece di sparare un fatal: overflow, considera come successivo il valore al lato opposto del range che può rappresentare, senza fare danni. Esempio: una variabile dichiarata byte che può rappresentare 256 valori da 0 a 255, se si trova a 255 e faccio +1, va a zero. Stessa cosa nella direzione opposta: se sottraggo 1 a una variabile a zero, succede che 0 – 1 = 255.

Questo è il rollover.

Se non mi credi, prendi un Arduino e prova questo:

void setup()
{

  Serial.begin(115200);
  Serial.println("rollover test 01 coming up");

  // start at 253
  //
  byte vartest = 253;

  // increase by 1, rinse and repeat 6 times
  //
  Serial.println("start forward test");

  for(byte ind = 0; ind < 6; ind++)
  {
    vartest++;
    Serial.println(vartest);
  }

  Serial.println("end forward test");

  // then go backwards, again 6 times
  //
  Serial.println("start backing test");

  for(byte ind = 0; ind < 6; ind++)
  {
    vartest--;
    Serial.println(vartest);
  }

  Serial.println("end backing test");

}

void loop()
{
  // just hold
  //
  delay(1000);
}

risultato:

rollover test 01 coming up
start forward test
254
255
0
1
2
3
end forward test
start backing test
2
1
0
255
254
253
end backing test

questo succede con tutte le variabili; un unsigned int (2 byte) farà il rollover dopo 65535:

void setup()
{

  Serial.begin(115200);
  Serial.println("rollover test 02 coming up");

  // start at 65533
  //
  unsigned int vartest = 65533;

  // increase by 1, rinse and repeat 6 times
  //
  Serial.println("start forward test");

  for(byte ind = 0; ind < 6; ind++)
  {
    vartest++;
    Serial.println(vartest);
  }

  Serial.println("end forward test");

  // then go backwards, rinse and repeat 6 times
  //
  Serial.println("start backing test");

  for(byte ind = 0; ind < 6; ind++)
  {
    vartest--;
    Serial.println(vartest);
  }

  Serial.println("end backing test");

}

void loop()
{
  // just hold
  //
  delay(1000);
}

questo già spiega alcuni grattacapi che abbiamo avuto in passato quando non abbiamo dimensionato le variabili in modo adeguato.

Infatti a volte in debug ci troviamo con valori che non tornano, ma non ci tirano neanche vicini, e spesso quella è la causa: se aggiungo (o tolgo), a una variabile che ha una certa capacità, un valore che non riesce a “tenere”, quella farà rollover finché non ha esaurito l’addendo. Come si vede qui:

void setup()
{

  Serial.begin(115200);
  Serial.println("rollover test 03 coming up");

  // start at 65533
  //
  byte vartest;

  // add a value within range
  //
  vartest = 0;
  vartest += 37;
  Serial.println("add 37: within capacity");
  Serial.println(vartest);

  // add values exceeding max capacity
  //
  vartest = 0;
  vartest += 256;
  Serial.println("add 256: make it roll over once");
  Serial.println(vartest);

  vartest = 0;
  vartest += 1024;
  Serial.println("add 1024: make it roll over 4 times");
  Serial.println(vartest);

  vartest = 0;
  vartest += 525;
  Serial.println("add 525: make it roll over twice plus 13 (512 + 13 = 525)");
  Serial.println(vartest);

}

void loop()
{
  // just hold
  //
  delay(1000);
}

risultato:

rollover test 03 coming up
add 37: within capacity
37
add 256: make it roll over once
0
add 1024: make it roll over 4 times
0
add 525: make it roll over twice plus 13 (512 + 13 = 525)
13

Sì, OK, ma a cosa ci serve saperlo?

Ci sono due variabili in particolare, che riceviamo dal sistema e che sono estremamente utili, che non possiamo dimensionare come vogliamo e che dobbiamo trattare come vengono. Si tratta del risultato di millis() e di micros(), rispettivamente il numero di millisecondi e microsecondi trascorsi dall’ultima accensione o reset dell’arduino. E’ l’unico legame affidabile e real-time che abbiamo con il tempo che passa.

Sono entrambe variabili unsigned long, ovvero occupano 4 byte.
256^4 = 4294967296 (-1, visto che lo zero conta)
In millisecondi, quel numero risulta essere uguale a 49.7 giorni circa
In microsecondi, equivale a poco meno di di 1h 11m 35s

Quindi, 49.7 giorni dopo l’accensione o l’ultimo reset, millis() raggiungerà la sua massima capacità e farà un rollover, mentre micros() lo farà ogni ora, 11 minuti e spicci.

Proviamo ora a pensare cosa succede quando abbiamo delle attività periodiche per l’ATMega.
Facciamo finta che, ad esempio, ogni 10 minuti debba scrivere CIAO sulla seriale.
Un modo per farlo:
dichiaro una variabile che contiene il millisecondo di quando dovrò scrivere il prossimo CIAO; quando la condizione sarà vera, scriverò, e sposterò il paletto in avanti. Sembra un buon concetto, giusto?
Vediamolo.

unsigned long next_ciao = 0;

void setup()
{
  Serial.begin(115200);
  Serial.println("rollover test 04 coming up");
}

void loop()
{

  // if the condition is met, write CIAO
  // then move the goalpost by 600 seconds
  //
  if(millis() >= next_ciao)
  {
    Serial.println("CIAO!");
    next_ciao = millis() + 600000;
  }

  // just hold
  //
  delay(1);

}

Carino, eh? Non chiederò di lasciar andare un arduino per 50 giorni per scoprire l’assassino; faccio uno spoiler e lo racconto adesso:
quando siamo vicini al rollover abbiamo scritto CIAO 7198 volte e siamo al millisecondo 4294800000; al rollover di millis() mancano 167295ms ma quando aggiungiamo 600000 a next_ciao, è lui ad andare in rollover, e da quel momento conterrà 432705, mentre millis() sta arrancando sulla strada per arrivare al suo massimo, ovvero 4294967295.
Cosa succede nei prossimi 167295ms? Che la condizione IF per stampare CIAO sarà sempre vera, e siccome il delay è breve, significa che stamperemo CIAO (quasi) una volta ogni ms. A quel punto, anche millis() farà il rollover, tornerà tutto a posto ed il problema si ripresenterà solo tra 49.7 giorni.

Va peggio se invece di:

    next_ciao = millis() + 600000;

facciamo:

    next_ciao += 600000;

il che tra parentesi non è propriamente “good practice”; comunque apparentemente sembra meglio poiché circa a metà dei 167295 cicli, next_ciao supera il valore di millis(), quindi smette di stampare CIAO; tuttavia, a seconda della situazione e dei parametri, potrebbe oscillare, nel senso di ricominciare a stampare CIAO pazzamente, fermarsi di nuovo, ecc. Infatti non ho neanche parlato dei ritardi possibili dovuti al tempo di elaborazione, ed al relativo slittamento in avanti. Insomma, un comportamento impossibile da prevedere, e una situazione fuori controllo che riempie di orrore i sonni di qualunque programmatore.

Ovviamente il problema è accentuato perché se sto usando un arduino è molto probabile che io stia interagendo con il mondo circostante.
Ad esempio: ipotizzando un intervallo da 2 ore anziché i 10 minuti del nostro esempio:

  • accendo una pompa per l’irrigazione che deve rimanere attiva 5 minuti
  • do l’assenso alla caldaia per una durata di 15 minuti ogni volta, per mantenere la casa calda

quando arrivo a casa il 50o giorno trovo la palude in giardino, il Carnevale di Rio in casa e la bolletta del gas in fiamme.
No, decisamente non è il metodo giusto. Infatti le cose andrebbero anche peggio se usassimo micros(), visto che il rollover lo fa molto più spesso.

ANT, visto che fai tanto il figo, dì allora qual è il metodo giusto secondo te?

Prima dimostriamo un esperimento su scala più piccola:

void setup()
{

  Serial.begin(115200);
  Serial.println("rollover test 05 coming up");

  // vars
  //
  byte vartest = 20;
  byte varsub = 253;

  // subtract varsub from vartest
  //
  vartest -= varsub;
  Serial.println(vartest);

}

void loop()
{
  // just hold
  //
  delay(1000);
}

risultato:

rollover test 05 coming up
23

OK da 20 abbiamo sottratto 253 in una botta sola. Come fa a fare 23?
Proviamo a toglierli uno per uno:

void setup()
{

  Serial.begin(115200);
  Serial.println("rollover test 06 coming up");

  // vars
  //
  byte vartest = 20;

  // decrease by 1; rinse and repeat 253 times
  //
  for(byte ind = 0; ind < 253; ind++)
  {
    vartest--;
    Serial.println(vartest);
  }

}

void loop()
{
  // just hold
  //
  delay(1000);
}

risultato:

rollover test 06 coming up
19
18
17
16
15
14
13
12
11
10
9
8
7
6
5
4
3
2
1
0
255 <-- rollover
254
253

[..non c'è bisogno di vedere tutta la storia]

27
26
25
24
23

Oh, adesso è più chiaro: gli ha fatto fare tutto il giro, diminuendo di uno ogni volta, per 253 volte, eccallà!

Adesso possiamo applicare il concetto al nostro rollover.
anziché memorizzare next_ciao, che come abbiamo visto è un sistema fallace, ecco l’idea: ricordiamoci invece l’ultima volta che abbiamo stampato CIAO, che chiameremo last_ciao. Ma ecco il possibile passo falso:

if(millis() >= (last_ciao + 600000))

bravo ANT, hai usato last_ciao, che va bene, ma non ci sei ancora.

Come regola spannometrica, diciamo che quando millis() o micros() si trovano da soli su un lato di una comparazione in una IF, certamente NON è rollover-safe (*). Intendiamoci, può andare bene per eventi che devono verificarsi ENTRO il primo rollover e poi mai più, ma non vanno bene per eventi ripetitivi.

(*) Nota: dimenticavo che nel tempo ho trovato qualcuno che voleva, per una mera questione di principio, dimostrarmi che come indizio non è valido. Per farlo hanno dovuto fare carpiature di codice prima della IF, brutte, ineleganti, e ruba-risorse. Quindi, per evitare che succeda, la forma intera della mia affermazione è:
Come regola spannometrica, diciamo che quando millis() o micros() si trovano da soli su un lato di una comparazione in una IF, e non ci sono lì intorno complicati calcoli per preparare il valore da sottoporre sull’altro lato (che sono lì solo perché qualcuno ha voluto fare il fenomeno oppure non sa scrivere codice), certamente NON è rollover-safe.

E dopo tutta questa suspense, vediamolo ‘sto rollover-safe.

è necessario sottrarre da millis() (o micros()) il tempo in cui è successo l’evento l’ultima volta; la differenza è (e sarà sempre) un numero positivo, che corrisponde esattamente al tempo trascorso dall’ultimo last_ciao, ANCHE a cavallo di un rollover.

Tutto qui.

Prima un esempio su scala piccola, che con meno cifre ci incasina di meno gli occhi; è al rallentatore (la scala è secondi) e serve per seguire passo per passo e vedere cosa succede al momento del rollover.

Questa simulazione usa la variabile “fake_millis” che sostanzialmente è un contasecondi su una variabile di tipo byte che quindi rollerà a 255 + 1; già dall’inizio simuliamo che l’ultimo evento sia occorso al secondo 253: quando partiamo, mentre i secondi scorrono, la differenza tra il fake_millis e il last_ciao sarà, progressivamente:
1 – 253 = 4
2 – 253 = 5
3 – 253 = 6
ecc
Confrontando la differenza con l’intervallo desiderato, avremo il polso preciso del tempo trascorso.
Consiglio di guardare il monitor seriale fino al primo rollover di fake_millis ed osservare le altre variabili.

In questo esempio CIAO viene stampato ogni 15 secondi (parametro interval in testa).
Sulla seriale il codice riporterà lo stato di fake_millis, il momento in cui è avvenuto l’ultimo CIAO, e la differenza (il tempo trascorso dall’ultimo CIAO). Inoltre ci avverte quando fake_millis sta per fare il rollover, nel caso ci sfugga.

Si noti come, indifferentemente dal fatto che fake_millis sia lineare o a cavallo del rollover, l’intervallo di 15 secondi viene sempre rispettato.

Nota: uno dei parametri in testa (divisor) serve per non dover aspettare 256 secondi per osservare il rollover: se impostato a 1000 va a tempo normale (1 secondo); se impostato a 200 (come già adesso) il tempo è 5x.
Nota: quanto contenuto nella funzione time_fixer() serve solo ad aggiornare fake_millis, ma si può lasciar perdere: quello che ci interessa è il resto.

// globals
//

// create fake_millis
//
byte fake_millis = 0;

// create a fake memory of the last event
//
byte last_ciao = 253;

// set an interval
//
const byte interval = 15;


// timing
//

// set this one for actual time (fake_millis steps: 1 second each)
//
// unsigned long divisor = 1000;

// set this one for time 5x (fake_millis steps: 200ms each)
//
const unsigned long divisor = 200;

// DO NOT set both at the same time
//





// run once
//
void setup()
{

  Serial.begin(115200);
  Serial.println("rollover test 07 coming up");

}


// Serial.print("run fovever");
// while(true)
//   Serial.print(" and ever");
//
void loop()
{
  // generate the fake millis
  // this is not the important bit
  //
  time_fixer();


  // this is
  //

  // if the condition is met, write CIAO
  // then store the last time we did it
  //
  // notice the cast to byte
  //
  if((byte)(fake_millis - last_ciao) >= interval)
  {
    Serial.println("CIAO!");
    last_ciao = fake_millis;
  }

  // just hold
  //
  delay(1);
}






// what are you doing here?
// I said this is NOT the interesting part.
// back off!
//
void time_fixer()
{

  // do your math
  //
  byte tempcalc = (millis() / divisor) % 256;

  if(tempcalc != fake_millis)
  {
    if(tempcalc < fake_millis)
      Serial.println(F("--- LOOK OUT: fake_millis is about to roll over"));

    fake_millis = tempcalc;

    Serial.print(F("fake_millis: "));
    Serial.print(fake_millis);
    Serial.print(F(" last_ciao: "));
    Serial.print(last_ciao);
    Serial.print(F(" difference: "));
    Serial.print((byte)(fake_millis - last_ciao));
    Serial.println();
  }
}

Per altri programmini ho postato il risultato, ma questa vale la pena vederla eseguita.
Se proprio non hai un Arduino sottomano, il CIAO viene stampato ogni 15, ed è indifferente a rollover o inizializzazioni strane.

Piace?

Quindi come dobbiamo fare con il vero millis()?

Così:

// setting last_ciao way far in the past to make sure
// to have a hit at startup
//
unsigned long last_ciao = 1000000;

// set the interval here so it can be changed with ease
//
const unsigned long interval = 600000;

void setup()
{
  Serial.begin(115200);
  Serial.println("rollover test 08 coming up");
}

void loop()
{

  // if the condition is met, write CIAO
  // then store the last time we did it
  //
  if((millis() - last_ciao) >= interval)
  {
    Serial.println("CIAO!");
    last_ciao = millis();
  }

  // just hold
  //
  delay(1);

}

il trucchetto di impostare last_ciao a 1000000 serve per avere il primo CIAO stampato a startup, ingannando millis() e facendogli pensare che l’ultimo CIAO è stato stampato ~49.69 giorni fa e che quindi è ora già al primo ciclo. Infatti millis() a quel punto sarà nell’ordine di poche decine, che meno 1000000 fa un numero molto elevato (poco meno di un milione prima del rollover) e che sicuramente è più alto del nostro limite di 600000. Alla prima IF soddisfatta, tutto va a posto.
Se invece non è necessario stampare CIAO a startup ma si desidera che inizi a stampare dopo l’intervallo, è sufficiente impostare:

unsigned long last_ciao = 0;

La minaccia del rollover è domata: basta ricordarsi che il controllo deve sempre assomigliare a:

if((millis() - last_quel) >= intervallo_desiderato)
{
  last_quel = millis();
  quel();
  quel();
  quel();
}

last_quel deve essere unsigned long

intervallo_desiderato deve contenere un numero con la stessa unità di misura che si sta utilizzando (ms o us)

esperienza personale: ad un certo punto viene automatico, ma nel periodo che serve per memorizzarlo definitivamente e non dover fare ogni volta il ragionamento, un post-it sul monitor aiuta.

TLDR;

promemoria veloce se non hai voglia di leggere tutto (che se sai già come funziona, non serve).

intervalli in millisecondi

  if((millis() - ultimo_evento) >= intervallo_tra_eventi)
  {
    ultimo_evento = millis();
    fai_roba();
  }

ultimo_evento deve essere dichiarato unsigned long
intervallo_tra_eventi è in millisecondi

oppure:

intervalli in microsecondi

  if((micros() - ultimo_evento) >= intervallo_tra_eventi)
  {
    ultimo_evento = micros();
    fai_roba();
  }

ultimo_evento deve essere dichiarato unsigned long
intervallo_tra_eventi è in microsecondi

Pubblicità

Rispondi

Inserisci i tuoi dati qui sotto o clicca su un'icona per effettuare l'accesso:

Logo di WordPress.com

Stai commentando usando il tuo account WordPress.com. Chiudi sessione /  Modifica )

Foto di Facebook

Stai commentando usando il tuo account Facebook. Chiudi sessione /  Modifica )

Connessione a %s...

Questo sito utilizza Akismet per ridurre lo spam. Scopri come vengono elaborati i dati derivati dai commenti.