2 Punkte von GN⁺ 2025-02-05 | 1 Kommentare | Auf WhatsApp teilen
  • Ein Entwickler baute über sechs Monate hinweg abends und am Wochenende die C-basierte WebAssembly-VM Semblance und machte daraus statt eines kurzlebigen Side Projects ein langfristiges Lernprojekt, bei dem er tief in die Spezifikation einstieg
  • WebAssembly ist ein standardisiertes Bytecode-Format, um nicht vertrauenswürdigen Code im Browser mit nahezu nativer Geschwindigkeit auszuführen; zugleich nimmt die Nutzung außerhalb des Browsers bei Fastly, Shopify und Zed zu
  • Das Ziel von Semblance war es, die WebAssembly Core Specification zu lernen und sich darauf vorzubereiten, zu produktionsreifen Runtimes wie Wasmtime beizutragen; die Implementierung aller Opcodes oder das Bestehen der Core Test Suite gehörten deshalb nicht zum Umfang
  • Der Interpreter arbeitet, indem er die Magic Bytes und Version 1 einer .wasm-Datei prüft, danach Sections decodiert, Imports auflöst, das Modul instanziiert, exportierte Funktionen nachschlägt, Stack und Activation Frame initialisiert und anschließend eine Opcode-Switch-Schleife ausführt
  • Es ist noch keine vollständige Runtime, daher langsam und weiterhin anfällig für Memory Leaks sowie potenziell bösartige Module, doch mit der Ausführung von Hello, World! wurde das Lernziel rund um die Spezifikation erreicht

Warum WebAssembly gewählt wurde

  • WebAssembly ist ein standardisierter Befehlssatz und ein Bytecode-Format für eine virtuelle Stack-Maschine
  • Ausgangspunkt war Emscriptens Compiler von C/C++ nach JavaScript
    • Emscripten wandelte LLVM IR in JavaScript um, damit sich C- und C++-Code im Web ausführen ließ
    • Browser-Entwickler und das Emscripten-Projekt beschränkten den erzeugten Code zur Performance-Optimierung auf eine einfache JavaScript-Teilmenge, die als asm.js standardisiert wurde
    • Um später den Overhead des JavaScript-Parsings zu vermeiden, wurde mit Wasm ein eigenständiges Bytecode-Format entworfen
  • In jüngerer Zeit nimmt die Nutzung von WebAssembly auch außerhalb des Browsers zu
    • Fastlys Edge Compute und Shopifys Functions bauen auf WebAssembly-Engines auf
    • Auch das Erweiterungssystem von Zed basiert auf Wasm
  • Die Bytecode Alliance treibt branchenweit die Nutzung von WebAssembly außerhalb des Browsers voran und entwickelt die Runtime Wasmtime sowie die Standardisierung von WASI
    • WASI ermöglicht WebAssembly-Modulen die Kommunikation mit dem Host-Betriebssystem über host calls, die POSIX ähneln
    • Es bietet eine standardisierte Art, mit externen Schnittstellen wie Standard-Ein-/Ausgabe, Dateisystem und Netzwerk zu interagieren

Vom Side Project zum Lernprojekt

  • Es gab ein wiederkehrendes Muster, neue Side Projects zu beginnen, nach einigen Wochen das Interesse zu verlieren und dann zum nächsten Projekt weiterzuziehen
  • Benötigt wurde ein größeres Projekt, auf das man sich länger konzentrieren konnte und das niedrigere Ebenen des Computing-Stacks berührt als die übliche Arbeit
  • Weil viele Side Projects aus Mangel an Richtung aufgegeben wurden, wurde ein Projekt mit klarem Ziel und sichtbarem Ergebnis gesucht
  • Nachdem der Eindruck entstand, dass ein generalistisches Engineering-Profil die Suche nach stabiler Arbeit beeinflusst, wurde ein spezialisiertes Feld gesucht, mit dem sich ein T-förmiges Skillset aufbauen lässt
  • Dass sich mit benutzerdefinierten Systemaufrufen in WebAssembly maßgeschneiderte Computing-Umgebungen entwerfen lassen, machte es als Werkzeug für Platform Engineering attraktiv

