Compilation de C dans WebAssembly sans Emscripten

Le compilateur fait partie d' Emscripten . Mais que se passe-t-il si vous retirez tous les sifflets et ne le laissez que?

Emscripten est requis pour compiler C / C ++ dans WebAssembly . Mais c'est bien plus qu'un simple compilateur. L'objectif d'Emscripten est de remplacer complètement votre compilateur C / C ++ et d'exécuter du code sur le Web qui n'est pas initialement conçu pour le Web. Pour cela, Emscripten émule l'intégralité du système d'exploitation POSIX. Si le programme utilise fopen () , Emscripten fournira une émulation du système de fichiers. Si OpenGL est utilisé, Emscripten fournira un contexte GL compatible C pris en charge par WebGL . C'est beaucoup de travail et beaucoup de code qui devra être implémenté dans le package final. Mais pouvez-vous juste ... l'enlever?

Le compilateur réel dans la boîte à outils Emscripten est LLVM. C'est lui qui traduit le code C en bytecode WebAssembly. Il s'agit d'un cadre modulaire moderne pour l'analyse, la transformation et l'optimisation des programmes. LLVM est modulaire dans le sens où il ne se compile jamais directement en code machine. Au lieu de cela, le compilateur frontal intégré génère une représentation intermédiaire (IR). Cette représentation intermédiaire, en fait, est appelée LLVM, une abréviation de Low-Level Virtual Machine, d'où le nom du projet.

Le compilateur principal traduit ensuite l'IR en code machine hôte. L'avantage de cette séparation stricte est que les nouvelles architectures sont prises en charge par l'ajout «simple» d'un nouveau compilateur. En ce sens, WebAssembly n'est que l'un des nombreux objectifs de compilation pris en charge par LLVM, et pendant un certain temps, il a été activé avec un indicateur spécial. À partir de LLVM 8, la cible de compilation WebAssembly est disponible par défaut.

Sur MacOS, vous pouvez installer LLVM en utilisant homebrew :

$ brew install llvm $ brew link --force llvm 

Vérifiez la prise en charge de WebAssembly:

 $ llc --version LLVM (http://llvm.org/): LLVM version 8.0.0 Optimized build. Default target: x86_64-apple-darwin18.5.0 Host CPU: skylake Registered Targets: # …,  … systemz - SystemZ thumb - Thumb thumbeb - Thumb (big endian) wasm32 - WebAssembly 32-bit # ! ! ! wasm64 - WebAssembly 64-bit x86 - 32-bit X86: Pentium-Pro and above x86-64 - 64-bit X86: EM64T and AMD64 xcore - XCore 

Il semble que nous soyons prêts!

Compiler C à la dure


Remarque: voici quelques formats RAW WebAssembly de bas niveau. Si vous avez du mal à comprendre, c'est normal. Une bonne utilisation de WebAssembly ne nécessite pas une compréhension de l'intégralité du texte de cet article. Si vous recherchez du code pour copier-coller, consultez l'appel au compilateur dans la section Optimisation . Mais si ça vous intéresse, continuez à lire! J'ai précédemment écrit une introduction à Webassembly pur et à WAT: ce sont les bases nécessaires pour comprendre ce post.
Avertissement: je vais légèrement dévier de la norme et essayer d'utiliser des formats lisibles par l'homme à chaque étape (dans la mesure du possible). Notre programme ici sera très simple afin d'éviter les situations frontalières et de ne pas se laisser distraire:

 // Filename: add.c int add(int a, int b) { return a*a + b; } 

Quel magnifique exploit d'ingénierie! Surtout parce que le programme s'appelle ajouter , mais en réalité il n'ajoute rien (n'ajoute pas). Plus important encore: le programme n'utilise pas la bibliothèque standard, et des types ici, seulement «int».

Transformer C en une vue LLVM interne


