ZeroVM Teil 1 - Crashkurs: wie man eine virtuelle Maschine schreibt
Hier möchte ich einen Crashkurs über die Programmierung von virtuellen Maschinen beginnen. Auch ich habe bei der L1VM von Null an angefangen. Hier ist jetzt der Code von der ZeroVM für den Kurs. Auch hier fangen wir klein an. Hier das C Programm in Teilen Schritt für Schritt:
// zeroVM - VM crash course
// How to build a simple virtual machine
// Stefan Pietzonke 2019
//
#include <stdio.h>
#include <stdlib.h>
#include <stdarg.h>
#include <signal.h>
#include <stdint.h>
#include <ctype.h>
#include <errno.h>
#include <string.h>
typedef unsigned char U1; /* UBYTE */
typedef int16_t S2; /* INT */
typedef uint16_t U2; /* UINT */
typedef int32_t S4; /* LONGINT */
typedef long long S8; /* 64 bit long */
typedef double F8; /* DOUBLE */
Am Beginn werden die benötigten Header mit “#include” eingefügt. Danach mit “typedef” werden die Variablentypen, die wir später brauchen werden, festgelegt. Das “U” in der Typbezeichnung steht für “unsigned” (U1). Also “U1” für eine Ganzzahl im Bereich von 0 - 255.
Das “S” beim Typ steht für signed, also eine Zahl die auch in den negativen Bereich reicht.
In der VM werden wir mit 64 Bit Genaugkeit rechnen.
#define DEBUG 1
#define EXE_NEXT(); ep = ep + eoffs; goto *jumpt[code[ep]];
/* opcodes */
#define PUSHB 0
#define PULLB 1
#define LOADB 2
#define ADDI 3
#define PRINTI 4
#define PEXIT 5
// space for code and data
unsigned char code[1024];
unsigned char data[1024];
Jetzt wird mit der Zeile “#define” ein Makro erstellt, das wir später für die Ausführung in der VM brauchen werden. Wir fügen das Makro später mit “EXE_NEXT();” in den Code ein!
Und es werden die internen Werte für die Opcodes (Befehle) der VM festgelegt. Dann werden noch die Variablen für Code und Daten erstellt.
int main (int ac, char *av[])
{
S8 ep; // execution pointer
S8 eoffs = 0; // number of bytes to skip to get next opcode
S8 regi[256]; // integer registers 64 bit
S8 arg1, arg2, arg3; // opcode arguments
S8 cpu_core = 0;
printf ("zeroVM\n");
// data bytes 23 and 42
data[0] = 23;
data[1] = 42;
// code
// load byte from data_size
code[0] = LOADB;
code[1] = 0; // data ptr
code[2] = 0; // offset
code[3] = 0; // target register
// load byte from data
code[4] = LOADB;
code[5] = 1;
code[6] = 0;
code[7] = 1;
code[8] = ADDI;
code[9] = 0; // register 0: 23
code[10] = 1; // register 1: 42
code[11] = 2; // target register 2: = 65
code[12] = PRINTI;
code[13] = 2; // register 2: 65
code[14] = PEXIT;
// execution bytecode pointer
ep = 0;
Hier geht es mit der “Main” Funktion weiter. Es werden die 64 Bit Register deklariert. Die Zeilen:
data[0] = 23;
data[1] = 42;
Legen die Werte für unsere zwei Variablen fest. Es folgt der Bytecode für das Programm das wir ausführen möchten:
// load byte from data_size
code[0] = LOADB;
code[1] = 0; // data ptr
code[2] = 0; // offset
code[3] = 0; // target register
Hier wird mit dem “LOADB” Opcode die Variable an der Adresse 0 in das Register 0 geladen. Also 23 steht jetzt in Register 0.
Der “ADDI” Befehl addiert die Register 0 und 1. Das Ergebnis wird in Register 2 gespeichert.
Mit “PRINTI” wird das Register 2 angezeigt. Und “PEXIT” beendet das Programm.
// jumptable for indirect threading execution
static void *jumpt[] =
{
&&pushb, &&pullb, &&loadb, &&addi, &&printi, &&pexit
};
printf ("running test program...\n\n");
// fetch first bytecode
EXE_NEXT();
Jetzt kommt ein besonderer Teil: die Sprungtabelle der VM. Beim GCC und Clang C Compiler kann man mit Hilfe des “labels as values” Sprunglabels in Variablen speichern. Erinnerst Du dich noch an das Makro vom Beginn des Kurses?
#define EXE_NEXT(); ep = ep + eoffs; goto *jumpt[code[ep]];
Schauen wir uns das mal genauer an! Hier wird mit “ep = ep + eoffs” die neue Position im Code berechnet. Die “eoffs” Variable wird am Befehlsende gesetzt. Dazu kommen wir später. Mit “goto *jumpt[code[ep]];” wird die Adresse des nächsten Befehls angesprungen. Der ja in “code[ep]” festgelegt wurde.
Hier kommen nun die Befehle mit ihren Sprungmarken z.B. “addi:” am Anfang des Befehls:
pushb:
#if DEBUG
printf ("%lli PUSHB\n", cpu_core);
#endif
arg1 = regi[code[ep + 1]];
arg2 = regi[code[ep + 2]];
arg3 = code[ep + 3];
regi[arg3] = data[arg1 + arg2];
eoffs = 4;
EXE_NEXT();
pullb:
#if DEBUG
printf ("%lli PULLB\n", cpu_core);
#endif
arg1 = code[ep + 1];
arg2 = regi[code[ep + 2]];
arg3 = regi[code[ep + 3]];
data[arg2 + arg3] = regi[arg1];
eoffs = 4;
EXE_NEXT();
loadb:
#if DEBUG
printf ("%lli LOADB\n", cpu_core);
#endif
arg1 = code[ep + 1] // data address
arg2 = code[ep + 2]; // data offset
arg3 = code[ep + 3]; // register
regi[arg3] = data[arg1 + arg2];
eoffs = 4;
EXE_NEXT();
addi:
#if DEBUG
printf ("%lli ADDI\n", cpu_core);
#endif
arg1 = code[ep + 1];
arg2 = code[ep + 2];
arg3 = code[ep + 3];
regi[arg3] = regi[arg1] + regi[arg2];
eoffs = 4;
EXE_NEXT();
printi:
#if DEBUG
printf ("PRINTI\n");
#endif
arg1 = code[ep + 1];
printf ("%lli\n", regi[arg1]);
eoffs = 2;
EXE_NEXT();
pexit:
printf ("shutdown...\n");
exit (0);
}
Jeder der Befehle endet mit dem Makro, das zum nächsten Befehl springt. Außer “PEXIT”, dort wird die VM beendet.
So sieht jetzt unser Grundgerüst der ZeroVM aus.
Ich habe eine Aufgabe für euch: Ergänzt die VM um Befehle für die restlichen Grundrechenarten: Subtraktion, Multiplikation und Division.
Hier gibt es ein ZIP Archiv mit dem vollständigem Programm: