Interrupts – Teil 1: Externe Interrupts

Über den Beitrag

Mithilfe von Interrupts lassen sich viele Herausforderungen bei der Programmierung von Arduinoboards oder anderen Mikrocontrollern elegant bewältigen. Allerdings gibt es bei ihrer Nutzung eine Reihe von Stolperfallen. Hinzu kommt, dass die Programmierung von Interrupts recht hardwarespezifisch ist.

Über externe Interrupts gibt es eigentlich schon mehr als genug Beiträge im Netz und viele davon sind auch ausgezeichnet. Meine Motivation für einen eigenen Beitrag war, bei einigen Punkten etwas mehr in die Tiefe zu gehen. Vielleicht findet ja auch der eine oder andere fortgeschrittene Leser noch neue Aspekte. Allerdings ist das Werk dadurch recht länglich geworden, selbst in zwei Teilen.

Im ersten Teil geht es nach einer allgemeinen Einführung um die externen Interrupts. Folgendes kommt auf euch zu:

Was ist ein Interrupt?

Beim Programmieren ist man es gewohnt, sequentiell zu denken, d. h. dass eine Anweisung (Statement) nach der anderen abgearbeitet wird. Jetzt stellt euch vor, dass beispielsweise beim Drücken eines Tasters eine LED an- oder ausgeschaltet werden soll. Ob der Taster gedrückt wurde, ließe sich mit digitalRead() abfragen. Dabei gibt es zwei Optionen:  

  1. Ihr fragt, beispielsweise in einer While-Schleife, den Tasterzustand permanent ab. Ihr haltet das Programm also an einer Stelle an, bis der Taster gedrückt wurde.
  2. Wenn ein loop()-Durchlauf kürzer ist als ein Tasterdruck dauert, dann könntet ihr die digitalRead() Abfrage ohne blockierende Schleife einfügen.

Beides kann unter Umständen akzeptabel sein. Was aber, wenn das Programm weiterlaufen muss, weil andere Aufgaben regelmäßig erledigt werden müssen? Und was, wenn die Abarbeitung dieser Aufgaben länger dauert als ein Tasterdruck? Da helfen die Interrupts.

Für einen Interrupt braucht ihr zwei Dinge, nämlich einen Auslöser (Trigger) und eine Aktion (Interrupt Service Routine = ISR). Wenn die Auslösebedingung erfüllt ist, springt das Programm in die ISR, führt die dort festgelegten Anweisungen aus und springt dahin zurück, wo es unterbrochen wurde:

Programmablauf mit Interrupts
Programmablauf mit Interrupts

Eine ISR ist in gewissem Sinne vergleichbar mit dem Time-Out in bestimmten Ballsportarten, wie es das Beitragsbild andeuten soll.

Welche Interrupts gibt es?

Mikrocontroller verwenden viele verschiedene Interrupts. Zum Teil nutzt ihr sie, ohne es zu merken, da der zugrundeliegende Code in Bibliotheken und Makros implementiert ist. Ein Beispiel dafür ist der Timer0 Overflow, der bei AVR-basierten Arduino Boards für die millis() Funktion genutzt wird. 

Welche Interrupts zur Verfügung stehen, ist hardwarespezifisch. Hier ein Überblick über die Interrupts des ATmega328P (Arduino UNO, Nano, Pro Mini):

Interrupts des ATmega328P
Interrupts des ATmega328P

Praktisch jeder Mikrocontroller verfügt über Interrupts, die durch ein externes Ereignis wie den Wechsel eines Pinlevels (HIGH/LOW, LOW/HIGH) ausgelöst werden. Beim ATmega328P sind das die Interrupts INT0 und INT1. In der Arduinowelt programmiert ihr diese Sorte Interrupts über die attachInterrupt() Funktion. Das Angenehme daran ist, dass ihr den Code bei Wechsel des Mikrocontrollers, z. B. vom Arduino UNO auf einen ESP32 nur geringfügig modifizieren müsst.

Andere nützliche Interrupts, wie z. B. Timer Interrupts, Watchdog Timer Interrupts oder die Pin Change Interrupts sind so unterschiedlich in den Mikrocontrollern implementiert, dass man sie nicht (direkt) über Arduinofunktionen zugänglich gemacht hat. Gleichwohl gibt es Arduinofunktionen, die diese Interrupts im Hintergrund nutzen, wie das schon erwähnte millis().

Externe Interrupts

Wir wenden uns den externen Interrupts am Beispiel der ATmega328P basierten Arduinos (Arduino UNO, Nano, Pro Mini) zu. Ein Tasterdruck soll einen Interrupt auslösen. Als Reaktion soll die Board LED des Arduinos für eine halbe Sekunde leuchten. Dazu könnte folgende Schaltung zum Einsatz kommen, die den Interruptpin D2 auf LOW zieht und bei Tasterdruck ein HIGH Signal auslöst:

Externer Interrupt - Beispielschaltung
Externer Interrupt – Beispielschaltung

Bei der Schaltung ist zu beachten, dass die auf der linken Seite des Tasters liegenden Anschlüsse permanent verbunden sind. Und hier der zugehörige Sketch:

const int ledPin = 13; // On-board LED
const int interruptPin = 2;
volatile bool event = false;
 
void eventISR(){
    event = true;   
}

void setup() {
    pinMode(interruptPin, INPUT);
    pinMode(ledPin, OUTPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, RISING);
}
 
void loop() {
    if(event){
        digitalWrite(ledPin, HIGH);
        delay(500);
        digitalWrite(ledPin, LOW);
        event = false;
    }
}

Erklärungen zu interrupt_basic.ino

Der Interrupt wird mithilfe der Funktion attachInterrupt(interrupt, ISR, mode) konfiguriert. Die drei Parameter sind:

  1. interrupt: Nummer des Interrupts (durch digitalPinToInterrupt(interruptPin))
  2. ISR: Interrupt Service Routine
  3. mode: Modus (LOW, RISING, FALLING, CHANGE)

Der ATmega328P hat zwei externe Interrupts, nämlich INT0 (am Arduino Pin 2) und INT1 (am Arduino Pin 3). Damit ihr die Interruptnummern nicht heraussuchen müsst, sondern einfach nur Pins verwenden könnt, gibt es die Funktion digitalPinToInterrupt(pin). Mit pin = 2 liefert die Funktion 0 zurück.

Die ISR wird aufgerufen, wenn der Interrupt ausgelöst wurde.

Der Modus spezifiziert genauer, unter welchen Bedingungen der Interrupt ausgelöst wird:

  • LOW: Pinlevel ist LOW.
  • RISING: Wechsel von LOW zu HIGH (steigende Flanke).
  • FALLING: Wechsel von HIGH zu LOW (fallende Flanke).
  • CHANGE: Wechsel HIGH / LOW oder LOW / HIGH.

Einfacher wäre es übrigens gewesen, die Logik der obigen Schaltung umzudrehen, d. h. bei Drücken des Tasters für einen HIGH-LOW Übergang („FALLING“) zu sorgen. Durch Verwendung des internen Pull-Up Widerstandes hätten wir den externen Widerstand einsparen können. 

Die Besonderheiten der ISR als Funktion

Für die ISR ist zu beachten:

  • Ihr übergebt der ISR keine Parameter und lasst sie auch nichts zurückgeben.
  • Die ISR sollte immer so kurz wie möglich gehalten werden.
  • Bei einigen Boards muss die ISR vor setup() platziert werden. Deswegen mache ich das in diesem Beitrag durchgängig so.
  • Globale Variablen, die ihr in der ISR ändert, müssen mit dem Schlüsselwort volatile definiert werden.
  • Innerhalb der ISR sind alle weiteren Interrupts ausgesetzt.

Der letzte Punkt braucht noch ein paar Erläuterungen. Treten die Bedingungen für einen weiteren Interrupt während der Ausführung der ISR ein, werden die Interruptflags der zuständigen Register zwar gesetzt, aber ansonsten bleibt der Interrupt zunächst folgenlos. Aus einer ISR heraus können keine weiteren ISRs aufgerufen werden.

Da delay() Interrupt-basiert arbeitet, funktioniert es in der ISR nicht. millis() an sich funktioniert zwar, aber die Zeit innerhalb der ISR bleibt stehen, da die Zeitmessung auf Timer0 Interrupts basiert.

Da das vielleicht ein wenig theoretisch klingt, hier ein Beispiel:

const int ledPin = 13;
const int interruptPin = 2;
volatile bool event = false;
 
void eventISR(){
    unsigned long x = 0;
    
    x = millis();
    Serial.println(x);
    Serial.flush();
    
    myDelay(2000);
    
    x = millis();
    Serial.println(x);
    Serial.flush();
    
    event = true; 
}

void setup() {
    Serial.begin(9600);
    pinMode(interruptPin, INPUT);
    pinMode(ledPin, OUTPUT); 
    attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, RISING);
}
 
void loop() {
    if(event){
        digitalWrite(ledPin, HIGH);
        delay(500);
        digitalWrite(ledPin, LOW);
        event = false;
    }
}

void myDelay(unsigned int milli_secs) { 
    for(unsigned int i=0; i<milli_secs; i++){ 
        delayMicroseconds(1000); 
    }
}

 

Und das ist die Ausgabe dazu:

Ausgabe interrupt_millis_delay_test.ino
Ausgabe interrupt_millis_delay_test.ino

Schlussfolgerungen aus dem Sketch

Aus der obigen Ausgabe können wir folgende Schlüsse ziehen:

  • delayMicroseconds() funktioniert in der ISR (auch wenn das kein guter Stil ist!).
  • Die Zeit zwischen den millis() Aufrufen ist stehengeblieben. Ein guter Grund, die ISRs kurzzuhalten.
  • Der Mikrocontroller ist während der Ausführung der ISR nicht „taub“ für neue Interrupts.

Zum letzten Punkt: Eigentlich würde man immer nur zwei identische Werte in Folge erwarten, teilweise seht ihr aber vier davon. Der Grund ist das Tasterprellen, das dafür sorgt, dass die Interruptbedingung schon wieder erfüllt ist, während die ISR noch mit der Abarbeitung des aktuellen Interrupts beschäftigt ist. Präziser ausgedrückt: Mit dem Sprung in die ISR wird das Interruptflag im zuständigen Register gelöscht. Eine erneut auftretende Interruptbedingung setzt es wieder, was aber während der Ausführung der ISR folgenlos bleibt. Das ändert sich nach dem Beenden der ISR, sodass das Programm sofort wieder in die ISR zurückspringt. Ein Sechserpack identischer Werte tritt nicht auf, weil das Prellen kürzer ist als die Abarbeitung der ISR und das Interruptflag nur einmal gesetzt werden kann.

Was bewirkt „volatile“?

Die Erklärung, warum volatile benutzt werden muss, wenn Variablen in der ISR geändert werden, ist nicht ganz einfach.

