QVINTVS · SCRIBET

mruby-Bytecode einbinden

MRuby-Code kann mithilfe von mrbc derart verarbeitet werden, das man die Ruby-Quellcodedateien nicht mehr mitliefern muss.

mruby ist und bleibt ein interessantes Thema. Nachdem ich mich gestern mit der Integration in CMake auseinandergsetzt habe, bin ich heute der Frage nachgegangen, wie der mruby-Kern es eigentlich anstellt, die ganzen .rb-Dateien, die er enthält, nicht mitliefern zu müssen. Wie so oft ist dies undokumentiert, und der Code des mruby-Buildsystems ist recht abenteuerlich, sodass er nicht wirklich als lesbare Quelle für Informationen herhalten kann.

Bei meiner Recherche habe ich im Internet ausschließlich Quellen gefunden, die nicht mehr auf dem aktuellen Stand der Technik sind. Anhand zweier Quellen jedoch kann in Verbindung mit dem aktuellen Quellcode von mruby das zur vollständigen Einkompilation des Ruby-Codes in eine C/C++-Executable erforderliche Verfahren hergeleitet werden. Die erforderlichen Schritte dokumentiere ich hier, damit ich sie nicht vergesse und auch andere etwas davon haben. Die Anweisungen beziehen sich auf mruby-Version 1.1.0.

MRuby enthält das Programm mrbc (nachdem man mruby einmal gebaut hat, liegt es im bin/-Verzeichnis), das der zentrale Dreh- und Angelpunkt des Verfahrens ist. mrbc ist in der Lage, Ruby-Quellcode in Bytecode für die RITE-VM zu übersetzen. Außerdem bietet es eine Option -B, die denselben Bytecode in Form eines für C verständlichen Arrays von Integers ausgibt, dessen Name der Wert für die Option -B ist, d. h. -Bfoobar wird eine globale Variable foobar definieren, deren Wert der Bytecode als Array von Integers ist. Die Option -C, die von einigen Artikeln im Internet erwähnt wird und die eine ganze C-Funktion erstellt hat, ist mittlerweile entfernt worden, es kann nur noch mit -B gearbeitet werden.

Die Theorie sieht nun also so aus:

  1. Eine Datei mit Ruby-Code anlegen.
  2. Die Datei mit mrbc parsen und in RITE-Bytecode übersetzen.
  3. Den Bytecode in eine mruby-Instanz laden
  4. Den Bytecode ausführen.

Man beachte Schritt 4, den man, wenn man vorwiegend mit einer kompilierenden Sprache arbeitet, durchaus auch einmal vergisst. In Ruby werden Klassen und anderes aber erst durch die Ausführung definiert, das bloße Laden des geparsten Codes genügt nicht.

Im Folgenden stelle ich ein kurzes, praktisches Beispiel dieser Theorie vor.

Schritt 0: Vorbereitung

Zunächst muss ein neues Verzeichnis für das Projekt angelegt werden. Das sollte schließlich so aussehen:

mrbtest/
  mruby/
  mrblib/
  src/

Das Verzeichnis mruby/ sollte mruby in der Version 1.1.0 enthalten. Das kann im einfachsten Falle durch Verwendung eines Release-Tarballs von mruby.org geschehen oder durch Git-Checkout.

Da für die weiteren Schritte die mruby-Bibliotheken benötigt werden, sollte man nun mruby kompilieren. Für die Zwecke dieses Tutorials genügt die Standardkonfiguration.

$ cd mruby
$ ruby minirake

Danach befindet sich mrbc im Verzeichnis build/host/bin sowie die zwei mruby-Bibliotheken in Form statischer Bibliotheken im Verzeichnis build/host/lib/. Ich erhalte bei der Kompilation zZt. den Fehler

LD    build/host/bin/mirb
/usr/bin/ld: cannot find -ltermcap
collect2: Fehler: ld gab 1 als Ende-Status zurück
/usr/bin/ld: cannot find -ltermcap
collect2: Fehler: ld gab 1 als Ende-Status zurück
rake aborted!
Command Failed: [gcc  -o "/tmp/foo/mruby/build/host/bin/mirb"
"/tmp/foo/mruby/build/host/mrbgems/mruby-bin-mirb/tools/mirb/mirb.o"
"/tmp/foo/mruby/build/host/lib/libmruby.a"  -lm -ltermcap -lreadline
]
Rakefile:67:in `block (4 levels) in <top (required)>

, was aber wohl unschädlich ist. Jedenfalls werden die Executable mrbc und die mruby-Bibliotheken trotzdem erstellt. Wen es stört, der entfernt mirb aus der mruby-Build-Konfiguration.

Schritt 1: Ruby-Code

Dies ist der wohl einfachste Schritt. Erstellt wird eine Datei mrblib/test.rb unterhalb des Hauptprojektverzeichnisses mit folgendem Inhalt:

module Kernel

  def tester
    puts "Hello, World!"
  end

end

Schritt 2: In RITE-Bytecode übersetzen

Jetzt kommt der interessante Teil. Mithilfe von mrbc wird die Datei test.rb in RITE-Bytecode übersetzt. Die Option -B weist wie beschrieben mrbc an, den Bytecode in Form eines C-Arrays auszugeben. Das Ergebnis ist dann eine Datei, die einfach wie jede andere C-Datei kompiliert werden kann.

$ cd ..
$ ./mruby/build/host/bin/mrbc -Bmruby_bytecode \
  -o src/rite_bytecode.c mrblib/test.rb

Dieser Befehl wird eine Datei src/rite_bytecode.c erstellen, deren Inhalt nichts weiter ist als ein C-Array mit dem Namen mruby_bytecode, welcher den kompilierten RITE-Bytecode enthält, der durch Verarbeitung der Datei mrblib/test.rb entstanden ist. Ihr Inhalt sieht so aus:

#include <stdint.h>
const uint8_t
#if defined __GNUC__
__attribute__((aligned(4)))
#elif defined _MSC_VER
__declspec(align(4))
#endif
mruby_bytecode[] = {
0x45,0x54,0x49,0x52,0x30,0x30,0x30,0x33,0xcd,0x78,0x00,0x00,0x00,0xdf,0x4d,0x41,
0x54,0x5a,0x30,0x30,0x30,0x30,0x49,0x52,0x45,0x50,0x00,0x00,0x00,0xb1,0x30,0x30,
0x30,0x30,0x00,0x00,0x00,0x33,0x00,0x01,0x00,0x02,0x00,0x01,0x00,0x00,0x00,0x04,
0x05,0x00,0x80,0x00,0x44,0x00,0x80,0x00,0x45,0x00,0x80,0x00,0x4a,0x00,0x00,0x00,
0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x06,0x4b,0x65,0x72,0x6e,0x65,0x6c,
0x00,0x00,0x00,0x00,0x37,0x00,0x01,0x00,0x03,0x00,0x01,0x00,0x00,0x00,0x05,0x00,
0x48,0x00,0x80,0x00,0xc0,0x00,0x00,0x01,0x46,0x00,0x80,0x00,0x04,0x00,0x80,0x00,
0x29,0x00,0x80,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x01,0x00,0x06,0x74,0x65,
0x73,0x74,0x65,0x72,0x00,0x00,0x00,0x00,0x45,0x00,0x02,0x00,0x05,0x00,0x00,0x00,
0x00,0x00,0x05,0x00,0x26,0x00,0x00,0x00,0x06,0x00,0x00,0x01,0x3d,0x00,0x80,0x01,
0xa0,0x00,0x00,0x01,0x29,0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x00,0x00,0x0d,0x48,
0x65,0x6c,0x6c,0x6f,0x2c,0x20,0x57,0x6f,0x72,0x6c,0x64,0x21,0x00,0x00,0x00,0x01,
0x00,0x04,0x70,0x75,0x74,0x73,0x00,0x4c,0x56,0x41,0x52,0x00,0x00,0x00,0x10,0x00,
0x00,0x00,0x00,0xff,0xff,0x00,0x00,0x45,0x4e,0x44,0x00,0x00,0x00,0x00,0x08,
};

Man erkennt, wie die globale Variable mruby_bytecode definiert wird.

Schritt 3 und 4: Bytecode laden und ausführen

Angelegt wird nun eine Datei src/main.c, die den eigentlichen Hauptcode enthalten und anschließend den vorhin erzeugten Bytecode laden wird. Ich gehe an dieser Stelle nicht weiter auf die Technik zur Initialisierung des mruby-Interpreters ein, sondern konzentriere mich auf den Umgang mit dem Bytecode. Zunächst der Inhalt der Datei src/main.c:

#include <stdio.h>
#include <stdint.h>
#include <mruby.h>
#include <mruby/irep.h>
#include <mruby/dump.h>
#include <mruby/proc.h>

extern uint8_t mruby_bytecode[];

int main(int argc, char* argv[])
{
    printf("Initialisiere Interpreter.\n");
    mrb_state* p_state = mrb_open();

    printf("Lade Bytecode\n");
    mrb_irep* bytecode = mrb_read_irep(p_state, mruby_bytecode);

    printf("Führe Bytecode aus.\n");
    mrb_run(p_state, mrb_proc_new(p_state, bytecode), mrb_top_self(p_state));

    printf("Gewöhnlicher mruby-Code:\n");
    mrb_load_string(p_state, "tester");

    printf("Beende Interpreter.\n");
    mrb_close(p_state);
    return 0;
}

In Einzelteile aufgebröselt ergibt sich folgendes:

#include <mruby.h>
#include <mruby/irep.h>
#include <mruby/dump.h>
#include <mruby/proc.h>

Dies sind die Header, derer wir zwecks Aufruf diverser mruby-Funktionen bedürfen.

extern uint8_t mruby_bytecode[];

Wie erwähnt erzeugt die von mrbc erzeugte C-Datei die Implementation des Arrays mruby_bytecode. Um aber darauf zugreifen zu können, ist es erforderlich, ihn in dieser anderen Datei zu deklarieren. Natürlich wäre es auch möglich gewesen, den gesamten Inhalt von rite_bytecode.c in die Datei main.c zu übernehmen, dann wäre die Deklaration entbehrlich. Bei wachsendem Bytecode ist das aber auf die Dauer sehr unübersichtlich, folglich sollte man es schon von Anfang an ordentlich machen und den Bytecode in seiner eigenen Datei abspeichern. Den Typ uint8_t habe ich der von mrbc generierten C-Datei entnommen; der Beitrag von Daniel Bovensiepen schlägt dagegen noch char vor, was mittlerweile wohl überholt ist.

mrb_irep* bytecode = mrb_read_irep(p_state, mruby_bytecode);
/* ... */
mrb_run(p_state, mrb_proc_new(p_state, bytecode), mrb_top_self(p_state));

Dieser Teil ist es, der im Web nicht mehr der aktuellen API von mruby entspricht, weil mruby zwischenzeitlich den Typ mrb_irep eingeführt und das API von mrb_read_irep() entsprechend verändert hat — Beiträge, die hier noch mit einem int arbeiten, sind veraltet. Diese Funktion liest den Bytecode ein und gibt ein entsprechendes Handle zurück, ohne ihn jedoch auszuführen. Dies besorgt mrb_run(). Das Handle ist jedoch selbst nicht ausführbar, vielmehr muss man es erst in einen Proc verpacken, den man dann anschließend ausführt (mrb_proc_new()). mrb_top_self gibt das main-Objekt zurück, welches als Binding dienen soll.

Der Rest des Programms besteht lediglich in einem Aufruf an die nun neu definierte Methode tester.

Schritt 5: Kompilation

Die Kompilation ist nicht weiter aufregend. Durch die entsprechende Verwendung von mrbc ist es nicht notwendig, auf die Ruby-Quelldatei test.rb zuzugreifen. Die erzeugten C-Quellen sind ausreichend.

$ gcc -Wall -Imruby/include src/main.c src/rite_bytecode.c \
  mruby/build/host/lib/libmruby_core.a mruby/build/host/lib/libmruby.a \
  -lm -o mrbtest

Beachtenswert an dieser Stelle ist insbesondere das Linking der Math-Library. Ansonsten handelt es sich um ein ziemlich selbsterklärendes Kompilationskommando.

Die Executable mrbtest ist jetzt standalone, der Ruby-Quellcode einkompiliert.

./mrbtest
Initialisiere Interpreter.
Lade Bytecode
Führe Bytecode aus.
Gewöhnlicher mruby-Code:
Hello, World!    # <<<<<<<
Beende Interpreter.

Das ist alles. Es sei zuletzt noch darauf hingewiesen, dass das Kommando mrbc auch mehrere Ruby-Dateien als Eingabe nimmt und dafür dann nur einen einzigen Bytecode herausgibt. Das kann die Einbindung in ein Build-System einfacher machen, allerdings gilt für den Bytecode dasselbe wie für gewöhnlichen Ruby-Code auch: Die Ausführreihenfolge ist entscheidend. Ruft früherer Bytecode eine Methode auf, die erst späterer Bytecode erzeugt, führt das, ganz wie auch in Ruby, zu einem NameError. Diesen Punkt sollte man bei der Arbeit mit mehreren Dateien unbedingt im Kopf behalten.

Gesamter Quellcode

Der gesamte Quellcode steht als Tarball zum Herunterladen zur Verfügung. MD5-Prüfsumme: aa75a02622c4d28631edfe36e0587390

Valete.