ArduinoElettronica digitale

Interrupt esterni e ISR su Arduino: attachInterrupt, PCINT, encoder rotativo e frequenzimetro

Come funzionano INT0, INT1 e gli interrupt di pin change dell'ATmega328P, come scrivere ISR corrette con volatile e sezioni critiche, e due progetti: encoder KY-040 senza perdere passi e frequenzimetro.

Un programma Arduino che legge un pulsante o un encoder con digitalRead() dentro loop() funziona bene finché il loop() è veloce. Basta però un delay(), una stampa lunga sulla seriale o un display lento perché alcuni eventi vadano persi: un impulso di pochi microsecondi, uno scatto dell’encoder girato in fretta, un fronte di un segnale da misurare. Gli interrupt di Arduino risolvono il problema alla radice: è l’hardware del microcontrollore a sorvegliare il pin e a interrompere il programma principale nel momento esatto in cui l’evento si verifica.

In questa guida vediamo come funzionano gli interrupt esterni dell’ATmega328P di Arduino UNO: INT0 e INT1 con attachInterrupt() e a registri, le modalità LOW, CHANGE, RISING e FALLING, le regole per scrivere una ISR corretta, le variabili volatile e le sezioni critiche per le variabili a più byte, gli interrupt di pin change (PCINT) su qualunque pin. Chiudono due progetti completi: la lettura di un encoder rotativo KY-040 con tabella di stato, che non perde passi anche con i rimbalzi, e un contatore di impulsi con misura di frequenza.

Polling e interrupt su Arduino: che cosa succede nella CPU

Con il polling il programma controlla periodicamente un ingresso, e un evento più breve della durata di loop() può sfuggire. Con un interrupt l’evento viene rilevato da una logica dedicata, che memorizza in un registro un flag anche se la CPU sta facendo altro. Se l’interrupt è abilitato e il bit globale I del registro di stato SREG vale 1, la CPU termina l’istruzione in corso, salva sullo stack l’indirizzo di ritorno e salta alla routine di servizio (ISR, Interrupt Service Routine) associata a quella sorgente.

Il datasheet dell’ATmega328P quantifica i tempi: la risposta a un interrupt abilitato richiede almeno 4 cicli di clock, durante i quali l’indirizzo di ritorno viene salvato sullo stack, più 3 cicli per il salto alla routine contenuto nella tabella dei vettori; il ritorno con l’istruzione RETI richiede altri 4 cicli. A 16 MHz significa meno di mezzo microsecondo prima che la ISR inizi a eseguire il proprio prologo. All’ingresso nella ISR il bit I viene azzerato automaticamente, quindi gli altri interrupt restano in attesa (con il loro flag memorizzato) fino alla fine della routine, quando RETI lo riporta a 1.

Ogni sorgente ha un proprio vettore. La priorità è fissa: a parità di richiesta pendente viene servito per primo il vettore con indirizzo più basso. Questi sono i vettori che interessano in pratica:

Vettore Nome in avr-libc Sorgente
1 INT0_vect interrupt esterno 0, pin PD2 = D2
2 INT1_vect interrupt esterno 1, pin PD3 = D3
3 PCINT0_vect pin change, porta B (D8–D13)
4 PCINT1_vect pin change, porta C (A0–A5)
5 PCINT2_vect pin change, porta D (D0–D7)
11 TIMER1_COMPA_vect Timer1, confronto A
16 TIMER0_OVF_vect Timer0, overflow (usato da millis())

Il vettore 0 è il reset. Poiché INT0 e INT1 hanno i numeri più bassi dopo il reset, sono anche le sorgenti a priorità più alta. Per i registri e le operazioni sui bit usati nel seguito è utile avere presente la guida Arduino registri e bit manipulation.

INT0 e INT1 con attachInterrupt()

Sull’ATmega328P gli interrupt esterni “veri” sono due: INT0 sul pin D2 e INT1 sul pin D3. Il core Arduino li gestisce con attachInterrupt(), la cui forma raccomandata dalla documentazione ufficiale è:

attachInterrupt(digitalPinToInterrupt(pin), funzione, modalita);

digitalPinToInterrupt() converte il numero del pin nel numero dell’interrupt: sulla UNO restituisce 0 per D2, 1 per D3 e NOT_AN_INTERRUPT per tutti gli altri pin. Usarla rende lo sketch portabile su schede come Leonardo o Mega. La funzione passata come secondo argomento non riceve parametri e non restituisce nulla; detachInterrupt(digitalPinToInterrupt(pin)) disabilita di nuovo l’interrupt.

