Das Bild in diesem Blogpost stammt von WikimediaImages auf pixabay.

Eine typische Debugging-Aktivität ist das Setzen von Haltepunkten (engl. Breakpoints) und das anschließende Durchlaufen des Programms von Haltepunkt zu Haltepunkt, wobei der interne Zustand des Programms an jedem Haltepunkt überprüft wird. Das hört sich zwar einfach an, ist in der Umsetzung dann aber doch komplizierter als man glaubt. In diesem Blogbeitrag werden wir einen Blick in den Maschinenraum eines Debuggers werfen.

Der Kontext

Mit einem Debugger wie GDB kannst du Haltepunkte setzen, die Ausführung fortsetzen, Einzelschritte durchführen, Variablen untersuchen und Variablenwerte ändern. Das ist sogar möglich, wenn das Programm auf einer MCU wie dem ATmega328P läuft, vorausgesetzt, du hast einen Hardware-Debugger und einen GDB-Server, der die Software-Schnittstelle zwischen der Debugging-Probe und dem GDB-Debugger bereitstellt. Wenn Du mit einer IDE, wie der Arduino IDE 2 arbeitest, kann das wie in der folgenden Abbildung aussehen.

Klickst du links neben eine Zeilennummer, wird ein Haltepunkt gesetzt, der durch einen roten Punkt markiert ist. Die Zeile, in der die Ausführung gerade angehalten wird, ist durch ein gelbes Dreieck gekennzeichnet. Die Debugging-Fenster auf der linken Seite zeigen einen Teil des internen Programmzustands, und die Ausführung wird über das Bedienfeld oben links gesteuert.

Welche Softwareschichten sind also am Setzen und Entfernen von Haltepunkten und an der Ausführung des Programms beteiligt? Wenn wir von der GUI-Schicht abstrahieren, gibt es

  • den symbolischen Debugger GDB,
  • den GDB-Server, der Befehle von GDB über das GDB RSP-Protokoll empfängt,
  • den Hardwaredebugger, die mit dem GDB-Server kommuniziert, z. B. über das EDBG-Protokoll, und schließlich
  • den On-Chip-Debugger (OCD), der z. B. über das debugWIRE-Protokoll mit dem Hardwaredebugger kommuniziert.

Im Folgenden gehen wir davon aus, dass wir das EDBG-Protokoll verwenden und uns nur mit AVR MCUs mit einer debugWIRE-Schnittstelle beschäftigen, was einige Dinge vereinfacht und andere verkompliziert.

Arten von Haltepunkten

Bevor wir uns mit dem Zusammenspiel der verschiedenen Softwareschichten befassen, müssen wir einen Blick auf die verschiedenen Arten von Haltepunkten werfen. Zunächst gibt es einen Unterschied zwischen Instruktions-Haltepunkten und Daten-Haltepunkten (auch Watchpoints genannt). Erstere bewirken, dass ein Programm angehalten wird, wenn eine bestimmte Instruktion im Program erreicht wird, während letztere ein Programm anhalten, wenn auf ein bestimmtes Datenspeicherelement zugegriffen wird. Auf debugWIRE MCUs sind keine Daten-Haltepunkte vorgesehen. Aus diesem Grund werden wir sie hier ignorieren.

Instruktions-Haltepunkte können Hardware-Breakpoints oder Software-Breakpoints sein. Ein Hardware-Breakpoint ist als ein Register implementiert, das mit dem aktuellen Programmzähler verglichen wird. Wenn der PC gleich dem Registerwert ist, wird die Ausführung angehalten. Normalerweise sind nur wenige solcher Hardware-Breakpoints verfügbar. Auf einer debugWIRE-MCU gibt es nur einen. Software-Breakpoints werden implementiert, indem ein bestimmter Trap-Befehl in den Maschinencode eingefügt wird. Bei AVRs ist das der BREAK-Befehl. Beide Arten von Breakpoints haben ihre Vor- und Nachteile. Hardware-Breakpoints sind schneller zu setzen und zu löschen, weil sie keine Neuprogrammierung des Flash-Speichers erfordern. Außerdem führen sie deshalb nicht zum Verschleiß des Flash-Speichers. Allerdings gibt es, wie bereits erwähnt, normalerweise nur sehr wenige Hardware-Breakpoints in eienr MCU. Und Hardware-Breakpoints können zum Skidding führen, d.h. das Programm stoppt aufgrund der Pipelining-Architektur der MCU erst ein paar Anweisungen später. Bei AVR-MCUs ist das kein Problem, solange wir nur über Instruktions-Haltepunkte sprechen.

Wie schwerwiegend ist also das Problem des Flash-Verschleißes? In den Datenblättern steht, dass für die klassischen AVR-MCUs 10.000 Schreib-/Löschzyklen garantiert ist. Bei den neueren MCUs mit UPDI-Schnittstelle sind es nur noch 1000 Zyklen!

