Hace mucho tiempo, por diversión, decidí demostrar la reversibilidad del proceso y aprender a generar JavaScript (más específicamente, Asm.js) a partir de código máquina. Para el experimento, se eligió QEMU, y algún tiempo después se escribió un artículo en Habr. En los comentarios, me sugirieron que rehiciera el proyecto en WebAssembly, pero no quería dejar casi terminado el proyecto... El trabajo avanzaba, pero de manera muy lenta, y recientemente apareció en ese artículo un tema que decía '¿Y cómo terminó todo?'. Ante mi respuesta detallada, escuché: 'Eso merece un artículo'. Bueno, si lo merece, entonces habrá un artículo. Quizás le sea útil a alguien. En él, el lector conocerá algunos hechos sobre cómo está estructurado el backend de la generación de código en QEMU, así como cómo escribir un compilador Just-in-Time para aplicaciones web. Dado que ya había aprendido a 'portar' QEMU a JavaScript de manera 'medianamente' efectiva, esta vez se decidió hacerlo correctamente y no repetir viejos errores.
Tareas
Error número uno: desviarse de la versión point release
Mi primer error fue desvincular mi versión de la versión upstream 2.4.1. En ese momento, me parecía una buena idea: si existe un point release, significa que probablemente sea más estable que el simple 2.4, y mucho menos que la rama
. Y como planeaba agregar una cantidad considerable de mis propios errores, los ajenos realmente no me interesaban. Así fue como resultó, probablemente. Pero el problema fue que QEMU no se queda quieto, y en algún momento incluso anunciaron una optimización del código generado del 10%. 'Ajá, ahora lo integraré', pensé y me estrellé. Aquí es necesario hacer un paréntesis: debido a la naturaleza monohilo de QEMU.js y el hecho de que el QEMU original no contempla la falta de multihilo (es decir, es crítica la posibilidad de que varios caminos de código independientes funcionen al mismo tiempo, no simplemente 'usar todos los núcleos'), las funciones principales de los hilos tuvieron que ser 'invertidas' para permitir su llamada desde fuera. Esto creó algunos problemas naturales al fusionar. Sin embargo, el hecho de que parte de los cambios de la rama master, desde la que intentaba fusionar mi código, también fueran cherry picked en el point release (y, por lo tanto, en mi rama) tampoco, probablemente, añadiera comodidad. masterEn general, decidí que de todas formas tenía sentido descartar el prototipo, desarmarlo en piezas y construir una nueva versión desde cero basada en algo más reciente y ahora ya de
Error número dos: metodología TLP master.
Error número tres: falta de documentación
En esencia, esto no es un error, en realidad es simplemente una característica de la creación de un proyecto en condiciones de completo desconocimiento sobre "¿a dónde y cómo avanzar?", y en general "¿llegaremos?". En estas condiciones la programación a lo loco era una opción justificada, pero, por supuesto, no quería repetirlo sin necesidad. Esta vez quería hacerlo bien: commits atómicos, cambios de código conscientes (y no "unir caracteres aleatorios hasta que compile (con advertencias)", como dijo alguna vez Linus Torvalds, si se cree a Wikiquote) y así sucesivamente.
Error número tres: no saber cómo nadar y meterse al agua
De esto aún no me he librado del todo, pero ahora he decidido no ir por el camino de la menor resistencia, y hacer "de manera adulta", es decir, escribir mi backend de TCG desde cero, para no tener que decir después "Sí, es cierto, es lento, pero no puedo controlar todo — así está escrito el TCI...". Además, al principio parecía una solución obvia, dado que genero código binario. Como se suele decir, "Recogí Gent, pero no el correcto": el código es, por supuesto, binario, pero no se puede simplemente transferir el control — es necesario enviarlo explícitamente al navegador para compilarlo, obteniendo como resultado un objeto del mundo de JS, que aún necesita ser guardado en algún lugar. Sin embargo, en arquitecturas RISC normales, hasta donde entiendo, una situación típica es la necesidad de restablecer explícitamente la caché de instrucciones para el código regenerado — si esto no es exactamente lo que necesitamos, al menos se acerca. Además, de mi intento anterior aprendí que el control no se transmite al medio del bloque de traducción, por lo que el bytecode, interpretado desde cualquier desplazamiento, no es particularmente útil, y se puede generar simplemente por función en TB.sa-logic-subsets-canary-vs.yamlLlegaron y dieron una patada
Aunque comencé a reescribir el código en julio, la patada mágica llegó sin que me diera cuenta: normalmente, los correos de GitHub llegan como notificaciones de respuestas a Issues y Pull requests, pero aquí,
una mención en el hilo sorpresivamente Binaryen como un backend de qemu Binaryen modificar de alguna manera arreglar de alguna manera (no sé: tal vez cambiar, tal vez doble licencia, tal vez algo más…). Esto me alegró, por supuesto, porque ya había estado observando WebAssembly, y me sentía algo triste y confundido. Sin embargo, aquí había una biblioteca que podría procesar los bloques básicos con el grafo de transiciones, generar el bytecode y, si era necesario, incluso ejecutarlo en un intérprete.
Luego también hubo en la lista de correos de QEMU, pero eso se relaciona más con la pregunta: '¿A quién realmente le importa?'. Y resulta que sorpresivamente, sí importa. Al menos, se pueden encontrar tales usos si funciona más o menos rápido:
- la ejecución de algo educativo sin instalación
- virtualización en iOS, donde, según rumores, la única aplicación permitida para generación de código en tiempo real es el motor JS (¿es esto verdad?)
- demostración de un mini-SO — de un solo disquete, embebidos, varios firmware, etc…
Características del entorno de ejecución en el navegador
Como ya mencioné, QEMU está vinculado a la multihilos, y en el navegador no hay. Bueno, es decir, como que no hay… Al principio no había en absoluto, luego aparecieron los WebWorkers — según entiendo, eso es multihilos basado en la transmisión de mensajes sin variables modificables en común. Naturalmente, esto crea problemas significativos al portar código existente basado en un modelo de memoria compartida. Después, bajo la presión del público, se implementó y apareció con el nombre de SharedArrayBuffers. Se introdujo gradualmente, celebraron su lanzamiento en diferentes navegadores, luego celebraron el año nuevo, y después Meltdown… Después de lo cual se llegó a la conclusión de que, independientemente de cómo se mida el tiempo, utilizando memoria compartida y un hilo que incremente un contador, aún . Así que desactivaron la multihilos con memoria compartida. Parece que luego la reactivaron, pero, como se entendió de la primera experiencia, incluso sin ello hay vida, y si es así, intentaremos hacerlo sin depender de la multihilos.
La segunda característica es la imposibilidad de realizar manipulaciones de bajo nivel con la pila: no se puede simplemente tomar, guardar el contexto actual y cambiar a uno nuevo con una nueva pila. La pila de llamadas es gestionada por la máquina virtual de JS. ¿Cuál es el problema, dado que hemos decidido manejar los antiguos hilos completamente de forma manual? El asunto es que la entrada/salida en bloque en QEMU se implementa a través de corrutinas, y aquí es donde realmente necesitaríamos manipulaciones de bajo nivel de la pila. Afortunadamente, Emscripten ya contiene un mecanismo para operaciones asincrónicas, incluso dos: y . El primero funciona mediante una considerable expansión del código JavaScript generado y ya no se admite. El segundo es el "método correcto" actual y funciona mediante la generación de bytecode para su propio intérprete. Funciona, por supuesto, lentamente, pero no expande el código. Sin embargo, se tuvo que contribuir al soporte de corrutinas para este mecanismo de manera independiente (ya había corrutinas escritas para Asyncify y una implementación aproximadamente del mismo API para Emterpreter, solo había que conectarlas).
En este momento, aún no he logrado dividir el código en lo que se compila a WASM y lo que se interpreta mediante Emterpreter, por lo que los dispositivos de bloque aún no funcionan (como se dice, estad atentos a los próximos episodios…). Es decir, en última instancia, debería resultar algo así como este curioso objeto estratificado:
- entrada/salida en bloque interpretable. Bueno, ¿qué esperaban, realmente? ¿Un NVMe emulado con rendimiento nativo? 🙂
- código base QEMU compilado estáticamente (traductor, otros dispositivos emulados, etc.)
- código huésped compilable dinámicamente en WASM
Características de los fuentes de QEMU
Como ya habrán adivinado, el código de emulación de arquitecturas huéspedes y el código de generación de instrucciones de máquina del host en QEMU están separados. En realidad, hay un poco más de complejidad:
- hay arquitecturas huéspedes
- hay aceleradores, específicamente, KVM para virtualización de hardware en Linux (para sistemas de huéspedes y hosts compatibles entre sí), TCG para la generación de código JIT en cualquier lugar. Desde QEMU 2.9 se agregó soporte para el estándar de virtualización de hardware HAXM en Windows ()
- si se utiliza TCG en lugar de virtualización por hardware, tiene soporte separado para la generación de código bajo cada arquitectura de host, así como para un intérprete universal
- … y alrededor de todo esto, una periferia emulada, interfaz de usuario, migración, grabación-reproducción, etc.
Por cierto, ¿sabías que: QEMU puede emular no solo una computadora completa, sino también un procesador para un proceso de usuario individual en el núcleo del host, algo que utiliza, por ejemplo, AFL fuzzer para la instrumentación de binarios. Quizás alguien querrá portar este modo de operación de QEMU a JS? 😉
Como la mayoría de los programas libres que llevan tiempo, QEMU se compila mediante una llamada — simplemente ejecuto y make. Supongamos que decidiste agregar algo: un backend TCG, una implementación de hilos, algo más. No te apresures a alegrarte/o aterrorizarti (subrayar lo que sea necesario) ante la perspectiva de lidiar con Autoconf — en realidad, — simplemente ejecuto QEMU, al parecer, tiene un generador propio y no se genera de ninguna otra forma.
WebAssembly
Entonces, ¿qué es esto: WebAssembly (también conocido como WASM)? Es un reemplazo de Asm.js, que ya no finge ser código JavaScript válido. Por el contrario, es estrictamente binario y optimizado, y ni siquiera escribir un número entero en él es tan fácil: se almacena en un formato .
Quizás hayas oído hablar del algoritmo de relooping para Asm.js — esto implica la recuperación de instrucciones de control de flujo "de alto nivel" (es decir, if-then-else, bucles, etc.), que están diseñadas para los motores de JS, desde un LLVM IR de bajo nivel que está más cerca del código de máquina ejecutado por el procesador. Naturalmente, la representación intermedia de QEMU está más cerca de la segunda. Podría parecer que aquí está el bytecode, el fin de las angustias... ¡Y aquí están los bloques, if-then-else y bucles!...
Y esta es otra razón por la cual Binaryen es útil: naturalmente, puede aceptar bloques de alto nivel, similares a lo que se guardará en WASM. Pero también puede producir código a partir del gráfico de bloques básicos y transiciones entre ellos. Además, ya mencioné lo que oculta detrás de una API conveniente de C/C++ el formato de almacenamiento de WebAssembly.
TCG (Tiny Code Generator)
TCG backend para el compilador C. Luego, aparentemente, no pudo competir con GCC, pero al final encontró su lugar dentro de QEMU como un mecanismo de generación de código para la plataforma host. También hay un backend TCG que genera un bytecode abstracto, que inmediatamente es ejecutado por un intérprete, aunque decidí no usarlo esta vez. Sin embargo, el hecho de que QEMU ya tenga la opción de cambiar a la TB generada a través de la función tcg_qemu_tb_exec, me resultó muy oportuno.
Para agregar un nuevo backend TCG a QEMU, debe crearse un subdirectorio tcg/ (en este caso, tcg/binaryen), y dentro de él, dos archivos: tcg-target.h y tcg-target.inc.c y todo esto en — simplemente ejecuto. También se pueden agregar otros archivos, pero, como se puede deducir de los nombres de estos dos, ambos se incluirán en algún lugar: uno como un archivo de encabezado común (se incluye en tcg/tcg.h, y ese en otros archivos en los directorios tcg, accel y no solo), el otro solo como un fragmento de código en tcg/tcg.c, pero tiene acceso a sus funciones estáticas.
Decidí que iba a perder demasiado tiempo en averiguar en detalle cómo funcionaba, así que simplemente copié los "esqueletos" de estos dos archivos de otra implementación de backend, indicando honestamente esto en el encabezado de la licencia.
Archivo tcg-target.h contiene predominantemente configuraciones en forma de #define-s:
- cuántos registros y de qué ancho hay en la arquitectura objetivo (en nuestro caso, cuántos queramos, la cuestión es más bien qué se generará en un código más eficiente por el navegador en una arquitectura "realmente objetivo"...)
- alineación de las instrucciones host: en x86, y en TCI, las instrucciones no están alineadas en absoluto, yo planeo poner en el búfer de código no instrucciones en sí, sino punteros a estructuras de la biblioteca Binaryen, así que diré: 4 bytes
- qué instrucciones opcionales puede generar el backend — incluimos todo lo que encontremos en Binaryen, el resto que el acelerador lo descomponga en partes más simples.
- ¿Cuál es aproximadamente el tamaño del caché TLB que solicita el backend? La cuestión es que en QEMU todo se toma en serio: aunque hay funciones de ayuda que realizan carga/almacenamiento considerando el MMU invitado (¿y adónde ir sin él?), su caché de traducción se guarda en forma de una estructura, cuyo procesamiento se puede integrar fácilmente en los bloques de traducción. La pregunta es, ¿qué desplazamiento en esta estructura se procesa de manera más eficiente con una pequeña y rápida secuencia de comandos?
- Aquí también se puede ajustar la asignación de uno o dos registros reservados, habilitar la llamada de TB a través de una función y, opcionalmente, describir un par de pequeñas
inline-funciones comoflush_icache_range(pero este no es nuestro caso)
Archivo tcg-target.inc.c, naturalmente, es normalmente mucho más grande y contiene varias funciones obligatorias:
- inicialización, que indica, entre otras cosas, las restricciones sobre qué instrucciones pueden operar con qué operandos. Copié descaradamente esto de otro backend.
- una función que toma una instrucción de bytecode interno
- también se pueden colocar funciones auxiliares aquí, y además se pueden utilizar funciones estáticas de
tcg/tcg.c
Para mí, elegí la siguiente estrategia: en las primeras palabras de cada bloque de traducción, escribí cuatro punteros: una marca de inicio (un cierto valor en los alrededores de 0xFFFFFFFF, que determinaba el estado actual de TB), el contexto, el módulo generado y un número mágico para depuración. Al principio, la marca se establecía en 0xFFFFFFFF - n, donde n — un pequeño número positivo, y al ejecutar a través del intérprete aumentaba en 1. Cuando llegaba a 0xFFFFFFFE, se producía la compilación, el módulo se guardaba en la tabla de funciones, importada a un pequeño "iniciador", al que se redirigía la ejecución desde tcg_qemu_tb_exec, y el módulo se eliminaba de la memoria de QEMU.
Parafraseando un clásico, "La broma, cuán mucho en este sonido se ha entrelazado para el corazón del programador...". Sin embargo, la memoria se escapaba a algún lado. Además, ¡era memoria gestionada por QEMU! Tenía un código que al escribir una nueva instrucción (bueno, es decir, un puntero) eliminaba la que antes apuntaba a esa ubicación, pero eso no ayudaba. De hecho, en el caso más simple, QEMU asigna memoria al inicio y escribe el código generado allí. Cuando el búfer termina, el código se elimina y en su lugar comienza a escribirse el siguiente.
Al estudiar el código, entendí que el truco con el número mágico permitía evitar el fallo por una destrucción de pila al liberar algo incorrecto en un buffer no inicializado durante el primer paso. Pero, ¿quién sobrescribe el buffer ignorando mi función después? Como sugieren los desarrolladores de Emscripten, al enfrentarme a un problema, porté el código resultante de nuevo a una aplicación nativa y lo sometí a Mozilla Record-Replay… En definitiva, entendí una cosa sencilla: para cada bloque se reserva struct TranslationBlock con su descripción. Adivina dónde... Correcto, justo antes del bloque en el buffer. Al darme cuenta de esto, decidí dejar de lado los trucos (aunque sea algunos) y simplemente eliminé el número mágico, trasladando las palabras restantes a struct TranslationBlock, creando una lista vinculada por la que se puede recorrer rápidamente al limpiar el caché de traducción y liberar memoria.
Algunos trucos permanecieron: por ejemplo, los punteros marcados en el buffer de código, parte de ellos simplemente son BinaryenExpressionRef, es decir, apuntan a las expresiones que deben ser colocadas linealmente en el bloque base generado. Parte de ellas representan una condición de transición entre bloques, y parte indican a dónde saltar. Además, ya hay bloques preparados para Relooper que deben ser conectados según las condiciones. Para diferenciarlos, se utiliza una suposición de que todos están alineados al menos a cuatro bytes, por lo que se pueden utilizar los dos bits menos significativos para la etiqueta, solo hay que recordar quitarla cuando sea necesario. Por cierto, estas etiquetas ya se utilizan en QEMU para indicar la razón de salida del ciclo TCG.
Uso de Binaryen
Los módulos en WebAssembly contienen funciones, cada una de las cuales tiene un cuerpo que consiste en una expresión. Las expresiones son operaciones unarias y binarias, bloques que consisten en listas de otras expresiones, control de flujo, etc. Como ya mencioné, el control de flujo aquí se organiza efectivamente como ramificaciones de alto nivel, ciclos, llamadas a funciones, etc. Los argumentos se pasan a las funciones no en la pila, sino explícitamente, al igual que en JS. Hay variables globales, pero no las utilicé, así que no hablaré de ellas.
Además, las funciones tienen variables locales numeradas desde cero, que pueden ser de tipo: int32 / int64 / float / double. Ten en cuenta que, aunque aquí todo no es del todo de bajo nivel en términos de flujo de control, los números enteros aún no tienen el atributo "con signo/sin signo": el comportamiento de un número depende del código de operación.
En general, Binaryen proporciona : se crea un módulo, en él se crean expresiones: unarias, binarias, bloques de otras expresiones, flujo de control, etc. Luego se crea una función, en la que hay que especificar una expresión como cuerpo. Si tienes, como yo, un gráfico de transición de bajo nivel, el componente relooper te será útil. Hasta donde entiendo, se puede usar un control de flujo de alto nivel en un bloque siempre que no salga de los límites del bloque, es decir, puedes hacer ramificaciones internas de camino rápido / camino lento dentro del código embebido de procesamiento de caché TLB, pero no puedes interferir en el flujo de control "externo". Cuando liberáis el relooper, se liberan sus bloques, cuando liberáis el módulo, desaparecen las expresiones, funciones, etc., que se asignaron en su arena..
Sin embargo, si deseas interpretar el código en tiempo real sin crear y eliminar instancias del intérprete innecesariamente, puede tener sentido extraer esta lógica a un archivo en C++, y desde allí gestionar directamente toda la API de C++ de la biblioteca, pasando por alto los envoltorios establecidos.
Por lo tanto, para generar código, es necesario
// настроить глобальные параметры (можно поменять потом)
BinaryenSetAPITracing(0);
BinaryenSetOptimizeLevel(3);
BinaryenSetShrinkLevel(2);
// создать модуль
BinaryenModuleRef MODULE = BinaryenModuleCreate();
// описать типы функций (как создаваемых, так и вызываемых)
helper_type BinaryenAddFunctionType(MODULE, "helper-func", BinaryenTypeInt32(), int32_helper_args, ARRAY_SIZE(int32_helper_args));
// (int23_helper_args приоб^Wсоздаются отдельно)
// сконструировать супер-мега выражение
// ... ну тут уж вы как-нибудь сами :)
// потом создать функцию
BinaryenAddFunction(MODULE, "tb_fun", tb_func_type, func_locals, FUNC_LOCALS_COUNT, expr);
BinaryenAddFunctionExport(MODULE, "tb_fun", "tb_fun");
...
BinaryenSetMemory(MODULE, (1 << 15) - 1, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
BinaryenAddMemoryImport(MODULE, NULL, "env", "memory", 0);
BinaryenAddTableImport(MODULE, NULL, "env", "tb_funcs");
// запросить валидацию и оптимизацию при желании
assert (BinaryenModuleValidate(MODULE));
BinaryenModuleOptimize(MODULE);… si olvidé algo, disculpa, esto es solo para representar las dimensiones, los detalles están en la documentación.
Y ahora comienza el kréks-féks-péks, más o menos así:
static char buf[1 << 20];
BinaryenModuleOptimize(MODULE);
BinaryenSetMemory(MODULE, 0, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
int sz = BinaryenModuleWrite(MODULE, buf, sizeof(buf));
BinaryenModuleDispose(MODULE);
EM_ASM({
var module = new WebAssembly.Module(new Uint8Array(wasmMemory.buffer, $0, $1));
var fptr = $2;
var instance = new WebAssembly.Instance(module, {
'env': {
'memory': wasmMemory,
// ...
}
);
// y ya tienes una instancia!
}, buf, sz);Para conectar el mundo de QEMU y JS de alguna manera y acceder rápidamente a las funciones compiladas, se creó un arreglo (tabla de funciones para importar en el lanzador), donde se colocaban las funciones generadas. Para calcular rápidamente el índice, inicialmente se utilizó el índice de la palabra cero del bloque de traducción, pero luego el índice calculado por esa fórmula se insertó simplemente en el campo en struct TranslationBlock.
Por cierto, (por ahora con una licencia poco clara) funciona correctamente solo en Firefox. Los desarrolladores de Chrome estaban algo no preparados para que alguien quisiera crear más de mil instancias de módulos WebAssembly, por lo que simplemente asignaban un gigabyte de espacio de dirección virtual para cada uno...
Por ahora, eso es todo. Quizás haya otro artículo si es de interés. Es decir, queda al menos solo lograr que los dispositivos de bloques funcionen. Tal vez tenga sentido hacer que la compilación de los módulos WebAssembly sea asíncrona, como se hace en el mundo de JS, ya que hay un intérprete que puede ejecutar todo esto mientras el módulo nativo no está listo.
Por último, una pregunta: has compilado un binario en una arquitectura de 32 bits, pero el código mediante operaciones de memoria se sale de Binaryen, hacia la pila o a alguna otra parte en los 2 GB superiores del espacio de direcciones de 32 bits. El problema es que desde la perspectiva de Binaryen, esta referencia es a una dirección resultante demasiado grande. ¿Cómo se puede evitar esto?
Desde un punto de vista administrativo
Yo no lo testé al final, pero la primera idea fue: '¿Y si instalo Linux de 32 bits?' Entonces, la parte superior del espacio de direcciones estará ocupada por el núcleo. La única pregunta es cuánto estará ocupado: ¿1 o 2 Gb?
Desde un punto de vista programador (opción para practicantes)
Haremos un globo en la parte superior del espacio de direcciones. Yo mismo no entiendo por qué funciona: ahí (envío de datos). debería haber una pila. Pero 'somos prácticos: a nosotros todo nos funciona, pero nadie sabe por qué...'.
// 2gbubble.c
// Usage: LD_PRELOAD=2gbubble.so <program>
#include <sys/mman.h>
#include <assert.h>
void __attribute__((constructor)) constr(void)
{
assert(MAP_FAILED != mmap(1u >> 31, (1u >> 31) - (1u >> 20), PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
}… con Valgrind, sin embargo, no es compatible, pero afortunadamente Valgrind mismo expulsará eficientemente a todos desde allí 🙂
Quizás alguien dé una mejor explicación de cómo funciona este código mío...
Fuente: habr.com