La première étape consiste à transformer notre programme C en LLVM IR. C'est la tâche du compilateur frontal clang , qui est installé avec LLVM:

 clang \ --target=wasm32 \ # Target WebAssembly -emit-llvm \ # Emit LLVM IR (instead of host machine code) -c \ # Only compile, no linking just yet -S \ # Emit human-readable assembly rather than binary add.c 

Et en conséquence, nous obtenons add.ll avec une représentation interne de LLVM IR. Je ne le montre que par souci d'exhaustivité . Lorsque vous travaillez avec WebAssembly ou même clang, en tant que développeur C, vous n'entrerez jamais en contact avec LLVM IR.

 ; ModuleID = 'add.c' source_filename = "add.c" target datalayout = "em:ep:32:32-i64:64-n32:64-S128" target triple = "wasm32" ; Function Attrs: norecurse nounwind readnone define hidden i32 @add(i32, i32) local_unnamed_addr #0 { %3 = mul nsw i32 %0, %0 %4 = add nsw i32 %3, %1 ret i32 %4 } attributes #0 = { norecurse nounwind readnone "correctly-rounded-divide-sqrt-fp-math"="false" "disable-tail-calls"="false" "less-precise-fpmad"="false" "min-legal-vector-width"="0" "no-frame-pointer-elim"="false" "no-infs-fp-math"="false" "no-jump-tables"="false" "no-nans-fp-math"="false" "no-signed-zeros-fp-math"="false" "no-trapping-math"="false" "stack-protector-buffer-size"="8" "target-cpu"="generic" "unsafe-fp-math"="false" "use-soft-float"="false" } !llvm.module.flags = !{!0} !llvm.ident = !{!1} !0 = !{i32 1, !"wchar_size", i32 4} !1 = !{!"clang version 8.0.0 (tags/RELEASE_800/final)"} 

LLVM IR regorge de métadonnées et d'annotations supplémentaires, ce qui permet au compilateur de prendre des décisions plus éclairées lors de la génération du code machine.

Transformez LLVM IR en fichiers objets


L'étape suivante consiste à appeler le compilateur d'arrière-plan llc pour créer un fichier objet à partir de la représentation interne.

Le fichier de sortie add.o est déjà un module WebAssembly valide qui contient tout le code compilé de notre fichier C. Mais généralement, vous ne pourrez pas exécuter les fichiers objets car ils manquent de parties essentielles.

Si nous -filetype=obj dans la commande, nous obtiendrions l'assembleur LLVM pour WebAssembly, un format lisible par l'homme qui est quelque peu similaire à WAT. Cependant, l'outil llvm-mc pour travailler avec de tels fichiers ne prend pas encore totalement en charge le format et ne peut souvent pas traiter les fichiers. Par conséquent, nous démontons les fichiers objets après coup. Un outil spécifique est nécessaire pour vérifier ces fichiers objets. Dans le cas de WebAssembly, il s'agit de wasm-objdump , qui fait partie de WebAssembly Binary Toolkit ou wabt pour faire court.

 $ brew install wabt # in case you haven't $ wasm-objdump -x add.o add.o: file format wasm 0x1 Section Details: Type[1]: - type[0] (i32, i32) -> i32 Import[3]: - memory[0] pages: initial=0 <- env.__linear_memory - table[0] elem_type=funcref init=0 max=0 <- env.__indirect_function_table - global[0] i32 mutable=1 <- env.__stack_pointer Function[1]: - func[0] sig=0 <add> Code[1]: - func[0] size=75 <add> Custom: - name: "linking" - symbol table [count=2] - 0: F <add> func=0 binding=global vis=hidden - 1: G <env.__stack_pointer> global=0 undefined binding=global vis=default Custom: - name: "reloc.CODE" - relocations for section: 3 (Code) [1] R_WASM_GLOBAL_INDEX_LEB offset=0x000006(file=0x000080) symbol=1 <env.__stack_pointer> 

