C-Bibliothek in Wasm einfügen

Manchmal möchten Sie eine Bibliothek verwenden, die nur in C- oder C++-Code verfügbar ist. Traditionell gibt man hier auf. Nun, nicht mehr, denn jetzt gibt es 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 Er umfasst viel verborgene Arbeit, emuliert ein Dateisystem, bietet Speicherverwaltung und umschließt OpenGL mit WebGL – vieles, das Sie eigentlich gar nicht selbst entwickeln müssen.

Das mag so klingen, als müssten Sie sich um Blähungen sorgen – ich habe mir sicherlich Sorgen gemacht – der Emscripten-Compiler entfernt jedoch alles, was nicht benötigt wird. In meinen Experimenten haben die resultierenden Wasm-Module die richtige Größe für die Logik, die sie enthalten, und die Teams von Emscripten und WebAssembly 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 ein Fan von Docker-Befehlen wie mir sind und nicht möchten, dass Dinge auf Ihrem System installiert werden, nur um mit WebAssembly zu experimentieren, 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 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. Auch wenn Sie C nicht kennen, aber JavaScript, werden Sie hoffentlich verstehen, was hier passiert.

emscripten.h ist eine von Emscripten bereitgestellte Headerdatei. 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, wird die Funktion vom Compiler entfernt. Schließlich wird sie von niemandem verwendet.

Speichern wir das alles in einer Datei namens fib.c. Um sie in eine .wasm-Datei umzuwandeln, müssen wir den Compilerbefehl emcc von Emscripten 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 Zahlen auswählen, um die Build-Zeit zu verringern. Dadurch werden aber auch die resultierenden Bundles größer, da der Compiler möglicherweise nicht verwendeten Code 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 haben. 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 werden auch der Stack, der Heap und andere Funktionen eingerichtet, die das Betriebssystem beim Schreiben von C-Code normalerweise bereitstellt. Daher ist die JavaScript-Datei mit 19 KB (ca. 5 KB mit gzip) etwas größer.

Etwas Einfaches 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 globaler Module 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

Bis jetzt wurde der von uns geschriebene C-Code mit Blick auf Wasm geschrieben. Ein zentraler Anwendungsfall für WebAssembly besteht jedoch darin, das bestehende System der C-Bibliotheken zu nutzen, damit Entwickler sie im Web nutzen können. Diese Bibliotheken stützen sich häufig auf die C-Standardbibliothek, 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: dem Kompilieren eines Encoders für WebP nach Wasm. 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

Lassen Sie uns zunächst versuchen, WebPGetEncoderVersion() aus encode.h für JavaScript verfügbar zu machen. Schreiben Sie dazu eine C-Datei mit dem Namen 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 zum Kompilieren erhalten können, da zum Aufrufen dieser Funktion keine Parameter oder komplexen Datenstrukturen erforderlich sind.

Zum Kompilieren dieses Programms müssen wir dem Compiler mit dem Flag -I mitteilen, wo er die Headerdateien von libwebp finden kann. Außerdem müssen wir alle erforderlichen C-Dateien von libwebp übergeben. Ich muss ehrlich sein: Ich habe alle C-Dateien ausgegeben, die ich finden konnte, und habe mich darauf verlassen, dass der Compiler alles entfernt, was unnötig war. 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? Also los!

Die erste Frage, die wir uns beantworten müssen, lautet: Wie gelangen wir ins Wasmland? Die Encoding API von libwebp erwartet ein Byte-Array in RGB, RGBA, BGR oder BGRA. Glücklicherweise verfügt die Canvas API über getImageData(), wodurch ein Uint8ClampedArray zurückgegeben wird, das die Bilddaten in RGBA enthält:

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“ die Daten aus JavaScript-Land in Wasm-Land kopieren. 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 Zwischenspeichers. Wenn der Zeiger an JavaScript zurückgegeben wird, wird er nur als Zahl behandelt. Nachdem wir die Funktion mit cwrap für JavaScript verfügbar gemacht haben, können wir mit dieser Nummer den Anfang des Zwischenspeichers finden und die Bilddaten 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);

Großes Finale: Bild codieren

Das Bild ist jetzt in Wasm 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 Ausgabepuffer 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 auf ein statisches globales Array zurückgekehrt. Ich weiß, kein einfaches C (es basiert tatsächlich auf der Tatsache, dass die Wasm-Zeiger 32 Bit breit sind), aber um die Dinge einfach zu halten, denke ich, dass dies eine gute 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 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 transcodiert. Um nachzuweisen, dass es funktioniert hat, können wir unseren Ergebnispuffer in ein 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);

Sieh mal, die Ehre eines neuen WebP-Bildes!

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 den Datenfluss verstanden haben, wird es einfacher und die Ergebnisse können überwältigend sein.

WebAssembly eröffnet im Web viele neue Möglichkeiten für Verarbeitung, Zahlenverarbeitung und Spiele. 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 den großen Weg bringen

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>

Von Emscripten erstellte WebAssembly-Module haben keinen Arbeitsspeicher, es sei denn, Sie stellen ihnen einen Arbeitsspeicher zur Verfügung. Sie können für ein Wasm-Modul irgendetwas angeben, indem Sie das Objekt imports verwenden – den zweiten Parameter der Funktion instantiateStreaming. Das Wasm-Modul kann auf alles innerhalb des Imports-Objekts zugreifen, aber auf nichts außerhalb. Konventionsgemäß erwarten durch Emscripting kompilierte Module einige Dinge aus der geladenen JavaScript-Umgebung:

  • 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.