La modalità corrisponde ai due bit ISCn1:ISCn0 del registro EICRA (le costanti del core hanno proprio i valori 0–3 dei bit):

Modalità ISCn1:ISCn0 Quando scatta l’interrupt
LOW 00 finché il pin resta a livello basso (la ISR viene richiamata di continuo)
CHANGE 01 a ogni cambiamento di livello, in salita e in discesa
FALLING 10 sul fronte di discesa (da 1 a 0)
RISING 11 sul fronte di salita (da 0 a 1)

Nelle modalità sui fronti il datasheet garantisce l’interrupt per impulsi più lunghi di un periodo del clock di I/O (62,5 ns a 16 MHz); impulsi più brevi non sono garantiti. La modalità LOW è particolare: la richiesta resta attiva per tutto il tempo in cui il pin è basso, quindi la ISR viene eseguita di continuo e, poiché dopo ogni RETI la CPU esegue una sola istruzione del programma principale prima di servire un interrupt pendente, il loop() avanza a passo di lumaca. Il suo impiego tipico è il risveglio dai modi di sospensione profonda, nei quali il rilevamento dei fronti non funziona perché il clock di I/O è fermo. Un dettaglio pratico: attachInterrupt() del core AVR non cancella il flag INTF0/INTF1, quindi un fronte avvenuto prima della chiamata può far eseguire la ISR una volta subito dopo l’abilitazione.

Esempio: contare le pressioni di un pulsante

Il primo programma collega un pulsante tra D2 e GND, con il pull-up interno attivo. La ISR conta due grandezze: tutti i fronti di discesa, compresi quelli dovuti ai rimbalzi, e le pressioni “vere”, accettate solo se il pin era fermo da almeno 30 ms. La modalità è CHANGE perché il filtro deve vedere anche i fronti di salita: con FALLING i rimbalzi al rilascio, che arrivano molto dopo la pressione, verrebbero contati come nuove pressioni.

// Conta le pressioni di un pulsante su D2 (INT0) con attachInterrupt.
// Due contatori: tutti i fronti di discesa (rimbalzi compresi) e le
// pressioni "vere", accettate solo se il pin era stabile da almeno 30 ms.

#include <util/atomic.h>

const uint8_t PIN_PULSANTE = 2;          // D2 = INT0
const uint32_t FILTRO_MS = 30;

volatile uint16_t fronti = 0;            // tutti i fronti di discesa
volatile uint16_t pressioni = 0;         // pressioni filtrate
volatile uint32_t tUltimoCambio = 0;

void isrPulsante() {
  uint32_t adesso = millis();            // leggere millis() nella ISR e' lecito
  bool premuto = !(PIND & (1 << PIND2)); // livello attuale di D2 (basso = premuto)
  if (premuto) {
    fronti++;
    if (adesso - tUltimoCambio >= FILTRO_MS) {
      pressioni++;                       // il pin era fermo (alto) da almeno 30 ms
    }
  }
  tUltimoCambio = adesso;                // ogni fronte, in salita o in discesa, riarma il filtro
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_PULSANTE, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(PIN_PULSANTE), isrPulsante, CHANGE);
  Serial.println(F("Pronto: premi il pulsante su D2"));
}

void loop() {
  static uint16_t frontiStampati = 0;
  uint16_t copiaFronti, copiaPressioni;

  ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {    // copia coerente delle variabili a 16 bit
    copiaFronti = fronti;
    copiaPressioni = pressioni;
  }

  if (copiaFronti != frontiStampati) {    // stampa a ogni nuovo fronte
    frontiStampati = copiaFronti;
    Serial.print(F("Pressioni: "));
    Serial.print(copiaPressioni);
    Serial.print(F("  fronti di discesa: "));
    Serial.println(copiaFronti);
  }
}

Nella simulazione lo script di stimolo ha generato tre pressioni con rimbalzi di durata compresa tra 0,15 e 2 ms sia alla chiusura sia all’apertura del contatto. Il programma ha stampato Pressioni: 3 fronti di discesa: 17: diciassette fronti per tre pressioni, cioè esattamente il problema descritto nell’articolo sui circuiti di debouncing. Il filtro temporale dentro la ISR funziona, ma ha un limite: legge il livello del pin qualche microsecondo dopo il fronte e, se un rimbalzo è più breve della latenza della ISR, può leggere il livello sbagliato. Per i pulsanti collegati a un interrupt la soluzione più robusta resta una piccola rete RC davanti al pin, che elimina i rimbalzi più rapidi, eventualmente seguita dal filtro software. Per i pulsanti azionati a mano, del resto, basta quasi sempre una lettura non bloccante nel loop(), come quella vista negli input digitali.

