Arduino Interrupts verstehen: attachInterrupt, ISR & Entprellen – nie wieder verpasste Signale

Dein Arduino soll auf einen Tastendruck reagieren – aber genau in dem Moment steckt er in einem delay() oder liest gerade einen Sensor aus, und der Druck verpufft ungehört. Die Lösung heißt Interrupts: Der Mikrocontroller unterbricht seine aktuelle Arbeit in Mikrosekunden, führt eine kurze Reaktions-Funktion aus und macht danach nahtlos weiter. In diesem Tutorial lernst du attachInterrupt() von Grund auf: Was eine ISR ist, warum volatile kein optionales Schmuckwort ist, wie du prellende Signale sauber entprellst und wie du Touch-Sensor, PIR-Bewegungsmelder und Rotary Encoder interrupt-gesteuert auswertest – auf dem Arduino UNO genauso wie auf dem ESP32.

Warum Polling nicht reicht – und Interrupts die Rettung sind

Der klassische Anfänger-Ansatz prüft Eingänge im loop(): if (digitalRead(PIN) == HIGH) ... – das nennt man Polling. Das funktioniert, solange dein loop() schnell durchläuft. Sobald aber ein delay(1000), eine langsame Sensor-Abfrage oder eine Display-Aktualisierung dazwischenfunkt, ist dein Programm für diese Zeit blind: Ein kurzer Impuls von 50 Millisekunden geht schlicht verloren.

Ein Interrupt dreht das Prinzip um: Die Hardware selbst überwacht den Pin. Ändert sich der Pegel, unterbricht der Controller sofort das laufende Programm, springt in deine ISR (Interrupt Service Routine), arbeitet sie ab und kehrt exakt an die unterbrochene Stelle zurück. Das Ganze dauert nur wenige Mikrosekunden.

  • Nichts geht verloren: Auch kürzeste Impulse werden erfasst – egal was der loop() gerade treibt
  • Sofortige Reaktion: Reaktionszeit im Mikrosekunden-Bereich statt „irgendwann im nächsten loop-Durchlauf“
  • Sauberer Code: Ereignis-Logik wandert aus dem loop() in eine eigene kleine Funktion
  • Strom sparen: Interrupts können den Controller sogar aus Schlafmodi aufwecken – die Basis von ESP32 Deep Sleep

ℹ️ Interrupts ersetzen millis() nicht: Für zeitgesteuerte Abläufe bleibt das millis()-Muster das Mittel der Wahl. Interrupts sind für externe Ereignisse da, die du nicht verpassen darfst. Die Kombination aus beidem macht deine Sketches wirklich reaktionsschnell.

Das brauchst du

UNO R3 Entwicklungsboard (USB-C) – der Klassiker mit zwei externen Interrupt-Pins (D2 und D3)
TTP223 Kapazitiver Touch-Sensor (10er-Pack) – liefert ein sauberes, prellfreies Digitalsignal – ideal für den ersten Interrupt
HC-SR501 PIR Bewegungsmelder – meldet Bewegung per Interrupt, ohne dass der loop() ständig nachfragen muss
Rotary Encoder EC11 Modul – das Paradebeispiel für Interrupts: Drehimpulse gehen beim Polling ständig verloren
Breadboard mit 400 Kontakten – für den schnellen Versuchsaufbau
Optional: ESP32 Entwicklungsboard (USB-C) – beim ESP32 ist fast jeder GPIO interrupt-fähig

Welche Pins können Interrupts?

Nicht jeder Pin eignet sich auf jedem Board für externe Interrupts – das ist der häufigste Stolperstein überhaupt:

Board Interrupt-fähige Pins Anzahl
Arduino UNO R3 D2, D3 2
Arduino Nano V3 D2, D3 2
ESP32 praktisch alle GPIOs ~30
ESP8266 (Wemos D1 Mini) alle außer GPIO 16 ~8

