C-Bibliothek in Wasm einfügen

Manchmal möchten Sie eine Bibliothek verwenden, die nur als C- oder C++-Code verfügbar ist. Normalerweise würden Sie hier aufgeben. Das ist jetzt vorbei, denn wir haben Emscripten und WebAssembly (oder Wasm).

Die Toolchain

Ich habe mir das 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 befasst. Einfache Programme lassen sich so kompilieren, aber sobald Sie die Standardbibliothek von C verwenden oder mehrere Dateien kompilieren möchten, treten wahrscheinlich Probleme auf. 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 für Wasm weiterentwickelt und ist gerade dabei, intern auf das offizielle LLVM-Backend umzusteigen. Emscripten bietet auch eine Wasm-kompatible Implementierung der Standardbibliothek von C. Emscripten verwenden Es erledigt viele versteckte Aufgaben, emuliert ein Dateisystem, bietet Speicherverwaltung und umschließt OpenGL mit WebGL – viele Dinge, die Sie bei der Entwicklung nicht selbst erledigen müssen.

Das klingt vielleicht so, als müsstest du dir Sorgen um Bloatware machen – ich habe mir jedenfalls Sorgen gemacht –, aber der Emscripten-Compiler entfernt alles, was nicht benötigt wird. In meinen Experimenten haben die resultierenden Wasm-Module eine angemessene Größe für die darin enthaltene Logik. Die Emscripten- und WebAssembly-Teams arbeiten daran, sie in Zukunft noch kleiner zu machen.

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

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

Einfaches Programm kompilieren

Sehen wir uns das fast schon kanonische Beispiel einer Funktion in C an, die die n-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, sollten Sie verstehen können, was hier passiert.

emscripten.h ist eine von Emscripten bereitgestellte Header-Datei. Wir benötigen es nur, um Zugriff auf das Makro EMSCRIPTEN_KEEPALIVE 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 wegoptimieren, da sie schließlich von niemandem verwendet wird.

Speichern wir das alles in einer Datei namens fib.c. Um daraus eine .wasm-Datei zu machen, müssen wir den Compiler-Befehl 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, eine Wasm-Datei anstelle einer asm.js-Datei zu erstellen. -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' weist den Compiler an, die Funktion cwrap() in der JavaScript-Datei verfügbar zu lassen. Mehr zu dieser Funktion später. -O3 weist den Compiler an, aggressiv zu optimieren. Sie können niedrigere Zahlen auswählen, um die Build-Zeit zu verkürzen. Dadurch werden die resultierenden Bundles jedoch 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 mit dem Namen a.out.js und eine WebAssembly-Datei mit dem Namen a.out.wasm haben. Die Wasm-Datei (oder das „Modul“) enthält unseren kompilierten C-Code und sollte ziemlich klein sein. Die JavaScript-Datei übernimmt das Laden und Initialisieren des Wasm-Moduls und stellt eine bessere API bereit. Bei Bedarf werden auch der Stack, der Heap und andere Funktionen eingerichtet, die normalerweise vom Betriebssystem beim Schreiben von C-Code bereitgestellt werden. Daher ist die JavaScript-Datei mit 19 KB (ca. 5 KB gzip-komprimiert) etwas größer.

Einfache Abfrage ausführen

Die einfachste Möglichkeit, Ihr Modul zu laden und auszuführen, ist die Verwendung der generierten JavaScript-Datei. Sobald Sie diese Datei geladen haben, steht Ihnen eine globale Module 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 Console „144“ angezeigt werden, die zwölfte Fibonacci-Zahl.

Der heilige Gral: Eine C-Bibliothek kompilieren

Bisher haben wir den C-Code mit Blick auf Wasm geschrieben. Ein wichtiger Anwendungsfall für WebAssembly ist jedoch, das bestehende Ökosystem von C-Bibliotheken zu nutzen und Entwicklern die Möglichkeit zu geben, sie im Web zu verwenden. Diese Bibliotheken basieren häufig auf der Standardbibliothek von C, einem Betriebssystem, einem Dateisystem und anderen Dingen. Emscripten bietet die meisten dieser Funktionen, es gibt jedoch einige Einschränkungen.

Kommen wir zurück zu meinem ursprünglichen Ziel: 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. Wir versuchen, WebPGetEncoderVersion() aus encode.h für JavaScript verfügbar zu machen, 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 benötigen, um diese Funktion aufzurufen.

Um dieses Programm zu kompilieren, müssen wir dem Compiler mit dem Flag -I mitteilen, wo er die Header-Dateien von libwebp findet, und ihm außerdem alle erforderlichen C-Dateien von libwebp übergeben. Ich bin ehrlich: Ich habe einfach alle C-Dateien angegeben, die ich finden konnte, und mich darauf verlassen, dass der Compiler alles Unnötige entfernt. Das hat 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 brauchen 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>

In der Ausgabe wird die korrekte Versionsnummer angezeigt:

Screenshot der DevTools-Konsole mit der richtigen Versionsnummer.

Bild aus JavaScript in Wasm abrufen

Die Versionsnummer des Encoders abzurufen, ist ja ganz nett, aber ein echtes Bild zu codieren, wäre doch beeindruckender, oder? Das wollen wir nun tun.

Die erste Frage, die wir beantworten müssen, lautet: Wie bekommen wir das Bild in die Wasm-Umgebung? Die Codierungs-API von libwebp erwartet ein Byte-Array in RGB, RGBA, BGR oder BGRA. Glücklicherweise bietet die Canvas API getImageData(), was uns 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 die Daten „nur“ noch von JavaScript nach Wasm kopiert werden. Dazu müssen wir zwei zusätzliche Funktionen verfügbar machen. Eine, die Speicher für das Bild in Wasm 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 einen Zwischenspeicher für das RGBA-Bild 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 wie eine normale Zahl behandelt. Nachdem wir die Funktion mit cwrap für JavaScript verfügbar gemacht haben, können wir diese Zahl verwenden, um den Anfang unseres Puffers 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);

Großes Finale: Bild codieren

Das Image ist jetzt in Wasm verfügbar. Jetzt ist es an der Zeit, den WebP-Encoder aufzurufen, damit er seine Arbeit erledigt. In der WebP-Dokumentation sieht WebPEncodeRGBA wie die perfekte Lösung aus. 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 wir mit dem WebP-Bild fertig sind.

Das Ergebnis des Codierungsvorgangs ist ein Ausgabepuffer 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 kein sauberer C-Code (tatsächlich wird davon ausgegangen, dass Wasm-Zeiger 32 Bit breit sind), aber um die Dinge einfach zu halten, ist das eine gute Abkürzung.

    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];
    }

Nachdem wir das alles erledigt haben, können wir die Codierungsfunktion aufrufen, den Zeiger und die Bildgröße abrufen, sie in einen eigenen JavaScript-Puffer einfügen und alle Wasm-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 Bildes kann es zu einem Fehler kommen, bei dem 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 enthält die Fehlermeldung die Lösung für dieses Problem. Wir müssen dem Kompilierungsbefehl nur -s ALLOW_MEMORY_GROWTH=1 hinzufügen.

Das war schon alles. Wir haben einen WebP-Encoder kompiliert und ein JPEG-Bild in WebP transcodiert. Um zu beweisen, dass es funktioniert hat, können wir unseren Ergebniszwischenspeicher 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);

Und schon haben Sie ein neues WebP-Bild.

Netzwerkbereich der Entwicklertools und das generierte Bild.

Fazit

Es ist nicht ganz einfach, eine C-Bibliothek im Browser zum Laufen zu bringen. Wenn Sie aber den Gesamtprozess und den Datenfluss verstanden haben, wird es einfacher und die Ergebnisse können beeindruckend sein.

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

Bonusinhalt: Einfache Dinge auf die harte Tour ausführen

Wenn Sie die generierte JavaScript-Datei vermeiden möchten, ist dies möglicherweise möglich. Kehren wir zum Fibonacci-Beispiel zurück. Wenn wir es selbst laden und ausführen möchten, können wir so vorgehen:

<!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, mit dem sie arbeiten können, 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 Objekt imports, den zweiten Parameter der Funktion instantiateStreaming. Das Wasm-Modul kann auf alles im Importobjekt zugreifen, aber auf nichts außerhalb davon. Konventionsgemäß erwarten von Emscripten kompilierte Module einige Dinge von der ladenden JavaScript-Umgebung:

  • Zuerst gibt es env.memory. Das Wasm-Modul ist sich sozusagen der Außenwelt nicht bewusst, daher muss es etwas Arbeitsspeicher erhalten, mit dem es arbeiten kann. Geben Sie WebAssembly.Memory ein. Es stellt einen (optional erweiterbaren) Teil des linearen Speichers dar. Die Größenparameter werden in „Einheiten von WebAssembly-Seiten“ angegeben. Das bedeutet, dass der obige Code eine Seite Arbeitsspeicher zuweist, wobei jede Seite eine Größe von 64 KiB hat. Ohne die Option maximum ist der Arbeitsspeicher theoretisch unbegrenzt (Chrome hat derzeit ein festes Limit von 2 GB). Für die meisten WebAssembly-Module muss kein Maximum festgelegt werden.
  • env.STACKTOP definiert, wo der Stack beginnen soll. Der Stack wird benötigt, um Funktionsaufrufe durchzuführen und Speicher für lokale Variablen zuzuweisen. Da wir in unserem kleinen Fibonacci-Programm keine dynamische Speicherverwaltung verwenden, können wir den gesamten Speicher als Stack verwenden, also STACKTOP = 0.