C-Bibliothek in Wasm einfügen

Manchmal möchten Sie eine Bibliothek verwenden, die nur als C- oder C++-Code verfügbar ist. Traditionell geben wir hier auf. Nun, nicht mehr, denn jetzt haben wir Emscripten und WebAssembly (oder Wasm).

Die Toolchain

Ich wollte herausfinden, wie ich vorhandenen C-Code in Wasm kompilieren kann. Um das Wasm-Back-End von LLVM gab es ein wenig Lärm, also habe ich angefangen, mich damit zu beschäftigen. Sie können einfache Programme auf diese Weise kompilieren lassen. Wenn Sie aber die Standardbibliothek von C verwenden oder sogar mehrere Dateien kompilieren möchten, werden Sie wahrscheinlich auf Probleme stoßen. Das führte mich zu der wichtigsten Lektion, die ich gelernt habe:

Emscripten war zuvor ein C-to-asm.js-Compiler, ist aber mittlerweile auf Wasm ausgereift und wird gerade intern zum offiziellen LLVM-Back-End gewechselt. Emscripten bietet auch eine Wasm-kompatible Implementierung der C-Standardbibliothek. Verwenden Sie Emscripten. Es tragt eine Menge versteckter Arbeit auf, emuliert ein Dateisystem, bietet Speicherverwaltung, umschließt OpenGL mit WebGL – viele Dinge, die Sie wirklich nicht selbst entwickeln müssen.

Das mag so klingen, als müssten Sie sich über aufgeblähte Inhalte Gedanken machen – ich mache mir aber Sorgen – der Emscripten-Compiler entfernt alles, was nicht benötigt wird. In meinen Experimenten haben die resultierenden Wasm-Module die richtige Größe für die darin enthaltene Logik und die Teams von Emscripten und WebAssembly arbeiten daran, sie in Zukunft noch kleiner zu machen.

Wenn Sie Emscripten erhalten möchten, folgen Sie der Anleitung auf der Website oder verwenden Sie Homebrew. Wenn Sie wie ich ein Fan von Docker-Befehlen sind und nichts auf Ihrem System installieren möchten, nur um mit WebAssembly zu spielen, gibt es ein gut gewartetes Docker-Image, das Sie stattdessen verwenden können:

    $ docker pull trzeci/emscripten
    $ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>

Etwas Einfaches kompilieren

Nehmen wir das fast kanonische Beispiel für das Schreiben einer Funktion in C, die die n-te Fibonacci-Zahl berechnet:

    #include <emscripten.h>

    EMSCRIPTEN_KEEPALIVE
    int fib(int n) {
      if(n <= 0){
        return 0;
      }
      int i, t, a = 0, b = 1;
      for (i = 1; i < n; i++) {
        t = a + b;
        a = b;
        b = t;
      }
      return b;
    }

Wenn Sie C kennen, sollte die Funktion selbst nicht allzu überraschend sein. Selbst wenn Sie C nicht, aber JavaScript kennen, werden Sie hoffentlich verstehen können, was hier vor sich geht.

emscripten.h ist eine von Emscripten bereitgestellte Headerdatei. Wir benötigen es nur, damit wir Zugriff auf das EMSCRIPTEN_KEEPALIVE-Makro haben, aber es bietet viel mehr Funktionen. Dieses Makro weist den Compiler an, eine Funktion nicht zu entfernen, auch wenn sie scheinbar nicht verwendet wird. Würden wir dieses Makro weglassen, würde der Compiler die Funktion so optimieren, dass sie gar nicht mehr verwendet wird.

Wir speichern das alles in einer Datei namens fib.c. Um sie in eine .wasm-Datei umzuwandeln, müssen wir den Compiler-Befehl emcc von Emscripten verwenden:

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c

Schauen wir uns diesen Befehl an. emcc ist der Compiler von Emscripten. fib.c ist unsere C-Datei. So weit, so gut. -s WASM=1 weist Emscripten an, uns eine Wasm-Datei anstelle einer asm.js-Datei bereitzustellen. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' weist den Compiler an, die cwrap()-Funktion in der JavaScript-Datei verfügbar zu lassen. Weitere Informationen zu dieser Funktion findest du später. -O3 weist den Compiler an, aggressiv zu optimieren. Sie können niedrigere Zahlen wählen, um die Build-Zeit zu reduzieren. Dadurch werden jedoch die resultierenden Bundles auch größer, da der Compiler möglicherweise nicht verwendeten Code nicht entfernt.

Nachdem Sie den Befehl ausgeführt haben, sollten Sie eine JavaScript-Datei namens a.out.js und eine WebAssembly-Datei namens a.out.wasm erhalten haben. Die Wasm-Datei (oder „Modul“) enthält unseren kompilierten C-Code und sollte recht klein sein. Die JavaScript-Datei übernimmt das Laden und Initialisieren unseres Wasm-Moduls und sorgt für eine bessere API. Bei Bedarf werden auch der Stack, der Heap und andere Funktionen eingerichtet, die normalerweise vom Betriebssystem beim Schreiben von C-Code zur Verfügung gestellt werden. Daher ist die JavaScript-Datei mit 19 KB etwas größer (ca. 5 KB mit gzip).

Eine einfache Lösung

Am einfachsten lässt sich das Modul mit der generierten JavaScript-Datei laden und ausführen. Nachdem Sie diese Datei geladen haben, steht Ihnen ein globales Module-Objekt zur Verfügung. Verwenden Sie cwrap, um eine native JavaScript-Funktion zu erstellen, die Parameter in ein C-freundliches Format konvertiert und die umschlossene Funktion aufruft. cwrap verwendet den Funktionsnamen, den Rückgabetyp und die Argumenttypen als Argumente in dieser Reihenfolge:

    <script src="a.out.js"></script>
    <script>
      Module.onRuntimeInitialized = _ => {
        const fib = Module.cwrap('fib', 'number', ['number']);
        console.log(fib(12));
      };
    </script>

Wenn Sie diesen Code ausführen, sollte in der Konsole „144“ angezeigt werden. Das ist die 12. Fibonacci-Nummer.

Der heilige Gral: Eine C-Bibliothek kompilieren

Bisher wurde der von uns geschriebene C-Code auf Wasm ausgerichtet. Ein zentraler Anwendungsfall für WebAssembly besteht jedoch darin, das bestehende Ökosystem der C-Bibliotheken zu nutzen und Entwicklern die Verwendung im Web zu ermöglichen. Diese Bibliotheken basieren häufig auf der Standardbibliothek von C, einem Betriebssystem, einem Dateisystem und anderen Elementen. Emscripten bietet die meisten dieser Funktionen, es gibt jedoch einige Einschränkungen.

Zurück zu meinem ursprünglichen Ziel: einem Encoder für WebP zu Wasm zu kompilieren. Die Quelle für den WebP-Codec ist in C geschrieben und steht auf GitHub sowie eine umfangreiche API-Dokumentation zur Verfügung. Das ist ein guter Ausgangspunkt.

    $ git clone https://github.com/webmproject/libwebp

Zuerst versuchen wir, WebPGetEncoderVersion() aus encode.h für JavaScript freizugeben. Dazu schreiben wir eine C-Datei namens webp.c:

    #include "emscripten.h"
    #include "src/webp/encode.h"

    EMSCRIPTEN_KEEPALIVE
    int version() {
      return WebPGetEncoderVersion();
    }

Dies ist ein gutes einfaches Programm, um zu testen, ob wir den Quellcode von libwebp kompilieren können, da wir keine Parameter oder komplexe Datenstrukturen benötigen, um diese Funktion aufzurufen.

Zum Kompilieren dieses Programms müssen wir dem Compiler mit dem Flag -I mitteilen, wo er die Headerdateien von libwebp finden kann, und außerdem alle erforderlichen C-Dateien von libwebp übergeben. Um ehrlich zu sein, habe ich einfach alle C-Dateien, die ich finden konnte, zur Verfügung gestellt und alle unnötigen C-Dateien vom Compiler entfernt. Es schien brillant zu funktionieren!

    $ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
        -I libwebp \
        webp.c \
        libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c

Jetzt benötigen wir nur noch etwas HTML und JavaScript, um unser glänzendes neues Modul zu laden:

<script src="/a.out.js"></script>
<script>
  Module.onRuntimeInitialized = async (_) => {
    const api = {
      version: Module.cwrap('version', 'number', []),
    };
    console.log(api.version());
  };
</script>

In der Ausgabe sehen Sie die Versionsnummer der korrigierten Version:

Screenshot der Entwicklertools-Konsole mit der korrekten Versionsnummer

Bild aus JavaScript in Wasm abrufen

Die Versionsnummer des Encoders zu ermitteln, ist großartig, aber die Codierung eines Bildes wäre viel beeindruckender, oder? Lass uns das machen.

Die erste Frage, die wir beantworten müssen, lautet: Wie gelangen wir das Bild ins Wasmland? In der Codierungs-API von libwebp wird ein Array von Byte in RGB, RGBA, BGR oder BGRA erwartet. Glücklicherweise enthält die Canvas API getImageData(), das ein Uint8ClampedArray mit den Bilddaten in RGBA liefert:

async function loadImage(src) {
  // Load image
  const imgBlob = await fetch(src).then((resp) => resp.blob());
  const img = await createImageBitmap(imgBlob);
  // Make canvas same size as image
  const canvas = document.createElement('canvas');
  canvas.width = img.width;
  canvas.height = img.height;
  // Draw image onto canvas
  const ctx = canvas.getContext('2d');
  ctx.drawImage(img, 0, 0);
  return ctx.getImageData(0, 0, img.width, img.height);
}

Jetzt müssen Sie nur noch die Daten aus dem JavaScript-Land in das Wasm-Land kopieren. Dazu müssen wir zwei zusätzliche Funktionen zur Verfügung stellen. Eine, die Arbeitsspeicher für das Image im Wasm-Land zuweist, und eine, die es wieder freigibt:

    EMSCRIPTEN_KEEPALIVE
    uint8_t* create_buffer(int width, int height) {
      return malloc(width * height * 4 * sizeof(uint8_t));
    }

    EMSCRIPTEN_KEEPALIVE
    void destroy_buffer(uint8_t* p) {
      free(p);
    }

create_buffer weist dem RGBA-Bild einen Zwischenspeicher zu, also 4 Byte pro Pixel. Der von malloc() zurückgegebene Zeiger ist die Adresse der ersten Speicherzelle dieses Zwischenspeichers. Wenn der Zeiger an das JavaScript-Land zurückgegeben wird, wird er nur als Zahl behandelt. Nachdem die Funktion mit cwrap für JavaScript verfügbar gemacht wurde, können wir diese Zahl verwenden, um den Anfang des Zwischenspeichers zu ermitteln und die Bilddaten zu kopieren.

const api = {
  version: Module.cwrap('version', 'number', []),
  create_buffer: Module.cwrap('create_buffer', 'number', ['number', 'number']),
  destroy_buffer: Module.cwrap('destroy_buffer', '', ['number']),
};
const image = await loadImage('/image.jpg');
const p = api.create_buffer(image.width, image.height);
Module.HEAP8.set(image.data, p);
// ... call encoder ...
api.destroy_buffer(p);

Das große Finale: Bild codieren

Das Bild ist jetzt in Wasm verfügbar. Jetzt muss der WebP-Encoder aufgerufen werden, um seine Aufgabe zu erledigen. In der WebP-Dokumentation scheint WebPEncodeRGBA perfekt zu passen. Die Funktion verwendet einen Zeiger auf das Eingabebild und seine Abmessungen sowie eine Qualitätsoption zwischen 0 und 100. Außerdem wird ein Ausgabepuffer für uns zugewiesen, den wir mit WebPFree() freigeben müssen, sobald das WebP-Image fertig ist.

Das Ergebnis des Codierungsvorgangs ist ein Ausgabepuffer und dessen Länge. Da Funktionen in C keine Arrays als Rückgabetypen haben können (es sei denn, wir weisen Arbeitsspeicher dynamisch zu), habe ich ein statisches globales Array verwendet. Ich weiß, nicht bereinigtes C (es basiert auf der Tatsache, dass Wasm-Zeiger 32 Bit breit sind), aber um die Dinge einfach zu halten, denke ich, dass das ein recht Abkürzung ist.

    int result[2];
    EMSCRIPTEN_KEEPALIVE
    void encode(uint8_t* img_in, int width, int height, float quality) {
      uint8_t* img_out;
      size_t size;

      size = WebPEncodeRGBA(img_in, width, height, width * 4, quality, &img_out);

      result[0] = (int)img_out;
      result[1] = size;
    }

    EMSCRIPTEN_KEEPALIVE
    void free_result(uint8_t* result) {
      WebPFree(result);
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_pointer() {
      return result[0];
    }

    EMSCRIPTEN_KEEPALIVE
    int get_result_size() {
      return result[1];
    }

Jetzt können wir die Codierungsfunktion aufrufen, den Zeiger und die Bildgröße erfassen, sie in einen eigenen JavaScript-Landpuffer einfügen und alle Wasm-Land-Puffer freigeben, die wir im Prozess zugewiesen haben.

    api.encode(p, image.width, image.height, 100);
    const resultPointer = api.get_result_pointer();
    const resultSize = api.get_result_size();
    const resultView = new Uint8Array(Module.HEAP8.buffer, resultPointer, resultSize);
    const result = new Uint8Array(resultView);
    api.free_result(resultPointer);

Je nach Größe des Images kann ein Fehler auftreten, bei dem Wasm den Arbeitsspeicher nicht ausreichend erweitern kann, um sowohl das Eingabe- als auch das Ausgabebild unterzubringen:

Screenshot der Entwicklertools-Konsole mit einem Fehler

Glücklicherweise enthält die Fehlermeldung die Lösung für dieses Problem. Wir müssen unserem Kompilierungsbefehl nur -s ALLOW_MEMORY_GROWTH=1 hinzufügen.

Sie haben das Lab erfolgreich abgeschlossen. Wir haben einen WebP-Encoder kompiliert und ein JPEG-Bild in WebP transkodiert. Um zu beweisen, dass es funktioniert hat, können wir den Ergebnispuffer in einen Blob umwandeln und für ein <img>-Element verwenden:

const blob = new Blob([result], { type: 'image/webp' });
const blobURL = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = blobURL;
document.body.appendChild(img);

Ein neues WebP-Bild wartet jetzt auf dich!

Netzwerkbereich der Entwicklertools und das generierte Bild.

Fazit

Es ist kein Spaziergang im Park, um eine C-Bibliothek im Browser zum Arbeiten zu bringen, aber wenn Sie den Gesamtprozess und die Funktionsweise des Datenflusses verstehen, wird es einfacher und die Ergebnisse können überwältigend sein.

WebAssembly eröffnet im Web viele neue Möglichkeiten in den Bereichen Verarbeitung, Zahlenverarbeitung und Gaming. Denken Sie daran, dass Wasm keine Wunderlösung ist, die auf alles angewendet werden sollte, aber wenn Sie einen dieser Engpässe erreichen, kann Wasm ein unglaublich hilfreiches Werkzeug sein.

Bonusinhalte: Einfache Ausführung auf die Probe

Sie können versuchen, die generierte JavaScript-Datei zu vermeiden. Kehren wir zum Fibonacci-Beispiel zurück. Um es selbst zu laden und auszuführen, können wir Folgendes tun:

<!DOCTYPE html>
<script>
  (async function () {
    const imports = {
      env: {
        memory: new WebAssembly.Memory({ initial: 1 }),
        STACKTOP: 0,
      },
    };
    const { instance } = await WebAssembly.instantiateStreaming(
      fetch('/a.out.wasm'),
      imports,
    );
    console.log(instance.exports._fib(12));
  })();
</script>

Von Emscripten erstellte WebAssembly-Module haben nur dann Arbeitsspeicher, wenn Sie ihnen Arbeitsspeicher zur Verfügung stellen. Ein Wasm-Modul kann mit allen Daten bereitgestellt werden, wenn Sie das Objekt imports verwenden. Es ist der zweite Parameter der Funktion instantiateStreaming. Das Wasm-Modul kann auf alles innerhalb des Importobjekts zugreifen, auf alles andere außerhalb des Objekts. Konventionsgemäß erwarten Module, die mit Emscripting kompiliert wurden, in der geladenen JavaScript-Umgebung einige Dinge:

  • Zuerst gibt es env.memory. Das Wasm-Modul kennt die Außenwelt sozusagen nicht, daher benötigt es etwas Arbeitsspeicher, mit dem es arbeiten kann. Geben Sie WebAssembly.Memory ein. Er steht für einen (optional erweiterbaren) Teil des linearen Arbeitsspeichers. Die Größenparameter werden in „Einheiten von WebAssembly-Seiten“ angegeben. Der obige Code weist also eine Seite Arbeitsspeicher zu, wobei jede Seite eine Größe von 64 KiB hat. Ohne die Option maximum ist das Wachstum des Arbeitsspeichers theoretisch unbegrenzt (Chrome hat derzeit ein festes Limit von 2 GB). Für die meisten WebAssembly-Module sollte es kein Maximum festlegen.
  • env.STACKTOP definiert, wo der Stack mit dem Ausbau des Stacks beginnen soll. Der Stack wird benötigt, um Funktionsaufrufe zu senden und Arbeitsspeicher für lokale Variablen zuzuweisen. Da wir in unserem kleinen Fibonacci-Programm keine dynamischen Speicherverwaltungsprobleme haben, können wir einfach den gesamten Arbeitsspeicher als Stack verwenden, daher STACKTOP = 0.