Il problema: un sistema composto da fasi

Una macchina a stati diventa utile quando un sistema non deve soltanto reagire a un ingresso, ma deve seguire un processo composto da fasi differenti.

Una lavatrice, per esempio, non esegue sempre la stessa azione. Durante un ciclo può trovarsi in condizioni come:

  • ferma;
  • carico dell’acqua;
  • lavaggio;
  • scarico;
  • centrifuga.

Queste condizioni non sono semplici nomi assegnati alle uscite. Rappresentano la fase raggiunta dal processo e determinano il significato degli ingressi, dei sensori e del tempo trascorso.

Quando la macchina è ferma, l’assenza di acqua è normale. Durante il carico, la stessa assenza di acqua significa che la valvola deve rimanere aperta. Durante il lavaggio può invece indicare un’anomalia. Durante lo scarico può significare che la pompa ha completato il proprio compito.

L’informazione acqua assente non contiene quindi, da sola, la decisione corretta. Per decidere serve anche sapere in quale fase si trova il sistema.

Lo stato è la memoria della fase raggiunta

Lo stato è una variabile interna che riassume la parte del passato necessaria per decidere il comportamento presente.

Se il programma conserva:

statoAttuale = STATO_SCARICO;

sta ricordando che il processo ha già attraversato le fasi precedenti ed è arrivato allo scarico. Da quel momento gli ingressi e le temporizzazioni vengono interpretati secondo le regole dello stato STATO_SCARICO.

In una macchina a stati semplice il sistema occupa un solo stato alla volta. Non può essere contemporaneamente in carico, lavaggio e scarico.

Questa unicità evita combinazioni contraddittorie come:

caricoAttivo = true;
lavaggioAttivo = true;
scaricoAttivo = true;

Con una sola variabile di stato, la fase corrente è invece inequivocabile.

Perché non è un semplice if

Un’istruzione if controlla se una condizione è vera:

if (pulsantePremuto)
{
  // esegui un'azione
}

Il problema è stabilire quale azione debba essere eseguita. Lo stesso pulsante può avere significati differenti:

  • in FERMA avvia il ciclo;
  • in CARICO_ACQUA annulla il ciclo e avvia lo scarico;
  • in LAVAGGIO interrompe il lavaggio e avvia lo scarico;
  • in SCARICO non arresta la pompa, perché il sistema deve completare lo svuotamento.

Gli if rimangono presenti nel programma, ma vengono valutati all’interno di uno stato preciso. La decisione completa assume la forma:

stato attuale + evento o condizione
→ azione + eventuale nuovo stato

La macchina a stati non sostituisce le istruzioni condizionali. Organizza le condizioni in modo che ogni evento venga interpretato secondo la fase corrente del processo.

Stati, eventi, transizioni e azioni

Una macchina a stati è descritta da quattro elementi.

Stati

Sono le condizioni possibili del sistema. Nel programma finale sono:

  • STATO_FERMA;
  • STATO_CARICO_ACQUA;
  • STATO_LAVAGGIO;
  • STATO_SCARICO.

Eventi e condizioni

Sono i fatti che possono modificare il comportamento: pressione del pulsante oppure termine di una temporizzazione.

Transizioni

Sono i passaggi da uno stato a un altro. Per esempio:

STATO_FERMA + pulsante
→ STATO_CARICO_ACQUA

Azioni

Sono le operazioni associate allo stato. Nel programma i tre LED rappresentano tre attuatori:

  • LED valvola acceso durante il carico;
  • LED motore acceso durante il lavaggio;
  • LED pompa acceso durante lo scarico.

Diagramma degli stati

Il diagramma mostra gli stati come rettangoli e le transizioni come frecce. Le scritte sulle frecce indicano l’evento o la condizione che provoca il passaggio.

Diagramma della macchina a stati con le fasi ferma, carico acqua, lavaggio e scarico
Diagramma della macchina a stati utilizzata dal programma. Fare clic sull’immagine per ingrandirla.

Tabella delle transizioni

Lo stesso comportamento può essere descritto senza codice mediante una tabella.

FERMA + pulsante → CARICO_ACQUA

CARICO_ACQUA + 5 secondi → LAVAGGIO

CARICO_ACQUA + pulsante → SCARICO

LAVAGGIO + 10 secondi → SCARICO

LAVAGGIO + pulsante → SCARICO

SCARICO + 5 secondi → FERMA

La tabella definisce quali passaggi sono ammessi. Non esiste, per esempio, una transizione diretta da CARICO_ACQUA a FERMA: anche in caso di annullamento il sistema passa prima allo scarico.

