Ich beschäftige mich ja seit einigen Jahren in meiner Freizeit mit Systemprogrammierung und habe auch schon einige Projekte in dem Bereich gemacht, unter anderem WoL und VADM. Das Projekt, um das es in diesem Artikel geht, hat auch mit Systemprogrammierung zu tun, aber es gibt ein paar Unterschiede zu den früheren Projekten. Aber erst mal, was ist das überhaupt für ein Projekt? Wie der Titel schon sagt geht es um einen Remote Debugger für den Commodore Amiga namens cwdbg. Aber was bedeutet das genau? Ich gehe mal davon aus, dass ihr wisst, was ein Debugger ist. Der Debugger, den ich geschrieben habe, dient zur Fehlersuche von in Assembler oder C geschriebenen Programmen für den Commodore Amiga. Der Amiga, falls ihr das nicht wisst, war ein Heimcomputer der späten 1980er und frühen 1990er Jahre und der Computer meiner Jugend. Spätestens seit VADM sollte klar sein, dass ich mich immer noch gerne mit ihm beschäftige. Remote bedeutet allerdings, dass der Debugger selber nur zu einem kleinen Teil auf dem Amiga läuft und zum grösseren Teil auf einem modernen Rechner unter Linux, macOS oder Windows. Daher auch der Titel dieses Artikels - das Projekt verbindet wie ein Wurmloch verschiedene Zeiten, in dem Fall moderne Technologien mit Retro-Computing.
Was ist nun bei diesem Projekt so besonders? Bei meinen anderen Projekten handelte es sich immer nur um Proofs of Concept ohne echte Funktionalität. Als ich mit cwdbg begann wollte ich eigentlich nur lernen, wie ein Debugger überhaupt funktioniert und sehen, ob ich einen für den Amiga schreiben könnte. Bei den ersten Versionen handelte es sich demzufolge auch genau um solche Proofs of Concept. Aber nach einer Weile dachte ich mir, dass es schön wäre, auch mal ein Programm zu schreiben, das zumindest halbwegs benutzbar wäre. Das hat natürlich den Umfang deutlich vergrössert und führte dazu, dass ich mittlerweile (mit Unterbrechungen) schon seit über vier Jahren an dem Projekt arbeite. Es ist sicher nicht so, dass der Debugger jetzt wirklich "fertig" wäre. Aber er befindet sich in einem vorzeigbaren Zustand, was ich zum Anlass genommen habe, diesen Artikel zu schreiben. Den Debugger in Aktion erleben kann man in zwei Screencasts, einmal beim Debuggen eines Assembler-Programms, einmal beim Debuggen eines in C geschriebenen Programms.
In den vier Jahren haben sich immer wieder die Ziele des Projekts geändert. Ursprünglich hatte ich geplant, dass der Debugger komplett auf dem Amiga läuft, eventuell sogar mit einer GUI und einem ARexx-Port. Aber nach einer Weile empfand ich die Programmierung in C doch als relativ mühsam, vor allem im Vergleich zu Python, der Sprache, mit der ich beruflich arbeite. Besonders schreckte mich die Vorstellung ab, das Einlesen und Verarbeiten der Debug-Informationen im STABS-Format nochmal in C schreiben zu müssen, einen Prototypen dafür hatte ich schon in Python geschrieben. So entstand die Idee, den Debugger in einen kleinen, in C und Assembler geschriebenen Server und einen in Python geschriebenen Host aufzuteilen. Der Server würde auf dem Amiga laufen, der Host auf einem anderen Rechner mit Linux, Windows oder macOS als Betriebssystem. Und mit Python als Programmiersprache für den Host wäre es viel einfacher und schneller möglich, Features zu implementieren. Genau so ist cwdbg jetzt aufgebaut.
Am Anfang des Projekts war das Debuggen auf Quellcode-Ebene das hauptsächliche Ziel. Nach einer Weile kamen dann Features dazu, die eher beim Debuggen von Programmen, deren Quellcode man gar nicht hat, nützlich sind, wie zum Beispiel das Annotieren von Aufrufen von Systemroutinen oder das Disassemblieren von beliebigen Speicherbereichen.
Eine weiterer Unterschied zu früheren Projekten ist, dass ich den Code mittlerweile schon einige Mal ziemlich grundlegend umstrukturiert habe. Ich stellte einfach beim Entwickeln immer wieder fest, dass die bisherige Struktur für die Weiterentwicklung oder auch die neue Ausrichtung des Projekts (z. B. die Aufteilung in Host und Server) hinderlich war.
Ich werde in diesem Artikel nicht erklären, wie ein Debugger grundsätzlich funktioniert. Für eine gut lesbare Einführung in das Thema kann ich die Reihe von Artikeln von Eli Bendersky empfehlen, die auch mir sehr geholfen haben. Allerdings geht es in diesen Artikeln um Debugger, die auf Linux (und einem Intel-Prozessor) laufen. Im folgenden werde ich zeigen, wie das Ganze auf dem Amiga funktioniert und welchen speziellen Herausforderungen es dabei gab. Der wesentliche Unterschied ist, dass das AmigaOS keinerlei Unterstützung für Debugger bietet (wie ptrace in Linux oder die Debugging API in Windows). Allerdings ist auf dem Amiga aufgrund seiner Architektur auch keine Unterstützung durch das Betriebssystem nötig.
Ausführen des Programms
Als erstes muss ein Debugger natürlich das Programm, das "entwanzt" werden soll, ausführen können. Damit ist nicht nur das Starten des Programms (wie in einer Shell) gemeint, sondern auch Breakpoints, also das Anhalten des Programms an interessanten Stellen und das schrittweise Ausführen (Zeile für Zeile bzw. Instruktion für Instruktion).
cwdbg führt das Programm in einem separaten Prozess aus (mit der Systemroutine CreateNewProc
) und die beiden Prozesse, der Debugger und das sogenannte Target, kommunizieren über Signale miteinander (wie das auf dem Amiga eben so üblich war).
Wie in dem zweiten Artikel der Reihe von Eli Bendersky beschrieben werden Breakpoints normalerweise implementiert, indem man an der Stelle im Code, an der man das Programm anhalten möchte, die Maschinensprache-Instruktion durch eine Interrupt- bzw. bei dem Motorola-680x0-Prozessor des Amigas eine Trap-Instruktion ersetzt. Zusätzlich bieten Prozessoren einen Einzelschritt- oder sogenannten Trace-Modus, in dem nach jeder ausgeführten Instruktion ein Interrupt bzw. eine Exception ausgelöst wird. Damit lässt sich ein schrittweises Ausführen des Programms auf Assembler-Ebene implementieren, was ich auch bei cwdbg so gemacht habe.
Was passiert nun mit diesen Interrupts oder Exceptions? Bei modernen Betriebssystemen wird ein entsprechender Handler im Kernel aufgerufen und das Betriebssystem kümmert sich um die Bearbeitung. Bei Linux bedeutet das, wenn das Programm mit Hilfe von ptrace
ausgeführt wird, dass ein Signal an den Eltern-Prozess geschickt wird.
Auf dem Amiga gibt es, wie schon erwähnt, diesen Komfort nicht. Eine Exception führt normalerweise zu der berüchtigten Guru Meditation mit anschliessendem Neustart des Rechners. Ich musste als für cwdbg einen eigenen Exception Handler schreiben.
Was macht nun dieser Exception Handler genau? Im Prinzip führt er einen Kontextwechsel durch, und zwar innerhalb eines Prozesses, nämlich des Target-Prozesses. Was bedeutet das genau? Zuerst wird der Prozess-Kontext, das heisst alle Register einschliesslich Program Counter (PC), Stack Pointer (SP) und Status-Register (SR) gesichert und dann wird der Supervisor-Modus, in dem der Exception Handler ausgeführt wird, verlassen und eine Routine des Debuggers aufgerufen. Es findet also ein Kontext-Wechsel von dem vom Debugger ausgeführten Programm zum Debugger selber statt, aber noch innerhalb des Prozesses, der für das ausgeführte Programm erzeugt wurde. Wenn sich die aufgerufene Routine beendet hat wird wieder zum Kontext des ausgeführten Programms gewechselt. Dabei werden die gesicherten Register wiederhergestellt (aus technischen Gründen ebenfalls in dem Exception Handler) und das Programm wird dann fortgesetzt. Weil so ein Exception Handler sehr nahe an der Hardware arbeitet und es dabei auf jede einzelne Instruktion ankommt habe ich ihn in Assembler geschrieben. Bis dahin hatte ich eigentlich keine Erfahrung mit Assembler, schon gar nicht mit dem Assembler für die Motorola-Prozessoren, und daher war der Exception Handler sicher einer der kniffligsten Teile von cwdbg. Hilfreich waren dabei dieses Tutorial und die Funktion kprintf
aus der debug.lib
, mit der man Ausgaben über die serielle Schnittstelle auch im Supervisor-Modus machen kann.
Was macht dann diese Routine des Debuggers, die vom Exception Handler aufgerufen wird? Sie sendet ein Signal an den Debugger-Prozess, um ihm mitzuteilen, dass das Target angehalten hat. Anschliessend wartet sie auf ein Signal des Debugger-Prozesses, das der Debugger-Prozess dann schickt wenn der Benutzer das Programm weiter ausführen möchte. Davor kann der Benutzer das Programm untersuchen (sich z. B. Register- oder Speicherinhalte anschauen) und solange wird durch das Warten auf das Signal der Target-Prozess angehalten.
Das folgende Bild zeigt nochmal den eben beschriebenen Ablauf, das Bild darunter den entsprechenden Ablauf in Linux.
Disassembler
Ein wichtiger Bestandteil eines Debuggers ist natürlich ein Disassembler zum Anzeigen der einzelnen Instruktionen des Programms als Assembler-Code (es sein denn man beschränkt sich auf das Debuggen auf Quellcode-Ebene). Das Schreiben eines eigenen Disassemblers erschien mir aber doch zu aufwendig (und auch zu uninteressant), so dass ich mich nach einer fertigen Lösung umsah, die ich nutzen könnte. Fündig wurde ich bei Musashi, einem Emulator für den Motorola-680x0-Prozessor, der auch einen Disassembler beinhaltet. Diesen Code nutzte ich für die ersten Versionen, die noch komplett auf dem Amiga liefen. Nach der Aufteilung in Host und Server nutze ich die Bibliothek Capstone für den Host. Interessanterweise nutzt Capstone aber auch den Disassembler von Musashi für Motorola-680x0-Code.
Debug-Informationen
Hätte ich mich auf das Debuggen auf Assembler-Ebene beschränkt wäre der Artikel jetzt zu Ende. Aber ich wollte in C geschriebene Programme ja auch auf Quellcode-Ebene mit cwdbg debuggen können, also Breakpoints im Quellcode setzen, das Programm Zeile für Zeile (und nicht nur Instruktion für Instruktion) ausführen und die Werte von Variablen anzeigen. Damit ein Debugger das machen kann braucht er Unterstützung vom Compiler in Form von Debug-Informationen. Diese Debug-Informationen enthalten im Allgemeinen eine Zuordnung von Zeilennummern im Quellcode zu Adressen im Maschinencode, eine Liste aller Funktionen und eine Beschreibung aller Variablen, nämlich welchen Typ und Gültigkeitsbereich sie haben und wo sie gespeichert sind (in welchem Register oder an welcher Speicheradresse).
Da ich als Compiler den GCC verwende hatte ich zwei Möglichkeiten, was das Format dieser Debug-Informationen angeht - STABS und DWARF. DWARF ist zwar das modernere, aber auch deutlich komplexere Format. Und da ich das Einlesen und Verarbeiten der Debug-Informationen selber implementieren und nicht auf eine bestehende Bibliothek zurückgreifen wollte entschied ich mich für STABS. Eine gute Beschreibung dieses Formats findet sich hier, zusätzlich habe ich auch ein bisschen im Quellcode der GNU Binutils gespickt.
Die Debug-Informationen in diesem Format bestehen aus einer Liste von sogenannten Symbol Table Strings (daher der Name des Formats), die die verschiedenen Elemente des Programms wie Funktionen und Variablen beschreiben. Im Prinzip stellen sie einen stark vereinfachten abstrakten Syntaxbaum des Programms dar. Und genau so einen Baum baut sich der Debugger beim Einlesen der Debug-Informationen auf. Das war dann übrigens, obwohl das STABS-Format an sich relativ einfach ist, doch nicht ganz trivial zu implementieren. Warum braucht der Debugger überhaupt einen Syntaxbaum? Das liegt daran, dass er auch den Gültigkeitsbereich von Variablen kennen muss, also zum Beispiel welche lokalen Variablen in einer bestimmten Funktion definiert sind. Und der Gültigkeitsbereich einer Variablen ist im STABS-Format dadurch beschrieben, dass erst mal der Gültigkeitsbereich selber definiert wird und dann innerhalb dieses Gültigkeitsbereichs die entsprechenden Variablen, also eben in einer Baumstruktur.
Aktuell (Dezember 2022) werden die Informationen, die die Typen der Variablen beschreiben, zwar eingelesen aber noch nicht verarbeitet. Damit ist cwdbg momentan noch nicht in der Lage, die Werte von Variablen auszugeben.
Remote Debugging über die serielle Schnittstelle
Wie schon erwähnt habe ich ja den Debugger nach eine Weile in einen Host und einen Server aufgeteilt. Aber wie sollten Server und Host (das sind übrigens die Begriffe, die auch der GDB verwendet) miteinander kommunizieren? Heutzutage würde so eine Kommunikation natürlich über TCP/IP laufen, und darauf aufsetzend würde man vielleicht eine REST API nutzen oder auch eine Bibliothek wie ZeroMQ und die Daten würde man zum Beispiel mit Google Protocol Buffers serialisieren und deserialisieren. Aber zum einen wollte ich dem Geist der 1980er Jahre, aus denen der Amiga stammt, nicht untreu werden. Zum anderen hat es mich auch gereizt, mal ein eigenes Netzwerkprotokoll zu entwickeln.
Die Entscheidung für die physikalische Schicht war schnell gefallen. Das konnte bei einem Projekt mit Retro-Bezug nur die serielle Schnittstelle sein. Aber die serielle Schnittstelle stellt ja erst mal nur einen Byte-Strom zur Verfügung. Möchte man, wie man das bei Protokollen ja üblicherweise macht, mit Paketen arbeiten, braucht man auf der nächsten Schicht ein sogenanntes Framing-Protokoll. Dabei liebäugelte ich kurz mit HDLC. Das erschien mir dann aber doch zu kompliziert und ich entschied mich für das wesentlich einfachere, aber für meine Zwecke ausreichende SLIP. Das wurde ja in der Anfangszeit des Internets gerne zur Übertragung von IP-Paketen über serielle Schnittstellen genutzt.
Entgegen dieser ursprünglichen Verwendung nutze ich SLIP in diesem Projekt aber nicht, um IP-Pakete zu transportieren sondern die Pakete meines für den Debugger entwickelten Protokolls. Ich überspringe also sozusagen die Schichten 3 und 4 des OSI-Modells und mein Protokoll implementiert die Schichten 5 bis 7.
Wie sieht nun dieses Protokoll genau aus? Es handelt sich um ein synchrones, binäres Request-Response-Protokoll. Das bedeutet, dass der Host eine Anfrage an den Server schickt und auf die Antwort des Servers wartet. Der Server bestätigt die Ausführung der gewünschten Aktion (zum Beispiel das Starten des Programms) oder meldet einen aufgetretenen Fehler. Sowohl Anfrage als auch Antwort können weitere Daten enthalten, zum Beispiel eine Speicheradresse beim Setzen eines Breakpoints oder der Speicherinhalt beim Auslesen eines Speicherbereichs. Zusätzlich meldet sich der Server bei dem Host jedes Mal wenn das Programm angehalten hat (wenn zum Beispiel ein Breakpoint erreicht wurde) und übermittelt ihm dabei den aktuellen Zustand des Programms (Grund des Anhaltens, die nächsten Instruktionen, der Inhalt der Register und der Anfang des Stacks).
Hier findet sich eine ganz interessante Diskussion zum Design eines Protokolls für die serielle Schnittstelle. Das folgende Bild zeigt den Nachrichtenfluss bei einer typischen Debugging-Sitzung. Der Einfachheit halber fehlen in dem Bild die internen Abläufe im Server.
Eine einzelne Protokollnachricht ist folgendermassen aufgebaut:
Zusätzlich zu dem Typ der Nachricht (MSG_*
) und eventuellen Daten gibt es noch eine fortlaufende Sequenznummer und eine Prüfsumme. Mit der Sequenznummer könnten verlorengegangene oder nach einem Timeout wiederholte Nachrichten erkannt werden, mit der Prüfsumme Bitfehler bei der Übertragung. Weder Timeouts noch die Prüfsumme sind momentan (Dezember 2022) implementiert.
Die Daten selber werden ohne weitere Informationen wie Typ oder Länge der einzelnen Elemente in Netzwerk-Byte-Reihenfolge (Big Endian) übertragen. Das bedeutet, dass der Empfänger immer genau wissen muss, welche Daten er vom Sender bekommt, aber so war die Datenübertragung deutlich einfacher zu implementieren als mit einem Datenformat wie ASN.1 oder XDR. Um das Packen und Entpacken der Daten nicht für jeden Nachrichtentyp separat implementieren zu müssen habe ich mir zwei Funktionen pack_data()
und unpack_data()
geschrieben, die analog zu den Funktionen pack()
und unpack()
aus dem Python-Modul struct
einen Format-String als Parameter haben, der angibt, welche Typen die einzelnen Datenelemente haben. Anhand dieses Format-Strings werden dann die Datenelemente entsprechend in einen Bytestrom gepackt bzw. aus einem Bytestrom entpackt. Die Idee zu diesen Funktionen habe ich aus dem Buch The Practice of Programming von Brian Kernighan (übrigens ein sehr lesenswertes Buch).
UI mit Urwid
Nachdem ich mich entschieden hatte, den Debugger in einen Host und einen Server aufzuteilen, stellte sich die Frage, was für eine Benutzerschnittstelle der Host bekommen sollte. Natürlich hätte ein einfaches CLI ausgereicht, das hatte ja auch die ursprüngliche Version, die noch komplett auf dem Amiga lief. Aber irgendwann hatte ich die Idee, den Host mit einer UI auszustatten, die dem Benutzer die wichtigsten Informationen wie Programmstatus, die nächsten Instruktionen bzw den Quellcode des Programms, die Registerinhalte usw. bei jedem Stopp des Programms übersichtlich anzeigen würde. Inspiriert wurde ich dabei von pwndbg, einer Erweiterung für den GDB. Weil ich mit der UI nur Informationen anzeigen wollte und die Steuerung des Debuggers weiterhin über die Kommandozeile erfolgen sollte entschied ich mich für eine TUI. Das klingt vielleicht erst mal sehr nach den 1980er Jahren (z. B. hatte Turbo Pascal von Borland so eine UI) aber mein Eindruck ist, dass TUIs in den letzten Jahren wieder in Mode gekommen sind.
Implementiert habe ich die TUI in diesem Projekt mit Hilfe von Urwid. Urwid ist eine Bibliothek für Python mit der üblichen Funktionalität einer GUI-Bibliothek wie verschiedene Widgets, Geometrie-Management und so weiter, nur eben für die Konsole. Damit ist die Erstellung einer TUI wesentlich einfacher als z. B. direkt mit ncurses.
Entwicklungsumgebung
Für diejenigen, die sich auch für Retro-Computing interessieren, ist vielleicht ganz interessant, wie meine Entwicklungsumgebung aussieht. Erst mal besitze ich keinen echten Amiga mehr sondern führe die Programme, die ich für den Amiga schreibe, in einem Emulator aus, nämlich FS-UAE. Damit emuliere ich einen Amiga 4000 mit AmigaOS 3.1 und der ClassicWB-Umgebung. Die serielle Schnittstelle, die cwdbg ja auch verwendet, wird dabei von FS-UAE auf einen TCP-Port auf dem Host abgebildet.
Den Code schreibe ich mit Visual Studio Code auf dem Host (in meinem Fall ein Mac Mini) und übersetze den C- und Assembler-Code mit einem Cross-Compiler bzw. Cross-Assembler. Dafür nutze ich momentan eine sehr alte Version des GCCs (2.95), die letzte Version, die noch das AmigaOS unterstützte.
Um mir das Testen und Debuggen leichter zu machen habe ich an verschiedenen Stellen Unit Tests verwendet, nämlich für die schon erwähnten Funktionen pack_data()
und unpack_data()
(mit cmocka), für das Einlesen und Verarbeiten der Debug-Informationen und für das Testen des Servers (auch wenn das streng genommen keine Unit Tests sind).
Was habe ich bei dem Projekt gelernt?
Als allererstes ist mir durch das Projekt klar geworden, wie komplex ein Debugger eigentlich ist und wie knifflig es ist, selber einen zu schreiben. Das sieht man zum einen daran, dass ich wie gesagt mit Unterbrechungen schon seit über vier Jahren an dem Projekt arbeite und zum anderen an dem Umfang des Codes. Selbst cwdbg mit seiner sehr beschränkten Funktionalität besteht mittlerweile aus ca. 4500 Zeilen (Python, C und Assembler). Das ist natürlich noch nichts im Vergleich zu einem professionellen Debugger, beim GDB (in der Version 12.1) sind es sehr beeindruckende 3,3 Millionen Zeilen.
Darüber hinaus habe beim Schreiben von cwdbg auch noch einiges über C gelernt. Auch wenn C nach BASIC meine zweite Programmiersprache war und ich schon seit Ende der 1980er Jahre immer wieder mal in C programmiert habe, hatte ich doch relativ wenig Erfahrung damit, grössere Programme in C zu schreiben. Und erst bei grösseren Programmen muss man sich ja mehr Gedanken machen wie man den Code strukturiert. So kommt es, dass ich in diesem Projekt zum ersten Mal in C objektorientiert programmiert und opake Datentypen und Unit Tests verwendet habe. Da das Programm ja auch einigermassen robust sein sollte hat auch die Fehlerbehandlung eine wichtige Rolle gespielt. Da musste ich mich unter anderem damit beschäftigen, wie ich bei Funktionen Fehler anzeige und was an den unterschiedlichen Stellen bei einem Fehler passieren soll. Spezielle Rückgabewerte wie -1 oder NULL verwenden und den Fehlercode in einer globalen Variablen speichern oder den Fehlercode als Rückgabewert und das eigentliche Ergebnis der Funktion als Ausgabe-Parameter zurückgeben? Programm beenden? Fehler loggen? Fehler an den Host melden?
Ansonsten war und ist cwdbg eine ziemlich spannende Reise durch eine Reihe unterschiedlicher Themen, auf die ich ja schon eingegangen bin. Diese Reise ist sicher noch nicht zu Ende, denn ich habe noch eine Menge Ideen, wie ich cwdbg erweitern und verbessern könnte. Damit bin ich am Ende des Artikels angelangt. Ich hoffe, ich konnte einen Teil der Faszination, die ich bei dem Projekt erlebt habe, weitergeben. Der vollständige Quellcode von cwdbg findet sich auf GitHub und steht unter der BSD-Lizenz.
Wie immer freue ich mich über Kommentare, Anmerkungen und Fragen und bin unter der Adresse constantin.wiemer@gmx.de erreichbar.