Variablen werden im SRAM gespeichert. Um sie während des Programmlaufs in der ALU (Arithmetic Logic Unit) für Rechen- oder Vergleichsoperationen nutzen zu können, müssen sie ausgelesen werden. Der Zugriff auf den SRAM ist aber vergleichsweise langsam. Deshalb gibt es Register für den schnelleren Zugriff, in denen Kopien der Variablen zwischengespeichert werden können. Beim ATmega328P stehen dafür 32 8-Bit Register zur Verfügung. Der Compiler versucht, diese Ressource effektiv zu nutzen. Dazu erlaubt er, dass die Variablen nicht für jeden Zugriff erneut aus dem SRAM gelesen werden müssen, sofern sich schon Kopien in den Registern befinden. Der Compiler „weiß“ zu jedem Zeitpunkt, wo der aktuelle Wert steht.

Das funktioniert aber nur, wenn das Programm keine Interrupts enthält, d. h. bei einem sequentiellen Ausführungsstrang. Dieser wird bei der Abarbeitung der ISR jedoch verlassen und damit kommt der Compiler hinsichtlich der Variablen nicht klar. Er kann nicht „voraussehen“, ob oder wann die ISR den Wert einer Variablen ändert und weiß deshalb auch nicht, wo sich die aktuelle Version der Variablen befindet. Sofern die Variable ausschließlich in der ISR geändert wird, ist sie für den Compiler praktisch eine Konstante. Der Zusatz volatile löst das Problem, indem er den Compiler anweist, Optimierungen zu unterlassen und die Variable ausschließlich aus dem SRAM zu lesen. Die Ausführung des Programms kann dadurch ein wenig langsamer werden, was sich aber in den allermeisten Fällen nicht bemerkbar machen sollte.

Umgang mit großen volatilen Variablen in ISRs

Es kommt noch schlimmer, sozusagen. Der ATmega328P ist ein 8-Bit Mikrocontroller. Entsprechend braucht ein auf ihm basierender Arduino wie der UNO, Nano oder Pro Mini mehrere Takte, um eine Variable zu lesen, die größer als ein Byte ist. Nun könnte Folgendes geschehen: Ihr verwendet eine volatile Variable, beispielsweise vom Typ long (4 Byte). Um die Variable zu verwenden, muss sie aus dem SRAM gelesen werden. Wenn während des Lesevorganges ein Interrupt ausgelöst wird – sagen wir nach dem zweiten Byte – unterbricht das den Lesevorgang. In der ISR wird die Variable verändert, danach wird der Lesevorgang fortgesetzt. Resultat: die ersten zwei Bytes stammen von der alten Version der Variable, die anderen zwei Bytes von der neuen Version. Das ist natürlich nicht gut.

Um so etwas zu verhindern, könnt ihr die Interrupts vor dem Verwenden der Variablen mit noInterrupts() aussetzen und dann mit interrupts() wieder aktivieren:

volatile long anyVariable; // example
...
noInterrupts(); // equals cli();
  // Gebrauch von / use of anyVariable
interrupts(); // equals sei();

Alternativ verwendet ihr für AVR-basierte Arduinos das Makro ATOMIC_BLOCK:

#include <util/atomic.h> 
volatile long anyVariable; // example
...
ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {  
  // Gebrauch von / use of anyVariable
}

Auch ATOMIC_BLOCK sorgt dafür, dass die Anweisungen in den geschweiften Klammern ungestört von Interrupts abgearbeitet werden. Ferner legt das Makro vor dem Aussetzen der Interrupts eine Kopie des Status Registers an und schreibt es später zurück. Ich komme darauf weiter unten wieder zurück. 

Debouncing

Blockierendes Debouncing

Im ersten Beispielsketch interrupt_basic.ino sind wir dem Problem des Tasterprellens aus dem Wege gegangen. Es fiel dort schlicht nicht auf, weil das mehrfache Aufrufen der sehr kurzen ISR keinen merklichen Einfluss auf die LED Leuchtphase (während delay(500)) hatte. Das Einfügen eines delay() ist somit ein einfaches Mittel, um Tasterprellen zu kaschieren, wenn auch nicht unbedingt das smarteste, da es den Sketch blockiert.

Dass das Prellen trotzdem da ist, zeigt das Oszilloskop:

Tasterprellen beim Drücken
Tasterprellen beim Drücken
Tasterprellen beim Loslassen
Tasterprellen beim Loslassen

Und da das Prellen sowohl beim Drücken, als auch bei Loslassen des Tasters auftreten kann, muss das Delay länger als der Tasterdruck sein.

Wie oft der Taster prellt, lässt sich mit dem folgenden Sketch prüfen, da er jeden ISR-Aufruf zählt:

const int interruptPin = 2;
volatile bool event = false;
volatile unsigned int counter = 0;
 
void eventISR() {
//    detachInterrupt(digitalPinToInterrupt(interruptPin)); 
    event = true;
    counter++;
}

void setup() {
    Serial.begin(9600);
    pinMode(interruptPin, INPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, RISING);
}
 
void loop() {   
    if(event) {
        Serial.print("Number of interrupts: ");
        Serial.println(counter);
        delay(200);
        event = false;
//        EIFR |= (1<<INTF0); // writing a "1" to interrupt flag 0 clears it
//        attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, RISING);
    }
}

Als Ergebnis bekommt ihr pro Tasterdruck auf dem seriellen Monitor genau eine Meldung, jedoch werden mehrere Interrupts angezeigt. Wie oft der Taster pro Druck prellt, hängt von seiner Beschaffenheit ab. Hier ein Beispiel:

Ausgabe von taster_press_counter_I.ino
Ausgabe von button_press_counter_I.ino

Debouncing mit detachInterrupt()

Wenn ihr die Zeilen 6 und 24 entkommentiert, werden die Interrupts an Pin 2 zwischenzeitlich mit detachInterrupt() ausgeschaltet und mit attachInterrupt() wieder eingeschaltet. Das ist nicht zu verwechseln mit der Wirkung der Funktionen noInterrupts() und interrupts(), die lediglich die Ausführung der ISR pausieren bzw. aktivieren.