Nehmen wir an, eine eifrige Entwicklerin programmiert ihre MCU alle 10 Minuten mit einer aktualisierten Version des Programms neu und debuggt mit fünf Software-Breakpoints, die sie bei jeder Episode setzt und löscht. Das führt wahrscheinlich im Durchschnitt zu 3 zusätzlichen Umprogrammierungen auf einer einzelnen Seite, was zu 4 solchen Vorgängen in 10 Minuten oder 192 solchen Vorgängen an einem Arbeitstag führt. Nach einer Arbeitswoche könnte sie also an das Limit für die modernen AVR MCUs stoßen. Die klassischen AVRs können 10 Wochen lang verwendet werden. Das gilt allerdings nur, wenn die Entwicklerin nicht ständig Haltepunkte setzt und löscht, sondern dabei eher zurückhaltend ist.

Haltepunkte setzen und löschen

Schauen wir uns nun an, was passiert, wenn ein Haltepunkt gesetzt wird. Über eine grafische Benutzeroberfläche kann ein Breakpoint gesetzt oder gelöscht werden. GDB sammelt alle aktiven Breakpoints, bis die Ausführung gestartet oder fortgesetzt wird. Dann sendet GDB alle gesammelten Breakpoints an den GDB-Server, gefolgt von einem Befehl zum Starten der Ausführung. Wenn das Programm auf einen Haltepunkt trifft oder asynchron angehalten wird, fordert GDB den GDB-Server auf, alle Haltepunkte zu löschen. Dies läuft dann jedes Mal ab, wenn der Start der Ausführung angefordert wird.

Würde man die Befehle, die GDB an den GDB-Server sendet, wörtlich interpretieren, würde dies zu einer massiven Neuprogrammierung des Flash führen, obwohl sich die Haltepunkte überhaupt nicht ändern. Glücklicherweise heißt es in der Spezifikation des EDBG-Protokolls: „Breakpoints werden nur dann in den Flashspeicher eingefügt/aus dem Flashspeicher entfernt, wenn der nächste Flusssteuerungsbefehl ausgeführt wird.“ Flusssteuerungsbefehle sind dabei „Auführung starten/fortführen“, „Einzelschritt“, „asynchroner Stop“, und „Reset“. Wenn GDB also zuerst alle Breakpoints löscht und denselben Satz von Breakpoints wieder einfügt und der GDB-Server all diese Befehle an den EDBG-Debugger weitergibt, ignoriert der EDBG-Debugger das alles, weil sich das Entfernen und Hinzufügen gegenseitig aufhebt.

Leider beschreibt dies die Situation nur ungefähr. GDB geht davon aus, dass, wenn man die Ausführung von einem Haltepunkt aus fortsetzen will, dieser Haltepunkt zuerst vorübergehend gelöscht (nicht wieder aktiviert) werden muss, dann wird ein einzelner Schritt ausgeführt, und erst dann wird der Haltepunkt wieder aktiviert und die Ausführung fortgesetzt. Angenommen, es gibt zwei Haltepunkte, einen an Adresse 0x100 und einen an 0x200, wobei wir zuerst auf 0x100 stoßen, könnte eine Befehlssequenz, die an den GDB-Server gesendet wird, wie folgt aussehen:

> Haltepunkt bei 0x100 setzen
> Haltepunkt bei 0x200 setzen
> Ausführung starten
... Haltepunkt bei 0x100 erreicht
> Haltepunkt bei 0x100 entfernen 
> Haltepunkt bei 0x200 entfernen
;;; Benutzer fordert die Fortsetzung der Ausführung an
> Haltepunkt bei 0x200 setzen
> einen einzelnen Schritt ausführen
> Haltepunkt bei 0x100 setzen
> Ausführung fortführen

Die Übergabe aller Befehle an den EDBG-Debugger führt zwar zum richtigen Verhalten, verursacht aber unnötigen Flash-Verschleiß. Der Grund dafür ist, dass es nicht notwendig ist, den Haltepunkt 0x100 vorübergehend zu entfernen. Ein EDBG-Debugger kann von einem Software-Breakpoint aus fortfahren, auch wenn sich die BREAK-Instruktion noch im Speicher befindet. Er führt die ersetzte Anweisung offline in einem speziellen OCD-Register aus und fährt von dort aus fort. Zumindest ist das bei Ein-Wort-Befehlen der Fall. Bei Zwei-Wort-Befehlen stellt der Hardware-Debugger das ursprüngliche Wort wieder her, fügt den BREAK-Bexfehl erneut ein und fährt dann fort. Bei einem Einzelschritt gilt entsprechendes.

Minimierung des Flash-Verschleißes

Wenn man die Befehle zum Setzen und Löschen von Haltepunkten direkt von GDB an den EDBG-Debugger weitergibt, werden bei jedem Haltepunkt zwei Ereignisse zur Neuprogrammierung des Flash ausgelöst. Und diese sind völlig überflüssig. Stell dir vor, was das für unsere eifrige Entwicklerin bedeuten könnte. Zusätzlich zum Setzen von fünf Haltepunkten pro Programmierentwicklungszyklus werden bei jedem Haltepunktstop zwei zusätzliche Umprogrammierungsschritte ausgelöst. Das bedeutet, dass wir leicht eine Größenordnung mehr Umprogrammierungsereignisse haben können. Was kann man also tun, um das zu vermeiden?

