Was ist der Zweck des C++-Qualifizierers volatile, was hat er mit Race-Conditions zu tun und was sind Heisenbugs?

Motivation

Heisenbugs sind Softwarefehler, die zu verschwinden scheinen, wenn du versuchst, sie mit einem Debugger (oder einer anderen Beobachtungsmethode) aufzuspüren. Der Begriff ist ein Wortspiel mit dem Namen Werner Heisenberg, der den Beobachtereffekt der Quantenmechanik formuliert hat.

Der C++-Typqualifizierer volatile teilt dem Compiler mit, dass er die damit markierte Variable als etwas betrachten soll, was durch unvorhersehbare Vorgänge von außen verändert werden kann. Wenn du dieses Schlüsselwort also in Fällen, in denen du es hätte machen sollen, nicht verwendest, könnte der Compiler Optimierungen vornehmen, die zu optimistische Annahmen machen und zu einem falschen Verhalten führen. Da die Verwendung des volatile-Typqualifizieres Einfluss darauf hat, wie der Compiler den Code optimiert, ist er ein idealer Kandidat, um einen Heisenbug zu provozieren.

Aber auch die Verwendung des volatile-Typqualifizierers ist keine Garantie dafür, dass alles klappt. Volatile Variablen können zu Race-Conditions (im Deutschen auch Wettkampfsituation genannt) führen. Das sind Bedingungen, bei denen zwei Codepfade asynchron zueinander ausgeführt werden und die tatsächliche Ausführungsreihenfolge über das Endergebnis entscheidet. Und manche dieser Ergebnisse mögen nicht intendiert, also fehlerhaft, sein. Fehler, die auf Race-Conditions basieren, sind extrem schwer zu lokalisieren. Aber du kannst das Auftreten solcher Race-Conditions schon im Vorfeld verhindern, indem du das Konstrukt atomarer Block verwendest.

Wann du volatile verwenden solltest

Schauen wir uns ein Beispiel an, das die Begriffe Heisenbug und Volatilität verdeutlicht. Das Beispiel ähnelt dem berühmten Blink-Sketch, enthält aber einen Teil in der setup-Funktion, bei dem auf einen Tastendruck gewartet wird, bevor es weitergeht. Und um die Sache noch komplizierter zu machen, implementieren wir das mit einem externen Interrupt.

bool ready;

void setup() {
  pinMode(2, INPUT_PULLUP); // Taste
  pinMode(4, OUTPUT);       // GND für Taster
  pinMode(LED_BUILTIN, OUTPUT);
  attachInterrupt(digitalPinToInterrupt(2), Taste, LOW);
  ready = false;            // Startbedingung
  while (!ready) {          // warte auf das Drücken der Taste
    delay(10);
  }
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);            
  digitalWrite(LED_BUILTIN, LOW); 
  delay(1000);
}

void button(void) {
  detachInterrupt(digitalPinToInterrupt(2));
  ready = true;
}

In den Zeilen 4 bis 6 werden die GPIOs initialisiert, in Zeile 7 wird ein Interrupt mit dem Drücken der Taste an Pin 2 verknüpft und in Zeile 8 wird ready auf false gesetzt. In den Zeilen 9–10 warten wir darauf, dass die Variable ready wahr wird. Das kann nur passieren, wenn der Taster gedrückt und der Interrupt an Pin 2 ausgelöst wird. Innerhalb der Interrupt-Routine button (ab Zeile 21) wird die Variable ready auf true gesetzt (Zeile 23).

Das Kompilieren und Hochladen des Sketches auf einen Arduino UNO führt zu der Beobachtung, dass das Drücken des Tasters keinen Effekt hat. Okay, Zeit fürs Debuggen. Unter Benutzung des debugfähigen MiniCores und dem Anschluss des UNO-Boards an den dw-link Hardware-Debugger starten wir den Arduino IDE 2 Debugger.

Nachdem wir im Sketch-Menü die Option Für Debugging optimieren aktiviert haben, den Sketch neu kompiliert und den Arduino IDE 2 Debugger gestartet haben, stoppen wir als Erstes in Zeile 4, die erste Zeile der setup-Funktion. Das ist so vom Arduino-Debugger vorgegeben.

Jetzt tragen wir im Überwachen-Fenster die Variable ready ein und setzen Haltepunkte in Zeile 9 (zur Überprüfung der ready-Variable), nach der While-Schleife in Zeile 12, und in Zeile 24, der letzten Zeile der Interrupt-Routine.

Nachdem wir die Ausführung gestartet haben, stoppen wir in Zeile 9. Wenn wir jetzt den Taster drücken und auf die Schaltfläche Fortfahren klicken, stoppen wir in Zeile 24 und die Variable ready hat den Wert true. Machen wir weiter, landen wir erst in Zeile 9, dann in Zeile 12. Es hat also alles funktioniert. Mit anderen Worten: Durch das Debuggen des Problems ist der Fehler verschwunden. Dies ist definitiv ein Heisenbug!