INT0 configurato a registri

attachInterrupt() è comodo ma aggiunge un livello di indirezione: il core definisce la vera ISR, salva i registri e richiama la funzione dell’utente tramite un puntatore. Scrivendo direttamente la ISR con la macro ISR() di avr-libc e configurando i registri si ottiene una risposta più rapida. I registri coinvolti sono tre: EICRA (modalità), EIMSK (abilitazione di INT0 e INT1) ed EIFR (flag, che si cancellano scrivendo 1).

// INT0 configurato direttamente a registri, senza attachInterrupt().
// Conta i fronti di discesa su D2 e inverte il LED su D13 a ogni fronte.

volatile uint16_t conteggio = 0;

ISR(INT0_vect) {
  conteggio++;
  PINB = (1 << PINB5);                   // scrivere 1 su PINx inverte il pin: D13 cambia stato
}

void setup() {
  Serial.begin(115200);
  DDRB |= (1 << DDB5);                   // D13 uscita
  DDRD &= ~(1 << DDD2);                  // D2 ingresso
  PORTD |= (1 << PORTD2);                // pull-up interno su D2

  EICRA = (EICRA & ~((1 << ISC01) | (1 << ISC00))) | (1 << ISC01); // ISC0 = 10: fronte di discesa
  EIFR = (1 << INTF0);                   // cancella un eventuale flag pendente (si scrive 1)
  EIMSK |= (1 << INT0);                  // abilita INT0
  Serial.println(F("INT0 a registri pronto"));
}

void loop() {
  static uint16_t stampato = 0;
  noInterrupts();
  uint16_t c = conteggio;
  interrupts();
  if (c != stampato) {
    stampato = c;
    Serial.print(F("Fronti: "));
    Serial.println(c);
  }
}

In simulazione, con impulsi bassi da 2 ms su D2, il programma ha contato 25 fronti su 25 e ha commutato D13 25 volte. Il ritardo tra il fronte su D2 e la commutazione di D13 è risultato di circa 1,5 µs (24 cicli) con ISR(INT0_vect) e di circa 2,9 µs (46 cicli) con la stessa operazione eseguita in una funzione registrata con attachInterrupt(). Attenzione a non mescolare i due metodi sullo stesso interrupt: se lo sketch definisce ISR(INT0_vect) e usa anche attachInterrupt(), il linker segnala una definizione multipla del vettore. Succede anche se attachInterrupt() è usata solo per D3: il modulo del core che la implementa definisce insieme i vettori di INT0 e INT1.

Le regole per scrivere una ISR

Una ISR interrompe il programma in un punto qualunque e, finché è in esecuzione, blocca tutti gli altri interrupt, compreso quello del Timer0 che fa avanzare millis() e quelli della seriale. Da qui derivano alcune regole pratiche.

  • Breve e veloce. La ISR deve registrare l’evento (un contatore, un flag, un istante) e uscire. L’elaborazione si fa nel loop().
  • Niente delay(). delay() si basa sugli interrupt del Timer0, che dentro la ISR sono sospesi. La documentazione Arduino precisa anche che millis() non avanza durante la ISR (si può leggere, ma restituisce sempre lo stesso valore) e che micros() funziona all’inizio ma diventa inaffidabile dopo 1–2 ms.
  • Niente Serial.print(). La trasmissione usa un buffer di 64 byte svuotato a sua volta da un interrupt: se il buffer è pieno, la stampa dentro la ISR resta in attesa che la UART trasmetta. A 115200 baud ogni carattere richiede circa 87 µs, quindi 20 caratteri possono allungare la ISR di quasi 2 ms, durante i quali altri eventi e i dati in arrivo sulla seriale possono andare persi.
  • Variabili condivise volatile e lette con una sezione critica se occupano più di un byte (vedi sotto).
  • Niente String o librerie lente (I2C, display, SD) nella ISR: molte usano a loro volta gli interrupt.

Che cosa fa davvero volatile

Il compilatore ottimizza il codice supponendo che una variabile cambi solo per effetto delle istruzioni che vede. In un ciclo come while (!flag) {} può caricare flag in un registro una sola volta e non rileggerla più dalla RAM: la modifica fatta dalla ISR non verrebbe mai vista. Il qualificatore volatile obbliga il compilatore a leggere e scrivere la variabile in memoria a ogni accesso. Non rende però l’accesso indivisibile: volatile risolve il problema della visibilità, non quello dell’atomicità.