Ziele und Umfang von Semblance

  • Der Projektname lautet Semblance und es handelt sich um einen in C geschriebenen WebAssembly-Interpreter
  • Das Hauptziel war, sich mit der WebAssembly Core Specification vertraut zu machen
  • Langfristig ging es auch darum, genügend WebAssembly-Wissen aufzubauen, um zu industriellen Runtimes wie Wasmtime beitragen zu können
  • Da es sich um ein Lernprojekt handelt, war nicht beabsichtigt, alle Opcodes zu implementieren oder die Core Test Suite zu bestehen
  • Als ausreichendes Erfolgskriterium galt, „Hello, World!“ ausführen zu können
  • Es wird eingeräumt, dass keine professionelle C-Entwicklungserfahrung vorhanden ist und die Codequalität nicht ideal sei; Code Reviews sind willkommen

Ablauf der Ausführung von Hello, World!

  • Der Beispielcode in C deklariert die externe Funktion puts und gibt in hello() "Hello, World!\n" aus
  • WebAssembly wird mit Clang erzeugt
    • --target=wasm32
    • -nostdlib
    • -Wl,--no-entry
    • -Wl,--export-all
    • -Wl,--allow-undefined
    • -O3
  • Die erzeugte Datei hello.wasm wird mit semblance hello.wasm --invoke hello ausgeführt
  • Beim Programmstart wird Speicher für Kommandozeilenargumente, das decodierte WebAssembly-Modul und den Store initialisiert, der den Runtime-Zustand enthält
  • Danach werden die Kommandozeilenargumente geparst und bei Ungültigkeit wird das Programm beendet

Binärdecodierung und Modulstruktur

  • Der Interpreter decodiert zunächst das WebAssembly-Binärformat
  • Am Dateianfang prüft er die Magic Bytes "\0asm"
  • Verifiziert wird außerdem, dass die Binärformat-Version 1 ist
  • Anschließend werden die enthaltenen Sections decodiert
  • Nach Abschluss der Decodierung ist die Struktur WasmModule mit den Funktionen, Typen, Imports, Daten usw. aus hello.wasm befüllt
    • types
    • funcs
    • tables
    • mems
    • globals
    • elems
    • datas
    • start
    • imports
    • exports
    • customs
    • meta

Auflösen von Imports und env::puts

  • Die einzige derzeit von Semblance bereitgestellte Host-Funktion ist env::puts
  • Host-Funktions-Imports werden verarbeitet, indem im Store ein nativer Function Pointer zugewiesen und die erzeugte funcaddr in das imports-Array des Moduls eingetragen wird
  • hostcall_puts verwendet den als Argument übergebenen i32-Offset
    • Es holt aus der ersten Memory-Instanz des Stores den Data Pointer
    • Die Zeichenkette an der Offset-Position wird per printf("%s", ...) auf die Standardausgabe geschrieben
  • Beim Durchlaufen der Importliste wird für Einträge mit Modulname "env" und Item-Name "puts" über register_hostcall_puts die Host-Funktion registriert

Instanziierung und Ausführung von Opcodes

  • Nach dem Auflösen der Imports wird das Modul instanziiert
  • Die Instanziierung umfasst einen Validierungsschritt
    • Dabei werden Typprüfungen und Tests ausgeführt, die sicherstellen, dass das Modul korrekt aufgebaut ist
    • Memory, Globals und Tabellen werden initialisiert
    • Falls eine start-Funktion vorhanden ist, wird sie aufgerufen
  • Das Ergebnis der Instanziierung ist WasmModuleInst, das die Datenstrukturen zur Laufzeit und die einsatzbereiten Exports enthält
  • Anhand des per Kommandozeilenargument --invoke angegebenen Namens wird die exportierte Funktion gesucht
    • Die Exports werden durchlaufen und ein WasmExportInst mit passendem Namen zurückgegeben
    • Anschließend wird geprüft, ob der zurückgegebene Export eine Funktion ist
  • Für den Funktionsaufruf müssen Stack und Activation Frame initialisiert werden
  • Das Herzstück der Ausführung ist eine Opcode-Switch-Schleife
    • Die Wasm-Instruktionen im Funktionskörper werden der Reihe nach gelesen
    • Je nach Opcode werden Operationen auf Stack und Store ausgeführt
    • Verarbeitet werden beispielsweise i32.const, i64.const, f32.const, f64.const, i32.ge_s, call, nop, unreachable, end
    • Nicht behandelte Opcodes geben "unhandled opcode [...]" aus und liefern einen Trap zurück

