El compilador es parte de
Emscripten . Pero, ¿qué pasa si quitas todos los silbatos y solo lo dejas?
Se requiere Emscripten para compilar C / C ++ en
WebAssembly . Pero esto es mucho más que un simple compilador. El objetivo de Emscripten es reemplazar completamente su compilador C / C ++ y ejecutar código en la web que
no está
diseñado originalmente para la Web. Para esto, Emscripten emula todo el sistema operativo POSIX. Si el programa usa
fopen () , Emscripten proporcionará la emulación del sistema de archivos. Si se utiliza OpenGL, Emscripten proporcionará un contexto GL compatible con C compatible con
WebGL . Esto es mucho trabajo y mucho código que deberá implementarse en el paquete final. Pero, ¿puedes simplemente ... eliminarlo?
El
compilador real en el kit de herramientas Emscripten es LLVM. Es él quien traduce el código C al código de bytes de WebAssembly. Este es un marco modular moderno para el análisis, transformación y optimización de programas. LLVM es modular en el sentido de que nunca se compila directamente en el código de la máquina. En cambio, el
compilador front-end incorporado genera
una representación intermedia (IR). Esta representación intermedia, de hecho, se llama LLVM, una abreviatura de máquina virtual de bajo nivel, de ahí el nombre del proyecto.
El
compilador de fondo traduce el IR al código de máquina host. La ventaja de esta separación estricta es que las nuevas arquitecturas son compatibles con la adición "simple" de un nuevo compilador. En este sentido, WebAssembly es solo uno de los muchos objetivos de compilación que admite LLVM, y durante algún tiempo se ha activado con un indicador especial. A partir de LLVM 8, el objetivo de compilación de WebAssembly está disponible de forma predeterminada.
En MacOS, puede instalar LLVM usando
homebrew :
$ brew install llvm $ brew link --force llvm
Consulte el soporte 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:
Parece que estamos listos!
Compilando C de la manera difícil
Nota: aquí hay algunos formatos RAW WebAssembly de bajo nivel. Si le resulta difícil de entender, esto es normal. El buen uso de WebAssembly no requiere una comprensión del texto completo en este artículo. Si está buscando código para copiar y pegar, consulte la llamada al compilador en la sección Optimización . Pero si te interesa, ¡sigue leyendo! Anteriormente escribí una introducción a Webassembly puro y WAT: estos son los conceptos básicos necesarios para comprender esta publicación.
Advertencia: Me desviaré un poco del estándar e intentaré utilizar formatos legibles por humanos en cada paso (en la medida de lo posible). Nuestro programa aquí será muy simple para evitar situaciones fronterizas y no distraerse:
¡Qué magnífica hazaña de ingeniería! Especialmente porque el programa se llama
agregar , pero en realidad no
agrega nada (no agrega). Más importante: el programa no usa la biblioteca estándar, y de los tipos aquí, solo 'int'.
Convertir C en una vista LLVM interna
El primer paso es convertir nuestro programa C en LLVM IR. Esta es la tarea del compilador frontend
clang
, que se instala con LLVM:
clang \ --target=wasm32 \
Y como resultado, obtenemos
add.ll
con una representación interna de LLVM IR.
Lo muestro solo en aras de la integridad . Al trabajar con WebAssembly o incluso clang, usted como desarrollador C nunca entrará en contacto con 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 está lleno de metadatos y anotaciones adicionales, lo que permite al compilador tomar decisiones más informadas al generar código de máquina.Convierta LLVM IR en archivos de objetos
El siguiente paso es llamar al compilador de back-end de
llc
para hacer un archivo objeto desde la representación interna.
El archivo de salida
add.o
ya es un módulo de WebAssembly válido que contiene todo el código compilado de nuestro archivo C. Pero, por lo general, no podrá ejecutar archivos de objetos porque carecen de partes esenciales.
Si omitimos
-filetype=obj
en el comando, obtendríamos el ensamblador LLVM para WebAssembly, un formato legible por humanos que es algo similar a WAT. Sin embargo, la herramienta
llvm-mc
para trabajar con dichos archivos aún no es totalmente compatible con el formato y, a menudo, no puede procesar archivos. Por lo tanto, desmontamos los archivos de objetos después del hecho. Se necesita una herramienta específica para verificar estos archivos de objetos. En el caso de WebAssembly, era
wasm-objdump
, parte del
kit de herramientas binarias de WebAssembly o wabt para abreviar.
$ brew install wabt
El resultado muestra que nuestra función add () está en este módulo, pero también contiene secciones
personalizadas con metadatos y, sorprendentemente, varias importaciones. En la siguiente etapa de
vinculación, se analizarán y eliminarán secciones personalizadas, y el vinculador (vinculador) se ocupará de la importación.
Diseño
Tradicionalmente, la tarea del vinculador es ensamblar varios archivos de objetos en un archivo ejecutable. El enlazador LLVM se llama
lld
, y se invoca con el enlace simbólico de destino. Para WebAssembly, este
wasm-ld
.
wasm-ld \ --no-entry \
El resultado es un módulo WebAssembly de 262 bytes de tamaño.
Lanzamiento
Por supuesto, lo más importante es ver que todo
realmente funcione. Como en el
último artículo , puede usar un par de líneas de JavaScript incrustado para cargar y ejecutar este módulo de 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 todo está bien, verá el número 17 en la consola de DevTool.
Acabamos de compilar con éxito C en WebAssembly sin tocar Emscripten. También vale la pena señalar que no hay middleware para configurar y cargar el módulo WebAssembly.
Compilar C es un poco más simple
Para compilar C en WebAssembly, hemos tomado muchos pasos. Como dije, con fines educativos, examinamos en detalle todas las etapas. Omitamos los formatos intermedios legibles para humanos e inmediatamente apliquemos el compilador de C como una navaja suiza, tal como se desarrolló:
clang \ --target=wasm32 \ -nostdlib \
Aquí obtenemos el mismo archivo
.wasm
, pero con un comando.
Optimización
Eche un vistazo al WAT de nuestro módulo WebAssembly ejecutando
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, qué gran código. Para mi sorpresa, el módulo usa memoria (como se ve en las
i32.store
i32.load
e
i32.store
), ocho variables locales y varias variables globales. Probablemente, puede escribir manualmente una versión más concisa. Este programa es muy grande porque no aplicamos ninguna optimización. Hagámoslo:
clang \ --target=wasm32 \ + -O3 \
Nota: técnicamente, la optimización de diseño (LTO) no ofrece beneficios ya que solo componimos un archivo. En proyectos grandes, LTO ayudará a reducir significativamente el tamaño del archivo.
Después de ejecutar estos comandos, el archivo
.wasm
disminuyó de 262 a 197 bytes, y WAT también se volvió mucho más 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)))
Llame a la biblioteca estándar.
Usar C sin la biblioteca libc estándar parece bastante grosero. Es lógico agregarlo, pero seré sincero:
no será fácil.
De hecho, no invocamos directamente ninguna biblioteca libc en el artículo . Hay varios adecuados, especialmente
glibc ,
musl y
dietlibc . Sin embargo, se supone que la mayoría de estas bibliotecas se ejecutan en el sistema operativo POSIX, que implementa un cierto conjunto de llamadas al sistema. Como no tenemos una interfaz de kernel en JavaScript, tendremos que implementar estas llamadas al sistema POSIX nosotros mismos, probablemente a través de JavaScript. Esta es una tarea difícil y no voy a hacerlo aquí. La buena noticia es que
esto es lo que Emscripten hace por usted .
Por supuesto, no todas las funciones de libc dependen de llamadas al sistema. Las funciones como
strlen()
,
sin()
o incluso
memset()
se implementan en C. simple, lo que significa que puede usar estas funciones o incluso simplemente copiar / pegar su implementación desde alguna de las bibliotecas mencionadas.
Memoria dinámica
Sin libc, las interfaces fundamentales de C como
malloc()
y
free()
no están disponibles para nosotros. En el WAT no optimizado, vimos que el compilador usa memoria si es necesario. Esto significa que no podemos usar la memoria como queramos, sin arriesgarnos a dañarla. Necesita entender cómo se usa.
Modelos de memoria LLVM
El método de segmentación de memoria de WebAssembly sorprenderá un poco a los programadores experimentados. En primer lugar, en WebAssembly, una dirección nula es técnicamente admisible, pero a menudo todavía se trata como un error. En segundo lugar, la pila viene primero y crece hacia abajo (a direcciones más bajas), y el montón aparece más tarde y crece. La razón es que la memoria de WebAssembly puede aumentar en tiempo de ejecución. Esto significa que no hay un extremo fijo para acomodar la pila o el montón.
Aquí está el diseño
wasm-ld
:
La pila crece y el montón crece. La pila comienza con __data_end
, y el montón __heap_base
con __heap_base
. Debido a que la pila se coloca primero, está limitada por el tamaño máximo establecido durante la compilación, es decir, __heap_base
menos __data_end
Si volvemos y miramos la sección global en nuestro WAT, encontramos estos valores:
__heap_base
establece en 66560, y
__data_end
se establece en 1024. Esto significa que la pila puede crecer hasta un máximo de 64 KiB, lo cual no es mucho. Afortunadamente,
wasm-ld
permite cambiar este valor:
clang \ --target=wasm32 \ -O3 \ -flto \ -nostdlib \ -Wl,--no-entry \ -Wl,--export-all \ -Wl,--lto-O3 \ + -Wl,-z,stack-size=$[8 * 1024 * 1024] \
Conjunto de asignación
Se sabe que el área de montón comienza con
__heap_base
. Como falta la función
malloc()
, sabemos que la siguiente área de memoria se puede usar de forma segura. Podemos colocar los datos allí como queramos, y no hay necesidad de temer la corrupción de la memoria, ya que la pila crece en la otra dirección. Sin embargo, un montón que es gratuito para todos puede obstruirse rápidamente, por lo que generalmente se requiere algún tipo de administración de memoria dinámica. Una opción es tomar una implementación completa de malloc (), como
la implementación de Dall Lee de malloc , que se usa en Emscripten. Hay varias implementaciones más pequeñas con varias compensaciones.
Pero, ¿por qué no escribes tu propio
malloc()
? Estamos tan empantanados que no hay diferencia. Uno de los más simples es un asignador de protuberancias: es súper rápido, extremadamente pequeño y fácil de implementar. Pero hay un inconveniente: no puedes liberar memoria. Aunque a primera vista, un asignador de este tipo parece increíblemente inútil, pero al desarrollar
Squoosh, encontré precedentes en los que sería una excelente opción. El concepto de un asignador de protuberancias es que almacenamos la dirección inicial de la memoria no utilizada como global. Si el programa solicita
n
bytes de memoria, movemos el marcador a
n
y devolvemos el valor anterior:
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) {
Las variables globales de WAT en realidad están definidas por
wasm-ld
, de modo que podemos acceder a ellas desde nuestro código C como variables ordinarias si las declaramos
extern
. Entonces,
acabamos de escribir nuestro propio malloc()
... en cinco líneas de C.Nota: nuestro asignador de golpes no es totalmente compatible con malloc()
de C. Por ejemplo, no ofrecemos ninguna garantía de alineación. Pero funciona lo suficientemente bien, así que ...
Uso de memoria dinámica
Para probar, hagamos una función C, que toma una matriz de números de tamaño arbitrario y calcula la suma. No es muy interesante, pero esto nos obliga a usar memoria dinámica, ya que no conocemos el tamaño de la matriz durante el ensamblaje:
int sum(int a[], int len) { int sum = 0; for(int i = 0; i < len; i++) { sum += a[i]; } return sum; }
La función sum (), con suerte, es bastante sencilla. Una pregunta más interesante es cómo pasar una matriz de JavaScript a WebAssembly; después de todo, WebAssembly solo comprende los números. La idea general es usar
malloc()
de JavaScript para asignar un trozo de memoria, copiar los valores allí y pasar la dirección (¡número!)
Donde se encuentra
la matriz:
<!DOCTYPE html> <script type="module"> async function init() { const { instance } = await WebAssembly.instantiateStreaming( fetch("./add.wasm") ); const jsArray = [1, 2, 3, 4, 5]; </script>
Después de comenzar, debería ver la respuesta 15 en la consola de DevTools, que es realmente la suma de todos los números del 1 al 5.
Conclusión
Entonces, lees hasta el final. Felicidades Nuevamente, si te sientes un poco sobrecargado, todo está en orden.
No es necesario leer todos los detalles. Comprenderlos es completamente opcional para un buen desarrollador web y ni siquiera es necesario para el excelente uso de WebAssembly . Pero quería compartir esta información, porque te permite apreciar realmente todo el trabajo que un proyecto como
Emscripten hace por ti. Al mismo tiempo, esto permite comprender cuán pequeños pueden ser los módulos puramente computacionales de WebAssembly. El módulo Wasm para sumar la matriz tiene solo 230 bytes de tamaño,
incluido un asignador de memoria dinámica . Compilar el mismo código con Emscripten producirá 100 bytes de código de WebAssembly y código de enlace de 11K JavaScript. Debe intentar por el resultado, pero hay situaciones en las que vale la pena.