Sezioni critiche: noInterrupts() e ATOMIC_BLOCK

L’ATmega328P è un processore a 8 bit: una variabile uint16_t si legge con due istruzioni, una uint32_t con quattro. Se l’interrupt arriva tra la lettura del byte basso e quella del byte alto, il programma principale ottiene un valore composto da metà vecchia e metà nuova. Con un contatore che passa da 255 a 256 si può leggere 511, un valore che la variabile non ha mai avuto.

Diagramma della lettura non atomica di una variabile a 16 bit interrotta da una ISR: risultato 0x01FF invece di 255 o 256

L’errore è raro e proprio per questo insidioso: compare dopo ore di funzionamento e non si riproduce a comando. La soluzione è leggere (e, se serve, azzerare) le variabili condivise con gli interrupt disabilitati per il minimo tempo indispensabile. Il modo più semplice è la coppia noInterrupts() / interrupts(), usata nel programma INT0 a registri; quello più robusto è la macro ATOMIC_BLOCK di avr-libc (header util/atomic.h), usata negli altri esempi:

  ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {    // copia coerente delle variabili a 16 bit
    copiaFronti = fronti;
    copiaPressioni = pressioni;
  }

Con ATOMIC_RESTORESTATE all’uscita dal blocco il bit I viene riportato al valore che aveva all’ingresso, quindi il blocco si può usare anche in una funzione chiamata con gli interrupt già disabilitati; ATOMIC_FORCEON invece li riabilita sempre. Il blocco viene chiuso correttamente anche se all’interno si esce con return o break. Le variabili da un byte si leggono con una sola istruzione e non richiedono protezione in lettura, ma un’operazione come contatore++ eseguita nel loop() su una variabile modificata anche dalla ISR è comunque una sequenza lettura-modifica-scrittura e va protetta. Il core Arduino applica la stessa tecnica: millis() copia la variabile a 32 bit aggiornata dall’interrupt del Timer0 dopo aver disabilitato gli interrupt e ripristina SREG subito dopo.

Interrupt di pin change (PCINT) a registri

Due soli interrupt esterni sono pochi. L’ATmega328P offre però gli interrupt di pin change, disponibili su quasi tutti i pin e raccolti in tre gruppi, uno per porta. Ogni gruppo ha un solo vettore, che scatta a qualunque cambiamento di uno dei pin abilitati: non si può scegliere il fronte, e la ISR deve capire da sola quale pin è cambiato e in che direzione.

Gruppo Bit in PCICR Maschera Vettore Pin Arduino UNO
PCINT0–5 PCIE0 PCMSK0 PCINT0_vect D8–D13 (PB0–PB5)
PCINT8–13 PCIE1 PCMSK1 PCINT1_vect A0–A5 (PC0–PC5)
PCINT16–23 PCIE2 PCMSK2 PCINT2_vect D0–D7 (PD0–PD7)

PB6/PB7 sono collegati al risonatore del clock e PC6 al reset, mentre D0 e D1 sono la seriale: in pratica restano disponibili D2–D13 e A0–A5. La configurazione richiede tre passi: si abilitano i singoli pin nel registro maschera PCMSKn, si cancella un eventuale flag pendente in PCIFR e si abilita il gruppo in PCICR. Nella ISR si legge la porta una sola volta, si confronta con lo stato precedente con uno XOR e si ottiene la maschera dei pin cambiati. L’esempio gestisce tre pulsanti su A0, A1 e A2 con un filtro antirimbalzo indipendente per ciascuno.

// Interrupt di pin change (PCINT) configurati a registri.
// Tre pulsanti verso GND su A0, A1, A2 = PCINT8, PCINT9, PCINT10 (gruppo PCINT1).

const uint8_t MASCHERA = (1 << PC0) | (1 << PC1) | (1 << PC2);
const uint8_t FILTRO_MS = 30;

volatile uint8_t contatori[3] = {0, 0, 0};
volatile uint8_t statoPrecedente = MASCHERA;   // a riposo i pin sono alti
volatile uint32_t tUltimo[3] = {0, 0, 0};
volatile bool nuovoEvento = false;

ISR(PCINT1_vect) {
  uint8_t ora = PINC & MASCHERA;               // una sola lettura della porta
  uint8_t cambiati = ora ^ statoPrecedente;
  statoPrecedente = ora;
  uint32_t adesso = millis();

  for (uint8_t i = 0; i < 3; i++) {
    uint8_t pinBit = 1 << i;
    if (cambiati & pinBit) {
      if (!(ora & pinBit) && (adesso - tUltimo[i] >= FILTRO_MS)) {
        contatori[i]++;                        // fronte di discesa accettato
        nuovoEvento = true;
      }
      tUltimo[i] = adesso;                     // ogni cambiamento riarma il filtro
    }
  }
}