Che cosa fa il programma

Il programma simula un ciclo automatico semplificato mediante un pulsante e tre LED.

  • In FERMA tutti i LED sono spenti.
  • La pressione del pulsante avvia CARICO_ACQUA e accende il LED della valvola.
  • Dopo 5 secondi il sistema passa a LAVAGGIO e accende il LED del motore.
  • Dopo 10 secondi passa a SCARICO e accende il LED della pompa.
  • Dopo 5 secondi di scarico torna a FERMA.

Se il pulsante viene premuto durante il carico o il lavaggio, il ciclo viene annullato ma il sistema passa comunque a SCARICO. Durante lo scarico il pulsante viene ignorato e la pompa simulata rimane attiva fino al termine dei 5 secondi.

Questo comportamento rende evidente che il significato del pulsante dipende dallo stato corrente.

Collegamenti

Il programma utilizza:

  • pulsante tra il pin 2 e GND;
  • LED della valvola sul pin 8 attraverso una resistenza da 330 Ω;
  • LED del motore sul pin 9 attraverso una resistenza da 330 Ω;
  • LED della pompa sul pin 10 attraverso una resistenza da 330 Ω;
  • catodi dei tre LED collegati a GND.

Il pulsante utilizza INPUT_PULLUP. A pulsante rilasciato il pin legge HIGH; quando il pulsante viene premuto e collega il pin a GND, la lettura diventa LOW.

I LED rappresentano soltanto gli attuatori. Un’elettrovalvola, un motore o una pompa reali non possono essere alimentati direttamente dai pin dell’ATmega328P e richiedono circuiti di potenza e protezioni adeguati.

Definizione degli stati con enum

Gli stati vengono definiti come un insieme chiuso di valori:

enum StatoSistema
{
  STATO_FERMA,
  STATO_CARICO_ACQUA,
  STATO_LAVAGGIO,
  STATO_SCARICO
};

La variabile:

StatoSistema statoAttuale = STATO_FERMA;

può contenere uno solo dei valori dichiarati. In questo modo il programma conserva esplicitamente la fase corrente.

enum non crea da solo una macchina a stati. Serve soltanto a rappresentare gli stati con nomi leggibili invece di utilizzare numeri privi di significato.

Una sola funzione per cambiare stato

Ogni transizione passa attraverso la funzione:

void cambiaStato(
  StatoSistema nuovoStato,
  unsigned long tempoCorrenteMs
)

La funzione esegue quattro operazioni:

  1. stampa sul monitor seriale lo stato precedente e quello nuovo;
  2. aggiorna statoAttuale;
  3. registra il momento di ingresso nel nuovo stato;
  4. imposta le uscite corrispondenti.

Concentrare queste operazioni in una sola funzione impedisce di cambiare stato dimenticando di aggiornare la temporizzazione oppure le uscite.

Le uscite dipendono dallo stato

La funzione impostaUscite() spegne prima tutti i LED e poi accende soltanto quello associato allo stato corrente.

La relazione è:

STATO_FERMA        → tutti i LED spenti
STATO_CARICO_ACQUA → LED valvola acceso
STATO_LAVAGGIO     → LED motore acceso
STATO_SCARICO      → LED pompa acceso

I LED non sono gli stati. Sono le uscite prodotte dagli stati. La macchina continuerebbe ad avere la stessa struttura anche sostituendo i LED con relè, transistor, messaggi seriali o indicazioni su un display.

La logica centrale con switch

Nel loop() viene eseguita soltanto la parte relativa allo stato corrente:

switch (statoAttuale)
{
  case STATO_FERMA:
    // attende il pulsante
    break;

  case STATO_CARICO_ACQUA:
    // controlla pulsante e tempo di carico
    break;

  case STATO_LAVAGGIO:
    // controlla pulsante e tempo di lavaggio
    break;

  case STATO_SCARICO:
    // controlla il tempo di scarico
    break;
}

switch non è la macchina a stati. È soltanto una forma ordinata per selezionare la logica associata allo stato corrente.

La macchina a stati è costituita dagli stati definiti, dagli eventi riconosciuti, dalle transizioni ammesse e dalle azioni eseguite.

Temporizzazioni senza delay()

Quando il sistema entra in un nuovo stato, viene memorizzato il valore di millis():

inizioStatoMs = tempoCorrenteMs;

Nel loop() il tempo trascorso viene calcolato con:

unsigned long tempoNelloStatoMs =
  tempoCorrenteMs - inizioStatoMs;

La transizione avviene quando il tempo raggiunge la durata prevista.

