Titelbild: Mit freundlicher Genehmigung des Naval Surface Warfare Center, Dahlgren, VA., 1988. – U.S. Naval Historical Center Online Library Foto NH 96566-KN

Wenn man darüber nachdenkt, wann man das letzte Mal ein System entworfen hat, das von Anfang an funktionierte und keine Korrekturen benötigte, wird man vermutlich feststellen, dass das schon ziemlich lange her ist. Tatsächlich verbringt man normalerweise sehr viel Zeit damit, Fehler zu identifizieren und zu korrigieren, die umgangssprachlich als Bugs bezeichnet werden. In diesem Blogbeitrag gebe ich einen Überblick über verschiedene Formen von Bugs und gehe darauf ein, wie man sie loswerden kann.

Ausfälle, Fehler und Irrtümer

Der informelle Begriff Bug (engl. für Käfer) bezieht sich auf etwas, das schiefgelaufen ist. Um etwas spezifischer zu werden, benutzen wir die IEEE-Standardterminologie, um zwischen konzeptionell unterschiedlichen Fehlertypen zu unterscheiden. Zunächst einmal gibt es die Beobachtung, dass sich das System nicht so verhält, wie man es erwartet hat. Es gibt ein falsches Ergebnis, es reagiert nicht auf einen Input, es handelt katastrophal oder etwas Ähnliches. Dies wird als Ausfall (engl. failure) des Systems bezeichnet. Ausfälle werden durch einen oder mehrere Fehler (engl. fault) im Programm (oder der Hardware) verursacht, d.h. durch einen Programmcode, der anders hätte geschrieben werden sollen. Zum Beispiel hätte man an einem Punkt Subtraktion anstelle von Addition verwenden sollen. Der Fehler wiederum wurde durch einen menschlichen Irrtum (engl. mistake) verursacht, der darin bestehen kann, dass der Programmierer sich vertippt hat oder dass die Vorstellungen des Programmierers über die Programmlogik falsch waren. Die Begriffe Bug und Fehler können sich dabei auf jedes dieser drei Phänomene beziehen.

Wie entstehen Bugs?

Zuerst wollen wir Programmierfehler entlang der Dimension kategorisieren, wie sie sich materialisieren:

  1. Kompilierungsfehler (oder Syntaxfehler), d. h. Fehler, die vom Compiler (oder dem Linker) erkannt und gekennzeichnet werden.
  2. Laufzeit-Fehler, d.h. Fehler, die nur zur Laufzeit auftreten und die in der Regel zum Beenden oder zum „Hängen“ des Programms führen.
  3. Semantische (oder logische) Fehler, die sich als nicht intendiertes Verhalten des Programms materialisieren (aber nicht zu einer Beendigung oder einem Absturz führen).

Die einfachste Art von Fehler ist der Kompilierungsfehler, bei dem der Compiler einem sagt, was falsch ist. Man muss nur einen Tippfehler korrigieren, eine Variable deklarieren oder den Typ einer Variablen ändern. Diese Art von Fehler wird oft auch als Syntaxfehler bezeichnet, kann aber viel mehr als nur Syntax beinhalten. Fehler, die vom Linker erkannt werden, würde ich auch zu dieser Klasse von Fehlern zählen. Hier fällt ein Tippfehler oder eine vergessene Deklaration nicht auf der Ebene einer Quellcodedatei auf, sondern beim Versuch, die verschiedenen Objektdateien zu verlinken.

Die zweite Art von Fehler ist viel unangenehmer, insbesondere wenn es um eingebettete Systeme geht. Sie können z. B. durch einen arithmetischen Überlauf, einen Pufferüberlauf, eine nicht initialisierte Variable, einen hängenden Zeiger oder eine falsche Bedingung in einer while-Schleife verursacht werden. Auf Desktop- oder Laptop-Computern führen solche Fehler in der Regel zu einer Fehlermeldung und dem Beenden des Programms. Alternativ kann das Programm „hängen“, d.h. auf nichts reagieren, entweder weil es sich in einer Endlosschleife befindet oder auf eine Eingabe wartet, die nie kommt. Im Gegensatz dazu stoppt das Programm auf einem eingebetteten System, z. B. einem Arduino, nie und gibt auch keine Laufzeitfehlermeldung. Stattdessen wird es seltsame Dinge tun, wie z.B. einen Neustart, es kann „einfrieren“ oder völlig verrückte Dinge tun. Der Grund dafür ist, dass die kleinen MCUs keine Speicherzugriffe überwachen und nicht prüfen, ob ein Programm oder eine Datenadresse „legal“ ist.

Wenn beispielsweise ein Stapelüberlauf auftritt, kann die MCU bei der Rückkehr von einer Unterroutine eine falsche Rückkehradresse anspringen, die außerhalb des Adressraums des Programmspeichers liegt. Im Falle eines AVR-Chips führt das dazu, dass der gesamte Adressraum durchlaufen wird, bis der Programmzähler wieder die Adresse 0 erreicht, die einen Sprung zur Start-Adresse enthält. Daraufhin startet das System neu, allerdings ohne die I/O-Register zu initialisieren. Dies alles bedeutet, dass es auf eingebetteten Systemen sehr schwierig sein kann, die Ursache für einen solchen „Ausfall“ zu diagnostizieren.

Die dritte Art von Fehler, der semantische, ist der schlimmste, weil diese Art von Fehlern schwer zu erkennen sind, insbesondere bei eingebetteten Systemen. Solche Fehler können durch Programmierfehler verursacht werden, bei denen zwei Symbole verwechselt wurden, oder durch sehr subtile Missverständnisse über die Bedeutung von Programmierkonstrukten oder die beabsichtigte Semantik von Funktionen.

Angenommen, man hat eine gute Vorstellung davon, was ein (Sub-)System tun soll (d.h., es gibt eine Spezifikation des gewünschten Verhaltens). Weiterhin wollen wir annehmen, dass man ein Programm geschrieben hat, das kompiliert und niemals abstürzt oder hängt. In der Tat tut es alles, was die Spezifikation sagt. Es kann jedoch trotzdem fehlerhaft sein, da bereits die Spezifikation fehlerhaft ist. Z. B. wurden falsche Annahmen über die Umgebung oder über andere Subsysteme gemacht, mit denen das Programm kommunizieren muss.

Ein extremes Beispiel für einen solchen Fehler ist der Absturz des Mars Climate Orbiter im Jahr 1999. Das Sensorsystem lieferte seine Messungen in imperialen Einheiten, während das Gesamtsystem erwartete, dass die Messwerte in metrischen Einheiten angegeben werden. Der Orbiter stürzte ab und mehr als 300.000.000 Dollar waren hin. Hier funktionierte alles perfekt, es gab nur das Missverständnis darüber, was eine Messoperation wirklich bedeutete, d.h. was die Semantik einer solchen Operation war.

Der Unterschied zwischen der zweiten und dritten Art von Fehler besteht darin, dass man nach anderen Ursachen suchen muss. Im Falle eines Laufzeitfehlers haben man meist vergessen, eine Variable zu initialisieren, oder man hat eine falsche Operation gewählt. Das bedeutet, dass man die Stelle im Programm identifizieren müssen, an der der Code etwas tut, das die Vorstellung davon, was das Programm tun soll, verletzt. Im Falle eines semantischen Fehlers muss man zuerst einmal verstehen, warum die Vorstellung davon, was das Programm tun soll, falsch ist. Die Suche nach dem Fehler mit Debugging-Tools (die in einem kommenden Blogbeitrag beschrieben werden) wird wahrscheinlich ähnlich sein. Die Korrektur wird jedoch gänzlich anders sein.

Was ist die Ursache?

Die Hauptursache für Fehler ist nicht unbedingt ein Fehler in der Programmlogik. In eingebetteten Systemen kann es viele verschiedene Ursachen geben. Um die Dinge einfach zu halten, werden wir nur vier sehr breit gefasste Klassen von Fehlern betrachten:

  1. Zeit-unabhängige Fehler, die unabhängig davon auftreten, wie schnell oder langsam das Programm ausgeführt wird.
  2. Zeit-abhängige Fehler, die nur unter bestimmten Zeit-Bedingungen auftreten.
  3. Fehler im Zusammenhang mit der Ereignisreihenfolge, die nur auftreten, wenn Ereignisse auf eine bestimmte Weise angeordnet werden.
  4. Hardware-bezogene Fehler, d.h. Fehler, die hauptsächlich durch fehlerhaftes(?) Hardware-Design verursacht werden.

