1. Modelos
  2. Léxico
  3. Sintaxis
  4. Semántica
  5. Código
  6. Optimizar
  7. Ejecutar

Capítulo 06 · La ejecución

¿Quién lo ejecuta de verdad?

El último acto del compilador es escribir un fichero de bytes. Convertir esos bytes en un programa en marcha es trabajo de otra pieza de software: un cargador, una máquina virtual o, en el caso de Wahoo, el motor de WebAssembly de tu navegador, que comprueba el módulo, lo compila una vez más y lo ejecuta dentro de un entorno aislado.

Un compilador nunca ejecuta tu programa. Escribe un fichero, y ahí acaba su responsabilidad. Lo que pasa después depende del tipo de fichero: un ejecutable nativo se entrega al cargador del sistema operativo; un .jar, a la máquina virtual de Java; un módulo .wasm, a un motor de WebAssembly. Este último capítulo sigue a un módulo de Wahoo desde unos bytes en el disco hasta las instrucciones que se ejecutan en tu procesador.

Los programas son datos

Todo esto descansa sobre una idea de 1945. En su Primer borrador de un informe sobre el EDVAC, John von Neumann describió un ordenador cuyo programa se guarda en la misma memoria que sus datos, como números. Antes de eso, máquinas como ENIAC se programaban recableándolas. Una vez que un programa es solo datos, un programa puede escribir otro, que es exactamente lo que hace un compilador, y otro puede leerlo y representarlo, que es lo que hace un intérprete. Cada capítulo de esta web es una consecuencia de ese diseño.

Para el código nativo, los últimos pasos son el enlazado y la carga. El enlazador une las piezas compiladas de un programa y sus bibliotecas en un único fichero ejecutable (ELF en Linux, PE en Windows, Mach-O en macOS), corrigiendo las direcciones que cada pieza usa para referirse a las demás. Al arrancar el programa, el cargador del sistema operativo lleva el fichero a memoria, lo conecta con las bibliotecas compartidas y salta a su primera instrucción.

WebAssembly

Durante dos décadas el único lenguaje que los navegadores sabían ejecutar fue JavaScript. En 2013 Alon Zakai y Luke Wagner, de Mozilla, mostraron con asm.js que un subconjunto estricto de JavaScript, con tipos deducibles estáticamente y producido compilando C y C++, podía optimizarse de antemano hasta una velocidad casi nativa. La lección llevó a los cuatro grandes fabricantes de navegadores a diseñar juntos un formato de verdad: WebAssembly se anunció en 2015, llegó a todos los grandes navegadores en 2017 y se convirtió en recomendación del W3C en diciembre de 2019. Sus objetivos caben en una frase: rápido de cargar, rápido de ejecutar, seguro para ejecutar código de cualquier procedencia, e independiente de cualquier procesador o lenguaje. Hoy también se usa fuera del navegador, en servidores, sistemas de plugins y redes de borde.

WebAssembly no es un lenguaje pensado para escribirlo a mano (aunque su forma de texto, WAT, se lee bien, como han mostrado los capítulos anteriores). Es un destino de compilación: C, C++, Rust, Go, Zig, Kotlin, y ahora Wahoo, compilan a él.

Anatomía de un módulo

Un fichero .wasm empieza con ocho bytes: el «número mágico» 00 61 73 6D, que se lee \0asm, y la versión, 1. Después vienen secciones, cada una precedida por un byte identificador y su tamaño: los tipos de función, las importaciones que debe aportar el anfitrión, qué tipo tiene cada función definida, la memoria, las exportaciones, el código de cada cuerpo de función y los datos que colocar en memoria. Pueden añadirse secciones personalizadas en cualquier sitio; Wahoo añade la sección estándar name, para que las herramientas y los mensajes de error muestren $fib en vez de «función 4».

Todo número del formato se escribe en LEB128, una codificación de longitud variable: siete bits por byte, primero los bajos, y el bit más alto de cada byte indica si viene otro detrás. Los números pequeños, que son la inmensa mayoría (índices de variables, constantes pequeñas, longitudes cortas), ocupan un solo byte. El número 624 485, por ejemplo, es E5 8E 26.

Los bytes que produce el compilador de verdad para este programa, coloreados por sección, con una tabla que describe cada una. Ejecútalo, alarga luego el programa y mira qué secciones crecen: un texto nuevo agranda la sección de datos; una pipe nueva, las de tipos, funciones y código. El codificador de Wahoo está escrito a mano en unos pocos cientos de líneas de Rust (wasm.rs).

Validación: un comprobador de tipos para código máquina