Damit dein Sketch auf allen Boards läuft, übersetzt du die Pin-Nummer immer mit digitalPinToInterrupt(pin) – niemals die rohe Interrupt-Nummer eintragen. Als Modus stehen zur Wahl:

  • RISING – löst bei steigender Flanke aus (LOW → HIGH)
  • FALLING – löst bei fallender Flanke aus (HIGH → LOW)
  • CHANGE – löst bei jeder Pegeländerung aus
  • LOW – löst dauerhaft aus, solange der Pin LOW ist (selten sinnvoll)

1Dein erster Interrupt: Touch-Zähler mit dem TTP223

Der TTP223 Touch-Sensor ist der perfekte Einstieg, weil er ein digital sauberes Signal liefert – kein Prellen, kein Rauschen. Verkabelung: VCC an 5V, GND an GND, Signal (I/O) an D2. Der Sketch zählt Berührungen, während der loop() demonstrativ mit einem trägen delay() beschäftigt ist:

const int TOUCH_PIN = 2;          // D2 = interrupt-faehig am UNO

volatile unsigned int beruehrungen = 0;   // volatile ist PFLICHT!

void beruehrungErkannt() {
  beruehrungen++;                 // ISR: so kurz wie moeglich
}

void setup() {
  Serial.begin(115200);
  pinMode(TOUCH_PIN, INPUT);
  attachInterrupt(digitalPinToInterrupt(TOUCH_PIN),
                  beruehrungErkannt, RISING);
  Serial.println("Touch-Zaehler bereit - beruehre den Sensor!");
}

void loop() {
  delay(2000);                    // absichtlich traege - Interrupt kommt trotzdem durch
  Serial.print("Beruehrungen bisher: ");
  Serial.println(beruehrungen);
}

Obwohl der loop() zwei Sekunden schläft, wird keine einzige Berührung verpasst – genau das ist die Magie der Interrupts.

⚠️ Warum volatile? Der Compiler optimiert Variablen gern in CPU-Register, wenn er glaubt, dass sie sich „von allein“ nicht ändern. Eine ISR ändert die Variable aber außerhalb des normalen Programmflusses. volatile zwingt den Compiler, die Variable bei jedem Zugriff frisch aus dem RAM zu lesen. Ohne volatile zeigt dein loop() unter Umständen ewig den alten Wert an – ein Fehler, der beim Testen oft zufällig nicht auftritt und später umso mysteriöser zuschlägt.

2Die goldenen Regeln für ISRs

Eine ISR unterbricht dein Programm mitten im Befehl. Damit dabei nichts schiefgeht, gelten eiserne Regeln:

  1. Kurz halten: Nur Flags setzen, Zähler erhöhen, Zeitstempel merken. Die eigentliche Arbeit (Display, Berechnungen) erledigt der loop()
  2. Kein Serial.print() in der ISR: Serial nutzt selbst Interrupts – im schlimmsten Fall friert dein Sketch ein
  3. Kein delay() und kein millis()-Warten: delay() steht in der ISR komplett still; millis() zählt während einer ISR nicht weiter
  4. Geteilte Variablen als volatile deklarieren – und bei Variablen größer als 1 Byte (int, long, float) den Zugriff im loop() mit noInterrupts()/interrupts() schützen

So sieht der sichere Zugriff auf einen mehrbyteigen Zähler aus:

volatile unsigned long impulse = 0;

void loop() {
  unsigned long kopie;

  noInterrupts();          // Interrupts ganz kurz anhalten ...
  kopie = impulse;         // ... Wert atomar kopieren ...
  interrupts();            // ... und sofort wieder freigeben

  Serial.println(kopie);   // mit der Kopie arbeiten
  delay(1000);
}

ℹ️ Hintergrund: Ein 8-Bit-AVR wie der ATmega328P liest einen unsigned long in vier einzelnen Byte-Zugriffen. Funkt die ISR genau dazwischen, bekommst du eine wilde Mischung aus altem und neuem Wert – die berüchtigte Race Condition. Die zwei Zeilen noInterrupts()/interrupts() kosten praktisch nichts und ersparen dir stundenlange Fehlersuche.

