QVINTVS · SCRIBET

mruby in CMake-Buildsystem integrieren

Wie man mruby in ein ansonsten von CMake gebautes Projekt einbaut.

Einführung

mruby ist eine minimale Implementation der Programmiersprache Ruby. Als ich zuletzt über mruby berichtet habe war es noch in der Entwicklungsphase, mittlerweile sind sogar schon zwei Veröffentlichungen durchgeführt worden. Aktuell ist die Version 1.1.0, und es ist gut festzustellen, dass mruby sich einer steigenden Beliebtheit zu erfreuen scheint.

Allerdings lässt die Dokumentation noch immer zu wünschen übrig. Es ist nach wie vor empfehlenswert, zumindest grundlegendes Wissen über die Erstellung von C-Extensions für den MRI zu besitzen, da man sich ansonsten in der doch recht komplexen API zu sehr verliert. Wer sich diesbezüglich informieren möchte, dem sei die Datei README.EXT aus dem offiziellen Ruby-Repositorium empfohlen.

In einem früheren Beitrag habe ich ein einfaches Build-Setup geschildert, nun soll es etwas komplizierter sein. Mittlerweile habe ich mit dem Bau von mruby etwas mehr Erfahrung, da ich es in einem ziemlich umfangreichen Projekt benutze. Ich darf mich rühmen, dass TSC das erste umfangreiche Spiel gewesen ist, das mruby als Scripting-Engine einsetzt. Wer möchte, kann sich die Einbindung von mruby im Repositorium ansehen. TSC nutzt mein präferiertes Build-System CMake, mruby dagegen setzt auf ein eigenes, rake-basiertes System. Dementsprechend ist es nicht ganz einfach, beides zu vereinen.

CMake besitzt zu diesem Zwecke ein eigenes Modul namens „ExternalProject“, welches es ermöglicht, praktisch jedes Build-System an ein CMake-Projekt anzubinden. Dieses muss man zunächst explizit in der CMakeLists.txt des Projekts einbinden:

include(ExternalProject)

Git-Submodule

Obwohl „ExternalProject“ auch in der Lage ist, das gewünschte externe Projekt selbst in Form eines Tarballs herunterzuladen, zu verifizieren, und zu entpacken, rate ich von einem solchen Vorgehen dann ab, wenn es sich beim eigenen und beim externen Projekt um mithilfe von Git verwaltete Projekte handelt, da Git mit Submodulen eine Funktionalität besitzt, die eine weit granularere Kontrolle ermöglicht. Bei einem Submodul handelt es sich im Grunde lediglich um ein Git-Repositorium, welches sich innerhalb eines anderen Git-Repositoriums befindet. Rekursives Git, wenn man so will. Git erkennt Submodule automatisch, d. h. wenn man die üblichen Git-Kommandos innerhalb eines Submodulverzeichnisses ausführt, so arbeiten sie ausschließlich mit diesem, ganz so, als ob das Eltern-Repositorium nicht existieren würde. Wechselt man dann zurück ins Elternrepositorium, so wird das Submodul als eine einzige Datei behandelt.

Für mruby wird ein Submodul im Verzeichnis mruby/ wie folgt angelegt:

$ git submodule add git://github.com/mruby/mruby.git mruby
$ cd mruby
$ git fetch origin --tags
$ git checkout 1.1.0
$ cd ..
$ git add mruby
$ git commit

Innerhalb des Submoduls setze ich den HEAD-Pointer auf das gewünschte Tag. Git würde ansonsten schlicht den aktuellen HEAD des Standard-Branchs benutzen, was normalerweise aus Stabilitätsgründen nicht erwünscht ist. 1.1.0 ist die aktuelle stabile Version von mruby, und das Tag heißt genauso, sodass sich die obige Kommandofolge ergibt.

Checkt man das Repositorium später aus, so muss man git submodule init und git submodule update durchführen, ansonsten ist das Verzeichnis leer.

MRuby-Konfiguration

In der CMakeLists.txt sollte man zunächst auf mrubys eigene Abhängigkeiten prüfen. mruby selbst besitzt zwar keine Laufzeitabhängigkeiten, zur Kompilation aber sind Ruby und Bison erforderlich. Für letzteres besietzt CMake ein eigenes Modul, für ersteres kann das generiche find_program()-Makro herangezogen werden:

find_program(RUBY_EXECUTABLE ruby)
find_package(BISON REQUIRED)

if (RUBY_EXECUTABLE)
  message(STATUS "Found ruby: ${RUBY_EXECUTABLE}")
else()
  message(SEND_ERROR "ruby executable not found.")
endif()

mruby verwaltet seine Build-Konfiguration mithilfe einer eigenen Datei, bei der es sich (wie so oft in Ruby) um lauffähigen Ruby-Code handelt (interne DSL). mruby bringt eine Beispielkonfiguration in der Datei build_config.rb mit, die mir allerdings nicht wirklich gefällt. Weder benötige ich die mruby- und mirb-Executables, noch will ich auf UTF-8-Support in der Klasse String verzichten. Genau das aber wird durch die „Gembox“ default konfiguriert. mruby bietet zwecks Vereinfachung von Konfiguration diese sogenannten Gemboxen an, die nichts weiter sind als eine Gruppe von einzelnen Bauanweisungen. Auf default kann ich gut und gern verzichten und verwende stattdessen direkt die Kommandos, die ich für richtig halte.

Dazu kopiert man zunächst die Beispielkonfiguration in ein Verzeichnis außerhalb des Submoduls.

cp mruby/build_config.rb mruby_build_config.rb

Danach entfernt man die Zeile

conf.gembox 'default'

und ersetzt sie durch alle conf.gem-Zeilen aus der Datei mrbgems/default.gembox. Danach entfernt man die Zeilen für die Executables mruby und mirb und fügt eine Zeile für den UTF-8-Support hinzu:

conf.gem :core => "mruby-string-utf8"

Den Rest der Datei kann man auskommentieren, wenn man keine Debug-Builds und keine Kreuzkompilation benötigt. Danach muss man mruby noch mitteilen, dass man nicht innerhalb des Standardverzeichnisses bauen will. Das Build-System selbst kennt dafür leider keine Parameter, allerdings kann man in der Build-Konfiguration eine entsprechende Einstellung vornehmen. Weil es sich bei der Build-Konfiguration um gewöhnlichen Ruby-Code handelt und das Rake-basierte Build-System auf der Kommandozeile übergebene Variablen in den ENV-Hash schreibt, kann man mit folgendem kleinen Hack das Build-Verzeichnis dynamisch durch Kommandozeilenargument verändern:

conf.build_dir = ENV["MRUBY_BUILD_DIR"] || raise("MRUBY_BUILD_DIR undefined!")

Schließlich sollte die Datei mruby_build_config.rb (ohne Kommentare) so aussehen:

MRuby::Build.new do |conf|
  if ENV['VisualStudioVersion'] || ENV['VSINSTALLDIR']
    toolchain :visualcpp
  else
    toolchain :gcc
  end

  conf.build_dir = ENV["MRUBY_BUILD_DIR"] || raise("MRUBY_BUILD_DIR undefined!")

  conf.gem :core => "mruby-sprintf"
  conf.gem :core => "mruby-print"
  conf.gem :core => "mruby-math"
  conf.gem :core => "mruby-time"
  conf.gem :core => "mruby-struct"
  conf.gem :core => "mruby-enum-ext"
  conf.gem :core => "mruby-string-ext"
  conf.gem :core => "mruby-numeric-ext"
  conf.gem :core => "mruby-array-ext"
  conf.gem :core => "mruby-hash-ext"
  conf.gem :core => "mruby-range-ext"
  conf.gem :core => "mruby-proc-ext"
  conf.gem :core => "mruby-symbol-ext"
  conf.gem :core => "mruby-random"
  conf.gem :core => "mruby-object-ext"
  conf.gem :core => "mruby-objectspace"
  conf.gem :core => "mruby-fiber"
  conf.gem :core => "mruby-enumerator"
  conf.gem :core => "mruby-enum-lazy"
  conf.gem :core => "mruby-toplevel-ext"
  conf.gem :core => "mruby-bin-strip"
  conf.gem :core => "mruby-kernel-ext"

  conf.gem :core => "mruby-string-utf8"
end

mrubys Build-System basiert wie beschrieben auf Rake, genauer gesagt, auf einer eigenen Minimalversion von Rake, minirake. Diese verhält sich für den Zweck des Kompilierens genauso wie ein gewöhnliches Rake; vor allem wandelt es Kommandozeilenargumente der Form GROSSBUCHSTABE=Wert in Umgebungsvariablen um, auf die man mit ENV zugreifen kann, was den oben gezeigten Hack ermöglicht. So kann man mruby danach grundsätzlich so kompilieren:

$ cd mruby
$ ruby ./minirake MRUBY_BUILD_DIR=/tmp/meinbuilddir MRUBY_CONFIG=../mruby_build_config.rb

Der Parameter MRUBY_CONFIG gibt dabei die gewünschte Konfigurationsdatei an. Ließe man ihn weg, so würde das Build-System die Datei build_config.rb im mruby-Verzeichnis benutzen. Den Parameter MRUBY_BUILD_DIR, dessen Verwendung bereits weiter oben beschrieben wurde, muss man natürlich anpassen.

Integration in CMake

Die eigentliche Integration in das Build-System des Hauptprojekts erfolgt über das Makro ExternalProject_Add() wie folgt:

ExternalProject_Add(mruby
  PREFIX "${CMAKE_BINARY_DIR}/mruby"
  DOWNLOAD_COMMAND ""
  UPDATE_COMMAND ""
  BUILD_IN_SOURCE 1
  SOURCE_DIR "${CMAKE_SOURCE_DIR}/../mruby"
  CONFIGURE_COMMAND ""
  BUILD_COMMAND ${RUBY_EXECUTABLE} minirake MRUBY_BUILD_DIR=${CMAKE_BINARY_DIR}/mruby MRUBY_CONFIG=${CMAKE_SOURCE_DIR}/mruby_build_config.rb
  INSTALL_COMMAND "")
include_directories("${CMAKE_SOURCE_DIR}/../mruby/include")
set(MRUBY_LIBRARIES "${CMAKE_BINARY_DIR}/mruby/lib/libmruby.a" "${CMAKE_BINARY_DIR}/mruby/lib/libmruby_core.a")

Der Reihe nach. Definiert wird durch dieses Makro ein CMake-Target mruby, auf das später noch einmal zurückzukommen sein wird. PREFIX gibt das intendierte Build-Verzeichnis an, welches zu erstellen CMake Sorge tragen wird. DOWNLOAD_COMMAND und UPDATE_COMMAND wären Kommandos, die zum Herunterladen und Aktualisieren eines externen Projekts erforderlich wären, welche aber aufgrund der hier vorgestellten Submodul-Konfiguration unnötig sind und welche daher durch Angabe eines Leerstrings abgeschaltet werden. BUILD_IN_SOURCE weist CMake an, vor der Ausführung des eigentlichen Baukommandos in das mit SOURCE_DIR angegebene Verzeichnis zu wechseln. mruby benötigt keinen Konfigurationsschritt, daher kann man ihn ebenfalls abschalten, dasselbe gilt für einen Installationsschritt. Kern ist der Baubefehl, BUILD_COMMAND, der auf die zuvor gefundene Ruby-Executable zugreift damit im wesentlichen das Kommando ausführt, welches oben für einen manuellen Bau angeführt wurde. Angepasst wird lediglich das MRUBY_BUILD_DIR, sodass es mit dem von CMake erstellen PREFIX übereinstimmt, sowie eine dynamischere Angabe der Build-Konfiguration.

Anschließend wird das Verzeichnis mruby/include in die Liste der vom C-Präprozessor zu durchsuchenden Verzeichnisse aufgenommen, damit das Hauptprogramm auch in der Lage ist, die mruby-Header zu finden.

mruby besteht aus zwei statisch gelinkten Programmbibliotheken, libmruby.a und libmruby_core.a, welche unterhalb des angegebenen Build-Verzeichnisses im Verzeichnis lib/ abgelegt werden. Diese werden als absolute Pfade in der Variablen MRUBY_LIBRARIES abgespeichert. Dies ist erforderlich, weil sie in die Executable des Hauptprogramms gelinkt werden müssen, welche zum jetzigen Zeitpunkt noch gar nicht definiert ist, denn diese Definition findet sich üblicherweise gegen Ende der CMakeLists.txt. Eine typische Definition eines Targets für eine Executable sieht so aus:

add_executable(foobar ${foobar_sources})
target_link_libraries(foobar ${SOME_LIBRARIES})

Dies wird nun in zweifacher Hinsicht ergänzt, indem den zu linkenden Bibliotheken die zwei mruby-Bibliotheken hinzugefügt werden (unter Rückgriff auf die vorhin definierte Variable MRUBY_LIBRARIES). Da CMake in ein externes Projekt nicht hineinschauen und seinen Zweck erkennen kann — hier die Erstellung der MRUBY_LIBRARIES —, muss die Abhängigkeit der Executable vom externen Target mruby noch explizit deklariert werden. Ansonsten käme es bei einem parallelen Build mit mehreren Tasks gleichzeitig zu einem Wettrennen zwischen den einzelnen Bauschritten. Es ist aber erforderlich, dass das Target mruby vollständig abgeschlossen ist, bevor das Target foobar gelinkt werden kann (sonst sind ja die mruby-Bibliotheken noch gar nicht fertig). Das geschieht mithilfe des Makros add_dependencies(), sodass folgende Abfolge ensteht:

add_executable(foobar ${foobar_sources})
target_link_libraries(foobar ${SOME_LIBRARIES} ${MRUBY_LIBRARIES})
add_dependencies(foobar mruby)

Fertig ist die Integration. Von hier an kann man mit dem Projekt verfahren wie mit jedem anderen CMake-Projekt auch.

$ mkdir build
$ cd build
$ cmake -DCMAKE_BUILD_TYPE=Debug ..
$ make -j2
$ ./foobar

Valete.