Wenn man ein fehlerhaftes Verhalten reproduzieren kann, unabhängig davon, wie schnell die MCU getaktet ist, handelt es sich um einen zeitunabhängigen Fehler. Vermutlich gibt es eine untere Grenze, wie langsam die MCU laufen kann, weil die MCU ja nie isoliert existiert und auf externe Signale reagieren muss. Es ist jedoch höchstwahrscheinlich ein Fehler, der ohne Berücksichtigung von Zeitverhaltens erkannt werden kann. Das macht es einfach, die Stelle im Programm zu bestimmen, die ursächlich für den Fehler ist. Mit klassischen Debugging-Methoden (die ich in einem kommenden Blogartikel beschreiben werde) sollte man in der Lage sein, den Fehler zu identifizieren.

Meistens ist bei der Entwicklung von Embedded-Systemen jedoch das Zeitverhalten kritisch und die Manifestation eines fehlerhaften Verhaltens hängt davon ab, wie schnell die MCU läuft, d.h. man hat einen zeitabhängigen Fehler. Um solche Fehler zu vermeiden, versucht man in der Regel, zeitkritischen Code vorher zu identifizieren und entwickelt und testet ihn separat. So werden insbesondere Kommunikationsroutinen und Interrupt-Dienste isoliert entwickelt und getestet. Nur wenn man sicher ist, dass sie funktionieren, verwendet man sie im eignen System. Im Arduino-Universum versprechen alle offiziellen Bibliotheken implizit, im Gesamtsystem nutzbar zu sein, ohne Probleme zu verursachen. Aber man weiß nie, ob das tatsächlich stimmt. Aus diesem Grund sollte man zeitabhängige Fehler, die durch eine importierte Bibliothek verursacht werden, immer als Möglichkeit in Betracht ziehen.

Eine klassische Problemquelle ist, dass zusätzliche Interrupt-Service-Routinen (ISRs) kritische MCU-Zyklen konsumieren. Wenn man beispielsweise Bit-Banging-Kommunikationsroutinen einsetzen, kann man die Synchronisation verlieren, wenn eine Interrupt-Routine zu viele Rechenzyklen benötigt. Manchmal können solche Probleme vermieden werden, wenn man eine langsamere Kommunikationsrate wählt. Eine weitere Möglichkeit, solch ein Problem zu lösen, besteht darin, Interrupts zu verbieten, während die Bit-Banking-Routine aktiv ist. In jedem Fall erfolgt das Debuggen eines solchen zeitkritischen Codes in der Regel nicht über einen klassischen Debugger, sondern man benötigt einen Logikanalysator oder ein Oszilloskop.

Zeitabhängige Fehler können jedoch auch an Stellen auftreten, bei denen man keine Zeitabhängigkeiten erwartet. Als ich kürzlich die Ausgabe von Debug-Informationen deaktivierte, nachdem alles zu funktionieren schien, funktionierte das Programm ohne die Ausgabe der Debug-Informationen überhaupt nicht mehr. Dies ist ein gutes Beispiel für das, was man einen Heisenbug nennt, einen Fehler, der verschwindet, wenn man versucht, ihn zu beobachten.

Ähnlich zu den zeitabhängigen Fehlern sind diejenigen, die von der Reihenfolge der Ereignisse abhängen, ereignisreihenbezogene Fehler. Dies sind Fehler, die sich nur dann manifestieren, wenn externe und interne Ereignisse auf eine bestimmte Weise angeordnet sind. Zum Beispiel könnte ein Hauptprogramm eine bestimmte globale ganzzahlige Variable lesen und ein Interrupt-Dienst diese Variable ändern. In diesem Fall muss die Variable als volatile deklariert werden. Es könnte dann vorkommen, dass im Hauptprogramm das erste Byte der Variablen in die MCU geladen wird, dann ein Interrupt auftritt, der die Werte ändert, und danach das zweite Byte innerhalb des Hauptprogramms in die MCU geladen wird. In diesem Fall ist der geladene Wert Müll, da das erste und das zweite Byte nichts miteinander zu tun haben. Und dieser Fehler tritt nur auf, wenn der Interrupt während des Ladens des Werts ausgelöst wird. Dieser Fehler kann einfach vermieden werden, indem man das ATOMIC_BLOCK Makro verwendet. Einen solchen Fehler überhaupt zu erkennen, ist jedoch sehr schwierig, da er nicht sehr oft auftreten wird und nur schwer reproduzierbar ist. Also versucht man besser, solch einen Fehler um jeden Preis zu vermeiden!

Schließlich gibt es Hardware-bezogene Bugs, die wir uns im nächsten Blogbeitrag dieser Serie ansehen werden.

Views: 7