Interrupts – Teil 2: Pin Change Interrupts

Über den Beitrag

In dieser Fortsetzung meines letzten Beitrages werde ich Pin Change Interrupts behandeln. AVR Mikrocontroller und darauf basierende Arduinos sind relativ spärlich mit jenen externen Interrupts ausgestattet, die ihr über attachInterrupt() einrichten könnt. Die gute Nachricht ist jedoch, dass euch an allen I/O Pins stattdessen die Pin Change Interrupts zur Verfügung stehen. Sie sind nicht ganz so komfortabel und außerdem ein wenig schwieriger zu konfigurieren, da man sich mit den Registern der zugrundeliegenden Mikrocontroller auseinandersetzen muss. Aber keine Sorge, Raketenwissenschaft ist das auch nicht.

Ich werde den Umgang mit Pin Change Interrupts ausführlich am ATmega328P (Arduino UNO, Nano, Pro Mini) erklären und dann am Beispiel des ATtiny85 zeigen, dass die Systematik dahinter bei allen AVR-Mikrocontrollern sehr ähnlich ist.

Folgendes kommt auf euch zu:

Ein Blick in den ATmega328P

Ports, Pins und PCINT

Die I/O Pins des ATmega328P sind in den drei Ports PORTB, PORTC und PORTD organisiert, die jeweils bis zu 8 Pins umfassen. Die Pins heißen entsprechend: PB[7:0], PC[6:0], PD[7:0]. Dabei bedeutet die Schreibweise „PB[7:0]“: PB7, PB6, PB5 … PB1, PB0. Jeder Pin hat eine eindeutige Pin Change Interrupt Nummer, nämlich PCINT[23:0].

Pinout ATmega328P vs. Arduino Pins (UNO, Nano, Pro Mini)
Pinout ATmega328P vs. Arduino Pins (UNO, Nano, Pro Mini)

Pin Change Interrupts am ATmega328P – die relevanten Register

Für jeden Port steht nur ein Pin Change Interrupt zur Verfügung, der jedoch an jedem einzelnen Pin des Ports ausgelöst werden kann, sofern der Pin dafür eingerichtet worden ist. Um einen Pin für Change Interrupts zu aktivieren, setzt ihr das zugehörige Bit im zuständigen Pin Change Mask Register PCMSK[2:0]. Hier eine Übersicht:

Pin Change Interrupt Mask Register PCMSK[0:2]
Pin Change Interrupt Mask Register PCMSK[2:0]

Überdies müsst ihr noch den Interrupt selbst aktivieren. Das macht ihr, indem ihr das zuständige Pin Change Enable Bit PCIE[2:0] im Pin Change Interrupt Control Register PCICR setzt.

Pin Change Interrupt Control Register PCICR
Pin Change Interrupt Control Register PCICR

PCIE0 ist für PORTB zuständig, PCIE1 für PORTC und PCIE2 für PORTD.

Im Falle eines Interrupts wird das entsprechende Flag PCIF[2:0] im Pin Change Interrupt Flag Register PCIFR gesetzt:

Pin Change Interrupt Flag Register PCIFR
Pin Change Interrupt Flag Register PCIFR

Praktische Beispiele mit dem ATmega328P

Ein einzelner Pin Change Interruptpin

Im ersten Beispiel richten wir nur einen einzigen Interruptpin ein. Dafür nehmen wir Arduino Pin 6 (PD6 / PCINT22), dessen Level auf Tasterdruck von HIGH nach LOW wechseln soll. Dazu schalten wir den internen Pull-Up Widerstand zu. Als Folge des Tasterdrucks soll die Board LED für eine halbe Sekunde leuchten.

Pin Change Interrupt an D6
Pin Change Interrupt an D6

Hier der Beispielsketch dazu:

const int ledPin = 13;  // = LED_BUILTIN
const int interruptPin = 6;
volatile bool event = false;
 
ISR (PCINT2_vect){ // PCINT2_vect: interrupt vector for PORTD
  event = true;
}

void setup() {
    pinMode(interruptPin, INPUT_PULLUP);
    PCICR = (1<<PCIE2);    // enable PCINT[23:16] interrupts
    PCMSK2 = (1<<PCINT22); // D6 = PCINT22
}
 
void loop() {
    if(event){
        digitalWrite(ledPin, HIGH);
        delay(500);
        digitalWrite(ledPin, LOW);
        event = false;
    }
}

