Suchen

Debugging schwieriger Probleme ohne Debugger

| Autor / Redakteur: Alex Taradov * / Franz Graser

Je nach Design eines Embedded-Systems ist der Einsatz eines Hardware-Debuggers für die Fehlersuche nicht immer praktikabel. Dann müssen alternative Debugging-Methoden verwendet werden.

Firma zum Thema

Auf der Suche nach Softwarefehlern: Je nach System-Design kann der Einsatz eines Hardware-Debuggers nicht immer praktikabel sein, sondern es müssen stattdessen alternative Debugging-Methoden verwendet werden.
Auf der Suche nach Softwarefehlern: Je nach System-Design kann der Einsatz eines Hardware-Debuggers nicht immer praktikabel sein, sondern es müssen stattdessen alternative Debugging-Methoden verwendet werden.
(Bild: Goldmund/istockphoto )

Viele Softwarefehler in Embedded-Systemen lassen sich mit einem Hardware-Debugger beseitigen. Es gibt aber Fälle, bei denen ein Problem nur schwierig reproduzierbar ist und nur bei der Ausführung eines sehr langen Codes und/oder bei komplexen Wechselwirkungen mit anderen Knoten im System auftritt. Je nach System-Design kann der Einsatz eines Hardware-Debuggers nicht immer praktikabel sein, sondern es müssen stattdessen alternative Debugging-Methoden verwendet werden.

Dieser Artikel diskutiert solche Probleme und stellt eine Softwaretechnik vor, die Call-Stacks in Echtzeit erfasst und im Falle eines Fehlers den Stack-Dump von Embedded-Systemen nutzt. Zudem wird ein Python-basiertes Tool vorgestellt, mit dem sich der Inhalt des Stacks mit dem Disassembler-Output vergleichen lässt, um den vollständigen Funktionsaufruf im Stack wiederherzustellen und dadurch den Fehlerpunkt zu ermitteln.

Dieser Artikel konzentriert sich deshalb auf das Debugging drahtloser Sensornetzwerke. Dieselben Techniken lassen sich aber auch auf jedes andere vernetzte System oder Einzelgeräte übertragen, wo ein Debugger nicht einsetzbar ist.

Eines der Hauptprobleme beim Debugging von Netzwerken mit mehreren Geräten ist, dass das Verhalten der einzelnen Geräte vom Verhalten der Umgebungsknoten und der ausgetauschten Datenmenge ist. Das macht ein einfaches Debugging derartiger Systeme nahezu unmöglich.

Die hier vorgestellte Methode basiert auf der Analyse des Stack-Inhalts des ausgefallenen Knotens und dem Vergleich dieses Inhalts mit der Disassemblierung des Binär-Codes der Anwendung, um den Stack des Funktionsaufrufs wiederherzustellen. Ein Watchdog-Timer dient dazu, eine Endlosschleife zu erkennen und die Speicherung des Stack-Inhalts auszulösen.

Diese Methode ist zwar allgemein bekannt, verschiedene MCUs und Compiler nutzen aber unterschiedliche Möglichkeiten beim Handling des Stacks, so dass die hier vorgestellten Tools auf das jeweilige Zielsystem angepasst werden müssen. Nachfolgend konzentrieren wir uns auf die AVR-Mikrocontroller von Atmel, insbesondere auf die Typen ATmega128RFA1 und ATmega256RFR2 sowie einen GCC-Compiler.

Zwei Arten von Daten werden im Stack abgelegt

Um diese Methode effektiv nutzen zu können, müssen wir verstehen, wie der Compiler die Rücksprungadresse der aufgerufenen Funktion im Stack speichert. Im Wesentlichen gibt es zwei verschiedene Arten von Daten, die temporär im Stack abgelegt werden: Lokale Variablen der aufgerufenen Funktion und die Rücksprungadressen der aufrufenden Funktion. Einige Compiler arbeiten mit zwei getrennten Stacks, was die Wiederherstellung des Call-Stacks erleichtert, da dieser in einem zusammenhängenden Speicherbereich abgelegt wird.

