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 chemillis()non avanza durante la ISR (si può leggere, ma restituisce sempre lo stesso valore) e chemicros()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
volatilee lette con una sezione critica se occupano più di un byte (vedi sotto). - Niente
Stringo 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.
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.
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.
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.
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.
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
volatilesulle 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 emillis()rallenta. - Collegare un contatto meccanico a un interrupt senza alcun antirimbalzo: un pulsante produce decine di fronti per pressione.
- Usare
FALLINGcon 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.
- Microchip: pagina prodotto e datasheet ATmega328P
- Microchip Online Docs: External Interrupts (famiglia megaAVR 48/88/168, stessa periferica)
- Arduino: riferimento di attachInterrupt()
- avr-libc: util/atomic.h e ATOMIC_BLOCK