Erläuterungen zum Sketch:

  • Mit PCICR = (1<<PCIE2) werden Pin Change Interrupts an PORTD erlaubt.
  • PCMSK2 = (1<<PCINT22) aktiviert den Pin Change Interrupt an PD6 (= Arduino Pin 6).
  • Der ISR übergeben wir den zuständigen Interruptvektor PCINT2_vect. Wie wohl die meisten schon richtig vermuten, wäre PCINT1_vect der zuständige Vektor für PORTC und PCINT0_vect der Vektor für PORTB.

In diesem ersten, minimalistischen Beispiel wird der Pin Change Interrupt sowohl bei Wechseln von HIGH nach LOW als auch von LOW nach HIGH berücksichtigt. Haltet ihr den Taster länger als 500 Millisekunden gedrückt, wird die LED beim Loslassen noch einmal aufleuchten.

Das Tasterprellen ist in dieser Konstellation unauffällig. Da ich auf Tasterprellen und Debouncing schon im letzten Beitrag intensiv eingegangen bin, werde ich das hier nicht noch einmal wiederholen.

Und wer noch Nachholbedarf beim Thema Binäroperationen (z.B. 1<<PCIE2)hat, der könnte sich diesen Beitrag anschauen.

Mehrere Pin Change Interruptpins an verschiedenen Ports

Im nächsten Schritt werden wir den Zustand von drei Tastern überwachen. Diesmal geben wir auf dem seriellen Monitor aus, wenn ein Interrupt ausgelöst wurde und an welchem Pin das passiert ist. Dazu verwenden wir drei Pins aus verschiedenen Ports:

  • PB1 = Arduino Pin 9 = PCINT1
  • PC2 = Arduino Pin A2 = PCINT10
  • PD5 = Arduino Pin 5 = PCINT21

Hier die Schaltung:

3 Pin Change Interrupts an 3 Ports
3 Pin Change Interrupts an 3 Ports

Und hier der zugehörige Sketch:

const int interruptPin_1 = 9; //PB1
const int interruptPin_2 = A2; //PC2
const int interruptPin_3 = 5; //PD5
volatile bool event_1 = false;
volatile bool event_2 = false;
volatile bool event_3 = false;
 
ISR (PCINT0_vect){   
    event_1 = true;
}

ISR (PCINT1_vect){   
    event_2 = true;
}

ISR (PCINT2_vect){   
    event_3 = true;
}


void setup() {
    Serial.begin(115200);
    pinMode(interruptPin_1, INPUT_PULLUP);
    pinMode(interruptPin_2, INPUT_PULLUP);
    pinMode(interruptPin_3, INPUT_PULLUP);
    PCICR = (1<<PCIE2) | (1<<PCIE1) | (1<<PCIE0);
    PCMSK0 = (1<<PCINT1);
    PCMSK1 = (1<<PCINT10);
    PCMSK2 = (1<<PCINT21); 
}
 
void loop() {
    if(event_1){
        Serial.println("Interrupt at pin 9");
        delay(300); 
        event_1 = false;
    }
    if(event_2){
        Serial.println("Interrupt at pin A2");
        delay(300);
        event_2 = false;
    }
    if(event_3){
        Serial.println("Interrupt at pin 5");
        delay(300);
        event_3 = false;
    }   
}

 

Erläuterung zu three_pcint_interrupts_at_three_ports.ino

  • Mit PCICR = (1<<PCIE2) | (1<<PCIE1) | (1<<PCIE0); werden Interrupts an PORTB, PORTC und PORTD erlaubt.
  • Die drei Pins werden über ihr zuständiges PCMSK[2:0] Register als Interruptpins eingerichtet.
  • Für jeden Pin Change Interrupt Pin ist eine eigene ISR zuständig.
  • Die Wartezeiten von 300 Millisekunden kaschieren das Tasterprellen und den erneuten Interrupt beim Loslassen des Tasters.

Mehrere Pin Change Interrupt Pins an einem Port

Im nächsten Beispiel verwenden wir Pin Change Interrupts für drei Taster an den Pins 5, 6 und 7, also PCINT21, PCINT22 und PCINT23. Hier müssen wir jetzt berücksichtigen, dass alle drei Pins zu PORTD gehören und uns deswegen nur ein Interrupt zur Verfügung steht. Aber da wir ja die Level der Pins im Grundzustand kennen, überprüfen wir in der ISR einfach mittels digitalRead() welcher Pin abweicht und damit „der Schuldige“ ist.