3Entprellen: Wenn ein Tastendruck zehnmal zählt

Mechanische Taster und Schalter prellen: Beim Schließen federn die Kontakte einige Millisekunden lang, und aus einem Druck werden fünf bis zwanzig Flanken – dein Interrupt-Zähler explodiert. Die Lösung ist eine Zeitsperre direkt in der ISR: Auslösungen, die weniger als ~200 ms nach der letzten kommen, werden ignoriert:

const int TASTER_PIN = 3;                  // zweiter Interrupt-Pin am UNO
const unsigned long PRELL_SPERRE = 200;    // ms - je nach Taster 50-250

volatile unsigned int druecke = 0;
volatile unsigned long letzteAusloesung = 0;

void tasterGedrueckt() {
  unsigned long jetzt = millis();          // millis LESEN ist in ISR ok
  if (jetzt - letzteAusloesung > PRELL_SPERRE) {
    druecke++;
    letzteAusloesung = jetzt;
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(TASTER_PIN, INPUT_PULLUP);       // interner Pull-up: Taster gegen GND
  attachInterrupt(digitalPinToInterrupt(TASTER_PIN),
                  tasterGedrueckt, FALLING);
}

void loop() {
  static unsigned int letzterStand = 0;
  unsigned int kopie;

  noInterrupts();
  kopie = druecke;
  interrupts();

  if (kopie != letzterStand) {
    letzterStand = kopie;
    Serial.print("Tastendruck Nr. ");
    Serial.println(kopie);
  }
}

Dank INPUT_PULLUP brauchst du keinen externen Widerstand: Der Pin liegt in Ruhe auf HIGH, der Taster zieht ihn beim Drücken auf GND – deshalb lauscht der Interrupt auf FALLING.

4Praxis: PIR-Alarm und Rotary Encoder per Interrupt

Bewegungsmelder ohne Polling

Der HC-SR501 hält seinen Ausgang bei Bewegung für einige Sekunden auf HIGH – perfekt für RISING. Die ISR setzt nur ein Flag, der loop() erledigt die Reaktion:

const int PIR_PIN = 2;
const int LED_PIN = 13;

volatile bool bewegungErkannt = false;

void pirAusgeloest() {
  bewegungErkannt = true;          // nur das Flag setzen - mehr nicht!
}

void setup() {
  Serial.begin(115200);
  pinMode(PIR_PIN, INPUT);
  pinMode(LED_PIN, OUTPUT);
  attachInterrupt(digitalPinToInterrupt(PIR_PIN), pirAusgeloest, RISING);
  Serial.println("PIR-Waechter aktiv (60 s Aufwaermzeit beachten)");
}

void loop() {
  if (bewegungErkannt) {
    bewegungErkannt = false;       // Flag zuruecksetzen
    digitalWrite(LED_PIN, HIGH);
    Serial.println("Bewegung erkannt!");
    // hier: Buzzer, Nachricht, Foto ausloesen ...
  }

  if (digitalRead(PIR_PIN) == LOW) {
    digitalWrite(LED_PIN, LOW);    // PIR wieder in Ruhe -> LED aus
  }
}

Wie du daraus eine komplette Alarmanlage mit ESP32 baust, zeigt das PIR-Alarmanlagen-Tutorial.

Rotary Encoder: das Interrupt-Paradebeispiel

Beim schnellen Drehen eines Rotary Encoders feuern die Impulse im Millisekunden-Takt – mit Polling verlierst du Schritte, die Drehrichtung springt. Mit einem Interrupt auf CLK sitzt jeder Schritt (CLK an D2, DT an D3, VCC an 5V, GND an GND):

const int CLK_PIN = 2;   // Interrupt-Pin
const int DT_PIN  = 3;

volatile int position = 0;

void encoderDreht() {
  // Drehrichtung: Zustand von DT im Moment der CLK-Flanke
  if (digitalRead(DT_PIN) != digitalRead(CLK_PIN)) {
    position++;          // im Uhrzeigersinn
  } else {
    position--;          // gegen den Uhrzeigersinn
  }
}

void setup() {
  Serial.begin(115200);
  pinMode(CLK_PIN, INPUT_PULLUP);
  pinMode(DT_PIN,  INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(CLK_PIN), encoderDreht, CHANGE);
}

void loop() {
  static int letztePosition = 0;
  int kopie;

  noInterrupts();
  kopie = position;
  interrupts();

  if (kopie != letztePosition) {
    letztePosition = kopie;
    Serial.print("Position: ");
    Serial.println(kopie);
  }
}

Auf dieser Basis baust du komfortable Bedienoberflächen – etwa das OLED-Menüsystem mit Rotary Encoder. Alle Details zum Encoder selbst stehen im KY-040 Grundlagen-Tutorial.

💡 ESP32-Besonderheit: Auf dem ESP32 markierst du ISRs zusätzlich mit void IRAM_ATTR meineISR() { ... }. Das legt die Funktion ins schnelle interne RAM statt in den Flash – sonst drohen sporadische Abstürze, wenn der Flash gerade durch WLAN-Aktivität blockiert ist. Ansonsten funktioniert attachInterrupt() identisch, nur eben auf fast jedem GPIO.

Troubleshooting

⚠️ attachInterrupt tut gar nichts: Falscher Pin! Am UNO/Nano funktionieren nur D2 und D3. Und immer digitalPinToInterrupt(pin) verwenden – wer die Pin-Nummer direkt einträgt, erwischt am UNO zufällig die richtige Nummer und wundert sich dann auf jedem anderen Board.

⚠️ Ein Tastendruck zählt mehrfach: Klassisches Prellen. Zeitsperre wie in Schritt 3 einbauen oder gleich einen prellfreien TTP223 Touch-Sensor verwenden.

⚠️ Sketch friert ein oder stürzt ab: Serial.print(), delay() oder lange Berechnungen in der ISR. Alles außer Flag setzen und Zähler erhöhen gehört in den loop().

⚠️ Werte im loop() sind Unsinn: volatile vergessen – oder bei int/long den Kopier-Trick mit noInterrupts()/interrupts() ausgelassen.

⚠️ ESP32 stürzt mit „Guru Meditation Error“ ab: Das IRAM_ATTR vor der ISR fehlt, oder in der ISR wird auf Flash-Funktionen zugegriffen.

⚠️ PIR löst ständig aus: Der HC-SR501 braucht nach dem Einschalten ~60 Sekunden Aufwärmzeit, und WLAN-Boards direkt daneben können ihn stören – etwas Abstand schafft Ruhe.

🚀 Dein Interrupt-Experimentierpaket

Alle Bauteile für dieses Tutorial bekommst du im Shop:

Fazit

Interrupts sind der Schritt vom „Sketch, der zufällig reagiert“ zum „Programm, das garantiert reagiert“. Du weißt jetzt, welche Pins am UNO interrupt-fähig sind, wie attachInterrupt() mit RISING, FALLING und CHANGE arbeitet, warum volatile und der atomare Kopier-Trick keine Kür, sondern Pflicht sind, und wie du prellende Taster mit einer simplen Zeitsperre zähmst.

Die Merkregel für jede ISR: rein, Flag setzen, raus. Alles Weitere – Displays, Berechnungen, Nachrichten – erledigt der loop() in Ruhe. Mit diesem Muster laufen Touch-Bedienung, Bewegungsmelder und Rotary Encoder parallel und verlustfrei, während dein Hauptprogramm sich um die eigentliche Aufgabe kümmert. Viel Spaß beim Unterbrechen!

Zurück zum Blog