Der call-Befehl und Host-Funktionsaufrufe

  • Die Verarbeitung von WasmOpCall verwendet den unmittelbaren Wert funcidx des call-Opcodes, um im Store die Funktionsinstanz zu finden
  • Auf Basis der Funktions-Typinformationen werden die benötigten Argumente vom Stack gepoppt
  • Anhand der Art der Funktionsinstanz wird unterschieden, ob es sich um eine Wasm-Funktion oder eine native Host-Funktion handelt
  • Bei einer Host-Funktion wird der gespeicherte hostfunc-Function-Pointer als nativer Aufruf ausgeführt
  • Falls es einen Rückgabewert gibt, wird dieser wieder auf den Stack gepusht
  • Sobald beim hello-Funktionskörper der end-Opcode erreicht wird, endet die Opcode-Switch-Schleife und die Ausführung kehrt zu main zurück

Ergebnis und verbleibende Grenzen

  • Das Ausführungsergebnis erscheint auf der Standardausgabe wie folgt
    • Hello, World!
    • Ok []
  • Das Projekt kann ein einfaches „Hello, World!“-Programm ausführen
  • Die Opcode-Abdeckung ist nicht vollständig
  • Der Code ist unordentlich, langsam und enthält Memory Leaks
  • Er könnte gegenüber bösartigen Modulen verwundbar sein
  • Trotzdem wurde viel über die WebAssembly Core Specification gelernt, und das Projekt half dabei, als Engineer aus vertrauten Bereichen herauszugehen
  • Nach dem Bau eines eigenen Interpreters besteht das Gefühl, nun genug WebAssembly-Wissen zu haben, um zu industriellen Runtimes wie Wasmtime beitragen zu können
  • Es könnten zwar noch einige weitere Opcodes ergänzt werden, doch die Phase, dieses Projekt als Hauptprojekt zu verfolgen, soll abgeschlossen werden