void setup() {
  Serial.begin(115200);
  DDRC &= ~MASCHERA;                           // A0..A2 ingressi
  PORTC |= MASCHERA;                           // pull-up interni attivi
  statoPrecedente = PINC & MASCHERA;
  PCMSK1 = MASCHERA;                           // abilita PCINT8..10
  PCIFR = (1 << PCIF1);                        // cancella un eventuale flag pendente
  PCICR |= (1 << PCIE1);                       // abilita il gruppo PCINT1 (porta C)
  Serial.println(F("PCINT pronto su A0, A1, A2"));
}

void loop() {
  if (nuovoEvento) {
    uint8_t c[3];
    noInterrupts();
    nuovoEvento = false;
    for (uint8_t i = 0; i < 3; i++) c[i] = contatori[i];
    interrupts();

    Serial.print(F("A0="));
    Serial.print(c[0]);
    Serial.print(F(" A1="));
    Serial.print(c[1]);
    Serial.print(F(" A2="));
    Serial.println(c[2]);
  }
}

Lo stimolo simulato ha premuto sette volte i tre pulsanti, con rimbalzi a ogni pressione e rilascio e con due pressioni sovrapposte nel tempo (A1 e A2) o quasi simultanee (A2 e A0, a 5 ms di distanza). Il risultato finale A0=3 A1=1 A2=3 coincide con le pressioni generate. Per pin di gruppi diversi servono più ISR, una per vettore; librerie come PinChangeInterrupt nascondono questi dettagli, ma la versione a registri è breve e senza dipendenze.

Progetto: encoder rotativo KY-040 senza perdere passi

L’encoder incrementale meccanico tipo EC11, montato sul diffuso modulo KY-040, contiene due contatti, A e B, che si chiudono verso il comune C con uno sfasamento di un quarto di periodo: è la cosiddetta quadratura. Il modulo porta le resistenze di pull-up da 10 kΩ sui piedini CLK (contatto A) e DT (contatto B) e, su molti esemplari, lascia vuota la piazzola del pull-up del pulsante SW. Il collegamento ad Arduino UNO usa i due interrupt esterni per A e B e un pin qualunque per il pulsante.

Schema di collegamento del modulo encoder KY-040 ad Arduino UNO: CLK su D2 (INT0), DT su D3 (INT1), SW su D4, pull-up da 10 kOhm sul modulo

Nelle posizioni di riposo (gli scatti, detent) della maggior parte dei KY-040 entrambi i contatti sono aperti e i due ingressi valgono 1. Ruotando di uno scatto, le due linee compiono un ciclo completo di quattro transizioni, sempre una linea alla volta; l’ordine in cui cambiano indica il verso di rotazione. Indicando lo stato con i due bit B e A, in un verso la sequenza è 11 → 01 → 00 → 10 → 11, nell’altro è percorsa al contrario.

Diagramma temporale della quadratura dei canali A e B di un encoder con gli stati BA e i valori della tabella di transizione

La tabella di stato

Il metodo robusto consiste nel ricordare lo stato precedente e, a ogni cambiamento di uno dei due pin, combinarlo con lo stato attuale in un indice a 4 bit: indice = (precedente << 2) | attuale. Le 16 combinazioni possibili si dividono in tre gruppi: 8 transizioni valide (+1 o −1 a seconda del verso), 4 casi senza cambiamento e 4 salti non validi, in cui entrambe le linee risultano cambiate. Una tabella di 16 valori restituisce direttamente l’incremento. Il vantaggio decisivo riguarda i rimbalzi: se un contatto rimbalza, la linea va avanti e indietro tra due stati adiacenti e la tabella produce +1, −1, +1, −1, una sequenza che si annulla da sola. Lo stesso accade se l’utente ruota di mezzo scatto e torna indietro. Per contare gli scatti, il programma accumula le transizioni in sottoPassi e aggiorna la posizione solo quando l’encoder torna nello stato di riposo 11.

// Encoder rotativo KY-040 / EC11 letto con due interrupt (INT0 e INT1)
// e tabella di stato della quadratura. Pulsante dell'encoder su D4.

#include <util/atomic.h>

const uint8_t PIN_CLK = 2;               // canale A -> INT0
const uint8_t PIN_DT  = 3;               // canale B -> INT1
const uint8_t PIN_SW  = 4;               // pulsante (letto nel loop)
const int8_t TRANSIZIONI_PER_SCATTO = 4; // KY-040/EC11 tipico: 1 ciclo completo per scatto

// Indice = (stato precedente << 2) | stato attuale, con stato = (B << 1) | A.
// +1 / -1 per le transizioni valide, 0 per "nessun cambio" o salto non valido.
const int8_t TABELLA[16] = {
   0, -1, +1,  0,
  +1,  0,  0, -1,
  -1,  0,  0, +1,
   0, +1, -1,  0
};

volatile uint8_t statoAB = 0;
volatile int8_t  sottoPassi = 0;
volatile int32_t posizione = 0;          // in scatti

void isrEncoder() {
  uint8_t ora = (PIND >> 2) & 0x03;      // bit0 = D2 (A), bit1 = D3 (B)
  uint8_t indice = (statoAB << 2) | ora;
  int8_t delta = TABELLA[indice];
  sottoPassi += delta;
  statoAB = ora;

  if (ora == 0x03) {                     // posizione di riposo (A = B = 1)
    if (sottoPassi >= TRANSIZIONI_PER_SCATTO / 2) posizione++;
    else if (sottoPassi <= -TRANSIZIONI_PER_SCATTO / 2) posizione--;
    sottoPassi = 0;
  }
}

int32_t leggiPosizione() {
  int32_t copia;
  ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {    // int32_t: 4 byte, lettura non atomica
    copia = posizione;
  }
  return copia;
}

void azzeraPosizione() {
  ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
    posizione = 0;
    sottoPassi = 0;
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_CLK, INPUT_PULLUP);        // il KY-040 ha gia' 10 kOhm verso +
  pinMode(PIN_DT, INPUT_PULLUP);
  pinMode(PIN_SW, INPUT_PULLUP);
  statoAB = (PIND >> 2) & 0x03;
  attachInterrupt(digitalPinToInterrupt(PIN_CLK), isrEncoder, CHANGE);
  attachInterrupt(digitalPinToInterrupt(PIN_DT), isrEncoder, CHANGE);
  Serial.println(F("Encoder pronto"));
}

void loop() {
  static int32_t ultimaStampata = 0;
  static uint8_t swPrecedente = HIGH;
  static uint32_t tCambioSw = 0;
  static uint8_t swStabile = HIGH;

  int32_t pos = leggiPosizione();
  if (pos != ultimaStampata) {
    ultimaStampata = pos;
    Serial.print(F("Posizione: "));
    Serial.println(pos);
  }

  // pulsante dell'encoder: debounce non bloccante da 25 ms
  uint8_t sw = digitalRead(PIN_SW);
  if (sw != swPrecedente) {
    swPrecedente = sw;
    tCambioSw = millis();
  }
  if (millis() - tCambioSw >= 25 && sw != swStabile) {
    swStabile = sw;
    if (swStabile == LOW) {
      azzeraPosizione();
      Serial.println(F("Azzerato"));
    }
  }
}

La ISR legge i due pin con una sola lettura del registro PIND invece di due chiamate a digitalRead(): è più rapida e, soprattutto, fotografa A e B nello stesso istante. La posizione è un int32_t, quindi il loop() la legge e la azzera dentro un ATOMIC_BLOCK. Se il conteggio aumenta nel verso opposto a quello desiderato basta scambiare CLK e DT. Alcuni encoder hanno due transizioni per scatto o il riposo su 00 invece che su 11: in quel caso va adattata la costante TRANSIZIONI_PER_SCATTO e la condizione sullo stato di riposo, verificando il comportamento con il monitor seriale.

Verifica in simulazione e confronto con il metodo “ingenuo”

Lo script di stimolo genera la quadratura su D2 e D3 con un numero casuale di rimbalzi (da 0 a 4, distanziati 20–80 µs) su ogni transizione: 30 scatti in un verso a 4 ms per scatto, 12 nel verso opposto a 2 ms per scatto, 5 scatti lenti, un mezzo scatto avanti e indietro, 200 scatti alla velocità esagerata di 1000 scatti al secondo, poi la pressione del pulsante e 3 scatti all’indietro. La figura mostra un dettaglio dei segnali generati.

Simulazione dei segnali CLK e DT dell'encoder con rimbalzi su ogni transizione durante due scatti

In molti tutorial si trova una lettura più semplice: un solo interrupt sul fronte di discesa di CLK e una lettura di DT per decidere il verso. Per confronto, lo stesso stimolo è stato applicato anche a questa versione:

// Lettura "ingenua" dell'encoder: un solo interrupt sul fronte di discesa
// di CLK e lettura di DT per decidere il verso. Serve solo come confronto.

const uint8_t PIN_CLK = 2;
const uint8_t PIN_DT  = 3;

volatile int32_t posizione = 0;

void isrClk() {
  if (digitalRead(PIN_DT) == HIGH) posizione--;
  else posizione++;
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_CLK, INPUT_PULLUP);
  pinMode(PIN_DT, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(PIN_CLK), isrClk, FALLING);
  Serial.println(F("Encoder pronto"));
}

void loop() {
  static int32_t ultimaStampata = 0;
  noInterrupts();
  int32_t pos = posizione;
  interrupts();
  if (pos != ultimaStampata) {
    ultimaStampata = pos;
    Serial.print(F("Posizione: "));
    Serial.println(pos);
  }
}

Il programma con la tabella di stato ha stampato esattamente le posizioni attese in tutte le fasi (30, 18, 23, 23, 223, poi 0 dopo l’azzeramento e −3), mentre la versione ingenua ha contato 44 scatti invece di 30 già nella prima fase e ha registrato anche il mezzo scatto annullato: ogni rimbalzo di CLK produce un fronte di discesa in più. Senza rimbalzi (fase veloce) i due metodi concordano: ecco perché la versione semplice sembra funzionare con alcuni encoder e non con altri.

Grafico di simulazione: posizione dell'encoder letta con la tabella di stato e con il metodo ingenuo confrontata con il valore atteso

Sul circuito reale, se i rimbalzi sono molto rapidi o i cavi lunghi, si può aggiungere un condensatore da 10 nF tra ciascun piedino CLK e DT e GND: con il pull-up da 10 kΩ del modulo la costante di tempo vale 0,1 ms, sufficiente a smussare i rimbalzi più brevi senza rallentare un encoder girato a mano.

Contatore di impulsi e misura di frequenza

Gli interrupt sono lo strumento naturale per contare eventi: impulsi di un flussimetro, giri di una ventola, passaggi davanti a una fotocellula. La frequenza si misura in due modi complementari. Il conteggio a finestra conta gli impulsi in un tempo fisso, qui 1 s: la risoluzione è di ±1 impulso, cioè ±1 Hz, ottima alle frequenze alte e scarsa a quelle basse. La misura del periodo registra con micros() l’istante di ogni fronte e calcola la differenza tra due fronti successivi: la risoluzione di micros() sulla UNO a 16 MHz è di 4 µs, ottima per periodi lunghi e grossolana per periodi di poche decine di microsecondi.

// Contatore di impulsi e frequenzimetro su D2 (INT0, fronte di salita).
// Due metodi: conteggio in una finestra di 1 s e misura del periodo con micros().

#include <util/atomic.h>

const uint8_t PIN_INGRESSO = 2;
const uint32_t FINESTRA_MS = 1000;

volatile uint32_t impulsi = 0;           // impulsi nella finestra corrente
volatile uint32_t totale = 0;            // impulsi dall'accensione
volatile uint32_t tUltimoFronte = 0;
volatile uint32_t periodo = 0;           // in microsecondi

void isrImpulso() {
  uint32_t t = micros();
  periodo = t - tUltimoFronte;
  tUltimoFronte = t;
  impulsi++;
  totale++;
}

void setup() {
  Serial.begin(115200);
  pinMode(PIN_INGRESSO, INPUT);          // segnale da sorgente esterna 0-5 V
  attachInterrupt(digitalPinToInterrupt(PIN_INGRESSO), isrImpulso, RISING);
  Serial.println(F("Frequenzimetro pronto"));
}

void loop() {
  static uint32_t inizioFinestra = 0;

  if (millis() - inizioFinestra >= FINESTRA_MS) {
    inizioFinestra += FINESTRA_MS;

    uint32_t n, tot, p, tFronte;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {  // variabili a 32 bit: copia e azzeramento atomici
      n = impulsi;
      impulsi = 0;
      tot = totale;
      p = periodo;
      tFronte = tUltimoFronte;
    }

    Serial.print(F("Conteggio 1 s: "));
    Serial.print(n);
    Serial.print(F(" Hz  | periodo: "));
    if (n > 0 && p > 0 && micros() - tFronte < 2000000UL) {
      Serial.print(p);
      Serial.print(F(" us = "));
      Serial.print(1000000.0 / p, 1);
      Serial.print(F(" Hz"));
    } else {
      Serial.print(F("---"));
    }
    Serial.print(F("  | totale: "));
    Serial.println(tot);
  }
}

