Compilación de C en WebAssembly sin Emscripten

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: # …,  … 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 

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:

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

¡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 \ # 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 

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 # 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> 

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 \ # We don't have an entry function --export-all \ # Export everything (for now) -o add.wasm \ add.o 

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 \ # 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 

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 \ # 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 

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] \ # Set maximum stack size to 8MiB -o add.wasm \ add.c 

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) { // lol } 

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]; // 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> 

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.

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


All Articles