01. April 2026
Tests als Programme mit LLVM JIT
Als ich 2023 an meinen Compiler gearbeitet habe, war mir natürlich klar, dass ich zumindest das Verhalten der Programme auf Korrektheit überprüfen muss. Nicht nur, um einen gewissen Grad an Stabilität zu garantieren, sondern auch, damit ich meinen Compiler leichter umbauen kann und dann mit einer gewissen Sicherheit trotzdem alles wie vorgesehen funktioniert.
Ein Nachteil dieses Verfahrens ist natürlich, dass der Compiler bzw. die Sprache selbst bereits sehr ausdrucksstark sein muss. (Zumindest wenn man es so umsetzen möchte, wie es hier näher beschrieben wird), also als Voraussetzung sind: Funktionen definieren und aufrufen, externe Funktion definieren, stabile Ausdrucksverarbeitung, usw. zu nennen.
Natürlich ist das keine wohlimplementierte Version, sondern nur ein stark vereinfachter Ansatz, auf den man aufbauen kann und soll.
Ich beschreibe den Aufbau in meiner speziellen Implementierung.
Als "Testdatei" habe ich test.c. Der Hintergrund ist der, dass hier eine main-Funktion drin steht. Der ganze Compiler kommt als drunter, während alles drumherum die eigentliche Testumgebung ist.
int main() {
// Initialisierungsfunktionen des Compilers ...
memory_init(...);
// Usw.
// LLVM Initialisierung, z.B.
LLVMInitializeAllTargetInfos();
LLVMInitializeAllTargets();
LLVMInitializeAllTargetMCs();
LLVMInitializeAllAsmParsers();
LLVMInitializeAllAsmPrinters();
tests();
return 0;
}tests ruft test_files_recursively auf (wo der eigentliche Spaß beginnt), und schreibt dann anschließenden einen Report mit total_tests, successful_tests, usw. (s.u.).
Außerdem brauchen wir ebenfalls ein paar globale / statische Sachen:
struct TestContext {
LLVMExecutionEngineRef engine;
LLVMModuleRef module;
char* function_name; // Aktuelle Testfunktion
char* file_path; // Aktuelles Testprogramm
struct ASTNode* ast;
};
unsigned int total_tests = 0; // Wird von test_function
unsigned int successful_tests = 0; // aufgezeichnet
static struct TestContext* ctx; // Aktueller TestkontextStatt eine Konfigurationsdatei zu pflegen, habe ich mich entschieden, einfach jede Datei in dem test-Verzeichnis als Testdatei anzusehen. Der erste Aufruf dieser rekursiven Funktion startet trivialerweise beim Testverzeichnis.
void test_files_recursively(char* base_path) {
struct dirent* handler;
DIR* directory = opendir(base_path);
if(directory == 0) { // Basisfall
test_context_destroy(ctx);
ctx = test_context_create(base_path);
symbol_traverse(ctx->ast->file.block.table, &test_execute_function, 0);
return;
}
while((handler = readdir(directory)) != 0) {
if(strcmp(handler->d_name, ".") == 0 || strcmp(handler->d_name, "..") == 0) {
continue;
}
char path[1000];
strcpy(path, base_path);
strcat(path, "/");
strcat(path, handler->d_name);
test_files_recursively(path);
}
closedir(directory);
}Zu char path[1000]; sei erwähnt, dass der Umgang mit dem Overflow hier der Leserschaft als Übung überlassen wird.
Die while-Schleife kann hier sonst im Großen und Ganzen außer Acht gelassen werden, da es nur darum geht, dass ja auch jede Datei genau einmal aufgerufen wird.
Im ersten Schritt wird also für jede Datei ein eigenständiger Kontext test_context_create(...) erstellt, um anschließend im zweiten Schritt jede Funktion innerhalb der Datei aufzurufen. Bei symbol_traverse handelt es sich folglich um einen compiler-internen Aufruf, der für jedes Symbol test_execute_function aufruft.
An dieser Stelle betrachten wir, wie wir den Kontext korrekt aufsetzen und die Testdateien kompilieren.
struct TestContext* test_context_create(char* filename) {
// TestContext allokieren oder statisch erzeugen
struct TestContext* ctx = ...;
ctx->file_path = filename;
// Compiler anschmeißen
struct ASTNode* ast = ...;
LLVMModuleRef module = ...;
ctx->module = module;
LLVMValueRef externalFunction = LLVMGetNamedFunction(module, "assert");
char* error;
if(LLVMCreateExecutionEngineForModule(&ctx->engine, module, &error) == 1) {
fprintf(stderr, "%s\n", error);
LLVMDisposeMessage(error);
abort();
}
if(externalFunction != 0) {
LLVMAddGlobalMapping(ctx->engine, externalFunction, &test_function);
}
ctx->ast = ast;
return ctx;
}Die Datei wird also zunächst mit LLVM kompiliert. Das geschieht, indem wir dann ein LLVMModuleRef erhalten. Überdies benötigen wir einen Symbol-Table oder zumindest (wie hier der AST) ein transitives Äquivalent.
Das Herzstück bzw. die Brücke zwischen JIT und der Testumgebung ist die assert-Funktion.
void test_function(uint32_t line, bool condition) {
total_tests++;
if(condition == true) {
successful_tests++;
} else {
assert(ctx != 0);
assert(ctx->function_name != 0);
printf("[Test #%04d]: %s:%i (%s): FAILED!\n", total_tests, ctx->file_path, line, ctx->function_name);
}
}Die assert-Funktion heißt in der Testumgebung test_function. Das ist ein Grund, warum hier der Kontext eine globale Variable ist.
Obwohl line nicht zwingend erforderlich ist, ist es hier gegeben, da mein Compiler über eine Funktion verfügt, dass die aktuelle Zeile beim Funktionsaufruf bei einem bestimmten Argument automatisch übergeben wird.
test_function zählt anhand von condition die erfolgreichen bzw. gescheiterten Testfällen und schreit ggf. bei den gescheiterten Testfällen.
Nun verfügen wir über alle Grundlagen, alle Funktionen der Testprogramme auszuführen.
void test_execute_function(struct SymbolNode* symbol, void* data) {
// Aufgerufen durch symbol_traverse
assert(symbol != 0);
assert(data == 0);
struct ASTNode* node = (struct ASTNode*) symbol->value;
// Symbol ist keine Funktion oder nicht public => ignorieren
if(node->type != AST_FUNCTION || node->function.visibility != VISIBILITY_PUBLIC) {
return;
}
// Aktuelle Funktion
ctx->function_name = node->function.symbol.identifier;
void* function = (void*) LLVMGetFunctionAddress(ctx->engine, ctx->function_name);
void (*test_function)() = (void (*)()) function;
test_function();
}Natürlich können wir nicht ohne Weiteres jedes Symbol als Funktion interpretieren. Trivialerweise muss der AST-Node hier den Type AST_FUNCTION haben. Testfunktionen müssen hierbei auch als public markiert worden sein. Das ist hilfreich, wenn eine TESTfunktion andere HILFSfunktionen aufruft. Selbstverständlich wird assert durch diese strenge Bedingung bereits ignoriert (was mir erst jetzt beim Schreiben aufgefallen ist ...).
LLVM hat eine nette Funktion LLVMGetFunctionAddress, mit der man die Funktionsadresse holen kann. Da wir nur einen void-Pointer erhalten, müssen wir denselben in einen Funktionspointer umwandeln. Außerdem vertrauen wir einfach Mal fest darauf, dass alle Testfunktionen KEINE Argumente fordern und NIX (also void) zurückgeben.
Falls die Testfunktion abstürzt, zieht das unsere Testumgebung natürlich mit ins Verderben.
Nun kann man versuchen, die Testumgebung auch praktisch einzusetzen, hier sieht es wie folgt aus:
\Extern
function assert(\Line line: u32, condition: boolean) end
enum Day
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
end
public function enums()
let firstDay = Day.MONDAY
assert(Day.MONDAY == firstDay)
assert(isWeekend(Day.SATURDAY) == true)
assert(isWeekend(Day.MONDAY) == false)
return
end
function isWeekend(day: Day): boolean
return day == Day.SATURDAY || day == Day.SUNDAY
endNatürlich hier wieder die assert-Funktion, welche die test_function-Funktion in der Testumgebung abbilden soll. Man beachte außerdem, dass nur die public-Funktion enums aufgerufen wird, nicht jedoch die Funktion isWeekend, denn in dieser Zielsprache sind alle Funktionen automagisch private.