Das Bild in diesem Beitrag wurde von DALL-E erstellt.

Beim Embedded Debugging passiert es oft, dass man beim zeilenweisen Single-Stepping plötzlich in der Interrupt-Vektor-Tabelle landet. Ein weiteres Problem ist, dass einzelne Schritte manchmal Ewigkeiten dauern können. In diesem Blogbeitrag gehe ich auf beide Probleme ein, und zeige, welche Maßnahmen in einem Gdbserver diese Probleme verhindern können. Alles natürlich im Kontext von AVR MCUs.

Vor ein paar Jahren habe ich einen Blogbeitrag über Interrupts während des Single-Steppens geschrieben und eine elegante Lösung für den Hardware-Debugger dw-link vorgestellt. Drei Jahre später muss ich mich erneut mit demselben Problem beschäftigt, weil ich ein Python-Skript schreibe, das einen Gdbserver implementiert, der mit den EDBG-basierten Hardware-Debuggern von Microchip kommunizieren kann.

Einzelschritte und Interrupts

Was ist das Problem? Der Gdbserver wird von GDB angewiesen, eine einzelne Maschineninstruktion auszuführen, während der Benutzer Zeile für Zeile durch den Code schreitet. Wenn in dieser Situation ein Interrupt ausgelöst wird, wird er nach der Ausführung einer einzelnen Maschineninstruktion bedient. Im Einzelschrittmodus wird der Programmzähler an die entsprechende Stelle in der Interrupt-Vektor-Tabelle gesetzt. Dann wird die Ausführung gestoppt und die Kontrolle an den Gdbserver zurückgegeben. GDB weiß nicht, wie es im Einzelschrittmodus weitergehen soll und hält an. Der Versuch, mit dem finish-Befehl zu dem Punkt im Programm zurückzukehren, an dem die Unterbrechung ausgelöst wurde, funktioniert nicht, weil GDB nach einem Interrupt den Aufruf nicht zurückverfolgen kann. Man ist also „gestrandet“.

Man könnte argumentieren, dass das, was hier passiert, vollkommen korrekt ist. Eine Instruktion wurde ausgeführt, der Interrupt wurde ausgelöst und der Programmfluss wurde aus diesem Grund in der Interrupt-Routine fortgesetzt. Allerdings macht ein solches Verhalten keinen Sinn, wenn man auf der Quellcode-Ebene per Einzelschritt durch den Code geht.

Eine Möglichkeit, herauszufinden, wo es nach der Interrupt-Behandlung weitergeht, ist, den Stack zu untersuchen, die Rücksprungadresse der Interrupt-Routine zu ermitteln und an dieser Adresse einen temporären Breakpoint zu setzen. Dies manuell zu tun, ist lästig. Der Gdbserver AVaRICE erledigt das für dich, wenn du die Kommandozeilenoption --ignore-intr verwendest. Ein mögliches Problem könnte sein, dass man durch einen expliziten Sprung in die Interrupt-Vektor-Tabelle gelangt ist, was aber sehr unwahrscheinlich ist. Ein weiteres Problem ist, dass nicht genau klar ist, wann der „Reparatur“-Modus beendet werden muss. Hier ist also ein bisschen Rätselraten für AVaRICE angesagt.

In verschiedenen Diskussionsforen findet man Ratschläge, um das Problem zu entschärfen:

  1. Deaktiviere Interrupts, bevor du einen Einzelschritt ausführst, und aktiviere sie danach.
  2. Simuliere einen Einzelschritt, indem du wiederholt einen temporären Breakpoint vor die nächste Zeile setzt, die das Programm erreichen soll, und führe das Programm dann aus. Die Unterbrechungen werden dann im Hintergrund abgearbeitet.

Ein Benutzer könnte GDB-Befehle definieren, die diese Methoden implementieren. Oder noch besser, man könnte diese Strategien im Hardware-Debugger oder im Gdbserver implementieren.