Deshalb versuchen wir, die ursprünglichen Bedingungen wiederherzustellen, unter denen der Fehler auftrat, und deaktivieren Für Debugging optimieren. Dann kompilieren wir den Sketch neu und beginnen erneut mit dem Debugging (immer noch mit den Haltepunkten in Zeile 9, 12 und 24). Dieses Mal stoppen wir zunächst in der Datei main.cpp in Zeile 43, wo die Funktion setup aufgerufen wird. Der Grund dafür ist, dass der Compiler statt eines Funktionsaufrufs die Funktion an dieser Stelle eingesetzt hat. Das ist verwirrend, hält uns aber nicht davon ab, den Fehler weiterzusuchen.

Wenn wir den Taster drücken und dabei auf Fortfahren klicken, wird das Programm in Zeile 23 unterbrochen. Nach einem Klick auf die Schaltfläche Einzelschritt wird der Wert der Variablen ready als true angezeigt. Wenn wir jetzt auf Fortfahren klicken, wird die Ausführung als Nächstes in Zeile 10 unterbrochen und der Wert von ready wird als true angezeigt. Und das ändert sich nicht, wenn wir das wiederholen. Mit anderen Worten, etwas ist hier ernsthaft falsch gelaufen. Der Test in der While-Schleife findet offensichtlich nicht statt.

Wenn man sich den generierten Assembler-Code ansieht (erzeugen mit Kompilierte Binärdatei exportieren im Sketch-Menü ), stellt man fest, dass innerhalb der While-Schleife kein Test erzeugt wurde!

/Users/.../volatile.ino:8
  attachInterrupt(digitalPinToInterrupt(2), button, LOW);
  ready = false; // Startbedingung
 468: 10 92 04 01 sts 0x0104, r1 ; 0x800104 <__data_end>
/Users/.../volatile.ino:10
  while (!ready) { // warte auf gedrückte Taste
    delay(10);
 46c: 6a e0 ldi r22, 0x0A ; 10
 46e: 70 e0 ldi r23, 0x00 ; 0
 470: 80 e0 ldi r24, 0x00 ; 0
 472: 90 e0 ldi r25, 0x00 ; 0
 474: 0e 94 1e 01 call 0x23c ; 0x23c 
 478: f9 cf rjmp .-14 ; 0x46c <__LOCK_REGION_LENGTH__ 0x6c>

Wenn du dir den Quellcode ansiehst, ist klar, warum der Compiler beschlossen hat, den Test wegzuoptimieren. Die Variable ready wird auf false gesetzt. Dann wird die Schleife ausgeführt, in der wir darauf warten, dass ready true wird. Wenn man nur den lokalen Kontext betrachtet, ist es natürlich überflüssig, den Wert von ready in der While-Schleife zu prüfen. Die Variable wird direkt vor der Schleife auf false gesetzt und innerhalb der Schleife nie geändert.

Hier kommt also der Qualifizierer volatile ins Spiel. Indem der Definition der Variablen ready dieses Schlüsselwort vorangestellt wird, weiß der Compiler, dass er Zuweisungen und Tests dieser Variablen niemals wegoptimieren oder die Werte nur in Registern speichern darf. Bei jeder Zuweisung an die Variable wird der neue Wert im RAM (deutsch Direktzugriffsspeicher) gespeichert, und bei jedem Test wird der Wert aus dem RAM geladen. Und tatsächlich funktioniert jetzt alles, wie erwartet.

Race-Conditions

Betrachten wir eine leichte Abwandlung des obigen Beispiels, bei der wir eine Variable wait haben, die hochgezählt wird. Damit verlassen wir die setup-Funktion entweder nach 25 Sekunden oder wenn die Taste gedrückt wird.

volatile byte wait;

void setup() {
  pinMode(2, INPUT_PULLUP); // Taster
  pinMode(4, OUTPUT);       // Taster GND
  pinMode(LED_BUILTIN, OUTPUT);
  attachInterrupt(digitalPinToInterrupt(2), Taste, LOW);
  wait = 1;                 // Startbedingung
  while (wait) {            // warte auf gedrückte Taste
    wait++ ;
    delay(100);
  }
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(1000);            
  digitalWrite(LED_BUILTIN, LOW); 
  delay(1000);
}

void button(void) {
  detachInterrupt(digitalPinToInterrupt(2));
  wait = 0;
}

Da wir den Typqualifizierer volatile verwendet haben, wird der Compiler vorsichtig sein und keine kritischen Dinge weg optimieren. Und tatsächlich scheint der Sketch so zu funktionieren. Nur manchmal macht er das Falsche. Das heißt, er reagiert nicht auf den Taster. Aber das passiert nur in einem von tausend Fällen.

Solche nicht-deterministischen, zeitabhängigen, selten auftretenden Fehler sind extrem ärgerlich und schwer zu finden. Sie sind die typische Art von Heisenbugs, denn schon kleine Abweichungen im Timing können sie maskieren. Trotzdem werden wir versuchen, diesen Fehler mit dem Debugger zu finden.

Der Plan ist, systematisch alle Stellen in der While-Schleife auszuprobieren und zu sehen, was passiert, wenn der Interrupt kurz vor der Ausführung der Zeile eintritt. Das heißt, wir führen mehrere Debug-Sitzungen durch und unterbrechen dabei nacheinander bei den Zeilen 9, 10, 11 und 12. Dann drücken wir jeweils den Taster und klicken dann auf Fortführen. Dabei zeigt sich, dass wir in allen Zeilen außer in Zeile 10 das gewünschte Verhalten (Verlassen der Schleife) erhalten.

Ein genauerer Blick zeigt, dass dies von Anfang an hätte offensichtlich sein können. Wenn der Taster kurz vor wait++ in Zeile 10 gedrückt wird, wird wait von der Interrupt-Routine auf null gesetzt. Wenn die Interrupt-Routine jedoch beendet ist und das Programm weiterläuft, wird der Wert der Variablen wait erhöht und die While-Schleife kann nicht beendet werden.

Wir hatten tatsächlich Glück, den Fehler zu finden, denn im Allgemeinen kann die kritische Stelle innerhalb einer Programmzeile liegen (zum Beispiel, wenn Multibyte-Arithmetik durchgeführt wird). Daher ist es viel ratsamer, solche potenziellen Race-Conditions zu vermeiden, indem man entsprechende Programmierkonstrukte verwendet. In unserem Fall würde das bedeuten, die Test- und Schreibanweisungen zusammen in einen sogenannten atomaren Block zu packen, der nicht unterbrochen werden kann. Das kann wie im folgenden Listing gemacht werden, bei dem eine Include-Datei hinzugefügt wurde und die Zeilen 9–12 wie folgt ersetzt wurden.

#include <util/atomic.h>

...

  while (true) {
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE)
    {
      if (wait == 0) break;
      else wait++;
    }
  }

...

Jetzt wird die Interrupt-Routine entweder vor oder nach dem atomaren Block ausgeführt. Das heißt, der Wert der Variablen kann nicht zwischen dem Test und der Änderung des Wertes durch die Interrupt-Routine geändert werden.

Der Makro-Parameter ATOMIC_RESTORESTATE führt dazu, dass der zu Beginn geltende Zustand des Statusregisters am Ende wieder hergestellt wird. Stattdessen kann man auch das effizientere ATOMIC_FORCEON einsetzen, bei dem am Anfang die Interrupts blockiert und am Ende wieder freigegeben werden.

Thread-sichere Programmierung auf klassischen AVRs

Thread-Sicherheit bedeutet, dass mehrere Threads gleichzeitig auf Daten zugreifen können, ohne dass es zu unerwartetem Verhalten, Race-Conditions oder Datenkorruption kommt. Im AVR-Kontext gibt es nicht viele Gelegenheiten, bei denen mehrere Threads aktiv sind. Nur Interrupt-Routinen können parallel zum User-Sketch laufen. Deswegen ist es einfach, sicherzustellen, dass der eigene Code Thread-sicher ist:

  1. Wenn eine Variable im Benutzer-Sketch und in einer Interrupt-Routine verwendet wird, markiere sie mit dem volatile-Qualifizierer.
  2. Wenn eine Variable im Benutzer-Sketch und in einer Interrupt-Routine verwendet wird und sie gelesen und dann aufgrund ihres Inhalts im Benutzer-Sketch geändert wird, füge diese beiden Vorgänge in einen atomaren Block ein.
  3. Wenn eine Multi-Byte-Variable im Benutzer-Sketch und einer Interrupt-Routine verwendet wird, dann füge Zuweisungen und Tests dieser Variablen, die im Benutzer-Sketch stattfinden, jeweils in einen atomaren Block ein.

Wenn du diesen Ratschlag nicht befolgst, kann es leicht zu sporadischen, schwer zu lokalisierenden Fehlern kommen, die sogar verschwinden können, wenn man versucht, sie zu debuggen. Beachte, dass es nicht notwendig ist, atomare Blöcke in der Interrupt-Routine zu verwenden, da diese nicht unterbrechbar sind (vorausgesetzt, das Interrupt-Enable-Bit wird in der Interrupt-Routine nicht geändert). Alles in allem sollte es nicht allzu schwierig sein, diesen Ratschlag zu befolgen, um solche Probleme zu vermeiden.

Views: 0