Auf dem seriellen Monitor solltet ihr dann sehen, dass sich die „Number of interrupts“ nur noch um 1 pro Ausgabe erhöht – theoretisch. Unglücklicherweise bekommt ihr auf AVR basierten Arduinos jetzt dennoch zwei Ausgaben pro Tasterdruck. Andere Boards, die z.B. auf einem LGT8F328P oder ESP32 basieren, zeigen dieses Verhalten nicht. Abhilfe schafft das aktive Löschen des Interruptflags vor dem Wiedereinschalten des Interrupts (Zeile 23).

Nicht blockierendes Debouncing

Der Sketch button_press_counter_I.ino hat aber auch in seiner verbesserten Version noch zwei Nachteile:

  1. Durch das delay(200) ist er blockierend.
  2. Wenn ihr den Taster länger als 200 Millisekunden gedrückt haltet, kann das Prellen beim Loslassen einen erneuten Interrupt verursachen.

Delays können wir umgehen, indem wir die Zeit über millis() Abfragen messen. Das Prellen beim Loslassen des Tasters eliminieren wir durch die Einführung der folgenden Bedingung: Ein gültiges RISING liegt nur dann vor, wenn die davor liegende LOW-Phase länger als das Prellen ist.

Da wir also auch die Dauer der LOW-Phase messen wollen, müssen wir auch fallende Flanken detektieren und wechseln deshalb auf die Interruptbedingung CHANGE. Die maximale Prellzeit nennen wir im folgenden Sketch debounceTime. Ein gültiger HIGH- oder LOW-Zustand (lastValidState) liegt nur dann vor, wenn er länger als die debounceTime andauert.

const int interruptPin = 2;
volatile bool event = false;
volatile unsigned int counter = 0;
volatile int lastValidState = LOW;
volatile unsigned long lastInterrupt = 0;
const unsigned long debounceTime = 20;

void eventISR(){
    if((millis() - lastInterrupt) > debounceTime){           
        if( (digitalRead(interruptPin) == HIGH) && (lastValidState == LOW) ){
            event = true;
            lastValidState = HIGH;
            counter++;   
        }
        else {
            lastValidState = LOW;
        }
        lastInterrupt = millis();
    }
}

void setup() {
    Serial.begin(9600);
    pinMode(interruptPin, INPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, CHANGE);
}

void loop() {
    if(event){           // if event
        Serial.print("Number of Interrupts: ");
        Serial.println(counter);
        event = false;
    }
//    delay(4000);
}

Bei Entkommentierung von Zeile 34 werdet ihr sehen, dass die gültigen Tasterdrücke fleißig im Hintergrund gesammelt werden. Ihr könnt den Mikrocontroller also, sofern nicht sofort auf den Interrupt reagiert werden muss, ganz gemütlich andere Dinge erledigen lassen.

Alternatives, nicht blockierendes Debouncing

Viele Wege führen nach Rom. Hier noch ein etwas anderer Ansatz:

const int interruptPin = 2;
volatile byte buttonState = 0;
unsigned int counter = 0;
unsigned long lastCheck = 0;
const unsigned long checkInterval = 10;
byte stateHistory = 0;

void eventISR(){
    buttonState = digitalRead(interruptPin);
}

void setup() {
    Serial.begin(9600);
    pinMode(interruptPin, INPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, CHANGE);
}

void loop() {
    if((millis() - lastCheck) > checkInterval){
        stateHistory = (stateHistory << 1) | (buttonState);
        if(stateHistory == 1){ 
            counter++;
            Serial.println(counter);
        }
        lastCheck = millis();      
    }        
}

Die Funktionsweise erschließt sich vielleicht nicht für jeden auf den ersten Blick. In buttonState ist die aktuelle Polarität des Interruptpins gespeichert. Im Abstand von checkInterval wird die Statushistorie stateHistory um ein Bit nach links verschoben und der aktuelle Status angehängt. Da stateHistory als Byte definiert ist, umfasst die Historie acht Werte. Nur wenn die zurückliegenden sieben Checks einen LOW Status ergeben haben und der aktuelle Status HIGH ist, wird der Counter erhöht.

Der Vorteil gegenüber der vorherigen Methode ist die kurze ISR und der etwas straffere Code. Potenziell nachteilig ist, dass loop() keine Zeitfresser wie Delays enthalten darf, da sonst die Check-Intervalle entsprechend in die Länge gezogen werden.

Hardware Debouncing mit RC-Glied

Ich schweife hier (noch mehr) vom eigentlichen Thema der Interrupts ab, aber ich möchte das Hardware Debouncing nicht unerwähnt lassen. Dazu nutzen wir ein RC-Glied, welches aus einem Widerstand und einem Kondensator besteht.

Die sogenannte Zeitkonstante τ verrät uns, wie schnell ein Kondensator über einen in Reihe geschalteten Widerstand geladen wird:

\tau\; \text{[s]}= R\; \text{[}\Omega\text{]} \cdot C\; \text{[F]}

Der Ladevorgang ist nicht linear:

Kondensator Ladezustand in Abhängigkeit der Zeitkonstante
Kondensator Ladezustand in Abhängigkeit der Zeitkonstante

Hier eine Beispielschaltung:

Hardware Debouncing mit RC-Glied
Hardware Debouncing mit RC-Glied

Ein eindeutiges High Signal liegt bei einem AVR basierten Arduino bei ca. 0.6 x VCC. Bei Einsatz eines RC Gliedes ist das Potenzial nach ca. 1 τ erreicht. Drückt ihr den Taster in der obigen Schaltung, dann wird der Kondensator über den 1 kOhm Widerstand geladen. τ beträgt also 1 kOhm x 10 µF = 10 Millisekunden.