In meinem alten Blogbeitrag habe ich argumentiert, dass beide Strategien nicht wirklich perfekt sind. Die erste Strategie könnte mit Anweisungen interferieren, die das Interrupt-Bit setzen oder lesen, z. B. SEI, CLI, BRIE und BRID. Auch wenn man diese Befehle berücksichtigen würde, können alle Lade- und Speicheroperationen auf das Statusregister zugreifen. Das bedeutet, dass man die Quell- und Zieladressen für alle verschiedenen Adressmodi berechnen muss, um solche Konflikte zu erkennen und aufzulösen.

Die zweite Strategie hat Probleme mit bedingten Anweisungen. In diesem Fall müsste man entweder zwei Haltepunkte setzen, das Ziel der bedingten Anweisung vorausberechnen oder die Ausführung insgesamt simulieren.

Interrupt-sichere Einzelschritte

Vor drei Jahren habe ich die interne Funktion von debugWIRE genutzt, die die Offline-Ausführung einer einzelnen Instruktion ermöglicht, die gegen Interrupts geschützt ist. Da diese Funktion im EDBG-Protokoll nicht zur Verfügung gestellt wird, muss eine andere Lösung her.

Obwohl keine der beiden oben genannten Strategien besonders attraktiv ist, könnte man sie wie folgt kombinieren:

  • Für alle Nicht-Sprunginstruktionen (dazu gehören SEI und CLI) verwenden wir den Hardware-Breakpoint, um die Ausführung zu stoppen, nachdem die Anweisung ausgeführt wurde.
  • Für alle unbedingten Verzweigungsbefehle und alle bedingten Befehle, außer BRIE/BRID, verwenden wir ein Interrupt-Disable/-Enable-Paar, um einen einzelnen Schritt auszuführen.
  • Für BRIE/BRID berechnen wir die Zieladresse der Verzweigung aufgrund des Status des I-Bits und setzen den Hardware-Haltepunkt an dieser Stelle.

Das beeinträchtigt das I-Bit nicht, erfordert keine zwei Haltepunkte und führt nur zu einem minimalen Aufwand beim Interpretieren von Anweisungen (letzter Fall). Im Gegensatz zur AVaRICE-Strategie muss man nichts erraten. Inzwischen wurde die Methode im Python-Gdbserver implementiert und funktioniert recht gut.

Beachte, dass es noch einige potenzielle Interferenzen gibt. Die Befehle CALL und RCALL legen die Rücksprungadresse auf den Stack und RET und RETI holen die Rücksprungadressen vom Stack zurück. Ähnlich manipulieren PUSH und POP den Stack. Wenn der Stack Pointer auf die Adresse 0x0060 oder 0x005F zeigt, bevor eine Rücksprungadresse auf den Stack gepusht wird, oder wenn er auf 0x005D oder 0x005E zeigt, bevor eine Rücksprungadresse vom Stack gepoppt wird, dann ist das Statusregister (an 0x005F) betroffen. Wenn der Stack aber ohnehin schon so voll ist, ist eine Menge Chaos zu erwarten, weil wichtige IO-Register überschrieben werden oder wurden. Zum Beispiel enthält 0x005D/0x005E den Stapelzeiger! Aus diesen Gründen werde ich diese Situation ignorieren und es dem Benutzer überlassen, eine solche Situation zu entwirren.

Sehr lange Einzelschritte

Ein weiteres lästiges Phänomen bei Einzelschritten ist, dass sie sehr lange dauern können. Und zwar so lange, dass man gezwungen ist, Strg-C zu benutzen, um den Schritt zu unterbrechen. Der Grund dafür ist, dass GDB bei der Aufforderung, einen Einzelschritt zu machen, alle Instruktionen, die zur Quellzeile gehören, per Einzelschritt ausführen lässt. Das ist kein Problem, wenn die Zeile keine Sprunganweisungen enthält. Wenn die Zeile jedoch eine Schleife enthält, kann es sehr lange dauern. In einem anderen Zusammenhang habe ich die Zeit, die für die Kommunikation zwischen GDB und Gdbserver bei der Ausführung einer einzelnen Instruktion benötigt wird, auf 100 Millisekunden geschätzt. Eine durchschnittliche Anweisung wird in 2 Zyklen ausgeführt, was 0,125 Mikrosekunden auf einer 16 MHz MCU entspricht. Mit anderen Worten: Die Einzelschrittausführung ist fast eine Million Mal langsamer als die direkte Ausführung. Schau dir die folgende, harmlos aussehende Zeile an:

_delay_ms(100)

Dies ist ein Makro, das zu der folgenden Schleife aufgefaltet wird:

start: ldi r18, 0xFF ; 255
       ldi r24, 0xE1 ; 225
       ldi r25, 0x04 ; 4
loop:  subi r18, 0x01 ; 1
       sbci r24, 0x00 ; 0
       sbci r25, 0x00 ; 0
       brne loop 
       rjmp end
end:   nop
exit:  ....

1.000.000 Mal langsamer zu sein bedeutet, dass du jetzt 100.000 Sekunden oder ungefähr einen Tag warten musst, anstatt 100 ms.

Range-Stepping

Seit GDB Version 7.7 gibt es einen Range-Stepping-Befehl im seriellen Remote-Protokoll von GDB. Anstatt die Ausführung einer einzelnen Anweisung zu verlangen, kann GDB jetzt die Ausführung eines ganzen Bereichs von Anweisungen verlangen. Nur wenn die Ausführung diesen Bereich verlässt, muss der Gdbserver die Kontrolle an GDB übergeben. Er kann die Kontrolle aber auch schon vorher an GDB zurückgeben.

Wie können wir diesen Range-Stepping-Befehl ausnutzen, um das Single-Stepping zu beschleunigen? Die erste offensichtliche Optimierung besteht darin, über alle Nicht-Sprunganweisungen zu laufen und nur bei Verzweigungsanweisungen anzuhalten. Dies lässt sich mit einem temporären Breakpoints erreichen (der auch ein Hardware-Breakpoint sein kann). Die Verzweigungsanweisung kann dann im Single-Stepping-Modus ausgeführt werden, und danach geht es weiter wie oben. Das würde die obige Schleife um den Faktor 7 beschleunigen. Wir müssen also nur noch ein paar Stunden, statt eines ganzen Tages warten. Nicht gut genug!

Eine Kontrollflussanalyse kann uns helfen, alle potenziellen Exits des Bereichs zu bestimmen. Das sind Stellen außerhalb des Bereichs, die durch Verzweigungsanweisungen erreicht werden, oder Instruktionen innerhalb des Bereichs mit laufzeitabhängigen Verzweigungszielen wie RET oder IJMP. Wendet man eine solche Analyse auf den obigen Code an, stellt man fest, dass der einzige Exit-Punkt derjenige ist, der mit exit: gekennzeichnet ist. Im obigen Fall reicht es also aus, einen (Hardware-)Haltepunkt an exit: zu setzen. Und damit sind wir wieder bei der Ausführung dieser Zeile in etwa 100 Millisekunden.

Ist es immer möglich, das Range-Stepping auf das Setzen von nur einem Haltepunkt zu reduzieren? Offensichtlich nicht, wie das nächste Beispiel zeigt.

while (++i) { if (i < 0) return; }

Dies wird in den folgenden AVR-Code kompiliert, vorausgesetzt, i ist ein signed char.

start:  subi r24, 0xFF ; 255
        breq exit 
        brpl start 
return: ret
exit: ...

Wie du siehst, haben wir jetzt zwei Exit-Punkte, einen bei return: und einen bei exit:. Wenn man nur einen Hardware-Breakpoint hat, kann man entweder auf die weiter oben beschriebene Methode zurückgreifen und jeweils bei der nächsten Sprunginstruktion stoppen, oder aber man verwendet Software-Breakpoints, die dann nach Verlassen des Bereichs wieder gelöscht werden müssen. Ich weiß nicht, was die beste Alternative ist. Aber mal ehrlich, wie oft packt man so einen verrückten Code in eine einzige Quellzeile? Die aktuelle Implementierung verwendet nur Hardware-Breakpoints und stoppt bei jeder Verzweigungsinstruktion ab, soweit nicht genügend Hardware-Breakpoints vorhanden sind.

Zusammenfassung

Alles in allem sollte das Interrupt-sichere Single-Stepping und Range-Stepping das Debuggen mit dw-gdbserver angenehmer machen.

Views: 7