Antes de ejecutar una sola instrucción, el motor valida el módulo entero, y lo rechaza si algo está mal. La validación es una comprobación de tipos al nivel más bajo: cada instrucción tiene un efecto conocido sobre la pila (i32.add desapila dos i32 y apila uno), cada bloque declara qué deja tras de sí, todo destino de salto debe existir, todo índice de función debe estar en rango. Como el control de flujo es estructurado (capítulo 04), todo esto se hace en una sola pasada lineal sobre el código. Por eso el generador de código de Wahoo tenía que añadir un unreachable tras un question cuyas dos ramas devuelven: el validador no se fía de la palabra del compilador.

El entorno aislado

Una página web puede ejecutar un módulo escrito por cualquiera, así que WebAssembly está diseñado para que el peor caso sea inofensivo (es un sandbox). Un módulo solo puede tocar su propia memoria lineal, y cada acceso se comprueba contra su tamaño (los motores lo hacen casi gratis reservando una enorme región de memoria virtual y dejando que la protección de memoria del procesador atrape los accesos perdidos). No puede saltar a cualquier dirección: las llamadas van a funciones conocidas y los saltos a bloques conocidos. La pila de llamadas, con sus direcciones de retorno, vive fuera de la memoria del módulo, donde este no puede sobrescribirla. Y solo puede alcanzar el mundo exterior a través de sus importaciones. Un programa en Wahoo tiene exactamente cuatro: puede imprimir un número, un switch, un texto y un salto de línea, y nada más.

De WebAssembly al procesador

El módulo sigue sin ser código máquina. El navegador lo compila, una vez más, para el procesador en el que se ejecuta, con el mismo compromiso entre velocidad de compilación y velocidad del resultado que describía el capítulo 00. V8, el motor de Chrome, compila primero cada función con Liftoff, un compilador básico que traduce en una sola pasada, para que el módulo pueda empezar a ejecutarse casi de inmediato. Las funciones que resultan estar calientes se recompilan en segundo plano con TurboFan, el compilador optimizador (el mismo que optimiza JavaScript), y se sustituyen. Firefox y Safari siguen un diseño por niveles parecido.

No todos los anfitriones compilan. La herramienta de línea de órdenes wahoo ejecuta los módulos con wasmi, un intérprete de WebAssembly escrito en Rust: valida el módulo y luego lo ejecuta a través de su propio bytecode interno. El mismo fichero .wasm se interpreta en la línea de órdenes y se compila a código máquina en el navegador. Es la lección del capítulo 00, por última vez: ser compilado o interpretado es una propiedad de la implementación.

Cuando algo falla al ejecutar

Algunos fallos solo se descubren al ejecutar. Cuando una instrucción de WebAssembly no puede hacer su trabajo, provoca una trampa (trap): la ejecución se detiene en el acto y el anfitrión recibe un error. Los programas de Wahoo pueden caer en tres, y cada una se cuenta en clave de juego:

  • División entre cero (i32.div_s con divisor cero): «Has caído a un foso».
  • División que desborda: -2147483648 / -1 sería 2147483648, que no cabe en 32 bits.
  • Desbordamiento de pila: demasiadas llamadas anidadas, normalmente una recursión sin salida: «Demasiadas tuberías anidadas».

Un bucle que no termina no provoca ninguna trampa; simplemente no se detiene nunca, y por el problema de la parada ningún comprobador puede saberlo siempre de antemano. Los dos anfitriones lo resuelven de forma distinta. En el navegador, Wahoo ejecuta cada programa en un Web Worker, un hilo aparte, y lo mata a los tres segundos («¡Se acabó el tiempo!»). En la línea de órdenes, wasmi puede medir combustible: cada instrucción consume una unidad, y la ejecución se detiene cuando el depósito se vacía (wahoo run --fuel 1000000).

El viaje completo

Sigue una vez más a un programa en Wahoo. Los caracteres se convierten en tokens (01); los tokens, en un árbol (02); el árbol se comprueba y se simplifica (03); se traduce a instrucciones de máquina de pila (04), se abarata (05) y se codifica en bytes. El navegador valida esos bytes, los compila a las instrucciones propias del procesador y los ejecuta en un entorno aislado, llamando a JavaScript cada vez que el programa dice wahoo. En el playground puedes ver cada uno de esos pasos con tus propios programas.

Referencias

  1. J. von Neumann (1945). First Draft of a Report on the EDVAC. Moore School of Electrical Engineering, Universidad de Pensilvania.
  2. J. R. Levine (1999). Linkers and Loaders. Morgan Kaufmann.
  3. A. Zakai (2011). “Emscripten: An LLVM-to-JavaScript Compiler”. Proceedings of SPLASH (OOPSLA companion).
  4. A. Haas, A. Rossberg, D. L. Schuff, B. L. Titzer et al. (2017). “Bringing the Web up to Speed with WebAssembly”. Proceedings of PLDI.
  5. Equipo de V8 (2018). “Liftoff: a new baseline compiler for WebAssembly in V8”. Blog v8.dev.
  6. WebAssembly Community Group (2019). WebAssembly Core Specification, recomendación del W3C.