Der 10 kOhm Pull-Down Widerstand bekommt hier eine zusätzliche Aufgabe. Er ist Teil des RC Gliedes, dass beim Loslassen des Tasters aktiv wird. Die Obergrenze für ein eindeutiges LOW Signal liegt bei ca. 0.3 x VCC erreicht. Da die Entladegeschwindigkeit denselben Regeln folgt, ist das auch ungefähr 1 τ. Wegen des zehnfach größeren Widerstandes beträgt τ 100 ms.

Theorie und Praxis stimmen recht gut überein:

Spannungsverlauf am Interruptpin mit Hardware Debouncing
Spannungsverlauf am Interruptpin mit Hardware Debouncing

Ich habe dabei aber unterschlagen, dass die beiden Widerstände in der obigen Schaltung beim Drücken des Tasters einen Spannungsteiler bilden. Die Widerstände habe ich also nicht zufällig unterschiedlich groß gewählt. Mit meiner Kombination liegt die maximale Spannung an Pin D2 bei 10/11tel VCC, also im sicheren Bereich, um einen HIGH Level zu erreichen. Mit zwei gleich großen Widerständen würde es nicht funktionieren.

Im Prinzip sollte auch die unten abgebildete, einfachere Variante funktionieren (Danke, Neil!). Sie hat den Vorteil, dass der Einschaltvorgang wesentlich schneller ist. Ich hatte Taster, mit denen das zuverlässig ging, bei anderen wurde der Prell-Effekt damit nicht zuverlässig unterbunden. Probiert es am besten aus.

Hardware Debouncing Simple
Hardware Debouncing Simple

Gezieltes Aussetzen von Interrupts mit noInterrupts()

Ich möchte noch einmal auf die Wirkung von noInterrupts() und interrupts() zurückkommen. Dazu zunächst folgender Sketch:

const int interruptPin = 2;
const int ledPin = 13;
volatile bool event = false;

void eventISR(){
    for(int i=0; i<5; i++){
        digitalWrite(ledPin, HIGH);
        myDelay(50);
        digitalWrite(ledPin, LOW);
        myDelay(50);
    }
}

void setup() {
    pinMode(interruptPin, INPUT);
    pinMode(ledPin, OUTPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin), eventISR, RISING);
}

void loop() {
    noInterrupts();
    for(int i=0; i<4; i++){
        digitalWrite(ledPin, HIGH);
        myDelay(500);
        digitalWrite(ledPin, LOW);
        myDelay(200);
    }
    myDelay(1000);
    interrupts(); 
}

void myDelay(unsigned int milli_secs)   {
  for(unsigned int i=0; i<milli_secs; i++){
    delayMicroseconds(1000);
  }
}

Am Anfang der Hauptschleife wird die Ausführung der ISRs durch noInterrupts() pausiert. Trotzdem werden Interruptbedingungen registriert und die zuständigen Interruptflags gesetzt. Die ISRs werden dann nach der Reaktivierung durch interrupts() erst am Ende der Hauptschleife ausgeführt, was sich im konkreten Beispiel durch ein schnelles Blinken bemerkbar macht. Drückt ihr den Taster während eines Hauptschleifendurchlaufs mehrfach, wird die ISR trotzdem nur einmal ausgeführt, da das zuständige Interruptflag (INTF0) nur einmal gesetzt werden kann. Würdet ihr hingegen noch einen weiteren externen Interrupt an D3 (INT1) einrichten, könntet ihr zusätzlich INTF1 setzen. Infolgedessen würden beide ISRs abgearbeitet werden.

cli() und sei()

Vielleicht seid ihr schon mal über die Funktionen cli() und sei() gestolpert. Diese sind mit noInterrupts() und interrupts() identisch. Es sind lediglich andere Bezeichnungen, die über entsprechende #define Direktiven in Arduino.h vergeben wurden.

Und vielleicht habt ihr auch schon mal gelesen, dass man anstelle cli() …. sei() folgenden Code verwenden sollte:

uint8_t oldSREG = SREG;
cli();
...
SREG = oldSREG;

Der wesentliche Unterschied ist, dass mit sei() die Interrupts auf jeden Fall aktiviert werden. Das Sichern und Zurückschreiben des Statusregisters SREG sorgt hingegen dafür, dass lediglich der vorherige Zustand wieder hergestellt wird. D. h.: wenn die Interrupts vor der Ausführung von cli() schon deaktiviert waren, werden sie mit SREG = oldSREG; nicht aktiviert. Im Arduinobereich ist das von geringerer Bedeutung, da Interrupts dort standardmäßig aktiviert sind.

Externe Bauteile mit Interruptausgang

Ein wenig kompliziert wird es, wenn ihr externe Bauteile (Sensoren o. ä.) verwendet, die über einen eigenen Interruptausgang verfügen, um ein Ereignis anzuzeigen. Beispiel: Im Falle des Bauteil-Interrupts geht der Ausgang auf HIGH. Auf der Mikrocontrollerseite nutzt ihr einen Interrupteingang, um das Ereignis zu detektieren. Bei den meisten Bauteilen bleibt der Interrupt des Bauteils aktiv, bis bestimmte Messwertregister oder Interruptflags ausgelesen wurden. Damit schaltet ihr die Interruptfunktion des Bauteils wieder scharf. In dem Beispiel würde der Ausgang wieder auf LOW gehen.

