C-Bibliothek in Wasm einfügen

Manchmal möchten Sie eine Bibliothek verwenden, die nur als C- oder C++-Code verfügbar ist. Normalerweise geben Sie an dieser Stelle auf. Das ist jetzt nicht mehr der Fall, denn wir haben jetzt Emscripten und WebAssembly (oder Wasm).

Die Toolchain

Ich habe mir zum Ziel gesetzt, herauszufinden, wie ich vorhandenen C-Code in Wasm kompilieren kann. Es gab einige Gerüchte um das Wasm-Backend von LLVM, also habe ich mich damit beschäftigt. So lassen sich zwar einfache Programme kompilieren, aber sobald Sie die C-Standardbibliothek verwenden oder sogar mehrere Dateien kompilieren möchten, werden Sie wahrscheinlich auf Probleme stoßen. Das hat mich zu der wichtigsten Lektion geführt, die ich gelernt habe:

Emscripten war früher ein C-zu-asm.js-Compiler, wurde aber inzwischen so weiterentwickelt, dass er auf Wasm ausgerichtet ist. Intern wird der Wechsel zum offiziellen LLVM-Back-End vorbereitet. Emscripten bietet auch eine Wasm-kompatible Implementierung der C-Standardbibliothek. Emscripten verwenden Es führt viele versteckte Aufgaben aus, emuliert ein Dateisystem, bietet Arbeitsspeicherverwaltung und umhüllt OpenGL mit WebGL – viele Dinge, die Sie nicht selbst entwickeln müssen.

Das klingt zwar so, als müssten Sie sich Gedanken über unnötige Codezeilen machen – ich habe mir jedenfalls Sorgen gemacht –, aber der Emscripten-Compiler entfernt alles, was nicht benötigt wird. In meinen Tests haben die resultierenden Wasm-Module eine angemessene Größe für die enthaltene Logik. Die Emscripten- und WebAssembly-Teams arbeiten daran, sie in Zukunft noch kleiner zu machen.

Sie können Emscripten gemäß der Anleitung auf der Website oder mit Homebrew herunterladen. Wenn Sie wie ich Fan von dockerisierten Befehlen sind und nicht nur zum Ausprobieren von WebAssembly etwas auf Ihrem System installieren möchten, können Sie stattdessen ein gut gepflegtes Docker-Image verwenden:

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

Einfache Programme kompilieren

Sehen wir uns das fast kanonische Beispiel für das Schreiben einer Funktion in C an, die die th 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. Auch wenn Sie C nicht kennen, aber JavaScript, werden Sie hoffentlich verstehen, was hier passiert.

emscripten.h ist eine Headerdatei, die von Emscripten bereitgestellt wird. Wir benötigen es nur, um Zugriff auf das EMSCRIPTEN_KEEPALIVE-Makro zu haben, aber es bietet viel mehr Funktionen. Dieses Makro weist den Compiler an, eine Funktion nicht zu entfernen, auch wenn sie nicht verwendet wird. Wenn wir dieses Makro weglassen würden, würde der Compiler die Funktion entfernen, da sie schließlich von niemandem verwendet wird.

Speichern wir das alles in einer Datei namens fib.c. Um sie in eine .wasm-Datei umzuwandeln, müssen wir den Emscripten-Compilerbefehl emcc verwenden:

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

Sehen wir uns diesen Befehl genauer 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 zu geben. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' weist den Compiler an, die Funktion cwrap() in der JavaScript-Datei verfügbar zu lassen. Weitere Informationen zu dieser Funktion finden Sie später. -O3 weist den Compiler an, aggressiv zu optimieren. Sie können niedrigere Werte auswählen, um die Buildzeit zu verkürzen. Dadurch werden die resultierenden Bundles jedoch größer, da der Compiler möglicherweise nicht verwendeten Code nicht entfernt.

Nach Ausführung des Befehls sollten Sie eine JavaScript-Datei namens a.out.js und eine WebAssembly-Datei namens a.out.wasm erhalten. Die Wasm-Datei (oder das „Modul“) enthält unseren kompilierten C-Code und sollte relativ klein sein. Die JavaScript-Datei lädt und initialisiert unser Wasm-Modul und stellt eine schönere API bereit. Bei Bedarf wird auch der Stack, der Heap und andere Funktionen eingerichtet, die normalerweise vom Betriebssystem bereitgestellt werden, wenn C-Code geschrieben wird. Daher ist die JavaScript-Datei etwas größer und hat eine Größe von 19 KB (ca. 5 KB komprimiert).

Einfache Programme ausführen

Die einfachste Methode zum Laden und Ausführen Ihres Moduls ist die Verwendung der generierten JavaScript-Datei. Nachdem Sie diese Datei geladen haben, steht Ihnen ein Module-Glas zur Verfügung. Mit cwrap können Sie eine native JavaScript-Funktion erstellen, die Parameter in eine C-kompatible Form umwandelt und die gewrappte Funktion aufruft. cwrap nimmt in dieser Reihenfolge den Funktionsnamen, den Rückgabetyp und die Argumenttypen als Argumente an:

    <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, die 12. Fibonacci-Zahl.

Der heilige Gral: Eine C-Bibliothek kompilieren

Bisher haben wir den C-Code mit Blick auf Wasm geschrieben. Ein zentraler Anwendungsfall für WebAssembly besteht jedoch darin, das vorhandene C-Bibliotheks-Ökosystem zu nutzen und Entwicklern die Möglichkeit zu geben, diese im Web zu verwenden. Diese Bibliotheken stützen sich häufig auf die Standardbibliothek von C, ein Betriebssystem, ein Dateisystem und andere Dinge. Emscripten bietet die meisten dieser Funktionen, es gibt jedoch einige Einschränkungen.

Kehren wir zu meinem ursprünglichen Ziel zurück: einen Encoder für WebP nach Wasm zu kompilieren. Der Quellcode für den WebP-Codec ist in C geschrieben und auf GitHub verfügbar. Außerdem gibt es eine umfangreiche API-Dokumentation. Das ist ein guter Ausgangspunkt.

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

Beginnen wir mit einem einfachen Beispiel: Versuchen wir, WebPGetEncoderVersion() aus encode.h für JavaScript freizugeben, indem wir eine C-Datei namens webp.c schreiben:

    #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 komplexen Datenstrukturen zum Aufrufen dieser Funktion benötigen.

Um dieses Programm zu kompilieren, müssen wir dem Compiler mit dem Flag -I mitteilen, wo er die Headerdateien von libwebp finden kann, und ihm alle benötigten C-Dateien von libwebp übergeben. Ich bin ehrlich: Ich habe ihm einfach alle C-Dateien gegeben, die ich finden konnte, und darauf vertraut, dass der Compiler alles Unnötige entfernt. Es hat anscheinend hervorragend funktioniert.

    $ 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 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>

Die Versionsnummer der Korrektur wird in der Ausgabe angezeigt:

Screenshot der DevTools-Konsole mit der korrekten Versionsnummer

Bild aus JavaScript in Wasm abrufen

Die Versionsnummer des Encoders zu erhalten ist zwar schön und gut, aber das Codieren eines tatsächlichen Bildes wäre beeindruckender, oder? Dann machen wir das.

Die erste Frage, die wir beantworten müssen, lautet: Wie bringen wir das Bild in die Wasm-Umgebung? Die Encoding API von libwebp erwartet ein Byte-Array in RGB, RGBA, BGR oder BGRA. Glücklicherweise gibt es in der Canvas API getImageData(), wodurch wir ein Uint8ClampedArray mit den Bilddaten in RGBA erhalten:

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 die Daten „nur“ noch aus dem JavaScript-Land in das Wasm-Land kopiert werden. Dazu müssen wir zwei zusätzliche Funktionen freigeben. Eine, die Speicher für das Bild im Wasm-Land zuweist, und eine, die ihn 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 Puffer zu, also 4 Byte pro Pixel. Der von malloc() zurückgegebene Zeiger ist die Adresse der ersten Speicherzelle dieses Buffers. Wenn der Zeiger an JavaScript zurückgegeben wird, wird er nur als Zahl behandelt. Nachdem wir die Funktion mit cwrap für JavaScript freigegeben haben, können wir diese Zahl verwenden, um den Anfang unseres Buffers zu finden 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);

Finale: Bild codieren

Das Bild ist jetzt in Wasm-Land verfügbar. Es ist an der Zeit, den WebP-Encoder aufzurufen. Laut der WebP-Dokumentation scheint WebPEncodeRGBA perfekt zu passen. Die Funktion nimmt einen Verweis auf das Eingabebild und seine Abmessungen sowie eine Qualitätsoption zwischen 0 und 100 an. Außerdem wird ein Ausgabebuffer für uns zugewiesen, den wir mit WebPFree() freigeben müssen, sobald wir mit dem WebP-Bild fertig sind.

Das Ergebnis der Codierungsoperation ist ein Ausgabebuffer und seine Länge. Da Funktionen in C keine Arrays als Rückgabetypen haben können (es sei denn, wir weisen Speicher dynamisch zu), habe ich ein statisches globales Array verwendet. Ich weiß, das ist nicht einwandfreies C (tatsächlich basiert es darauf, dass Wasm-Pointer 32 Bit breit sind), aber um es einfach zu halten, ist das meiner Meinung nach eine gute Vereinfachung.

    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 abrufen, sie in einen eigenen JavaScript-Land-Puffer legen und alle Wasm-Land-Puffer freigeben, die wir dabei 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 Bilds kann es zu einem Fehler kommen, wenn Wasm den Arbeitsspeicher nicht ausreichend vergrößern kann, um sowohl das Eingabe- als auch das Ausgabebild aufzunehmen:

Screenshot der Entwicklertools-Konsole mit einem Fehler

Glücklicherweise finden Sie die Lösung für dieses Problem in der Fehlermeldung. Dazu müssen wir 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 unseren Ergebnispuffer in einen Blob umwandeln und ihn 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);

Seht euch das neue WebP-Bild an!

Der Netzwerkbereich der DevTools und das generierte Bild

Fazit

Es ist nicht ganz einfach, eine C-Bibliothek im Browser zum Laufen zu bringen. Sobald Sie jedoch den gesamten Prozess und die Funktionsweise des Datenflusses verstanden haben, wird es einfacher und die Ergebnisse können überwältigend sein.

WebAssembly eröffnet viele neue Möglichkeiten im Web für die Verarbeitung, Berechnungen und Gaming. Wasm ist kein Allheilmittel, das auf alles angewendet werden sollte. Wenn Sie jedoch auf einen dieser Engpässe stoßen, kann Wasm ein unglaublich hilfreiches Tool sein.

Bonusinhalte: Etwas Einfaches auf die harte Tour ausführen

Wenn Sie die generierte JavaScript-Datei vermeiden möchten, ist das unter Umständen möglich. Kehren wir zum Fibonacci-Beispiel zurück. So laden und führen wir sie selbst aus:

<!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>

WebAssembly-Module, die von Emscripten erstellt wurden, haben keinen Arbeitsspeicher, es sei denn, Sie stellen ihnen Arbeitsspeicher zur Verfügung. Wenn Sie einem Wasm-Modul etwas zur Verfügung stellen möchten, verwenden Sie das imports-Objekt, den zweiten Parameter der instantiateStreaming-Funktion. Das Wasm-Modul kann auf alles innerhalb des Imports-Objekts zugreifen, aber auf nichts außerhalb. Konventionsgemäß erwarten von Emscripting kompilierte Module einige Dinge von der JavaScript-Laufzeitumgebung:

  • Erstens: env.memory. Das Wasm-Modul kennt sozusagen die Außenwelt nicht und benötigt daher Arbeitsspeicher, um arbeiten zu können. Geben Sie WebAssembly.Memory ein. Es stellt einen (optional erweiterbaren) Bereich linearen Arbeitsspeichers dar. Die Größenparameter sind in „Einheiten von WebAssembly-Seiten“ angegeben. Das bedeutet, dass im Code oben 1 Speicherseite zugewiesen wird, wobei jede Seite eine Größe von 64 KiB hat. Ohne Angabe einer maximum-Option ist das Wachstum des Arbeitsspeichers theoretisch unbegrenzt (Chrome hat derzeit ein hartes Limit von 2 GB). Für die meisten WebAssembly-Module muss kein Maximum festgelegt werden.
  • env.STACKTOP definiert, wo der Stapel wachsen soll. Der Stack ist erforderlich, um Funktionsaufrufe auszuführen und Speicher für lokale Variablen zuzuweisen. Da wir in unserem kleinen Fibonacci-Programm keine dynamische Arbeitsspeicherverwaltung verwenden, können wir den gesamten Arbeitsspeicher als Stack verwenden, daher STACKTOP = 0.