Compilation & langages formels · L3 · Section 5/8
WASM
Progression
#WebAssembly
WebAssembly (WASM) est un format binaire portable, exécutable dans le navigateur et dans des runtimes autonomes (wasmtime, WasmEdge). Il offre une exécution proche du natif dans un bac à sable strict: pas d'accès disque, pas d'accès réseau, pas de sortie écran, tout passe par des fonctions importées explicitement.
Prérequis: page Génération de code (l'IR à pile et le module WAT assemblé à la main).
Objectifs d'apprentissage
- Lire un module WAT: sections, paramètres, variables locales, pile.
- Décrire le cycle de vie compile → validate → instantiate → call.
- Connaître les types i32/i64/f32/f64 et le modèle d'exécution à pile.
#Format binaire, représentation texte
WASM existe en deux formes: le binaire (.wasm, ce que le navigateur télécharge) et le texte (.wat, lisible, converti par wat2wasm). Le binaire commence par la magie \0asm puis la version, puis une suite de sections: types, fonctions, mémoires, exports, code. La validation précède toujours l'exécution: un module mal formé (pile déséquilibrée, type incorrect, saut hors fonction) est rejeté avant toute exécution.
#Le fil conducteur, version WASM
Le programme let x = 5; let y = x*2; y+3 compilé à la page précédente devient:
1(module2 (func (export "main") (result i32)3 (local i32 i32)4 i32.const 55 local.set 06 local.get 07 i32.const 28 i32.mul9 local.set 110 local.get 111 i32.const 312 i32.add))Appeler main renvoie 13, comme l'interpréteur Python de la page AST et comme la simulation de la page codegen. Trois exécutions, un même résultat: c'est la définition opérationnelle d'un compilateur correct.
#Exemple: Fibonacci récursif
Un module un peu plus riche, avec paramètre, branchement et appel récursif:
1(module2 (func $fib (export "fib") (param $n i32) (result i32)3 (if (result i32) (i32.lt_s (local.get $n) (i32.const 2))4 (then (local.get $n))5 (else6 (i32.add7 (call $fib (i32.sub (local.get $n) (i32.const 1)))8 (call $fib (i32.sub (local.get $n) (i32.const 2)))))))9 (func (export "main") (result i32)10 (call $fib (i32.const 10))))Lecture guidée:
(param $n i32)déclare le paramètre;(result i32)promet exactement uni32sur la pile en sortie. Cette promesse est vérifiée par le validateur, pas par la bonne volonté du programmeur.- En WAT « plié » (s-expression), l'ordre d'exécution reste postfixe: dans
(i32.add (call …) (call …)), les deux appels s'exécutent avant l'addition, exactement comme l'IR à pile de la page précédente. (if (result i32) c (then a) (else b))est la version structurée duif (result i32) … else … endde l'exercice codegen: chaque branche pousse une valeur.
fib(10) vaut 55. Attention: la complexité est exponentielle (environ phi^n appels, phi ≈ 1,618), ce qui en fait aussi un bon test de charge pour un moteur JIT.
#Cycle de vie côté navigateur
1// 1. compiler+valider le flux, 2. instancier, 3. appeler.2const { instance } = await WebAssembly.instantiateStreaming(3 fetch('fib.wasm'), {} // imports: aucun ici4)5console.log(instance.exports.fib(10)) // 55instantiateStreaming compile pendant le téléchargement (le serveur doit servir Content-Type: application/wasm). Le second argument liste les imports: fonctions, mémoire ou tables que l'hôte fournit au module. C'est l'unique frontière du bac à sable: un module WASM ne peut appeler que ce qu'on lui a explicitement passé.
#Playground interactif
Le composant ci-dessous exécute un vrai module WASM dans votre navigateur: le module binaire est décodé, instancié, et sa fonction exportée appelée.
Le module binaire ci-dessous est l'exact équivalent de l'exemple fib ci-dessus, assemblé par wat2wasm depuis le même WAT et exporté sous le nom f (c'est le nom que le composant appelle). L'appel renvoie 55, la valeur de fib(10).
Le binaire est court (71 octets) parce que le format est compact: les noms de sections et les types sont encodés par des entiers, et seuls les exports gardent un nom lisible. Pour vérifier l'équivalence, désassemblez-le avec wasm2wat sur le même binaire: vous retrouverez le (if (result i32) …) de l'exemple.
#Mémoire et types
- Types:
i32,i64,f32,f64. Pas dei8/i16calculatoires: les octets vivent en mémoire, chargés puis étendus. Les entiers sont non signés par défaut dans certaines opérations, signés dans d'autres (i32.div_svsi32.div_u): le suffixe compte. - Mémoire linéaire: un tableau d'octets adressable de 0 à la taille courante, croissante par pages de 64 KiO (
memory.grow). C'est le seul état mutable global d'un module; C/C++/Rust y posent leur tas. - Appels: la pile d'appels est gérée par le moteur, pas accessible au module. La récursion profonde lève un piège
call stack exhaustedplutôt qu'un débordement mémoire.
#Limites
- Interopérabilité: les appels JS ↔ WASM ont un coût de conversion; on garde la frontière grossière (peu d'appels, beaucoup de calcul par appel).
- Débogage: possible (source maps,
WebAssembly.toString(), DevTools) mais moins mature que JS; nommer ses fonctions WAT aide. - Taille: un module peut dépasser quelques Mo; compression et découpage (dynamic import) limitent l'impact.
La page suivante détaille les contraintes d'exécution côté navigateur (mémoire, threads, CSP).
#Exercice : square en WAT, puis en binaire
#Instructions
- Écrivez un module WAT exportant
square(x)qui renvoiex * xaveci32.mul. - Vérifiez mentalement l'invariant de pile: un paramètre en entrée, un résultat en sortie.
- Testez-le dans le playground ci-dessous avec plusieurs valeurs, y compris négatives.
#Correction
1(module2 (func (export "square") (param $x i32) (result i32)3 local.get $x4 local.get $x5 i32.mul))Trace de pile pour square(7): local.get $x pousse 7, le second local.get $x pousse 7, i32.mul consomme les deux et pousse 49. La pile finale vaut [49], conforme au (result i32) promis. Pour square(-3), le résultat est 9: i32.mul sur deux négatifs donne un positif en complément à deux.
Le module ci-dessous est ce même code, assemblé par wat2wasm, avec deux exports: square (le paramètre) et f (la fonction sans argument que le composant appelle, et qui renvoie square(-3), soit 9).