Dabei kann dann ein typischer Fehler eintreten: das Bauteil löst schon den nächsten Interrupt aus (Interruptausgang des Bauteils ist wieder HIGH), während der Mikrocontroller noch die Aktionen aus dem letzten Interrupt abarbeitet. Ist er fertig damit, setzt er das Event Flag event auf false und wartet auf das nächste Interruptsignal (ein RISING). Dann kann er aber lange darauf warten, weil das Level schon längst HIGH ist!

Das Scharfschalten der Interruptfunktion externer Bauteile, das Einstellen des Flags und die Nutzung von attachInterrupt() / detachInterrupt() müssen also zeitlich gut aufeinander abgestimmt werden.

Hardware vs. Software Interrupts

In vielen Artikeln werden die externen Interrupts unter der Überschrift „Hardware Interrupts“ behandelt. Das stimmt auch, suggeriert jedoch, dass alle anderen Interrupts wie z.B. die Pin Change oder Timer Interrupts zur Gruppe der Software Interrupts gehörten. Das wiederum stimmt so nicht. Die meisten Mikrocontroller wie die in Arduinos verbauten 8-Bit AVR Modelle haben an sich keine Software Interrupts implementiert. Auch ein Timer Overflow Interrupt ist kein Software Interrupt.

Allerdings lassen sich Software Interrupts mithilfe von Hardwareinterrupts realisieren. Beispielsweise könnt ihr auf einem AVR-Arduino einen Interrupt an einem externen Interrupt Pin einrichten und den Pin auf OUTPUT setzen. Dabei lasst ihr ihn unverbunden. Mittels digitalWrite() könnt ihr dann gezielt Interrupts auslösen.

ATmega328P – Betrachtung externer Interrupts auf Registerebene

Wer Lust hat, der kann in diesem Kapitel erfahren, wie externe Interrupts beim ATmega328P auf Registerebene funktionieren. Das muss man nicht unbedingt wissen, um Interrupts anwenden zu können, ich persönlich finde es aber hilfreich. Insbesondere hilft es, Fehler besser zu verstehen.

Status Register SREG

Das Status Register SREG enthält viele Informationen über arithmetische Operationen, also z.B. ob ein negatives Ergebnis vorliegt (Bit 2). Für Interrupts ist das Bit 7 („I“ = Global Interrupt Enable) von Bedeutung. Die Funktion noInterrupts() bzw. cli() löscht es, interrupts() bzw. sei() setzt es. 

SREG Register
SREG Register

External Interrupt Control Register A – EICRA

Im External Interrupt Control Register A (EICRA) werden die Interruptbedingungen (LOW, CHANGE, FALLING oder RISING) für die externen Interrupts INT0 und INT1 festgelegt.

EICRA Register
EICRA Register

Bit 0 und Bit 1 sind für INT0 zuständig, Bit 2 und Bit 3 für INT1.

Festlegung der Interruptbedingungen in EICRA am Beispiel von INT0
Festlegung der Interruptbedingungen in EICRA am Beispiel von INT0

External Interrupt Mask Register

Im External Interrupt Mask Register EIMSK werden die externen Interrupts aktiviert. In der Arduinoprache geschieht das durch attachInterrupt(digitalPinToInterrupt(pin), ... , ...).

EIMSK Register
EIMSK Register

External Interrupt Flag Register

Im External Interrupt Flag Register EIFR werden die Interruptflags INTF0 und INTF1 gesetzt, wenn der entsprechende Interrupt ausgelöst wurde. Die Interruptflags werden gelöscht, wenn die entsprechende ISR ausgeführt wird. Alternativ löscht Ihr das jeweilige Bit, indem ihr es mit einer 1 beschreibt.

EIFR Register
EIFR Register

Beispielsketch mit Registeranweisungen

So sieht dann die „Übersetzung“ des Sketches button_press_counter_I.ino von weiter oben aus, wenn wir die Interrupts direkt über die Register einstellen: 

const int interruptPin = 2;
volatile bool event = false;
volatile static unsigned int counter = 0;
 
ISR (INT0_vect){          // ISR for INT0
    EIMSK &= ~(1<<INT0);  // detach INT0 interrupt
    event = true;
    counter++; 
}

void setup() {
    Serial.begin(9600);
    pinMode(interruptPin, INPUT);
    EICRA = (1<<ISC01) | (1<<ISC00);  // Interrupt @ rising edge
    EIMSK = (1<<INT0); 
}
 
void loop() {
    if(event){
        Serial.print("Number of interrupts: ");
        Serial.println(counter);
        delay(200);
        event = false;
        EIFR |= (1<<INTF0); // clear interrupt flag 
        EIMSK = (1<<INT0);  // attach INT0 interrupt
    }
}

Wenn ihr einen dritten externen Interrupt braucht

Falls Ihr noch einen zusätzlichen externen Interrupt benötigt, dann könnt ihr den Input Capture Interrupt an ICP1 (PB0 / Pin 8) dafür „missbrauchen“. Wie das geht, erkläre ich in meinem noch zu schreibenden Beitrag über Timer Interrupts. 

Externe Interrupts auf anderen MCUs / Boards

Arduino Boards

Andere Arduino Boards, die nicht auf dem ATmega328P basieren, haben in der Regel mehr als zwei externe Interruptpins. Hier gibt es eine Übersicht.

Das Angenehme an der Arduino Umgebung ist, dass ihr euch ansonsten nicht umgewöhnen müsst. Anders sieht es aus, wenn ihr auf Registerebene geht. In diesem Fall müsst ihr ins Datenblatt des Mikrocontrollers schauen.

An dieser Stelle ein Hinweis für den Arduino MEGA 2560: theoretisch könnt ihr die Pins 2, 3, 18, 19, 20 und 21 als externe Interruptpins einsetzen, praktisch machen die Pins 20 und 21 jedoch Probleme, weil sie aufgrund ihrer Funktion als I2C Pins standardmäßig auf HIGH gezogen sind.