La sortie montre que notre fonction add () est dans ce module, mais elle contient également des sections personnalisées avec des métadonnées et, étonnamment, plusieurs importations. À l'étape suivante de la liaison, les sections personnalisées seront analysées et supprimées, et l'éditeur de liens (éditeur de liens) s'occupera de l'importation.

Disposition


Traditionnellement, la tâche de l'éditeur de liens est d'assembler plusieurs fichiers objets dans un fichier exécutable. L'éditeur de liens LLVM est appelé lld et il est appelé avec le lien symbolique cible. Pour WebAssembly, ce wasm-ld .

 wasm-ld \ --no-entry \ # We don't have an entry function --export-all \ # Export everything (for now) -o add.wasm \ add.o 

Le résultat est un module WebAssembly de 262 octets.

Lancement


Bien sûr, le plus important est de voir que tout fonctionne vraiment . Comme dans le dernier article , vous pouvez utiliser quelques lignes de JavaScript intégré pour charger et exécuter ce module WebAssembly.

 <!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); console.log(instance.exports.add(4, 1)); } init(); </script> 

Si tout va bien, vous verrez le numéro 17 dans la console DevTool Nous venons de compiler avec succès C dans WebAssembly sans toucher Emscripten. Il convient également de noter qu'il n'existe aucun middleware pour configurer et charger le module WebAssembly.

Compiler C est un peu plus simple


Pour compiler C dans WebAssembly, nous avons pris de nombreuses mesures. Comme je l'ai dit, à des fins pédagogiques, nous avons examiné en détail toutes les étapes. Ignorons les formats intermédiaires lisibles par l'homme et appliquons immédiatement le compilateur C comme un couteau suisse, tel qu'il a été développé:

 clang \ --target=wasm32 \ -nostdlib \ # Don't try and link against a standard library -Wl,--no-entry \ # Flags passed to the linker -Wl,--export-all \ -o add.wasm \ add.c 

Ici, nous obtenons le même fichier .wasm , mais avec une seule commande.

Optimisation


Jetez un œil au WAT de notre module WebAssembly en exécutant wasm2wat :

 (module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) (local i32 i32 i32 i32 i32 i32 i32 i32) global.get 0 local.set 2 i32.const 16 local.set 3 local.get 2 local.get 3 i32.sub local.set 4 local.get 4 local.get 0 i32.store offset=12 local.get 4 local.get 1 i32.store offset=8 local.get 4 i32.load offset=12 local.set 5 local.get 4 i32.load offset=12 local.set 6 local.get 5 local.get 6 i32.mul local.set 7 local.get 4 i32.load offset=8 local.set 8 local.get 7 local.get 8 i32.add local.set 9 local.get 9 return) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add))) 

Wow, quel bon code. À ma grande surprise, le module utilise de la mémoire (comme le i32.store i32.load et i32.store ), huit variables locales et plusieurs variables globales. Vous pouvez probablement écrire manuellement une version plus concise. Ce programme est si grand parce que nous n'avons appliqué aucune optimisation. Faisons-le:

 clang \ --target=wasm32 \ + -O3 \ # Agressive optimizations + -flto \ # Add metadata for link-time optimizations -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ + -Wl,--lto-O3 \ # Aggressive link-time optimizations -o add.wasm \ add.c 

Remarque: techniquement, l'optimisation de la mise en page (LTO) n'offre aucun avantage car nous ne composons qu'un seul fichier. Dans les grands projets, LTO contribuera à réduire considérablement la taille du fichier.
Après avoir exécuté ces commandes, le fichier .wasm passé de 262 à 197 octets, et WAT est également devenu beaucoup plus simple:

 (module (type (;0;) (func)) (type (;1;) (func (param i32 i32) (result i32))) (func $__wasm_call_ctors (type 0)) (func $add (type 1) (param i32 i32) (result i32) local.get 0 local.get 0 i32.mul local.get 1 i32.add) (table (;0;) 1 1 anyfunc) (memory (;0;) 2) (global (;0;) (mut i32) (i32.const 66560)) (global (;1;) i32 (i32.const 66560)) (global (;2;) i32 (i32.const 1024)) (global (;3;) i32 (i32.const 1024)) (export "memory" (memory 0)) (export "__wasm_call_ctors" (func $__wasm_call_ctors)) (export "__heap_base" (global 1)) (export "__data_end" (global 2)) (export "__dso_handle" (global 3)) (export "add" (func $add))) 