Um ein wenig Abwechslung hineinzubringen, ziehen wir Pin 5 und Pin 7 mit Pull-Down Widerständen auf LOW. Pin 6 verwendet den internen Pull-Up Widerstand.

3 Pin Change Interrupt Pins an einem Port
3 Pin Change Interrupt Pins an einem Port

Bei der Schaltung ist zu beachten, dass die Anschlüsse, die sich an den Tastern links oben und links unten bzw. rechts oben und rechts unten befinden, permanent verbunden sind.

Hier der Sketch:

const int interruptPin_1 = 5;
const int interruptPin_2 = 6;
const int interruptPin_3 = 7;
volatile bool event_1 = false;
volatile bool event_2 = false;
volatile bool event_3 = false;
 
ISR (PCINT2_vect){   
    if(digitalRead(interruptPin_1)){
        event_1 = true;
    }
    else if(!digitalRead(interruptPin_2)){
        event_2 = true;
    }
    else if(digitalRead(interruptPin_3)){
        event_3 = true;
    }
}

void setup() {
    Serial.begin(115200);
    pinMode(interruptPin_1, INPUT);
    pinMode(interruptPin_2, INPUT_PULLUP);
    pinMode(interruptPin_3, INPUT);
    PCICR = (1<<PCIE2);
    PCMSK2 = (1<<PCINT23)|(1<<PCINT22)|(1<<PCINT21); 
}
 
void loop() {
    if(event_1){
        Serial.println("Interrupt at pin 5");
        delay(300); 
        event_1 = false;
    }
    if(event_2){
        Serial.println("Interrupt at pin 6");
        delay(300);
        event_2 = false;
    }
    if(event_3){
        Serial.println("Interrupt at pin 7");
        delay(300);
        event_3 = false;
    }   
}

 

Durch die Prüfung mittels digitalRead() werden die Interrupts beim Loslassen der Taster ignoriert (es sei denn, der Taster prellt beim Loslassen). Das kann erwünscht sein – oder auch nicht. Im übernächsten Beispiel komme ich darauf zurück, wie ihr Pin Changes in beide Richtungen an einem Port detektieren könnt.

Einen ganzen Port verwenden

Jetzt werden wir (fast) den ganzen PORTD verwenden. Dieser umfasst die Arduino Pins 0 bis 7, wobei die Pins 0 und 1 für Pin Change Interrupts nicht gut geeignet sind, da es sich um RX und TX handelt. Wir beschränken uns also auf die Pins 2 bis 7 bzw. PD[7:2].

Dazu kommt die folgende Schaltung zum Einsatz:

Pin Change Interrupt Test-Schaltung
Pin Change Interrupt Test-Schaltung

Ich habe die Pins 3, 5 und 7 mit Pull-Down Widerständen versehen, die Pins 2, 4 und 6 werden mit ihren internen Pull-Up Widerständen auf HIGH gezogen. Wenn ihr lediglich Taster auswerten wollt, dann wäre es wesentlich einfacher, alle Pins auf dasselbe Level zu ziehen. Die hier verwendeten Taster sollen jedoch lediglich stellvertretend für irgendwelche Interruptauslöser stehen, und das könnte eine Mischung aus LOW-aktiven und HIGH-aktiven Vertretern sein.

Bei sechs Pins würde es etwas unhandlich, alle Taster- bzw. Pinzustände im Falle eines Interrupts mit digitalRead() abzufragen und individuelle Flags event_[1:6] zu verwenden. Das kann man smarter lösen – so zum Beispiel:

int interruptPin[6] = {2,3,4,5,6,7};
volatile bool event = false;
volatile byte changedPin = 0;
volatile unsigned long lastInterrupt = 0;
const byte interruptPinMask = 0b11111100;
const byte intPinPolarityMask = 0b01010100;
 
ISR (PCINT2_vect){   
    if(millis()-lastInterrupt > 300){
        byte interruptPinsStatus = PIND & interruptPinMask;
        interruptPinsStatus ^= intPinPolarityMask;
        if(interruptPinsStatus){
            changedPin = log(interruptPinsStatus)/log(2); // short, but slow! Alternative below:
//            changedPin = 0;
//            while(!(interruptPinsStatus & 1)){
//                changedPin++;
//                interruptPinsStatus = (interruptPinsStatus >> 1);
//            }
            event = true;
        }
        lastInterrupt = millis();
    }
}