LGT8F328P / MiniEVB Boards

Bezüglich der externen Interrupts ist der LGT8F328P bzw. die auf ihm basierenden MiniEVB Boards nahezu identisch. Sketche für ATmega328P basierte Boards sind 1:1 übertragbar. Das Verhalten kann jedoch im Detail abweichen (wie z. B. im Kapitel Debouncing mit detachInterrupt erwähnt).

ATtinys

Es gibt eine riesige Auswahl von ATtinys, die sehr bequem mit den Boardpaketen von Spence Konde programmierbar sind, inklusive der Interrupts. Ich habe über diese MCUs hier (ATTinyCore) und hier (megaTinyCore) berichtet. Im Hinblick auf die externen Interrupts könnten die Vertreter der megaTinyCore Familie (= tinyAVR 0/1/2) Serie für euch interessant sein, da externe Interrupts an allen Pins eingerichtet werden können.

ESP32

Am ESP32 könnt ihr bis zu 32 externe Interrupts pro Kern einrichten – jedenfalls theoretisch. Praktisch ist das durch die Anzahl verfügbarer Pins begrenzt. Außerdem haben einige Pins gewisse Eigenheiten, die sie generell oder für bestimmte Konstellationen ungeeignet machen (siehe hier).

Als Bedingung für den externen Interrupt könnt ihr neben LOW, CHANGE, FALLING und RISING auch HIGH auswählen.

Der ESP32 führt den Programmcode der ISRs im Gegensatz zu den Arduinos in seinem Flash Speicher aus. Um die ISRs in den schnelleren RAM (genauer: IRAM = Instruction RAM) umzuziehen, ergänzt ihr sie um das Attribut IRAM_ATTR. Es gibt neben dem Geschwindigkeitsvorteil auch andere Gründe, das zu tun, aber das würde hier zu weit führen.

Beim ESP32 ist es zwingend erforderlich, die ISR vor das Setup zu setzen, denn sonst bricht das Kompilieren mit einer Fehlermeldung ab. 

Hier ein Beispielsketch mit zwei LEDs und zwei Tastern:

const int ledPin_1 = 21;
const int ledPin_2 = 18;
const int interruptPin_1 = 16;
const int interruptPin_2 = 12;
volatile bool event_1 = false;
volatile bool event_2 = false;
 
void IRAM_ATTR eventISR_1(){
    event_1 = true;
}

void IRAM_ATTR eventISR_2(){
    event_2 = true;
}

void setup() {
    pinMode(interruptPin_1, INPUT);
    pinMode(interruptPin_2, INPUT);
    pinMode(ledPin_1, OUTPUT);
    pinMode(ledPin_2, OUTPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin_1), eventISR_1, RISING);
    attachInterrupt(digitalPinToInterrupt(interruptPin_2), eventISR_2, RISING);
}
 
void loop() {
    if(event_1){
        digitalWrite(ledPin_1, HIGH);
        delay(2000);
        digitalWrite(ledPin_1, LOW); 
        event_1 = false;   
    }
    if(event_2){
        digitalWrite(ledPin_2, HIGH);
        delay(2000);
        digitalWrite(ledPin_2, LOW); 
        event_2 = false;   
    }
}

 

Feine Unterschiede

Die „Arduinosprache“ ist sehr bequem, da sie die Portierung von Programmcode von einem Mikrocontroller zu einem anderen unkompliziert macht. Anderseits werden dabei manchmal feine Unterschiede überdeckt. Ich habe dazu den Sketch ESP32_interrupt_examle.ino modifiziert. Die Anweisungen zum Schalten der LEDs stehen jetzt in der ISR. Das ist kein guter Stil und dient nur der Anschauung.

const int ledPin_1 = 21;
const int ledPin_2 = 18;
const int interruptPin_1 = 16;
const int interruptPin_2 = 12;
volatile bool event_1 = false;
volatile bool event_2 = false;
 
void IRAM_ATTR eventISR_1(){
    digitalWrite(ledPin_1, HIGH);
    myDelay(2000);
    digitalWrite(ledPin_1, LOW);
}

void IRAM_ATTR eventISR_2(){
    digitalWrite(ledPin_2, HIGH);
    myDelay(2000);
    digitalWrite(ledPin_2, LOW);
}

void setup() {
    pinMode(interruptPin_1, INPUT);
    pinMode(interruptPin_2, INPUT);
    pinMode(ledPin_1, OUTPUT);
    pinMode(ledPin_2, OUTPUT);
    attachInterrupt(digitalPinToInterrupt(interruptPin_1), eventISR_1, RISING);
    attachInterrupt(digitalPinToInterrupt(interruptPin_2), eventISR_2, RISING);
}
 
void loop() {}

void myDelay(unsigned int milli_secs) {
    for(unsigned int i=0; i<milli_secs; i++){ 
        delayMicroseconds(1000); } 
}

Drückt ihr den Taster für die LED 2, während LED 1 leuchtet, dann bleibt das folgenlos. LED 2 leuchtet nicht auf, wenn LED 1 aufgehört hat. Wenn ihr den Sketch nun auf einen AVR basierten Arduino übertragt (entfernt das IRAM_ATTR und passt die Pins an), ändert sich das Verhalten. Jetzt leuchtet die LED 2 im Anschluss. Im Falle des Arduino wird das Interruptflag für den Taster 2 gesetzt und nach Beendigung der ISR 1 „abgearbeitet“. 

Das zeigt noch einmal: ISRs sollten so kurz wie möglich gehalten werden. Im Falle des ESP32 gerade dann, wenn ihr viele externe Interrupts einrichtet.