Il programma non si blocca durante l’attesa. Può continuare a leggere il pulsante e reagire all’annullamento mentre il carico o il lavaggio sono in corso.

Debounce del pulsante

Un contatto meccanico non passa istantaneamente e in modo pulito da aperto a chiuso. Per alcuni millisecondi può produrre più commutazioni elettriche.

La funzione pressionePulsanteRilevata() considera valida una variazione soltanto quando la nuova lettura rimane stabile per almeno 40 millisecondi.

La funzione restituisce true una sola volta quando viene riconosciuta la transizione stabile da pulsante rilasciato a pulsante premuto. Tenere premuto il pulsante non genera quindi eventi ripetuti a ogni esecuzione del loop().

Programma completo

Download: macchina_stati.ino

/*
  Macchina a stati: ciclo automatico semplificato.

  Hardware:
  - pulsante tra pin 2 e GND;
  - LED valvola sul pin 8 tramite resistenza;
  - LED motore sul pin 9 tramite resistenza;
  - LED pompa sul pin 10 tramite resistenza.

  Il pulsante utilizza INPUT_PULLUP:
  rilasciato = HIGH
  premuto   = LOW

  Sequenza:
  FERMA -> CARICO_ACQUA -> LAVAGGIO -> SCARICO -> FERMA

  Premendo il pulsante durante CARICO_ACQUA o LAVAGGIO,
  il ciclo viene annullato e passa a SCARICO.
*/

const uint8_t PIN_PULSANTE = 2;
const uint8_t PIN_LED_VALVOLA = 8;
const uint8_t PIN_LED_MOTORE = 9;
const uint8_t PIN_LED_POMPA = 10;

const unsigned long DURATA_CARICO_MS = 5000UL;
const unsigned long DURATA_LAVAGGIO_MS = 10000UL;
const unsigned long DURATA_SCARICO_MS = 5000UL;
const unsigned long DEBOUNCE_MS = 40UL;

enum StatoSistema
{
  STATO_FERMA,
  STATO_CARICO_ACQUA,
  STATO_LAVAGGIO,
  STATO_SCARICO
};

StatoSistema statoAttuale = STATO_FERMA;

unsigned long inizioStatoMs = 0;
unsigned long ultimoCambioLetturaMs = 0;

bool letturaPrecedentePulsante = HIGH;
bool statoStabilePulsante = HIGH;

void stampaNomeStato(StatoSistema stato)
{
  switch (stato)
  {
    case STATO_FERMA:
      Serial.print(F("FERMA"));
      break;

    case STATO_CARICO_ACQUA:
      Serial.print(F("CARICO_ACQUA"));
      break;

    case STATO_LAVAGGIO:
      Serial.print(F("LAVAGGIO"));
      break;

    case STATO_SCARICO:
      Serial.print(F("SCARICO"));
      break;
  }
}

void impostaUscite(StatoSistema stato)
{
  digitalWrite(PIN_LED_VALVOLA, LOW);
  digitalWrite(PIN_LED_MOTORE, LOW);
  digitalWrite(PIN_LED_POMPA, LOW);

  switch (stato)
  {
    case STATO_FERMA:
      break;

    case STATO_CARICO_ACQUA:
      digitalWrite(PIN_LED_VALVOLA, HIGH);
      break;

    case STATO_LAVAGGIO:
      digitalWrite(PIN_LED_MOTORE, HIGH);
      break;

    case STATO_SCARICO:
      digitalWrite(PIN_LED_POMPA, HIGH);
      break;
  }
}

void cambiaStato(
  StatoSistema nuovoStato,
  unsigned long tempoCorrenteMs
)
{
  Serial.print(F("Transizione: "));
  stampaNomeStato(statoAttuale);
  Serial.print(F(" -> "));
  stampaNomeStato(nuovoStato);
  Serial.println();

  statoAttuale = nuovoStato;
  inizioStatoMs = tempoCorrenteMs;

  impostaUscite(statoAttuale);
}

bool pressionePulsanteRilevata(
  unsigned long tempoCorrenteMs
)
{
  bool letturaAttuale = digitalRead(PIN_PULSANTE);

  if (letturaAttuale != letturaPrecedentePulsante)
  {
    letturaPrecedentePulsante = letturaAttuale;
    ultimoCambioLetturaMs = tempoCorrenteMs;
  }

  if (
    tempoCorrenteMs - ultimoCambioLetturaMs
    >= DEBOUNCE_MS
  )
  {
    if (letturaAttuale != statoStabilePulsante)
    {
      statoStabilePulsante = letturaAttuale;

      if (statoStabilePulsante == LOW)
      {
        return true;
      }
    }
  }

  return false;
}