Appelez la bibliothèque standard


L'utilisation de C sans la bibliothèque libc standard semble plutôt grossière. Il est logique de l'ajouter, mais je vais être honnête: ce ne sera pas facile. En fait, nous n'appelons directement aucune bibliothèque libc dans l'article . Il en existe plusieurs, en particulier la glibc , la musl et la dietlibc . Cependant, la plupart de ces bibliothèques sont supposées s'exécuter dans le système d'exploitation POSIX, qui implémente un certain ensemble d'appels système. Comme nous n'avons pas d'interface noyau en JavaScript, nous devrons implémenter nous-mêmes ces appels système POSIX, probablement via JavaScript. C'est une tâche difficile et je ne vais pas le faire ici. La bonne nouvelle est que c'est ce que Emscripten fait pour vous .

Bien sûr, toutes les fonctions libc ne dépendent pas des appels système. Des fonctions telles que strlen() , sin() ou même memset() sont implémentées en simple C. Cela signifie que vous pouvez utiliser ces fonctions ou même simplement copier / coller leur implémentation à partir d'une bibliothèque mentionnée.

Mémoire dynamique


Sans libc, les interfaces C fondamentales telles que malloc() et free() ne sont pas disponibles pour nous. Dans le WAT non optimisé, nous avons vu que le compilateur utilise de la mémoire si nécessaire. Cela signifie que nous ne pouvons pas simplement utiliser la mémoire comme nous le voulons, sans risquer de l'endommager. Vous devez comprendre comment il est utilisé.

Modèles de mémoire LLVM


La méthode de segmentation de la mémoire WebAssembly surprendra un peu les programmeurs expérimentés. Premièrement, dans WebAssembly, une adresse nulle est techniquement admissible, mais souvent elle est toujours traitée comme une erreur. Deuxièmement, la pile vient en premier et croît (vers des adresses inférieures), et le tas apparaît plus tard et grandit. La raison en est que la mémoire de WebAssembly peut augmenter lors de l'exécution. Cela signifie qu'il n'y a pas d'extrémité fixe pour accueillir la pile ou le tas.

Voici la disposition wasm-ld :



La pile grandit et le tas grandit. La pile commence par __data_end et le tas __heap_base par __heap_base . Étant donné que la pile est placée en premier, elle est limitée par la taille maximale définie lors de la compilation, c'est-à-dire __heap_base moins __data_end

Si nous revenons en arrière et regardons la section des globaux dans notre WAT, nous trouvons ces valeurs: __heap_base défini sur 66560, et __data_end est défini sur 1024. Cela signifie que la pile peut atteindre un maximum de 64 Ko, ce qui n'est pas beaucoup. Heureusement, wasm-ld vous permet de modifier cette valeur:

 clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \ + -Wl,-z,stack-size=$[8 * 1024 * 1024] \ # Set maximum stack size to 8MiB -o add.wasm \ add.c 

Ensemble d'allocateur


La zone de __heap_base de __heap_base est connue pour commencer par __heap_base . Comme la fonction malloc() est manquante, nous savons que la prochaine zone mémoire peut être utilisée en toute sécurité. Nous pouvons y placer les données comme nous le souhaitons, et il n'est pas nécessaire d'avoir peur de la corruption de la mémoire, car la pile croît dans l'autre sens. Cependant, un tas gratuit pour tout le monde peut rapidement se boucher, donc généralement une sorte de gestion dynamique de la mémoire est nécessaire. Une option consiste à prendre une implémentation à part entière de malloc (), telle que l'implémentation malloc de Doug Lee , qui est utilisée dans Emscripten. Il existe plusieurs autres petites implémentations avec divers compromis.

Mais pourquoi ne pas écrire votre propre malloc() ? Nous sommes si profondément enlisés que cela ne fait aucune différence. L'un des plus simples est un allocateur de relief: il est ultra-rapide, extrêmement petit et facile à implémenter. Mais il y a un inconvénient: vous ne pouvez pas libérer de mémoire. Bien qu'à première vue un tel allocateur semble incroyablement inutile, mais lors du développement de Squoosh, je suis tombé sur des précédents où ce serait un excellent choix. Le concept d'allocateur de relief est que nous stockons l'adresse de départ de la mémoire inutilisée comme globale. Si le programme demande n octets de mémoire, nous déplaçons le marqueur sur n et retournons la valeur précédente:

 extern unsigned char __heap_base; unsigned int bump_pointer = &__heap_base; void* malloc(int n) { unsigned int r = bump_pointer; bump_pointer += n; return (void *)r; } void free(void* p) { // lol } 

Les variables globales de WAT sont en fait définies par wasm-ld , de sorte que nous pouvons y accéder à partir de notre code C en tant que variables ordinaires si nous les déclarons extern . Donc, nous venons d'écrire notre propre malloc() ... en cinq lignes de C.

Remarque: notre bump allocator n'est pas entièrement compatible avec malloc() de C. Par exemple, nous ne donnons aucune garantie d'alignement. Mais cela fonctionne assez bien, alors ...

Utilisation dynamique de la mémoire


Pour tester, créons une fonction C, qui prend un tableau de nombres de taille arbitraire et calcule la somme. Pas très intéressant, mais cela nous oblige à utiliser la mémoire dynamique, car nous ne connaissons pas la taille du tableau lors de l'assemblage:

 int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum; } 

Espérons que la fonction sum () soit assez simple. Une question plus intéressante est de savoir comment passer un tableau de JavaScript à WebAssembly - après tout, WebAssembly ne comprend que les nombres. L'idée générale est d'utiliser malloc() partir de JavaScript pour allouer un morceau de mémoire, y copier les valeurs et passer l'adresse (nombre!) se trouve le tableau:

 <!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; // Allocate memory for 5 32-bit integers // and return get starting address. const cArrayPointer = instance.exports.malloc(jsArray.length * 4); // Turn that sequence of 32-bit integers // into a Uint32Array, starting at that address. const cArray = new Uint32Array( instance.exports.memory.buffer, cArrayPointer, jsArray.length ); // Copy the values from JS to C. cArray.set(jsArray); // Run the function, passing the starting address and length. console.log(instance.exports.sum(cArrayPointer, cArray.length)); } init(); </script> 

Après le démarrage, vous devriez voir la réponse 15 dans la console DevTools, qui est vraiment la somme de tous les nombres de 1 à 5.

Conclusion


Alors, vous lisez jusqu'à la fin. Félicitations! Encore une fois, si vous vous sentez un peu surchargé, tout est en ordre. Il n'est pas nécessaire de lire tous les détails. Les comprendre est complètement facultatif pour un bon développeur Web et n'est même pas nécessaire pour l'excellente utilisation de WebAssembly . Mais je voulais partager cette information, car elle vous permet d'apprécier vraiment tout le travail qu'un projet comme Emscripten fait pour vous. Dans le même temps, cela permet de comprendre à quel point les modules purement informatiques de WebAssembly peuvent être petits. Le module Wasm pour additionner le tableau ne fait que 230 octets, y compris un allocateur de mémoire dynamique . La compilation du même code avec Emscripten produira 100 octets de code WebAssembly et 11K de code de liaison JavaScript. Vous devez essayer pour un tel résultat, mais il y a des situations où cela en vaut la peine.

Source: https://habr.com/ru/post/fr454868/


All Articles