ESP8266

Der ESP8266 verhält sich hinsichtlich der Interrupts wie der ESP32. Auch bei ihm gibt es für einige Pins gewisse Einschränkungen. Das habe ich hier in meinem Beitrag über die Wemos D1 mini Boards besprochen.

Die ISRs benötigen zwingendermaßen ein Attribut. Nehmt am besten wieder IRAM_ATTR. Früher hieß es ICACHE_RAM_ATTR. Letzteres funktioniert war noch, gibt aber eine Compiler Warnung. 

Fazit und Ausblick

Externe Interrupts sind sehr nützlich, um den Zustand externer Hardware (im einfachsten Fall ein Taster) zu überwachen. Der große Vorteil ist, dass ihr auf ständige Abfragen des Zustandes verzichten könnt. Andererseits sind Interrupts immer wieder eine „beliebte“ Fehlerquelle. Die Funktionsweise von Interrupts kann auf unterschiedlichen Boards bzw. Mikrocontrollern variieren. Das kann bei der Übertragung von Sketchen auf andere Boards zu Überraschungen führen.

Im nächsten Beitrag werde ich die Pin Change Interrupts besprechen und im übernächsten Beitrag geht es dann zu den Timern.

Danksagung

Mein Beitragsbild habe ich aus verschiedenen Komponenten zusammengesetzt. Die Time-Out Geste habe ich Gerd Altmann zu verdanken, das Hintergrundbild stammt von federicoag, beides gefunden auf Pixabay.

12 thoughts on “Interrupts – Teil 1: Externe Interrupts

  1. Hallo,
    vielen Dank für diesen Artikel über die Interrupts. Er ist super gut verständlich geschrieben!
    Ich habe aber noch eine Frage: Manchmal werden Bits in Register mit „|=“ gesetzt z.B. EIFR |= (1<<INTF0), und manchmal wird das ODER weggelassen z.B. EIMSK = (1<<INT0). Kann man das machen, wenn alle Bits im Register Null sind? Oder was ist da der Grund?
    Außerdem habe ich noch einen kleinen Schreibfehler entdeckt: In der orangefarbenen EICRA-Tabelle in der untersten Zeile müsste es glaube ich heißen: (1<<ISC01) | (1<<ISC00).
    Liebe Grüße Stefan

    1. Hi Stefan,
      genau, das „|=“ setzt Bits selektiv. Alle nicht betroffenen Bits bleiben wie sie sind. Das „=“ hingegen würde alle nicht explizit gesetzten Bits auf Null setzen.
      Danke für den Hinweis auf den Fehler – schaue ich mir nachher an und werde es korrigieren.
      VG, Wolfgang

  2. Moin. Mal über ein Buch nachgedacht? Ich würde mich das was kosten lassen! WE ist ein Standard Nachschlagewerk. LG Stefan

    1. Hallo Stefan, vielen Dank! Drüber nachgedacht habe ich schon, aber wegen chronischem Zeitmangel wieder verworfen. Meine Familie ist so schon schlecht auf mein Hobby zu sprechen. Vielleicht, wenn ich mal pensioniert bin (das liegt allerdings noch in weiter Ferne).
      VG, Wolfgang

      1. Moin, Familie muss Vorrang haben. Mein Gedankengang rührt wohl von dem mir eigenen Egoismus her das durch andere aufbereitete Wissen stets griffbereit haben zu wollen.
        Das ist es auf der Webseite auch. Hab Dank dafür. LG Stefan

  3. Hallo Wolfgang,

    Herzlichen Dank ganz allgemein für all die vielen Beiträge die du rund um dieses Feld veröffentlicht hast.
    Ich kann nur für mich selbst reden, aber ich habe eine Unmenge an wirklich hilfreichen Informationen durch deine Seite erhalten.

    Ich hoffe, dass du noch lange aktiv bleibst und genauso weitermachst.
    Viele Grüße
    Wolfgang

  4. Mal wieder vielen Dank! Diesmal enthält der Artikel zwar zufälligerweise für mich nichts Neues. Das liegt jedoch daran, dass ich mich sehr intensiv einarbeiten musste, um einige Maßnahmen umzusetzen, die ich in der Vergangenheit angegangen bin. Hätte ich diesen Artikel damals doch schon lesen können – wäre Gold wert gewesen. Eine Anmerkung, die mir vor einem Jahr viel Zeit und Nerven gekostet hat, wird vielleicht im zweiten Teil angesprochen. Stromsparen mittels Sleep Modes tiefer als Idle nimmt die Option zum Nutzen von Rising/Falling weg. Ich bin sicher, du weißt das; vielleicht weiß es nicht jeder Leser und wundert sich dann, wenn er mittels Interrupt den Schlaf seines Mikrocontrollers beenden möchte.

    1. Hallo Leif, vielen Dank. Muss mal schauen, ob ich das Rising / Falling Problem noch einbauen kann. Guter Punkt!

      VG, Wolfgang

  5. Hallo,

    wunderbar der Artikel.

    Ich habe 1983 angefangen mit der Materie und habe mich damals am U880/Z80 „abgearbeitet“.
    Ich habe 2 Jahre gebraucht um Interrupts zu verstehen.
    „Was muss ich ein Programm unterbrechen, ich bin ja froh wenn’s läuft :-)“

    Nun sind das alte Hüte, aber das hier ist ein wunderbares Beispiel wie man sich gut in das Thema einarbeitet.
    Danke!

  6. Hallo Wolfgang,

    sehr schöner Artikel, der die anderen Beschreibungen in der Literatur vertieft und verdeutlicht – vielen Dank dafür!

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert