Alte Freundin - Virtuelle Maschine für Amiga-Programme

Sun 08 November 2020

Irgendwann im Frühjahr 2016 hatte ich die Idee zu einem Projekt, das mir im ersten Moment ziemlich verrückt erschien. Ich könnte doch einen Emulator schreiben, der Programme, die für den Commodore Amiga, den Computer meiner Jugend, entwickelt worden waren, auf einem Mac oder einem Linux-Rechner ausführt. Und dieser Emulator sollte die Programme wenn möglich in ihrer binären Form, also ohne erneute Übersetzen ausführen.

Dass so etwas grundsätzlich möglich ist war mir klar. Es gibt ja einige Beispiele solcher Emulatoren, zum Beispiel das Wine-Projekt, Wabi von Sun Microsystems, eine Software, die Windows-3.x-Programme auf Sun's Betriebssystem Solaris ausführen konnte, oder auch die Virtual DOS Machine in älteren Windows-Versionen. Bei dem Namen für das Projekt, Virtual AmigaDOS Machine oder VADM habe ich mich dann auch von letzterer inspirieren lassen.

Eine zusätzliche Schwierigkeit bei Amiga-Programmen ist allerdings, dass der Amiga ja Prozessoren der 68000er-Familie von Motorola verwendete. Wenn mein Emulator also binärkompatibel (und nicht nur quellkompatibel) sein sollte müsste ich auch den Prozessor emulieren. Auch dafür, dass so etwas möglich ist, gibt es Beispiele. Zum Beispiel war in der Version von Windows NT für den Alpha-Prozessor von DEC ein Emulator enthalten, der Programme ausführen konnte, die für Intel-Prozessoren geschrieben worden waren. So einen Prozessor-Emulator selber zu schreiben erschien mir aber als eine zu grosse Aufgabe, um sie als Hobbyprojekt anzugehen, und ein kurzer Blick in das Datenblatt der Motorola-Prozessoren bestätigte meine Meinung. Diese Prozessoren waren für ihre Zeit eben doch schon ziemlich komplex. Nach kurzer Recherche stellte ich allerdings fest, dass es bereits einen Emulator für diese Prozessoren gibt, der einen ganz brauchbaren Eindruck machte, nämlich Musashi. Dieser Emulator wird auch von dem MAME-Projekt verwendet.

Mit Hilfe von Musashi schaffte ich es dann tatsächlich, so einen Emulator schreiben. Der Code dazu findet sich hier. Obwohl er funktionierte (und sogar relativ mächtig war, er konnte einen Klon des von Unix und Linux bekannten find-Kommandos ausführen) war ich doch nicht ganz damit zufrieden weil er ja nicht vollständig meine eigene Leistung war. Einige Zeit später hörte ich dann zum ersten Mal von Dynamic Recompilation, auch Binary Translation genannt. Vielleicht könnte ich ja diese Technik nutzen, um Musashi überflüssig zu machen? Dazu sollte ich erstmal kurz erklären, wie diese Technik funktioniert. Allgemein geht es darum, den binären Code eines Programms zur Laufzeit in Maschinencode für einen Prozessor zu übersetzen. Bei dem ursprünglichen Code kann es sich dabei um Bytecode handeln oder, wie im Fall von VADM, um Maschinencode für eine andere Prozessorarchitektur. Diese Technik wird also sowohl von virtuellen Maschinen für Sprachen, die in Bytecode übersetzt werden (wie zum Beispiel Java oder C#), verwendet als auch von Emulatoren, wobei ein Emulator ja auch eine virtuelle Maschine darstellt.

Übersetzen zur Laufzeit bedeutet, dass das Übersetzen und Ausführen des Codes nicht in getrennten Schritten oder Programmen passiert. Vielmehr führt ein Programm, nämlich die virtuelle Maschine oder der Emulator beide Schritte durch, und das auch nicht nacheinander sondern abwechselnd. Das bedeutet, das zu übersetzende Programm wird in kleine "Häppchen" aufgeteilt (was das bei VADM bedeutet werden wir später sehen) und jeweils ein solches Häppchen wird zuerst übersetzt und dann sofort ausgeführt. Es handelt sich hierbei also um einen Just-in-time Compiler.

Als Ziel setzte ich mir schlisslich, ein einfaches, in Assembler geschriebenes Programm, das für einen Amiga mit einem Motorola-680x0-Prozessor (im folgenden Motorola-Prozessor genannt) und AmigaOS geschrieben wurde, auf einem Rechner mit Intel-x86-64-Prozessor (im folgenden Intel-Prozessor genannt) und macOS oder Linux zum Laufen zu bringen. Das ist der Quellcode dieses Programms loop, das dreimal den Text "Only Amiga made it possible" ausgibt. Es besteht aus 10 unterschiedlichen Instruktionen und 3 Systemroutinen.

/*
* some constants
*/
.set AbsExecBase, 4
.set OpenLibrary, -552 
.set CloseLibrary, -414
.set PutStr, -948

.text
    /* open DOS library */
    movea.l     AbsExecBase, a6
    movea.l     #libname, a1
    moveq.l     #0, d0
    jsr         OpenLibrary(a6)
    tst.l       d0
    beq.w       error_no_dos
    move.l      d0, DOSBase

    /* print text 3 times */
    movea.l     DOSBase, a6
    moveq.l     #3, d2                  /* loop counter */
loop_start:
    move.l      #msg, d1                /* text address, we can't use lea because it only works with address registers */
    jsr         PutStr(a6)
    subq.l      #1, d2
    bne.s       loop_start

normal_exit:
    /* close DOS library */
    movea.l     AbsExecBase, a6
    movea.l     DOSBase, a1
    jsr         CloseLibrary(a6)
    moveq.l     #0, d0                  /* exit code */
    rts

error_no_dos:
    moveq.l     #1, d0                  /* exit code */
    rts

.data
    .comm DOSBase, 4

    libname:    .asciz "dos.library"
    msg:        .asciz "Only Amiga made it possible\n"

Wie ich dieses Ziel erreicht habe beschreibt der Rest des Artikels. Beim Schreiben so eines Emulators gibt es im wesentlichen drei Aufgaben, auf die ich im folgenden näher eingehen werde. Dabei handelt es sich um das Laden des Programms, das Übersetzen des Codes und die Emulation der API (der Systemroutinen).

Laden des Programms

Hier bestand die Herausforderung darin, dass das AmigaOS und Linux unterschiedliche Dateiformate für Programme nutzen. Linux verwendet das Format ELF, das AmigaOS verwendete das sogenannte Hunk-Format. Der Loader von Linux, also die Komponente im Betriebssystem, die für das Laden von Programmen zuständig ist, kann daher Amiga-Programme gar nicht laden. So einen Loader musste ich also selber implementieren.

Eine genaue Beschreibung des Hunk-Formats findet sich im AmigaDOS Manual (2) oder auch im Amiga-Guru-Buch (1). Grob gesagt besteht eine Programmdatei in diesem Format aus mehreren Hunks, die den Segmenten bei anderen Formaten wie ELF oder PE entsprechen. Normalerweise besteht eine Programmdatei aus drei Hunks, je einem für Code, Daten und BSS. Optional gibt es noch einen Hunk mit den Debugging-Informationen. Diese Hunks sind dann nochmal in mehrere Blöcke unterteilt, und zwar in einen Block mit dem Code beziehungsweise den Daten (in dem BSS-Hunk ist dieser natürlich leer), einen Block mit Verschiebeinformationen und einen Block mit den Symbolen für diesen Hunk (wenn sie nicht vom Linker aus der Programmdatei gelöscht wurden). Verschiebeinformationen sind deshalb notwendig weil das AmigaOS keinen virtuellen Speicher verwendete und deshalb beim Linken eines Programms die absolute Adresse, an der das Programm geladen wird, nicht bekannt war. Das hat zur Folge, dass der Loader alle absoluten Adressen in dem Programm beim Laden anhand der Verschiebeinformationen anpassen muss.

Das Lesen der Programmdatei und das Laden des Codes und der Daten in den Speicher ist in der Routine load_program in der Datei loader.c implementiert und ebenso das Anpassen der Adressen. Dabei gibt es ein interessantes Detail. Auf dem Amiga waren alle Adressen 32 Bits breit und demzufolge natürlich auch die anzupassenden Adressen. Wie sollte ich also die 64 Bits der Adressen, an die die Hunks geladen werden, in den zur Verfügung stehenden 32 Bits unterbringen? Dieses Problem habe ich so gelöst, dass die Hunks mit mmap an feste Adressen unterhalb der 4GB-Grenze geladen werden und so 32-Bit-Adressen entstehen (die oberen 32 Bit sind eben alle 0).

Übersetzen des Codes

Nach dem Laden des Amiga-Programms muss der Programmcode übersetzt werden. Das bedeutet, dass für jede der Instruktionen für den Motorola-Prozessor eine (oder auch mehrere) entsprechende Instruktion(en) für den Intel-Prozessor erzeugt werden muss (müssen). Dabei bin ich in zwei Schritten vorgegangen. Im ersten Schritt wird die Original-Instruktion decodiert und in ihre Bestandteile, nämlich Opcode, Adressierungsart und die Operanden, zerlegt. Das ist im Prinzip auch das, was ein Prozessor tun muss bevor er eine Instruktion ausführen kann. Im zweiten Schritt wird aus diesen Bestandteilen eine neue Instruktion für den Intel-Prozessor erzeugt und entsprechend codiert. Den ersten Schritt habe ich nicht vollständig selber implementiert sondern ich habe dafür Teile des Dissassemblers von Musashi verwendet. Ein Dissassembler muss nämlich die Instruktionen genauso decodieren und zerlegen, nur dass er dann die Bestandteile in lesbarer Form ausgibt. Der Dissassembler von Musashi ist so geschrieben, dass er aus dem Code eine Folge von Instruktionen erzeugt und dann für jede Instruktion mit Hilfe einer Lookup-Tabelle und dem Opcode als Index einen sogeannten Handler aufruft (wer an den Details interessiert ist kann sich die Routine translate_tu in der Datei translate.c zu Gemüte führen). Der nachfolgende Code zeigt beispielhaft einen Teil des Handlers für die Instruktion MOVE.

Als erstes werden die beiden Operanden aus der codierten Instruktion extrahiert. In den 16 Bits des Opcodes ist der Typ der Operanden, die sogenannte effektive Adresse, angegeben, die Operanden selber sind im Speicher nach dem Opcode abgelegt. Ein Operand kann ein Register, eine Speicheradresse oder ein konstanter Wert (nur beim Quelloperanden) sein. Nachdem sowohl der Typ als auch die Operanden bei allen Instruktionen auf die gleiche Weise codiert sind habe ich dafür eine Funktion extract_operand geschrieben. Eine genaue Beschreibung aller Instruktionen und der Codierung derselben findet sich im Motorola M68000 Family Programmer’s Reference Manual (4).

uint8_t  src_mode_reg = m68k_opcode & 0x003f;
uint8_t  dst_mode_reg = (m68k_opcode & 0x0fc0) >> 6;
Operand  srcop, dstop;
int      nbytes_used = 0;

nbytes_used += extract_operand(src_mode_reg, inpos, &srcop);
// destination operand has mode and register parts swapped
dst_mode_reg = ((dst_mode_reg & 0x07) << 3) | ((dst_mode_reg & 0x38) >> 3);
nbytes_used += extract_operand(dst_mode_reg, inpos, &dstop);

Anschliessend wird mit diesen Operanden eine entsprechende Instruktion für den Intel-Prozessor (MOV) erzeugt. Dazu habe ich für die verschiedenen Kombinationen der Typen von Quell- und Zieloperand jeweils eine eigene Funktion geschrieben. Ähnlich wie bei den Motorola-Prozessoren besteht nicht überraschend auch bei den Intel-Prozessoren eine Instruktion aus einem Opcode (möglicherweise mit einem Prefix), dem Typ der Operanden (der durch die sogenannten ModR/M- und SIB-Bytes festgelegt wird) und den Operanden selber. Wer sich für die (relativ komplizierten) Details interessiert, dem sei das Intel 64 and IA-32 Architectures Software Developer’s Manual (5) (stolze 2200 Seiten) und eine hilfreiche Webseite des Bristol Community College (6) empfohlen.

// call the appropriate function depending on the combination of source / destination operand type
if ((srcop.op_type      == OP_MEM)  && (dstop.op_type == OP_DREG))
    x86_encode_move_mem_to_dreg(srcop.op_value, dstop.op_value, outpos);
else if ((srcop.op_type == OP_IMM)  && (dstop.op_type == OP_DREG))
    x86_encode_move_imm_to_dreg(srcop.op_value, dstop.op_value, outpos);
else if ((srcop.op_type == OP_DREG) && (dstop.op_type == OP_MEM))
    x86_encode_move_dreg_to_mem(srcop.op_value, dstop.op_value, outpos);
else if ((srcop.op_type == OP_DREG) && (dstop.op_type == OP_DREG))
    x86_encode_move_dreg_to_dreg(srcop.op_value, dstop.op_value, outpos);
else {
    ERROR("combination of source / destination operand types %d / %d not supported", srcop.op_type, dstop.op_type);
    return -1;
    }

Schwieriger wird die Sache bei den Sprungbefehlen (BCC). Bei diesen Befehlen ist der Operand die Adresse im Code (absolut oder relativ), an die verzweigt werden soll (oder auch ein Offset, der zu einem Adressregister addiert wird, was dann wiederum eine Adresse ergibt). Diese Adresse, also das Sprungziel, ist aber für den übersetzten Code beim Übersetzen der Sprungbefehle noch gar nicht bekannt und nicht gleich der Adresse im Originalcode. Sowohl absolute als auch relative Adressen und Offsets ändern sich nämlich beim Übersetzen. Das liegt zum einen daran, dass der übersetzte Code natürlich im Speicher an einer anderen Stelle zu liegen kommt als der Originalcode. Zum anderen sind im Allgemeinen funktional identische Instruktionen bei Motorola- und Intel-Prozessoren unterschiedlich lang, bestehen also aus einer unterschiedlichen Anzahl von Bytes.

Dieses Problem habe ich so gelöst, dass ich nicht das komplette Programm "in einem Rutsch" übersetze. Stattdessen unterteile ich das Programm in Blöcke, sogenannte Translation Units (TU), und übersetze diese einzeln. Die übersetzten Blöcke werden in einem Translation Cache abgelegt. Diese Methode stammt nicht von mir sondern aus einem Paper (3), das beschreibt, wie in VMware Binary Translation durchgeführt wird. Weil ich den Code ja zur Laufzeit des Amiga-Programms übersetzen wollte, und auch nur Code, der tatsächlich ausgeführt wird, habe ich die Methode noch etwas erweitert. Bei VADM bestehen die einzelnen Translation Units erstmal nur aus einem Stub, der sich selbst bei der ersten Verwendung der Translation Unit durch den übersetzten Code ersetzt und diesen Code dann auch gleich ausführt. Das ist ein ähnliches Prinzip wie bei einem Programm mit Overlays. Wie das alles im Detail funktioniert ist nachfolgend anhand des Diagramms beschrieben.

Ausführen des Programms

  1. Die Routine setup_tu wird mit der Adresse (im Originalcode) der ersten anzulegenden TU aufgerufen. Die erste TU befindet sich natürlich am Anfang des Programms.
  2. setup_tu überprüft, ob die TU bereits angelegt wurde und sich im Translation Cache befindet. Dieser Cache speichert die Zuordnung von Adressen im Originalcode zu Adressen im übersetzten Code für alle bereits angelegten TUs (implementiert als binärer Suchbaum). Befindet sich die TU im Cache gibt es nichts zu tun und setup_tu gibt einfach aus dem Cache die Adresse des Speicherblocks für den übersetzten Codes zurück.
  3. Befindet sich die TU noch nicht im Cache muss diese angelegt werden. Dazu wird zuerst ein Speicherblock allokiert, in dem dann zuerst der Stub und später der übersetzte Code abgelegt werden. Diese Blöcke sind der Einfachheit halber immer 4 KB (eine volle Speicherseite) gross und der Speicher muss logischerweise ausführbar sein. Die Adresse des Speicherblocks wird zusammen mit der Adresse im Originalcode im Translation Cache gespeichert.
  4. In dem allokierten Speicherblock wird der Code für den schon erwähnte Stub erzeugt. Dieser Stub sichert den Zustand des Amiga-Programms (die Register und Prozessor-Flags), ruft die Routine translate_tu auf, stellt den Zustand des Amiga-Programms wieder her und springt zu dem übersetzten Code.
  5. Die soeben angelegte TU, genauer gesagt der darin enthaltene Stub wird ausgeführt. Bei der erste TU des Programms passiert das durch die Routine exec_program in execute.c. Alle anderen TUs werden von der jeweils vorhergehenden TU aufgerufen. Als Folge davon wird translate_tu aufgerufen.
  6. Wie schon oben beschrieben wird dann die TU übersetzt, und zwar solange bis eine terminierende Instruktion erreicht ist, also eine Instruktion, die die TU abschliesst. Nachdem ich ja nur einen kleinen Teil des Befehlssatzes der Motorola-Prozessoren implementiert habe sind das bei VADM die Instruktionen BCC (Sprünge) und RTS (Rücksprung aus einer Routine). Der übersetzte Code wird im Speicherblock hinter dem Stub abgelegt.
  7. Handelt es sich bei der terminierenden Instruktion um einen Sprungbefehl passiert folgendes.
    • Es werden durch den Aufruf von setup_tu zwei weitere TUs angelegt, einmal mit dem Sprungziel als Adresse, einmal mit der Adresse der Instruktion nach dem Sprungbefehl.
    • Der Sprungbefehl wird mit der Adresse (des übersetzten Codes) der ersten TU als Sprungziel übersetzt. Zusätzlich wird nach dem Sprungbefehl ein unbedingter Sprung (JMP) eingefügt, mit dem zur zweiten TU gesprungen wird. Das bedeutet, dass die aktuelle TU mit diesen beiden neuen TUs verknüpft und die Programmausführung mit einer dieser beiden TUs fortgesetzt wird (mit der ersten wenn die Sprungbedingung erfüllt ist, ansonsten mit der zweiten).
  8. translate_tu fügt am Anfang des Speicherblocks einen Sprung zum übersetzten Code ein und beendet sich. Dann wird der restliche Teil des Stubs ausgeführt, das heisst, der Zustand des Amiga-Programms wird wiederhergestellt und die TU, also der soeben übersetzte Code wird ausgeführt. Der eingefügte Sprung ist notwendig damit beim eventuellen erneuten Ausführen dieser TU der Stub und damit ein erneuter Aufruf von translate_tu umgangen wird.
  9. Ist das Ende der aktuelle TU erreicht wird zu einer der beiden nachfolgenden TUs gesprungen (wenn das Amiga-Programm noch nicht zu Ende ist). Damit werden wieder die Schritte 5 bis 9 durchlaufen.

Die Translation Units stellen in den meisten Fällen sogenannte Basic Blocks dar. Allerdings kann es bei der beschriebenen Methode vorkommen, dass Basic Blocks entstehen, die nicht nur die erste Instruktion als "Eingang" haben sondern auch noch andere Instruktionen, so zum Beispiel bei der dritten TU, was der strikten Definition widerspricht (die vierte TU ist ein Teil der dritten TU). Das folgende Diagramm zeigt die TUs von loop.

Ausführen des Programms

Abschliessend möchte ich noch erwähnen, wie ich die Register des Motorola-Prozessors auf die des Intel-Prozessors abgebildet habe. Die Motorola-Prozessoren hatten ja von Anfang an schon 16 32 Bit breite Register, 8 Adress- und 8 Datenregister. Um überhaupt genügend Register zur Verfügung zu haben wird der 32-bittige Code des Amiga-Programms in 64-bittigen Code übersetzt. Denn nur im 64-Bit-Modus der Intel-Prozessoren kann man die Register R8D - R15D nutzen (das D bedeutet, dass nur die unteren 32 Bit der an sich 64 Bit breiten Register genutzt werden). Auf diese Register habe ich die Datenregister D0 - D7 abgebildet. Als Adressregister verwende ich die restlichen verfügbaren Register entsprechend der folgenden Tabelle (hier bedeutet das E, dass wieder nur die unteren 32 Bits der Register genutzt werden). Die Reihenfolge der Intel-Register entspricht der Nummerierung der Register in den codierten Instruktionen, mit einer Ausnahme: Ich habe EDI mit ESP getauscht, so dass, entsprechend der Verwendung als Stack Pointer, A7 auf ESP abgebildet wird.

Motorola-Register Intel-Register Verwendung
A0 EAX
A1 ECX
A2 EDX
A3 EBX
A4 EDI
A5 EBP Frame Pointer
A6 ESI Basisadressen der Bibliotheken des Amiga OS
A7 ESP Stack Pointer

Emulation der API

Die dritte Aufgabe des Emulators bzw. der virtuellen Maschine ist es, die von dem Beispielprogramm verwendeten Systemroutinen zu emulieren. Dabei handelt es sich um die Routinen OpenLibrary, PutStr und CloseLibrary. Das Problem war hierbei nicht, die eigentliche Funktionalität nachzubauen sondern, die Aufrufe dieser Routinen im Beispielprogramm abzufangen und auf die entsprechenden Routinen in der virtuellen Maschine "umzubiegen". Um diesen Teil verstehen zu können muss man zuerst wissen, wie der Aufruf von Systemroutinen im AmigaOS funktionierte. Im AmigaOS waren die Systemroutinen nach Themen in verschiedenen Bibliotheken (Exec, DOS, Intuition usw.) zusammengefasst (die sich entweder im ROM des Amigas befanden oder von Diskette bzw. Festplatte geladen wurden). Jede dieser Bibliotheken bestand neben den eigentlichen Routinen aus einer Sprungtabelle (und einer Struktur mit verschiedenen Verwaltungsinformationen für die Bibliothek, die aber für die Emulation keine Rolle spielt). Diese Tabelle befand sich unterhalb der Basisadresse der Bibliothek (die Adresse, die von OpenLibrary zurückgegeben wurde) und bestand aus absoluten Sprüngen zu den jeweiligen Routinen. Die Offsets in dieser Tabelle waren Teil der öffentlichen API der Bibliothek (beschrieben in den sogenannten FD-Dateien) und eine Routine einer Bibliothek wurde durch Laden der Basisadresse in das Register A6 und der Instruktion jsr -<Offset der Routine>(a6) aufgerufen.

Wie kann nun ein Emulator so einen Aufruf abfangen und auf eine Routine im Emulator umlenken? Ich habe das so gelöst, dass sich die Systemroutinen nicht in VADM selber sondern in Shared Libraries befinden (die im Unterverzeichnis libs liegen). Die Bibliothek libexec.so enthält die Routinen OpenLibrary und CloseLibrary, libdos.so enthält PutStr. libexec.so wird vor dem Starten des Amiga-Programms geladen (mit dlopen) und die Basisadresse an einer festen Adresse abgelegt (im AmigaOS war das als einzige feste Adresse die Adresse 4, bei VADM habe ich eine andere Adresse verwendet). Das Laden von libdos.so erfolgt durch den Aufruf der Systemroutine OpenLibrary im Amiga-Programm (die dann wiederum dlopen verwendet).

Zusätzlich zu den erwähnten Routinen enthalten beide Bibliotheken auch noch eine Routine zum Erzeugen der gerade beschriebenen Sprungtabelle. Allerdings sieht diese Tabelle etwas anders aus als die Sprungtabellen im AmigaOS, und das nicht nur weil sie natürlich aus Instruktionen für Intel-Prozessoren besteht. Tatsächlich werden sogar zwei Tabellen erzeugt, aber zu der zweiten Tabelle komme ich gleich. Für VADM ist der Normalfall nämlich, dass eine Routine nicht implementiert ist (weil ich ja nur drei von mehreren Hundert Routinen implementiert habe). In diesem Fall besteht der Eintrag in der ersten Tabelle für die entsprechende Routine nur aus den Instruktionen INT3 und RET. Das bedeutet, dass beim Aufruf einer nicht implementierten Routine ein Interrupt erzeugt wird, der wiederum ein SIGTRAP-Signal erzeugt, und das hat zur Folge, dass das Amiga-Programm mit einer Fehlermeldung beendet wird.

Interessanter wird es natürlich wenn ein Routine implementiert ist. Dann besteht der Eintrag in der ersten Sprungtabelle tatsächlich aus einem Sprung, allerdings noch nicht zu der Routine selber. Warum ist das so? Erstens wäre in dieser Tabelle nicht genügend Platz dafür. Die Einträge liegen nämlich nur 6 Bytes auseinander (und die Offsets sind ja durch die API vorgegeben). Diese 6 Bytes waren auf dem Amiga mit seinen 32-bittigen Adressen für einen absoluten Sprung ausreichend, auf einem Rechner mit 64-bittigem Intel-Prozessor sind sie es aber nicht. Zweitens müssen vor dem Aufruf der Routine noch einige Vorbereitungen getroffen werden. Aus diesen beiden Gründen befinden sich in der ersten Sprungtabelle für die implementierten Routinen relative Sprünge (die bei Intel-Prozessoren nur 5 Bytes benötigen) zu Thunks in einer zweiten Tabelle. In diesen Thunks passiert folgendes:

  • Der Name der aufgerufenen Routine wird geloggt. Dazu sind die Namen der Routinen in einer Datenstruktur in der jeweiligen Shared Library abgelegt.
  • Die Register, die im AmigaOS über einen Funktionsaufruf hinweg erhalten bleiben mussten, werden auf dem Stack gesichert (weil sie ja möglicherweise von der aufgerufenen Routine verändert werden könnten).
  • Die Argumente der Routine werden von den Registern, in denen sie vom Amiga-Programm gemäss der AmigaOS-ABI abgelegt wurden, in die entsprechenden Register der x86-64-ABI (7) verschoben. Das bedeutet zum Beispiel, dass das übersetzte Amiga-Programm beim Aufruf einer Routine, die ihr erstes Argument im Register A1 erwartet, dieses im Register ECX ablegt (entsprechend dem oben erwähnten Register-Mapping). Laut der x86-64-ABI muss sich das erste Argument einer Funktion jedoch im Register RDI bzw. EDI befinden. Der Thunk würde in diesem Fall also die Instruktion mov edi, ecx enthalten.
  • Die Routine wird aufgerufen (mit der Instruktion CALL).
  • Der Rückgabewert der Routine wird vom Register EAX (x86-64-ABI) in das entsprechende Register gemäss der AmigaOS-ABI (üblicherweise D0 = R8D) verschoben.
  • Die gesicherten Register werden wiederhergestellt.

Du fragst dich jetzt vielleicht woher diese Thunks kommen. Sie befinden sich nicht etwa in fertiger Form in der Shared Library sondern werden erst beim Laden einer Bibliothek erzeugt (von den Routinen setup_jump_tables und emit_thunk_for_func in der Datei execute.c). Dazu wird eine Datenstruktur aus der Shared Library genutzt, die zu jeder Routine in der Bibliothek

  • den Offset in der ersten Sprungtabelle
  • den Namen (wie schon oben erwähnt)
  • die Registerbelegung (Argumente und Rückgabewert)
  • und die Adresse der Routine in der Shared Library (natürlich nur für die implementierten Routinen)

enthält. Die Registerbelegung ist dabei in dem gleichen Format codiert, das auch die syscall- bzw. libcall-Pragmas in den Header-Dateien des AmigaOS verwendeten. Das ist natürlich kein Zufall sondern rührt daher, dass ich die gerade beschriebene Datenstruktur aus eben diesen Header-Dateien erzeugt habe.

Das nachfolgende Bild zeigt nochmal den Ablauf beim Aufruf einer Routine.

Ausführen des Programms

Demonstration des Programms

Die folgenden Listings zeigen die Ausgabe von VADM während es das Programm loop ausführt, zusammen mit einigen erläuternden Kommentaren.

Als erstes wird das Programm geladen:

$ ./vadm loop
vadm.c:26            | main                 | INFO  | loading program...
loader.c:47          | load_program         | DEBUG | installing signal handler for SIGSEGV
loader.c:58          | load_program         | DEBUG | mapping file 'loop' into memory
loader.c:75          | load_program         | DEBUG | file mapped at address 0x7feb9c5cb000
loader.c:77          | load_program         | DEBUG | reading individual hunks
loader.c:84          | load_program         | DEBUG | reading next block of hunk #0
loader.c:88          | load_program         | DEBUG | block type is HUNK_HEADER
loader.c:111         | load_program         | DEBUG | creating memory mapping for hunks
loader.c:126         | load_program         | DEBUG | size (in bytes) of hunk #0 = 72, will be stored at 0x100000
loader.c:126         | load_program         | DEBUG | size (in bytes) of hunk #1 = 44, will be stored at 0x110000
loader.c:126         | load_program         | DEBUG | size (in bytes) of hunk #2 = 4, will be stored at 0x120000
loader.c:84          | load_program         | DEBUG | reading next block of hunk #0
loader.c:134         | load_program         | DEBUG | block type is HUNK_CODE / HUNK_DATA
loader.c:136         | load_program         | DEBUG | copying code / data (72 bytes) to mapped memory region at 0x100000
loader.c:84          | load_program         | DEBUG | reading next block of hunk #0
loader.c:163         | load_program         | DEBUG | block type is HUNK_RELOC32
loader.c:177         | load_program         | DEBUG | applying reloc referencing hunk #1 at position 6
loader.c:177         | load_program         | DEBUG | applying reloc referencing hunk #1 at position 38
loader.c:177         | load_program         | DEBUG | applying reloc referencing hunk #2 at position 24
loader.c:177         | load_program         | DEBUG | applying reloc referencing hunk #2 at position 30
loader.c:177         | load_program         | DEBUG | applying reloc referencing hunk #2 at position 56
loader.c:84          | load_program         | DEBUG | reading next block of hunk #0
loader.c:192         | load_program         | DEBUG | block type is HUNK_SYMBOL
loader.c:84          | load_program         | DEBUG | reading next block of hunk #0
loader.c:206         | load_program         | DEBUG | block type is HUNK_END
loader.c:84          | load_program         | DEBUG | reading next block of hunk #1
loader.c:134         | load_program         | DEBUG | block type is HUNK_CODE / HUNK_DATA
loader.c:136         | load_program         | DEBUG | copying code / data (44 bytes) to mapped memory region at 0x110000
loader.c:84          | load_program         | DEBUG | reading next block of hunk #1
loader.c:192         | load_program         | DEBUG | block type is HUNK_SYMBOL
loader.c:84          | load_program         | DEBUG | reading next block of hunk #1
loader.c:206         | load_program         | DEBUG | block type is HUNK_END
loader.c:84          | load_program         | DEBUG | reading next block of hunk #2
loader.c:156         | load_program         | DEBUG | block type is HUNK_BSS
loader.c:158         | load_program         | DEBUG | zeroing mapped memory region at 0x120000 (4 bytes)
loader.c:84          | load_program         | DEBUG | reading next block of hunk #2
loader.c:192         | load_program         | DEBUG | block type is HUNK_SYMBOL
loader.c:84          | load_program         | DEBUG | reading next block of hunk #2
loader.c:206         | load_program         | DEBUG | block type is HUNK_END

Danach beginnt das Ausführen des Amiga-Programms, und zwar damit, dass die erste TU angelegt wird. Dazu ist noch zu sagen, dass das Amiga-Programm in einem separaten Prozess läuft. Der Grund dafür ist, dass ja nicht implementierte Bibliotheksroutinen wie schon erwähnt ein SIGTRAP-Signal erzeugen. Dieses Signal würde VADM normalerweise einfach beenden aber so kann der Eltern-Prozess den Tod seines Kindes feststellen und eine entsprechende Fehlermeldung ausgeben.

vadm.c:31            | main                 | INFO  | initializing translation cache and setting up first TU...
tlcache.c:87         | tc_put_addr          | DEBUG | putting mapping 0x100000 -> 0x7feb9c3bc000 into cache
vadm.c:39            | main                 | INFO  | executing program...

Hier wird zuerst noch wie schon oben erwähnt die Exec-Bibliothek geladen.

execute.c:141        | exec_program         | DEBUG | loading Exec library
execute.c:108        | load_library         | DEBUG | dlopen()ing library 'libs/libexec.so'
execute.c:119        | load_library         | DEBUG | setting up library jump tables
execute.c:97         | setup_jump_tables    | DEBUG | creating entry with jump and thunk for function CloseLibrary()
execute.c:97         | setup_jump_tables    | DEBUG | creating entry with jump and thunk for function OpenLibrary()
execute.c:164        | exec_program         | DEBUG | guest is starting...

Jetzt wird die erste TU übersetzt. Das endet damit, dass die zwei nachfolgenden TUs angelegt werden.

translate.c:659      | translate_tu         | DEBUG | building opcode handler table
translate.c:670      | translate_tu         | DEBUG | translating TU with source address 0x100000 and destination address 0x7feb9c3bc000
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x2c78 in opcode handler table
translate.c:350      | m68k_movea           | DEBUG | translating instruction MOVEA
translate.c:355      | m68k_movea           | DEBUG | destination register is A6
translate.c:81       | extract_operand      | DEBUG | operand is 16-bit address 0x0004
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x227c in opcode handler table
translate.c:350      | m68k_movea           | DEBUG | translating instruction MOVEA
translate.c:355      | m68k_movea           | DEBUG | destination register is A1
translate.c:96       | extract_operand      | DEBUG | operand is immediate value 0x00110000
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x7000 in opcode handler table
translate.c:383      | m68k_moveq           | DEBUG | translating instruction MOVEQ
translate.c:384      | m68k_moveq           | DEBUG | destination register is D0
translate.c:385      | m68k_moveq           | DEBUG | immediate value = 0
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x4eae in opcode handler table
translate.c:312      | m68k_jsr             | DEBUG | translating instruction JSR
translate.c:74       | extract_operand      | DEBUG | operand is register A6 with offset
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x4a80 in opcode handler table
translate.c:477      | m68k_tst_32          | DEBUG | translating instruction TST
translate.c:60       | extract_operand      | DEBUG | operand is register D0
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x6700 in opcode handler table
translate.c:235      | m68k_bcc             | DEBUG | translating instruction BCC
translate.c:239      | m68k_bcc             | DEBUG | 16-bit offset = 48
translate.c:261      | m68k_bcc             | DEBUG | BEQ => JE
translate.c:276      | m68k_bcc             | DEBUG | setting up TU of branch taken
tlcache.c:87         | tc_put_addr          | DEBUG | putting mapping 0x100044 -> 0x7feb9c3bc400 into cache
translate.c:281      | m68k_bcc             | DEBUG | setting up TU of branch not taken
tlcache.c:87         | tc_put_addr          | DEBUG | putting mapping 0x100016 -> 0x7feb9c3bc800 into cache
translate.c:695      | translate_tu         | DEBUG | instruction is the terminal instruction in this TU - continuing execution of guest

Die erste TU wird ausgeführt. In dieser TU wird die DOS-Bibliothek geöffnet.

execute.c:18         | log_func_name        | DEBUG | guest called library function OpenLibrary()
execute.c:108        | load_library         | DEBUG | dlopen()ing library 'libs/libdos.so'
execute.c:119        | load_library         | DEBUG | setting up library jump tables
execute.c:97         | setup_jump_tables    | DEBUG | creating entry with jump and thunk for function PutStr()

Die nächste TU (#3 im Diagramm oben) wird übersetzt...

translate.c:670      | translate_tu         | DEBUG | translating TU with source address 0x100016 and destination address 0x7feb9c3bc800
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x23c0 in opcode handler table
translate.c:400      | m68k_move            | DEBUG | translating instruction MOVE
translate.c:60       | extract_operand      | DEBUG | operand is register D0
translate.c:88       | extract_operand      | DEBUG | operand is 32-bit address 0x00120000
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x2c79 in opcode handler table
translate.c:350      | m68k_movea           | DEBUG | translating instruction MOVEA
translate.c:355      | m68k_movea           | DEBUG | destination register is A6
translate.c:88       | extract_operand      | DEBUG | operand is 32-bit address 0x00120000
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x7403 in opcode handler table
translate.c:383      | m68k_moveq           | DEBUG | translating instruction MOVEQ
translate.c:384      | m68k_moveq           | DEBUG | destination register is D2
translate.c:385      | m68k_moveq           | DEBUG | immediate value = 3
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x223c in opcode handler table
translate.c:400      | m68k_move            | DEBUG | translating instruction MOVE
translate.c:96       | extract_operand      | DEBUG | operand is immediate value 0x0011000c
translate.c:60       | extract_operand      | DEBUG | operand is register D1
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x4eae in opcode handler table
translate.c:312      | m68k_jsr             | DEBUG | translating instruction JSR
translate.c:74       | extract_operand      | DEBUG | operand is register A6 with offset
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x5382 in opcode handler table
translate.c:447      | m68k_subq_32         | DEBUG | translating instruction SUBQ
translate.c:452      | m68k_subq_32         | DEBUG | immediate value = 1
translate.c:60       | extract_operand      | DEBUG | operand is register D2
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x66f2 in opcode handler table
translate.c:235      | m68k_bcc             | DEBUG | translating instruction BCC
translate.c:249      | m68k_bcc             | DEBUG | 8-bit offset = -14
translate.c:257      | m68k_bcc             | DEBUG | BNE => JNE
translate.c:276      | m68k_bcc             | DEBUG | setting up TU of branch taken
tlcache.c:87         | tc_put_addr          | DEBUG | putting mapping 0x100024 -> 0x7feb9c3bcc00 into cache
translate.c:281      | m68k_bcc             | DEBUG | setting up TU of branch not taken
tlcache.c:87         | tc_put_addr          | DEBUG | putting mapping 0x100032 -> 0x7feb9c3bd000 into cache
translate.c:695      | translate_tu         | DEBUG | instruction is the terminal instruction in this TU - continuing execution of guest

...und ausgeführt. Hier wird die Schleife zum ersten Mal durchlaufen und der Text ausgegeben.

execute.c:18         | log_func_name        | DEBUG | guest called library function PutStr()
>>> Only Amiga made it possible

Die nächste TU (#4) wird übersetzt. Die nachfolgenden TUs (#4 selber weil es sich ja um eine Schleife handelt und #5) befinden sich dieses Mal schon im Translation Cache, deswegen müssen sie nicht mehr angelegt werden.

translate.c:670      | translate_tu         | DEBUG | translating TU with source address 0x100024 and destination address 0x7feb9c3bcc00
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x223c in opcode handler table
translate.c:400      | m68k_move            | DEBUG | translating instruction MOVE
translate.c:96       | extract_operand      | DEBUG | operand is immediate value 0x0011000c
translate.c:60       | extract_operand      | DEBUG | operand is register D1
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x4eae in opcode handler table
translate.c:312      | m68k_jsr             | DEBUG | translating instruction JSR
translate.c:74       | extract_operand      | DEBUG | operand is register A6 with offset
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x5382 in opcode handler table
translate.c:447      | m68k_subq_32         | DEBUG | translating instruction SUBQ
translate.c:452      | m68k_subq_32         | DEBUG | immediate value = 1
translate.c:60       | extract_operand      | DEBUG | operand is register D2
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x66f2 in opcode handler table
translate.c:235      | m68k_bcc             | DEBUG | translating instruction BCC
translate.c:249      | m68k_bcc             | DEBUG | 8-bit offset = -14
translate.c:257      | m68k_bcc             | DEBUG | BNE => JNE
translate.c:276      | m68k_bcc             | DEBUG | setting up TU of branch taken
translate.c:607      | setup_tu             | DEBUG | TU with source address 0x100024 is already in the cache - nothing to do
translate.c:281      | m68k_bcc             | DEBUG | setting up TU of branch not taken
translate.c:607      | setup_tu             | DEBUG | TU with source address 0x100032 is already in the cache - nothing to do
translate.c:695      | translate_tu         | DEBUG | instruction is the terminal instruction in this TU - continuing execution of guest

Die TU wird zweimal ausgeführt. Dann wird die Schleife verlassen.

execute.c:18         | log_func_name        | DEBUG | guest called library function PutStr()
>>> Only Amiga made it possible
execute.c:18         | log_func_name        | DEBUG | guest called library function PutStr()
>>> Only Amiga made it possible

Die letzte TU (#5) wird übersetzt und ausgeführt.

translate.c:670      | translate_tu         | DEBUG | translating TU with source address 0x100032 and destination address 0x7feb9c3bd000
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x2c78 in opcode handler table
translate.c:350      | m68k_movea           | DEBUG | translating instruction MOVEA
translate.c:355      | m68k_movea           | DEBUG | destination register is A6
translate.c:81       | extract_operand      | DEBUG | operand is 16-bit address 0x0004
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x2279 in opcode handler table
translate.c:350      | m68k_movea           | DEBUG | translating instruction MOVEA
translate.c:355      | m68k_movea           | DEBUG | destination register is A1
translate.c:88       | extract_operand      | DEBUG | operand is 32-bit address 0x00120000
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x4eae in opcode handler table
translate.c:312      | m68k_jsr             | DEBUG | translating instruction JSR
translate.c:74       | extract_operand      | DEBUG | operand is register A6 with offset
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x7000 in opcode handler table
translate.c:383      | m68k_moveq           | DEBUG | translating instruction MOVEQ
translate.c:384      | m68k_moveq           | DEBUG | destination register is D0
translate.c:385      | m68k_moveq           | DEBUG | immediate value = 0
translate.c:683      | translate_tu         | DEBUG | looking up opcode 0x4e75 in opcode handler table
translate.c:432      | m68k_rts             | DEBUG | translating instruction RTS
translate.c:695      | translate_tu         | DEBUG | instruction is the terminal instruction in this TU - continuing execution of guest
execute.c:18         | log_func_name        | DEBUG | guest called library function CloseLibrary()
execute.c:166        | exec_program         | DEBUG | guest is terminating...
execute.c:191        | exec_program         | INFO  | guest has exited with status 0

Noch ein paar Anmerkungen zur Entwicklung

Wie du dir wahrscheinlich vorstellen kannst ist das Entwickeln eines solchen Programms kein ganz einfaches Unterfangen. Nachfolgend möchte ich ein paar Dinge erwähnen, die mir dabei geholfen haben.

  • Generell bin ich ein grosser Freund von Logging und das habe ich auch bei VADM ausgiebig genutzt, wie man ja an der Ausgabe des Programms sehen kann.
  • Ich habe relativ viele Unit-Tests verwendet, insbesondere um die Generierung des Codes zu testen. Ich wollte dadurch sicherstellen, dass die einzelnen Instruktionen für sich korrekt übersetzt werden bevor ich den übersetzten Code zum ersten Mal ausführe. Dazu habe ich für jeden der Handler für die Instruktionen eine Reihe von Testfällen geschrieben (die für das Programm loop relevant sind). Diese Testfälle bestehen jeweils aus den Bytes, die die Motorola-Instruktion codieren und den Bytes für die entsprechende Intel-Instruktion. Der Testcode "füttert" dann einfach die Handler mit ersteren und vergleicht das Ergebnis mit letzteren.
  • Trotz der Unit Tests gab es natürlich immer noch genügend Fehler, die es zu debuggen galt. Dazu habe ich den GDB mit der Erweiterung Pwndbg verwendet. Pwndbg bietet neben vielen anderen Features beim schrittweisen Ausführen eines Programms eine laufend aktualisierte Anzeige des Quellcodes (wenn vorhanden, was bei dem generierten Code natürlich nicht der Fall war), dem entsprechenden Assembler-Code, der Prozessorregister und des Stacks in verschiedenen Fenstern (etwas, das der GDB von Haus aus nicht kann). Das hat die Fehlersuche sehr erleichtert.

Bei VADM handelt es sich natürlich nur um einen Proof-of-concept, der nur ein ganz einfaches Amiga-Programm ausführen kann. Wenn man daraus ein tatsächlich nutzbares Programm machen wollte müsste man natürlich viel mehr Systemroutinen implementieren, aber vor allem das Übersetzen des Codes überarbeiten. Zum einen wäre es sinnvoll, nicht für jede Instruktion des Motorola-Prozessors (deren es ja ziemlich viele gibt) eine eigene Routine zu schreiben. Mit etwas Nachdenken könnte man wahrscheinlich eine (oder vielleicht auch ein paar wenige) generische Routine(n) schreiben und die für die einzelnen Instruktionen unterschiedlichen Daten (Motorola-Opcode, entsprechender Intel-Opcode, welche Operanden hat die Instruktion, an welcher Stelle der codierten Instruktion befinden sich diese und so weiter) in einer Tabelle ablegen. Eventuell müsste man sich dazu eine kleine "Beschreibungssprache" ausdenken. Aber das überlasse ich gerne dem geneigten Leser als Übung ;-) Zum anderen würde man natürlich in einem professionellen Programm das Codieren der Intel-Instruktionen nicht selber implementieren sondern dafür auf eine bestehende Bibliothek zurückgreifen. Bei VADM habe ich das nur gemacht weil ich mal eine Ahnung davon bekommen wollte, was zum Beispiel ein Compiler so alles tun muss wenn er den Maschinencode generiert. Und ich habe festgestellt, dass das keine ganz triviale Aufgabe ist... Beispiele für Bibliotheken zur Code-Generierung sind Keystone, AsmJit oder auch LLVM, wenn man den Zwischenschritt über die LLVM Intermediate Representation (IR) gehen würde.

Damit bin ich am Ende des Artikels angelangt. Ich hoffe, ich konnte einen Teil der vielen Dinge, die ich beim Schreiben von VADM und dieses Artikels (was von der ersten Idee zu dem Projekt bis zum fertigen Artikel fast zwei Jahre gedauert hat) gelernt habe, weitergeben. Der vollständige Quellcode von VADM 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.


  1. Babel, Ralph (1989): Das Amiga Guru-Buch 

  2. Commodore-Amiga Inc. (1991): The AmigaDOS Manual, Third Edition, Bantam Books 

  3. Adams, Keith; Agesen, Ole (2006): A Comparison of Software and Hardware Techniques for x86 Virtualization, International Conference on Architectural Support for Programming Languages and Operating Systems 2006 

  4. Motorola Inc. (1992): Motorola M68000 Family Programmer’s Reference Manual 

  5. Intel Corporation (2016): Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 2: Instruction Set Reference 

  6. Encoding Real x86 Instructions 

  7. Lu, H. J.; Matz, Michael; Girkar, Milind; Hubicka, Jan; Jaeger, Andreas; Mitchell, Mark (2018): System V Application Binary Interface, AMD64 Architecture Processor Supplement