La finestra avanza con inizioFinestra += FINESTRA_MS invece di inizioFinestra = millis(), così il ritardo con cui il loop() si accorge della scadenza non si accumula di finestra in finestra. Le quattro variabili condivise sono a 32 bit e vengono copiate, e il contatore azzerato, nello stesso blocco atomico: altrimenti un impulso arrivato tra la lettura e l’azzeramento andrebbe perso. Il segnale deve essere un’onda rettangolare 0–5 V con massa in comune con Arduino.

In simulazione l’ingresso D2 ha ricevuto un’onda quadra a frequenze note. Questi sono i valori stampati nelle finestre interamente contenute in una fase dello stimolo:

Frequenza generata Conteggio in 1 s Periodo stampato Frequenza dal periodo
1000 Hz 1000, 1001 1000 µs, 1004 µs 1000,0 Hz, 996,0 Hz
40 Hz 40, 40 25000 µs 40,0 Hz
5000 Hz 4997, 5003 200 µs 5000,0 Hz
20000 Hz 19988 52 µs 19230,8 Hz

La tabella conferma la complementarità dei due metodi. A 40 Hz il conteggio non può distinguere 40,0 da 40,9 Hz, mentre il periodo è esatto al microsecondo; a 20 kHz il periodo di 50 µs viene letto come 52 µs per la granularità di 4 µs di micros() (errore del 4 %), mentre il conteggio resta entro lo 0,1 %. Le piccole oscillazioni del conteggio (4997 e 5003) derivano dal fatto che i confini della finestra sono stabiliti da millis(), che avanza a passi di circa 1 ms; il totale dall’accensione, invece, è risultato esattamente uguale al numero di fronti generati (58119), quindi nessun impulso è andato perso. Alzando la frequenza, in simulazione il programma conta ancora correttamente fino a circa 70 kHz, ma la misura del periodo perde significato (16 µs letti per 14,3 µs); a 80 kHz la ISR, con il prologo di attachInterrupt() e la chiamata a micros(), occupa praticamente tutto il tempo della CPU: il loop() smette di stampare e si perdono impulsi. Per misure più veloci o più precise si usano i timer hardware: l’ingresso di clock esterno T1 per il conteggio e l’Input Capture del Timer1, con risoluzione di 62,5 ns.

Errori comuni

  • Usare un pin che non supporta INT0/INT1. Sulla UNO attachInterrupt() funziona solo su D2 e D3; per gli altri pin servono i PCINT.
  • Dimenticare volatile sulle variabili scritte dalla ISR: il programma funziona con un livello di ottimizzazione e non con un altro.
  • Leggere variabili a 16 o 32 bit senza sezione critica: errori rari e non riproducibili.
  • Mettere delay(), Serial.print() o librerie lente nella ISR, o lasciare la ISR in esecuzione per millisecondi: si perdono altri interrupt e millis() rallenta.
  • Collegare un contatto meccanico a un interrupt senza alcun antirimbalzo: un pulsante produce decine di fronti per pressione.
  • Usare FALLING con un filtro a tempo sui pulsanti: i rimbalzi al rilascio vengono contati come nuove pressioni.
  • Leggere l’encoder con un solo interrupt su CLK: conteggi sbagliati con i rimbalzi, come mostrato dalla simulazione.
  • Disabilitare gli interrupt troppo a lungo: la sezione critica deve contenere solo le copie delle variabili.

Fonti e verifiche

I numeri dei vettori, i bit dei registri EICRA, EIMSK, EIFR, PCICR e PCMSKn e i tempi di risposta agli interrupt sono stati verificati sul datasheet Microchip dell’ATmega328P e sugli header di avr-libc; il comportamento di attachInterrupt(), millis() e micros() sui sorgenti del core Arduino AVR 1.8.6 e sulla documentazione ufficiale. Il codice di questo articolo è stato compilato con il core Arduino AVR 1.8.6 (avr-gcc 14.1) ed eseguito in simulazione con avr8js (ATmega328P a 16 MHz), con stimoli che generano rimbalzi, quadratura e onde quadre e con verifica automatica dei valori stampati; la prova su hardware reale va eseguita con la checklist fornita insieme ai sorgenti.

Articoli collegati

Admin | MST-Tutorial

Appassionato di elettronica, elettrotecnica e programmazione. Fondatore di MST-Tutorial, condivido con entusiasmo conoscenze, esperienze e progetti legati all’innovazione tecnologica, con l’obiettivo di rendere accessibili concetti tecnici complessi attraverso tutorial chiari e pratici.

Articoli Correlati

Pulsante per tornare all'inizio