Der GCC-Compiler nutzt denselben Stack, um sowohl Rücksprungadressen als auch lokale Variablen zu speichern. Dies macht eine Analyse des Stack ohne eine tiefgehende Untersuchung der ausgeführten Funktionen deutlich schwieriger. Glücklicherweise lassen sich viele nützliche Informationen mit Hilfe einer Brute-Force-Methode wiedererlangen. Anstatt zu versuchen, die Rücksprungadressen von bekannten Speicherstellen wiederherzustellen, werden alle Byte-Kombinationen mit geeigneter Länge im Stack durchgesehen und überprüft, ob diese einer Rücksprungadresse entsprechen. Diese Methode liefert gelegentlich falsche Ergebnisse, wenn Daten im Stack als gültige Rücksprungadresse identifiziert werden. Normalerweise sind diese aber sehr leicht zu identifizieren.

Byte-Kombinationen mit geeigneter Länge suchen

Um alle im Stack gespeicherten Funktionsaufrufe zu finden, müssen die call-Befehle in der disassemblierten Auflistung gesucht werden. Die Disassemblierungsliste lässt sich aus der ELF-Datei mit dem Programm avr-objdump erhalten. Hier die Beispielliste:

Disassemblierungsliste: Die erste Zeile enthält die Adresse und den Namen der Funktion. Die folgenden Zeilen enthalten ausführliche Informationen über die in der Funktion verwendeten Befehle.
Disassemblierungsliste: Die erste Zeile enthält die Adresse und den Namen der Funktion. Die folgenden Zeilen enthalten ausführliche Informationen über die in der Funktion verwendeten Befehle.
(Quelle: Atmel )

Die erste Zeile enthält die Adresse und den Namen der Funktion. Die folgenden Zeilen enthalten ausführliche Information über die in der Funktion verwendeten Befehle. Die erste Spalte enthält die Adresse des Befehls (in Bytes), die zweite Spalte den Operationscode des Befehls und die letzte Spalte die Befehlskurzbezeichnung (Mnemonik) mit einem optionalen Kommentar. Darin sollte nach allen Versionen des call-Befehls gesucht werden. Für jeden call-Befehl sind die Befehlsadresse und die Größe des Befehlscodes zu notieren. Alternativ kann die Adresse des folgenden Befehls notiert werden.

Die folgende Tabelle zeigt eine Liste aller call-Befehle des AVR-Core. Jeder call-Befehl speichert den Wert des Programmzählers, der beim Rücksprung aus der Subroutine wieder hergestellt werden sollte. Der Standard-AVR-Core verfügt über einen 2-Byte-Programmzähler und kann einen Programmraum von 64-K-Wörtern (oder 128 KBytes) adressieren. Aufgrund der immer größeren Bauteile – wie Flash mit mehr als 256 KBytes – wurde die Größe des Programmzählers auf 3 Bytes erweitert. Dies bedeutet auch, dass zwei Cores eine verschiedene Anzahl von Bytes auf dem Stack speichern. Dies muss bei der Untersuchung der Inhalte des Stacks berücksichtigt werden.

Liste aller call-Befehle des AVR-Core: Jeder call-Befehl speichert den Wert des Programmzählers, der beim Rücksprung aus der Subroutine wieder hergestellt werden sollte.
Liste aller call-Befehle des AVR-Core: Jeder call-Befehl speichert den Wert des Programmzählers, der beim Rücksprung aus der Subroutine wieder hergestellt werden sollte.
(Quelle: Atmel )

In diesem Beispiel hat die Rücksprungadresse des call-Befehls an der Adresse 0x40a den Wert 0x40e. Es ist wichtig zu beachten, dass AVR die Rücksprungadresse als Wörter speichert, da alle Befehle ebenfalls im Wortformat vorliegen. Das niederwertigste Byte wird zuerst in den Stack geladen und der Stack wächst zu niedrigeren Adressen hin. In diesem Beispiel ist die Wortadresse des nächsten Befehls 0x207 (0x40e / 2). Der Aufruf erscheint auf dem Stack als eine Folge von Bytes, und zwar als [0x02, 0x07] beim ATmega128RFA1 oder [0x00, 0x02, 0x07] beim ATmega256RFR2. Wenn diese Folge im Stack-Dump vorhanden ist, können wir annehmen, dass die Funktion PHY_DataInd() von der Funktion PHY_TaskHandler() aufgerufen wurde.