Für dw-gdbserver habe ich mich entschieden, alle Haltepunkte wie in anderen GDB-Server-Projekten, an denen ich gearbeitet habe (dw-link und avr_debug), zu speichern. Ein Grund dafür ist, dass dw-gdbserver auch den Hardware-Breakpoint verwendet und ihn immer dem zuletzt eingeführten Breakpoint zuweist. Dabei handelt es sich oft um einen temporären Haltepunkt, der von GDB eingefügt wird, um einen Funktionsaufruf zu überspringen.

Das heißt, es gibt eine breakpoint_set, eine breakpoint_clear und eine breakpoint_update Funktion. Die ersten beiden Funktionen werden aufgerufen, wenn GDB einen Set- bzw. Clear-Befehl sendet. Die letztgenannte Funktion wird unmittelbar vor dem Start der Ausführung aufgerufen, was entweder ein „Einzelschritt“- oder ein „Ausführung fortsetzen“-Befehl sein kann, und die Funktion sendet dann Set- oder Clear-Befehle für Haltepunkte an den EDBG-Debugger.

Die einzige Änderung, die notwendig war, um das oben beschriebene Problem zu lösen, bestand darin, einen Software-Breakpoint an der Stelle zu ’schützen‘, an der ein Einzelschritt ausgeführt werden soll. Ein solcher Haltepunkt wird nie vor dem Einzelschritt entfernt, sondern erst beim nächsten „Ausführung fortsetzen“-Befehl wieder berücksichtigt.

Im obigen Beispiel wird, wenn der Befehl in Zeile 9 von GDB empfangen wird, die Funktion breakpoint_update aufgerufen und der Haltepunkt bei 0x100 ist geschützt. Nach dem Einzelschritt wird der Haltepunkt an 0x100 von GDB wieder aktiviert und beim nächsten Ausführungsbefehl berücksichtigt. Er wird also nicht entfernt und dann wieder eingesetzt, sondern bleibt die ganze Zeit bestehen.

Außerdem wird die Ausführung aller Zwei-Wort-Befehle, die sich an einem Haltepunkt befinden, im GDB-Server simuliert. Der zusätzliche Programmieraufwand dafür hielt sich in Grenzen, da es nur vier verschiedene solcher Anweisungen gibt.

Eine interessante Frage ist, wie andere Open-Source-GDB-Server für die AVR-Architektur mit diesem Problem umgehen. AVaRICE verwendet auch eine Breakpoint-Buchhaltungslösung. Das oben erwähnte Problem wird dadurch gelöst, dass die Breakpoints vor einer Einzelschrittoperation nicht aktualisiert werden. Das hört sich vernünftig an, denn es scheint unmöglich zu sein, auf einen Breakpoint zu treffen, wenn man einen Einzelschritt macht. Allerdings könnte die Art und Weise, wie AVaRICE mit Interrupts während eines Einzelschritts umgeht, zu Problemen führen. Wenn ein Haltepunkt in einer Interrupt-Service-Routine vom Benutzer kurz vor dem Single-Stepping entfernt wurde, könnte die interne Fortsetzung nach dem Anhalten in der Interrupt-Dispatch-Tabelle auf diesen Haltepunkt treffen. AVaRICE und/oder GDB könnten verwirrt sein, weil an diesem Punkt keine BREAK-Instruktion erwartet wird.

Bloom, ein weiterer Open-Source-GDB-Server, ignoriert das Problem in seiner aktuellen Version 2.0.0 komplett und toleriert zwei Flash-Umprogrammierungen bei jedem Breakpoint-Stopp. Das könnte sich laut Autor des Systems in Zukunft ändern.

Beide Systeme implementieren keine Zwei-Wort-Instruktions-Simulation, so dass an solchen Breakpoints immer zwei Flash-Umprogramminierungoperationen durchgeführt werden müssen. Und dies scheint auch für die kommerziellen Systeme Microchip Studio und MPLAB X zu gelten, soweit ich festellen konnte.

Zusammenfassung

Obwohl das Setzen und Entfernen von Haltepunkten und das Ausführen eines Programms sehr einfach erscheinen, kann es kompliziert werden, wenn man sich die Mechanismen ansieht, die sie implementieren. Das gilt vor allem dann, wenn man den Flash-Verschleiß minimieren will. Microchip empfiehlt übrigens, dass Chips, die zum Debuggen mit debugWIRE verwendet wurden, nicht an Kunden ausgeliefert werden sollten. Nun, ich verschicke sowieso nie AVR-Chips an Kunden. Wenn du zu den wirklich Paranoiden gehörst, kannst du Software-Breakpoints mit dem Befehl monitor breakpoint hardware deaktivieren, wenn du dw-gdbserver benutzt. Danach kannst du nur noch den einen Hardware-Breakpoint verwenden oder Einzelschritte machen (aber nicht beides gleichzeitig).

Views: 18