void setup() {
    Serial.begin(115200);
    
    pinMode(interruptPin[0], INPUT_PULLUP);
    pinMode(interruptPin[1], INPUT);
    pinMode(interruptPin[2], INPUT_PULLUP);
    pinMode(interruptPin[3], INPUT);
    pinMode(interruptPin[4], INPUT_PULLUP);
    pinMode(interruptPin[5], INPUT);
    
    PCICR = (1<<PCIE2);
    PCMSK2 = interruptPinMask; 
}
 
void loop() {
    if(event){
        Serial.print("Interrupt at pin ");
        Serial.println(changedPin);
        event = false;
    }
}

 

Erläuterungen zu pcint_interrupt_generalized.ino

  • Die Interruptpins werden als Array angelegt.
  • Die interruptPinMask gibt an, welche PORTD Pins als Interruptpin dienen („1“) und welche ignoriert werden („0“).
  • intPinPolarityMask enthält die Information, welches Level die PORTD Pins im Grundzustand haben (Taster nicht gedrückt).
  • Im Falle eines Interrupts wird der Level der Pins mit PIND ermittelt. Das erspart die Abfragen mittels digitalRead().
    • Durch PIND & interruptPinMask ignorieren wir den Zustand der Pins 0 und 1.
    • Mittels interruptPinsStatus ^= intPinPolarityMask finden wir heraus, welcher Pin vom Grundzustand abweicht. Der logische Operator „^“ (XOR, exklusives Oder) liefert eine 1, wenn genau einer der Operanden 1 ist.
      • Wurde der Interrupt beispielsweise an PD4 (4) ausgelöst, hätte interruptPinsStatus nach den Bitoperationen den Wert 0b00010000.
  • Die Bedingung if(interruptPinsStatus){... fängt Fälle ab, in denen kein auslösender Interruptpin ermittelt werden kann.

Im Falle von PORTD kommt uns entgegen, dass PDx dem Arduino Pin x entspricht. Um x (changedPin) zu ermitteln, müssen wir die Stelle finden, an der die 1 in interruptPinsStatus steht. Dafür gibt es verschiedene Möglichkeiten:

  • x = log2(interruptPinsMask). Da aber kein Logarithmus zur Basis 2 als Funktion verfügbar ist, greifen wir auf die Logarithmenregeln zurück: log2(x) = log10(x)/log10(2). Das ist schöner, kurzer Code, allerdings ist das Errechnen eines Logarithmus recht aufwendig und entsprechend langsam. Ich habe gute hundert Mikrosekunden für diese Berechnung ermittelt.
  • Ihr verschiebt interruptPinsStatus so oft nach rechts, bis dessen Wert 1 ist (siehe auskommentierter Code).
  • Ihr teilt interruptPinsStatus so oft durch 2, bis der Rest 1 ist.

Ansonsten kann man natürlich auch switch...case Strukturen einsetzen.

HIGH-LOW und LOW-HIGH Wechsel verfolgen

Im letzten und vorletzten Sketch haben wir nur Level-Wechsel in eine Richtung, also Abweichungen vom Grundzustand, berücksichtigt. Um Wechsel in beide Richtungen auszuwerten, führen wir die Variable lastInterruptsPinStatus ein, in der wir den Status von PIND (nach Bereinigung um die nicht berücksichtigten Pins 0 und 1) zum jeweiligen Zeitpunkt des letzten Interrupts speichern. Bei einem erneuten Interrupt vergleichen wir den neuen Status (currentInterruptPinsStatus) mit dem vorherigen (anstelle intPinPolarityMask).

Ob ein HIGH-LOW oder LOW-HIGH Wechsel stattgefunden hat, erkennen wir daran, ob der neue Statuswert kleiner oder größer ist.

Hier erst einmal der Sketch:

int interruptPin[6] = {2,3,4,5,6,7};
volatile bool event = false;
volatile byte changedPin = 0;
const byte interruptPinMask = 0b11111100;
volatile byte lastInterruptPinsStatus = 0b01010100;
volatile bool risingEdge = false; 
 
ISR (PCINT2_vect){   
    byte currentInterruptPinsStatus = PIND & interruptPinMask;
    
    if(currentInterruptPinsStatus > lastInterruptPinsStatus){
        risingEdge = true;
    }
    else{
        risingEdge = false;
    }
    
    byte statusCopy = currentInterruptPinsStatus;
    currentInterruptPinsStatus ^= lastInterruptPinsStatus;
    lastInterruptPinsStatus = statusCopy;
    
    if(currentInterruptPinsStatus){
        changedPin = 0;
        while(!(currentInterruptPinsStatus & 1)){
            changedPin++;
            currentInterruptPinsStatus = (currentInterruptPinsStatus >> 1);
        }
    event = true;
    }
}

void setup() {
    Serial.begin(115200);
    
    pinMode(interruptPin[0], INPUT_PULLUP);
    pinMode(interruptPin[1], INPUT);
    pinMode(interruptPin[2], INPUT_PULLUP);
    pinMode(interruptPin[3], INPUT);
    pinMode(interruptPin[4], INPUT_PULLUP);
    pinMode(interruptPin[5], INPUT);
    
    PCICR = (1<<PCIE2);
    PCMSK2 = interruptPinMask; 
}
 
void loop() {
    if(event){
        Serial.print("Interrupt at pin ");
        Serial.println(changedPin);
        Serial.print("Pin went: ");
        if(risingEdge){
            Serial.println("HIGH");
        }
        else{
            Serial.println("LOW");
        }
        event = false;
    }
}

 

Wenn ihr den Sketch ausprobiert, werdet ihr wahrscheinlich feststellen, dass ich das Debouncing entfernt habe. Es verträgt sich nicht gut mit diesem Konzept, da Statusänderungen, die durch Tasterprellen ausgelöst werden, unter den Tisch fallen. Andererseits ist der Sketch in dieser Form auch nicht gut mit prellenden Tastern verträglich, da inmitten der Ausgabe auf dem seriellen Monitor erneute Interrupts auftreten können (Serial.print() ist ziemlich langsam). Zuverlässig arbeitet der Sketch hingegen mit Hardware-entprellten Tastern (siehe letzter Beitrag) oder anderen Interruptquellen, die nicht zu schnell hintereinander auslösen.

Pin Change Interrupts auf anderen AVR Mikrocontrollern

Dadurch, dass wir direkt auf die Register des Mikrocontrollers zugreifen, ist der Code für Pin Change Interrupts für diesen spezifisch. Die Übertragung auf andere AVR-Mikrocontroller ist aber nicht besonders schwierig. Nehmt euch das Datenblatt vor und schaut im Kapitel Externe Interrupts, Unterpunkt: Pin Change Interrupts.

Beispiel: ATtiny85

Wir spielen das einmal am ATtiny85, der z. B. auf dem Digispark Board verwendet wird, durch. Er besitzt nur einen einzigen Pin Change Interrupt, den ihr über das Pin Change Enable Bit PCIE im Global Interrupt Mask Register GIMSK „einschaltet“:

Global Interrupts Mask Register GIMSK
Global Interrupts Mask Register GIMSK

Die Pins aktiviert ihr im Pin Change Mask Register PCMSK.

Pin Change Interrupt Mask Register PCMSK
Pin Change Interrupt Mask Register PCMSK

So könnte dann ein Sketch aussehen:

const int ledPin = 4;
const int interruptPin = 3;
volatile bool event = false;
 
ISR (PCINT0_vect){
    event = true;
}

void setup() {
    pinMode(ledPin, OUTPUT);
    pinMode(interruptPin, INPUT_PULLUP);
    GIMSK |= (1<<PCIE); // General Interrupt Mask Register
    PCMSK = (1<<PCINT3); // 3 = PCINT3
}
 
void loop() {
    if(event){
        digitalWrite(ledPin, HIGH);
        delay(500);
        digitalWrite(ledPin, LOW);
        event = false;
    }
}

Pin Change Interrupts auf MiniEVB Boards

Die auf dem LGT8F328P basierenden MiniEVB Boards weisen eine hohe Kompatibilität mit dem ATmega328P auf. Besonders einfach ist die Portierung von Sketchen für ATmega328P-basierte Arduinos auf das LQFP32-MiniEVB Board. Ihr könnt die Sketche 1:1 übernehmen. Bei den LQFP48-basierten MiniEVB Boards kommen lediglich noch einige Pins hinzu, wohingegen bei den SSOP20-basierten Vertretern einige Pins nicht zur Verfügung stehen.

Ausblick

Im nächsten Beitrag behandele ich Timer Interrupts. Im Gegensatz zu den Pin Change Interrupts, die eine Besonderheit der AVR Mikrocontroller (und der LGT8F328P-Familie) darstellen, sind die Timer Interrupts wieder für alle Mikrocontroller relevant.

Schreibe einen Kommentar

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