Dieser Prozess ist sehr mühsam und für den täglichen Einsatz unpraktisch. Glücklicherweise lassen sich die meisten Schritte automatisieren. Entsprechende Automatisierungstools möchten wir hier vorstellen.

Zur Demonstration dient ein Netzwerk von drahtlosen Geräten, die auf der Basis des Lightweight Mesh Stack von Atmel arbeiten. Der Code des Stacks wurde absichtlich modifiziert, um beim Empfang eines neuen Frames einen zufälligen Fehler bei einer Zuweisung des Arbeitsspeichers zu erzeugen. Dieser Fehler entspricht Problemen, die auch in realen Anwendungen auftreten. Zum Beispiel kann die Arbeitsspeicherzuweisung bei hoher Last scheitern, wenn kein Speicher zugeordnet werden kann. Derartige Bedingungen können nur in einem großen Netzwerk reproduziert werden, so dass das Debugging dieses Fehlers mit konventionellen Methoden nicht möglich ist.

Mehrere Wege führen zum Stack-Dump

Die Stack-Struktur nach mehreren Funktionsaufrufen: Um alle im Stack gespeicherten Funktionsaufrufe zu finden, müssen die call-Befehle in der disassemblierten Auflistung gesucht werden.
Die Stack-Struktur nach mehreren Funktionsaufrufen: Um alle im Stack gespeicherten Funktionsaufrufe zu finden, müssen die call-Befehle in der disassemblierten Auflistung gesucht werden.
(Quelle: Atmel )

Bevor das Debugging beginnen kann, muss der Anwendungscode ausgeführt werden. Es gibt mehrere Möglichkeiten, um den Stack-Dump der MCU zu erhalten. In diesem Fall versuchen wir zwei Methoden – Speicherung des Stacks im EEPROM und Übertragung über den UART. Der Ausführungscode für beide Methoden ist in den Dateien dump_eeprom.h und dump_uart.h enthalten.

Die Speicherung im EEPROM hat den Vorteil, dass die Stack-Aufzeichnung auch noch für eine spätere Überprüfung zur Verfügung steht. Allerdings ist die Kapazität eingeschränkt und die normale Verwendung des EEPROMs kann durch die Anwendung gestört werden. Der UART-Stack-Dump muss während der Ausführung erfasst werden, wobei nur eine serielle Schnittstelle benötigt wird, welche die Anwendung kaum stört.

Beide Header-Dateien definieren eine Initialisierungsfunktion namens wdt_init(), die vom Initialisierungscode der Anwendung aufgerufen werden muss. Die Anwendung muss auch wdt_reset() regelmäßig aufrufen, um sicherzustellen, dass der Watchdog-Timer nicht zufällig ausgelöst wird. Die Header-Datei sollte einmal in die Anwendung eingebunden werden, wobei maximal eine Header-Datei eingebunden werden darf. Der Einsatz des Watchdog-Timers muss für die Dauer des Debuggings ausgesetzt werden.

Nach dem Auslösen des Watchdog-Ereignisses wird die normale Anwendungsausführung gestoppt. Das dient als Anzeige, dass das Gerät für das Auslesen des Stack-Dumps bereit ist. Der Watchdog-Ereignis-Handler kann erweitert werden, um einen besser sichtbaren Hinweis in Hardware-Form zu erhalten. Der Stack-Trace kann mit dem Device-Programming-Dialog in Atmel Studio aus dem EEPROM ausgelesen werden. Das hier dargestellte Stack-Reversal-Skript kann die Intel-HEX-Datei direkt lesen, so dass keine zusätzliche Bearbeitung erforderlich ist.