1 Kommentare

 
GN⁺ 2025-02-05
Meinungen auf Hacker News
  • Ich habe früher einmal einen Wasm-Interpreter in Scheme gebaut, daher freut es mich zu sehen, dass mehr Leute so etwas selbst implementieren.
    Es ist weniger schwierig, als man denkt; es lohnt sich, einmal in die Spezifikation zu schauen und es auszuprobieren. Man muss auch nicht alle Instruktionen implementieren – genug, um Spaß daran zu haben, reicht völlig.

  • Als jemand, der ebenfalls so etwas implementiert, ein Tipp: In spec-test gibt es viele seltsame textuelle Wasm-Formate, bei denen nicht intuitiv ist, wie man sie kompiliert. Mit dem wast2json-Konverter kann man aber neben normalen binären Wasm-Dateien auch einfachere JSON-Beschreibungen erzeugen.

    • Bei Chicory haben wir es genauso gemacht: https://github.com/dylibso/chicory
      Je schneller man diese Testsuite laufen lassen kann, desto mehr hilft das bei Iterationsgeschwindigkeit und Genauigkeit.
      Es hat eine Weile gedauert, bis alles funktionierte, aber sobald es so weit war, kamen wir sehr schnell an den Punkt, an dem sich praktisch alles ausführen ließ. Auch wenn die Testsuite nicht vollständig ist, deckt sie ungefähr 95 % ab: https://github.com/WebAssembly/testsuite
    • Ich baue ebenfalls einen Compiler, und das hilft mir.
      Ich hatte diese Tests schon einmal gesehen, wusste aber nicht, wie man sie verwendet, was frustrierend und verwirrend war.
  • Im Zusammenhang mit direkter Wasm-Interpretation fand ich dieses Paper ziemlich interessant: https://arxiv.org/abs/2205.01183
    Darauf aufbauend habe ich https://github.com/peterseymour/winter gebaut und gelernt, dass Wasm nicht so einfach ist, wie man denkt.

  • Anfängerfrage: Wie debuggt man einen Interpreter, wenn man nicht direkt für das Ziel Code schreibt?
    Ich frage mich auch, wie weit einem das Fuzzing von Opcode-Strings hilft, wie groß die praktischen Unterschiede zwischen serverseitigen Wasm-Engines und browserbasierten Engines sind und wie viel Aufwand es wäre, die eine gegen die andere auszutauschen.

  • Interessanter Ansatz und großartige Arbeit.
    Wer den Kern sehen möchte: Der steckt größtenteils in dieser Datei: https://github.com/irrio/semblance/blob/main/src/wrun.c
    Wenn man darüber nachdenkt, wäre es wohl gut gewesen, wenn das Projekt als Standard-Interface die Wasm-C-API(https://github.com/WebAssembly/wasm-c-api) befolgt hätte.
    Sie ist bereits eine C-API und wird von den meisten Wasm-Runtimes wie Wasmer, V8 und wasmi übernommen, sodass Entwickler, die mit dieser API vertraut sind, es leichter ausprobieren könnten.
    Wenn der Autor mit Wasm ausreichend vertraut ist und zu Wasmer beitragen möchte, sind Patches oder Verbesserungen ebenfalls willkommen.

    • Der Autor wirkt eindeutig ausreichend mit Wasm vertraut und weiß wahrscheinlich auch genug, um eine Firma zu meiden, die versucht hat, sich die WebAssembly-Marke zu sichern.
      Es hieß: „verständliche Bedenken darüber, dass das VC-finanzierte Wasmer versucht hat, WebAssembly, den Namen einer Non-Profit-Organisation, als Marke eintragen zu lassen“, und das ist ein Eingeständnis des Fehlers.
    • Wasmer mit Installed-Size: 266 MB – was soll das bitte sein?
    • Soweit ich weiß, ist Wasmer weit davon entfernt, die Wasm-C-API übernommen zu haben.
      https://github.com/wasmerio/wasmer/issues/2615 wurde liegen gelassen und dann automatisch geschlossen.
  • Eine etwas kontroversere Frage: Gibt es Überlegungen, vorläufige Tail-Call-Instruktionen hinzuzufügen?
    Auf Wasm-Spezifikationsseite wurden sie abgelehnt, weil sie zu „high-level“ seien, aber auch das C-Komitee hat einmal einen Vorschlag von Dennis Ritchie abgelehnt.
    Trotzdem würde ich mein Geld weiterhin auf Ritchie setzen, und Rob Pike scheint auch in diese Richtung gesetzt zu haben.
    Warum hätte er sonst Golang entwickelt? Calls sind nur dann high-level, wenn auch Tail Calls high-level sind.

  • Schau dir vielleicht einmal Orca an. Dort könntest du vermutlich gut beitragen.
    [0] https://orca-app.dev

  • Ich habe mich ebenfalls entschieden, mich auf ein Projekt zu konzentrieren, statt zwischen neuen, glänzenden Dingen hin und her zu wechseln; als Ausnahme lasse ich KI-Assistenten Kleinkram erledigen.
    Um es vorsichtig zu sagen: Das ist ziemlich frustrierend.
    Außerdem wollte ich vorschlagen, eine Bibliothek wie libffi zu verwenden, wenn man bei Aufrufen aus C mehrere Argumente und Ähnliches handhaben muss.

  • Ich frage mich, wie Plugin-Entwickler ihren Code debuggen, wenn WebAssembly als Plugin-API etwa in zed verwendet wird.
    Zum Beispiel, ob Breakpoint-Debugging möglich ist und ob man einen Stacktrace bekommt, wenn der Code crasht.