void setup()
{
  pinMode(PIN_PULSANTE, INPUT_PULLUP);

  pinMode(PIN_LED_VALVOLA, OUTPUT);
  pinMode(PIN_LED_MOTORE, OUTPUT);
  pinMode(PIN_LED_POMPA, OUTPUT);

  Serial.begin(115200);

  statoStabilePulsante = digitalRead(PIN_PULSANTE);
  letturaPrecedentePulsante = statoStabilePulsante;

  inizioStatoMs = millis();
  impostaUscite(statoAttuale);

  Serial.println(F("Sistema avviato"));
  Serial.print(F("Stato iniziale: "));
  stampaNomeStato(statoAttuale);
  Serial.println();
}

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

  bool pulsantePremuto =
    pressionePulsanteRilevata(tempoCorrenteMs);

  unsigned long tempoNelloStatoMs =
    tempoCorrenteMs - inizioStatoMs;

  switch (statoAttuale)
  {
    case STATO_FERMA:
      if (pulsantePremuto)
      {
        cambiaStato(
          STATO_CARICO_ACQUA,
          tempoCorrenteMs
        );
      }
      break;

    case STATO_CARICO_ACQUA:
      if (pulsantePremuto)
      {
        cambiaStato(
          STATO_SCARICO,
          tempoCorrenteMs
        );
      }
      else if (
        tempoNelloStatoMs >= DURATA_CARICO_MS
      )
      {
        cambiaStato(
          STATO_LAVAGGIO,
          tempoCorrenteMs
        );
      }
      break;

    case STATO_LAVAGGIO:
      if (
        pulsantePremuto
        || tempoNelloStatoMs >= DURATA_LAVAGGIO_MS
      )
      {
        cambiaStato(
          STATO_SCARICO,
          tempoCorrenteMs
        );
      }
      break;

    case STATO_SCARICO:
      if (
        tempoNelloStatoMs >= DURATA_SCARICO_MS
      )
      {
        cambiaStato(
          STATO_FERMA,
          tempoCorrenteMs
        );
      }
      break;
  }
}

Perché questo programma è una macchina a stati

Il programma non è una macchina a stati semplicemente perché contiene enum e switch.

Lo è perché:

  • possiede un insieme definito di fasi;
  • conserva una sola fase corrente;
  • interpreta gli eventi in funzione di quella fase;
  • ammette soltanto transizioni stabilite in precedenza;
  • associa a ogni stato determinate uscite;
  • registra il momento di ingresso nello stato per gestirne la durata.

Il pulsante fornisce l’esempio più evidente. In FERMA avvia il ciclo; in CARICO_ACQUA e LAVAGGIO lo annulla; in SCARICO non produce alcuna transizione.

Dal modello semplificato a un sistema reale

Il codice utilizza tempi fissi e LED per rendere visibile la sequenza. Un sistema reale introdurrebbe ulteriori stati e condizioni, per esempio:

  • controllo della porta;
  • livello dell’acqua;
  • temperatura;
  • errore di riempimento;
  • errore di scarico;
  • arresto di sicurezza;
  • centrifuga;
  • fine ciclo.

La struttura rimarrebbe la stessa: ogni nuovo comportamento dovrebbe essere definito come stato, evento, transizione oppure azione, evitando combinazioni implicite di flag e condizioni sparse.

Conclusioni

Una macchina a stati organizza il comportamento di un sistema che attraversa fasi differenti nel tempo.

Lo stato rappresenta la memoria della fase raggiunta. Gli eventi e le condizioni vengono interpretati in funzione di quella memoria; le transizioni stabiliscono quali passaggi sono consentiti; le azioni determinano le uscite associate a ogni fase.

Le istruzioni if continuano a essere utilizzate, ma non rimangono isolate. Ogni condizione viene valutata all’interno dello stato al quale appartiene.

Nel programma finale il ciclo passa da fermo a carico, lavaggio e scarico. La pressione dello stesso pulsante produce risultati differenti secondo lo stato corrente. È proprio questa dipendenza dalla fase memorizzata a distinguere una macchina a stati da una semplice reazione a un ingresso.

Fonti principali

David Harel — Statecharts: A Visual Formalism for Complex Systems, Science of Computer Programming, 1987.

Microchip Technology — ATmega328P: 8-bit AVR Microcontroller with 32K Bytes In-System Programmable Flash, datasheet.

Arduino — documentazione di millis(), switch, digitalRead(), digitalWrite() e INPUT_PULLUP.