Wird die dump_uart.h Datei genutzt, dann wird der Stack-Dump über das UART in einer Schleife ausgesendet. Jeder neue Dump beginnt mit einer vordefinierten Folge von vier Bytes [0x4e, 0xda, 0xf5, 0x25]. In diesem Fall kann der Stack-Trace manuell mit jedem Endprogramm gelesen werden, das Binärdaten empfangen kann, oder mit dem beigefügten Python-Skript extract.py. Dieses Skript sucht automatisch nach der Synchronisationsfolge und sendet richtig formatierte Stack-Inhalte in die Standardausgabe, die weiter in eine Datei umgeleitet werden kann. Zum Beispiel:

python extract.py-p COM12> WSNDemo.dump

Hier wird der Stack-Dump auf COM-Port COM12 ausgelesen und in eine Datei WSNDemo.dump gespeichert, die sich direkt mit dem Stack-Reversal-Skript lesen lässt.

Außerdem ist eine Disassemblierungsliste der Anwendung erforderlich, um die symbolischen Namen und zugehörigen Adressen der Funktionen zu erhalten. Eine Disassemblierungsliste lässt sich mit dem Programm avr-objdump erzeugen:

avr-objdump-d WSNDemo.elf> WSNDemo.txt

Durch die Ausführung des Stack-Reversal-Skript wird die Disassemblierungsliste und der Stack-Dump entweder im Roh- oder Intel HEX Format erzeugt.

python avrstackrev.py-b-l WSNDemo.txt-s WSNDemo.dump

Der Output dieses Befehls sollte wie folgt aussehen:

0x0000cf: call to main() from .do_clear_bss_start()

0x000f8b: call to SYS_TaskHandler() from main()

0x0009c7: call to PHY_TaskHandler() from SYS_TaskHandler()

0x000207: call to PHY_DataInd() from PHY_TaskHandler()

0x0005b2: call to nwkFrameAlloc() from PHY_DataInd()

Die einzelnen Zeilen des Outputs enthalten jeweils Rücksprungadresse und Informationen zu den decodierten Funktionsaufrufen. Die erste Zeile entspricht der ersten aufgerufenen Funktion. Normalerweise gibt es immer ein Aufruf von main() vom Initialisierungscode der untergeordneten Stufe, ansonsten zeigt der Output einen fehlerhaften oder unvollständigen Stack-Dump.

In diesem Output ist zu erkennen, dass die Ausführung in der Funktion nwkFrameAlloc() gestoppt wurde, wodurch ausreichend Information für eine manuelle Untersuchung dieser Funktion zur Verfügung steht.

Auch Probleme, die keine implizite Endlosschleife aufweisen, lassen sich mit der hier präsentierten Technik effektiv debuggen. So zum Beispiel ein ereignisgesteuertes System, bei dem die Ausführung des nächsten Schrittes von einer Bestätigung im vorherigen Schritt abhängt. Wenn die Bestätigung aus irgendeinem Grund verloren geht, läuft die Anwendung normal weiter und setzt den Watchdog-Timer regelmäßig zurück. Dies bedeutet, dass das Problem durch die vorgestellte Methode nicht angezeigt wird. Mit einem Anwendungs-Timer könnten solche Probleme aber diagnostiziert werden. Der Zeitgeber muss mit der Anfrage gestartet und im Bestätigungs-Handler gestoppt werden. Wenn der Timer nicht rechtzeitig angehalten wird, sollte der Timer-Ereignis-Handler den Watchdog auslösen. Die Stelle, an dem der Watchdog-Timer ausgelöst wurde, zeigt das Problem an. Alle erwähnten Tools und der komplette Quellcode sind im Atmel-Portal verfügbar unter: https://spaces.atmel.com/gf/project/avrstackrev .

* Alex Taradov ist Applications Engineer beim Halbleiterhersteller Atmel.

Dieser Beitrag ist urheberrechtlich geschützt. Sie wollen ihn für Ihre Zwecke verwenden? Kontaktieren Sie uns über: support.vogel.de (ID: 42868084)