Vous pouvez parfois vouloir utiliser une bibliothèque qui n'est disponible qu'en code C ou C++. En général, c'est là que vous abandonnez. Eh bien, ce n'est plus le cas, car nous disposons désormais d'Emscripten et de WebAssembly (ou Wasm).
La chaîne d'outils
Je me suis fixé comme objectif de trouver comment compiler du code C existant en Wasm. Le backend Wasm de LLVM a fait parler de lui, alors j'ai commencé à m'y intéresser. Bien que vous puissiez compiler des programmes simples de cette manière, dès que vous voudrez utiliser la bibliothèque standard de C ou même compiler plusieurs fichiers, vous rencontrerez probablement des problèmes. Cela m'a permis de tirer une leçon majeure :
Alors qu'Emscripten était un compilateur C-to-asm.js, il a depuis évolué pour cibler Wasm et est en train de passer au backend LLVM officiel en interne. Emscripten fournit également une implémentation compatible avec Wasm de la bibliothèque standard de C. Utilisez Emscripten. Il effectue de nombreuses tâches cachées, émule un système de fichiers, gère la mémoire, encapsule OpenGL avec WebGL, et bien d'autres choses que vous n'avez pas besoin de faire vous-même.
Même si cela peut sembler être un problème de surcharge (ce qui m'inquiétait), le compilateur Emscripten supprime tout ce qui n'est pas nécessaire. Dans mes tests, les modules Wasm obtenus sont dimensionnés de manière appropriée pour la logique qu'ils contiennent, et les équipes Emscripten et WebAssembly s'efforcent de les rendre encore plus petits à l'avenir.
Vous pouvez obtenir Emscripten en suivant les instructions sur son site Web ou en utilisant Homebrew. Si vous êtes un fan des commandes dockerisées comme moi et que vous ne souhaitez pas installer d'éléments sur votre système simplement pour jouer avec WebAssembly, vous pouvez utiliser une image Docker bien gérée :
$ docker pull trzeci/emscripten
$ docker run --rm -v $(pwd):/src trzeci/emscripten emcc <emcc options here>
Compiler quelque chose de simple
Prenons l'exemple presque canonique de l'écriture d'une fonction en C qui calcule le n-th nombre de Fibonacci :
#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;
}
Si vous connaissez le langage C, la fonction elle-même ne devrait pas vous surprendre. Même si vous ne connaissez pas le langage C, mais que vous connaissez JavaScript, vous devriez pouvoir comprendre ce qui se passe ici.
emscripten.h est un fichier d'en-tête fourni par Emscripten. Nous n'en avons besoin que pour accéder à la macro EMSCRIPTEN_KEEPALIVE, mais elle offre bien d'autres fonctionnalités.
Cette macro indique au compilateur de ne pas supprimer une fonction, même si elle semble inutilisée. Si nous omettions cette macro, le compilateur optimiserait la fonction, car personne ne l'utilise.
Enregistrons tout cela dans un fichier nommé fib.c. Pour le convertir en fichier .wasm, nous devons utiliser la commande de compilation emcc d'Emscripten :
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' fib.c
Analysons cette commande. emcc est le compilateur d'Emscripten. fib.c est notre fichier C. Jusque-là, tout va bien. -s WASM=1 indique à Emscripten de nous fournir un fichier Wasm au lieu d'un fichier asm.js.
-s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' indique au compilateur de laisser la fonction cwrap() disponible dans le fichier JavaScript. Nous reviendrons sur cette fonction plus tard. -O3 indique au compilateur d'optimiser de manière agressive. Vous pouvez choisir des nombres inférieurs pour réduire le temps de compilation, mais cela augmentera également la taille des bundles obtenus, car le compilateur risque de ne pas supprimer le code inutilisé.
Après avoir exécuté la commande, vous devriez obtenir un fichier JavaScript nommé a.out.js et un fichier WebAssembly nommé a.out.wasm. Le fichier Wasm (ou "module") contient notre code C compilé et doit être assez petit. Le fichier JavaScript se charge de charger et d'initialiser notre module Wasm, et de fournir une API plus agréable. Si nécessaire, il se chargera également de configurer la pile, le tas et d'autres fonctionnalités généralement fournies par le système d'exploitation lors de l'écriture de code C. Le fichier JavaScript est donc un peu plus volumineux, avec une taille de 19 Ko (environ 5 Ko compressé au format gzip).
Exécuter une commande simple
Le moyen le plus simple de charger et d'exécuter votre module consiste à utiliser le fichier JavaScript généré. Une fois ce fichier chargé, vous aurez une variable globale Module à votre disposition. Utilisez cwrap pour créer une fonction native JavaScript qui se charge de convertir les paramètres en un format compatible avec C et d'appeler la fonction encapsulée. cwrap utilise le nom de la fonction, le type renvoyé et les types d'arguments comme arguments, dans cet ordre :
<script src="a.out.js"></script>
<script>
Module.onRuntimeInitialized = _ => {
const fib = Module.cwrap('fib', 'number', ['number']);
console.log(fib(12));
};
</script>
Si vous exécutez ce code, vous devriez voir "144" dans la console, qui est le 12e nombre de Fibonacci.
Le Graal : compiler une bibliothèque C
Jusqu'à présent, le code C que nous avons écrit l'a été en pensant à Wasm. Toutefois, l'un des principaux cas d'utilisation de WebAssembly consiste à exploiter l'écosystème existant de bibliothèques C et à permettre aux développeurs de les utiliser sur le Web. Ces bibliothèques s'appuient souvent sur la bibliothèque standard de C, un système d'exploitation, un système de fichiers, etc. Emscripten fournit la plupart de ces fonctionnalités, bien qu'il existe certaines limites.
Revenons à mon objectif initial : compiler un encodeur pour WebP vers Wasm. Le code source du codec WebP est écrit en C et disponible sur GitHub, ainsi que dans une documentation de l'API complète. C'est un bon point de départ.
$ git clone https://github.com/webmproject/libwebp
Pour commencer, essayons d'exposer WebPGetEncoderVersion() de encode.h à JavaScript en écrivant un fichier C appelé webp.c :
#include "emscripten.h"
#include "src/webp/encode.h"
EMSCRIPTEN_KEEPALIVE
int version() {
return WebPGetEncoderVersion();
}
Il s'agit d'un programme simple et efficace pour tester si nous pouvons compiler le code source de libwebp, car nous n'avons pas besoin de paramètres ni de structures de données complexes pour appeler cette fonction.
Pour compiler ce programme, nous devons indiquer au compilateur où trouver les fichiers d'en-tête de libwebp à l'aide de l'option -I et lui transmettre tous les fichiers C de libwebp dont il a besoin. Pour être honnête, je lui ai simplement fourni tous les fichiers C que j'ai pu trouver et j'ai laissé le compilateur supprimer tout ce qui était inutile. Et ça a semblé fonctionner à merveille !
$ emcc -O3 -s WASM=1 -s EXTRA_EXPORTED_RUNTIME_METHODS='["cwrap"]' \
-I libwebp \
webp.c \
libwebp/src/{dec,dsp,demux,enc,mux,utils}/*.c
Il ne nous reste plus qu'à ajouter du code HTML et JavaScript pour charger notre nouveau module :
<script src="/a.out.js"></script>
<script>
Module.onRuntimeInitialized = async (_) => {
const api = {
version: Module.cwrap('version', 'number', []),
};
console.log(api.version());
};
</script>
Le numéro de version correct s'affiche dans le résultat :

Obtenir une image de JavaScript dans Wasm
Obtenir le numéro de version de l'encodeur est une bonne chose, mais encoder une image réelle serait plus impressionnant, n'est-ce pas ? C'est parti !
La première question à laquelle nous devons répondre est la suivante : comment intégrer l'image dans l'environnement Wasm ?
Si l'on examine l'API d'encodage de libwebp, on constate qu'elle attend un tableau d'octets au format RVB, RVBA, BGR ou BGRA. Heureusement, l'API Canvas dispose de getImageData(), qui nous fournit un Uint8ClampedArray contenant les données d'image au format RGBA :
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);
}
Il ne reste plus qu'à copier les données de JavaScript vers Wasm. Pour cela, nous devons exposer deux fonctions supplémentaires. Une qui alloue de la mémoire pour l'image dans l'environnement Wasm et une autre qui la libère :
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 alloue un tampon pour l'image RGBA, soit quatre octets par pixel.
Le pointeur renvoyé par malloc() est l'adresse de la première cellule de mémoire de ce tampon. Lorsque le pointeur est renvoyé à JavaScript, il est traité comme un simple nombre. Après avoir exposé la fonction à JavaScript à l'aide de cwrap, nous pouvons utiliser ce nombre pour trouver le début de notre tampon et copier les données d'image.
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);
Grande finale : encoder l'image
L'image est désormais disponible dans l'environnement Wasm. Il est temps d'appeler l'encodeur WebP pour qu'il fasse son travail. D'après la documentation WebP, WebPEncodeRGBA semble être la solution idéale. La fonction utilise un pointeur vers l'image d'entrée et ses dimensions, ainsi qu'une option de qualité comprise entre 0 et 100. Il alloue également un tampon de sortie, que nous devons libérer à l'aide de WebPFree() une fois que nous avons terminé avec l'image WebP.
Le résultat de l'opération d'encodage est un tampon de sortie et sa longueur. Comme les fonctions en C ne peuvent pas avoir de tableaux comme types renvoyés (sauf si nous allouons de la mémoire de manière dynamique), j'ai eu recours à un tableau global statique. Je sais que ce n'est pas du C propre (en fait, cela repose sur le fait que les pointeurs Wasm ont une largeur de 32 bits), mais pour simplifier les choses, je pense que c'est un raccourci acceptable.
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];
}
Maintenant que tout est en place, nous pouvons appeler la fonction d'encodage, récupérer le pointeur et la taille de l'image, les placer dans un tampon JavaScript et libérer tous les tampons Wasm que nous avons alloués au cours du processus.
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);
Selon la taille de votre image, vous pouvez rencontrer une erreur où Wasm ne peut pas augmenter suffisamment la mémoire pour accueillir à la fois l'image d'entrée et l'image de sortie :

Heureusement, la solution à ce problème se trouve dans le message d'erreur. Il nous suffit d'ajouter -s ALLOW_MEMORY_GROWTH=1 à notre commande de compilation.
Et voilà ! Nous avons compilé un encodeur WebP et transcodé une image JPEG au format WebP. Pour prouver que cela a fonctionné, nous pouvons transformer notre tampon de résultat en blob et l'utiliser sur un élément <img> :
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);
Admirez la splendeur de votre nouvelle image WebP !

Conclusion
Faire fonctionner une bibliothèque C dans le navigateur n'est pas une mince affaire, mais une fois que vous avez compris le processus global et le fonctionnement du flux de données, cela devient plus facile et les résultats peuvent être époustouflants.
WebAssembly offre de nombreuses nouvelles possibilités sur le Web pour le traitement, le calcul de nombres et les jeux. N'oubliez pas que Wasm n'est pas une solution miracle qui doit être appliquée à tout, mais lorsque vous rencontrez l'un de ces goulots d'étranglement, Wasm peut être un outil incroyablement utile.
Contenu bonus : exécuter une tâche simple de manière complexe
Si vous souhaitez essayer d'éviter le fichier JavaScript généré, vous pouvez peut-être le faire. Revenons à l'exemple de Fibonacci. Pour le charger et l'exécuter nous-mêmes, nous pouvons procéder comme suit :
<!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>
Les modules WebAssembly créés par Emscripten ne disposent d'aucune mémoire, sauf si vous leur en fournissez. Pour fournir quoi que ce soit à un module Wasm, vous devez utiliser l'objet imports, qui est le deuxième paramètre de la fonction instantiateStreaming. Le module Wasm peut accéder à tout ce qui se trouve dans l'objet d'importation, mais à rien d'autre en dehors de celui-ci. Par convention, les modules compilés par Emscripten attendent quelques éléments de l'environnement JavaScript de chargement :
- Tout d'abord, il y a
env.memory. Le module Wasm n'est pas au courant de ce qui se passe à l'extérieur, pour ainsi dire. Il a donc besoin de mémoire pour fonctionner. SaisissezWebAssembly.Memory. Il représente un segment de mémoire linéaire (pouvant être agrandi). Les paramètres de dimensionnement sont exprimés en "unités de pages WebAssembly", ce qui signifie que le code ci-dessus alloue une page de mémoire, chaque page ayant une taille de 64 KiB. Sans l'optionmaximum, la mémoire n'est théoriquement pas limitée (Chrome a actuellement une limite stricte de 2 Go). La plupart des modules WebAssembly ne devraient pas avoir besoin de définir un maximum. env.STACKTOPdéfinit l'endroit où la pile est censée commencer à croître. La pile est nécessaire pour effectuer des appels de fonction et allouer de la mémoire aux variables locales. Comme nous n'utilisons pas de gestion dynamique de la mémoire dans notre petit programme Fibonacci, nous pouvons simplement utiliser l'intégralité de la mémoire comme pile, d'oùSTACKTOP = 0.