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 aber nicht mehr so, denn wir haben jetzt 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 Neuigkeiten zum Wasm-Backend von LLVM, also habe ich mir das genauer angesehen. Einfache Programme lassen sich auf diese Weise kompilieren. Sobald Sie jedoch die Standardbibliothek von C verwenden oder mehrere Dateien kompilieren möchten, treten wahrscheinlich Probleme auf. Daraus habe ich Folgendes gelernt:
Emscripten war früher ein C-zu-asm.js-Compiler, hat sich aber inzwischen weiterentwickelt und zielt auf Wasm ab. Es wird derzeit auf das offizielle LLVM-Backend umgestellt. Emscripten bietet auch eine WASM-kompatible Implementierung der Standardbibliothek von C. Emscripten verwenden Es erfordert viel verborgene Arbeit, emuliert ein Dateisystem, bietet Speicherverwaltung und umschließt OpenGL mit WebGL – viele Dinge, die Sie nicht selbst entwickeln müssen.
Das klingt vielleicht so, als müssten Sie sich um Bloat kümmern – ich habe mir auf jeden Fall Sorgen gemacht –, aber der Emscripten-Compiler entfernt alles, was nicht benötigt wird. In meinen Tests haben die resultierenden Wasm-Module die richtige Größe für die darin enthaltene Logik. 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 installieren. Wenn Sie wie ich ein Fan von Docker-Befehlen sind und nicht alles auf Ihrem System installieren möchten, 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 Dinge kompilieren
Sehen wir uns das fast kanonische Beispiel an, eine Funktion in C zu schreiben, 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, können Sie hoffentlich nachvollziehen, was hier passiert.
emscripten.h
ist eine Headerdatei, die von Emscripten bereitgestellt wird. 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ürde der Compiler die Funktion optimieren, da sie ja nicht verwendet wird.
Speichern wir das alles in einer Datei mit dem Namen fib.c
. Um daraus eine .wasm
-Datei zu machen, 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 cwrap()
-Funktion in der JavaScript-Datei verfügbar zu lassen. Weitere Informationen zu dieser Funktion finden Sie weiter unten. -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 größer, da der Compiler möglicherweise keinen ungenutzten 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 kümmert sich um das Laden und Initialisieren unseres Wasm-Moduls und bietet eine benutzerfreundlichere API. Bei Bedarf werden 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 nach der Gzip-Komprimierung).
Einfache Aufgaben ausführen
Am einfachsten lässt sich das Modul mit der generierten JavaScript-Datei laden und ausführen. Nachdem Sie die Datei geladen haben, steht Ihnen ein 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
nimmt den Funktionsnamen, den Rückgabetyp und die Argumenttypen in dieser Reihenfolge 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. Das ist die 12. 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 besteht jedoch darin, das bestehende Ökosystem von 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 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 in Wasm zu kompilieren. Der Quellcode für den WebP-Codec ist in C geschrieben und auf GitHub verfügbar. Außerdem gibt es eine ausführliche API-Dokumentation. Das ist ein guter Ausgangspunkt.
$ git clone https://github.com/webmproject/libwebp
Beginnen wir mit einem einfachen Beispiel. Wir möchten WebPGetEncoderVersion()
aus encode.h
für JavaScript verfügbar machen. Dazu schreiben wir 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 der Quellcode von libwebp kompiliert werden kann, 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 findet, und ihm alle C-Dateien von libwebp übergeben, die er benötigt. Ich bin ehrlich: Ich habe ihm alle C-Dateien gegeben, die ich finden konnte, und mich darauf verlassen, 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:
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 doch beeindruckender, oder? Dann machen wir das.
Die erste Frage, die wir beantworten müssen, lautet: Wie bekommen wir das Bild in die Wasm-Welt?
Die Encoding API von libwebp erwartet ein Byte-Array in RGB, RGBA, BGR oder BGRA. Glücklicherweise hat die Canvas API getImageData()
, das uns ein Uint8ClampedArray mit den Bilddaten im RGBA-Format 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 in Wasm kopiert werden. Dazu müssen wir zwei zusätzliche Funktionen bereitstellen. Eine, die Speicher für das Bild im Wasm-Bereich 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);
}
Mit create_buffer
wird ein Puffer für das RGBA-Bild zugewiesen – also 4 Byte pro Pixel.
Der von malloc()
zurückgegebene Zeiger ist die Adresse der ersten Speicherzelle dieses Puffers. Wenn der Zeiger an JavaScript zurückgegeben wird, wird er als 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 Bild ist jetzt in Wasm verfügbar. Es ist an der Zeit, den WebP-Encoder aufzurufen, damit er seine Arbeit erledigen kann. Wenn wir uns die WebP-Dokumentation ansehen, scheint WebPEncodeRGBA
eine perfekte Lösung zu sein. 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 auf ein statisches globales Array zurückgegriffen. Ich weiß, das ist kein sauberer C-Code (tatsächlich wird davon ausgegangen, dass Wasm-Pointer 32 Bit breit sind), aber um die Dinge einfach zu halten, ist das meiner Meinung nach 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];
}
Jetzt, da alles eingerichtet ist, 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 Speicher nicht ausreichend erweitern kann, um sowohl das Eingabe- als auch das Ausgabebild aufzunehmen:
Die Lösung für dieses Problem ist in der Fehlermeldung enthalten. 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 transcodiert. Um zu beweisen, dass es funktioniert hat, können wir unseren Ergebnis-Buffer in einen Blob umwandeln und ihn in einem <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);
Fazit
Es ist nicht einfach, eine C-Bibliothek im Browser zum Laufen zu bringen, aber sobald Sie den Gesamtprozess und den Datenfluss verstanden haben, wird es einfacher und die Ergebnisse können beeindruckend sein.
WebAssembly eröffnet viele neue Möglichkeiten im Web für die Verarbeitung, das Berechnen von Zahlen und Spiele. Wasm ist kein Allheilmittel, das auf alles angewendet werden sollte. Wenn Sie jedoch einen dieser Engpässe erreichen, kann Wasm ein unglaublich hilfreiches Tool sein.
Bonusinhalte: Etwas Einfaches auf die harte Tour erledigen
Wenn Sie die generierte JavaScript-Datei vermeiden möchten, haben Sie möglicherweise die Möglichkeit dazu. Kehren wir zum Fibonacci-Beispiel zurück. Um sie 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>
WebAssembly-Module, die mit Emscripten erstellt wurden, haben keinen Arbeitsspeicher, mit dem sie arbeiten können, es sei denn, Sie stellen ihnen Arbeitsspeicher zur Verfügung. Sie stellen einem Wasm-Modul etwas über das imports
-Objekt bereit, den zweiten Parameter der instantiateStreaming
-Funktion. Das Wasm-Modul kann auf alles im Importobjekt zugreifen, aber auf nichts anderes außerhalb davon. Konventionsgemäß wird von Modulen, die mit Emscripten kompiliert wurden, Folgendes von der JavaScript-Ladeumgebung erwartet:
- Erstens:
env.memory
. Das Wasm-Modul ist sozusagen nicht mit der Außenwelt verbunden und benötigt daher etwas Arbeitsspeicher. Geben SieWebAssembly.Memory
ein. Sie stellt einen (optional erweiterbaren) linearen Speicherbereich dar. Die Größenparameter werden in Einheiten von WebAssembly-Seiten angegeben. Das bedeutet, dass mit dem obigen Code eine Seite mit Arbeitsspeicher zugewiesen wird, wobei jede Seite eine Größe von 64 KiB hat. Ohne die Optionmaximum
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 zu wachsen beginnen 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 Speicherverwaltung verwenden, können wir den gesamten Speicher als Stack verwenden, daherSTACKTOP = 0
.