QEMU.js: ahora en serio y con WASM

Érase una vez lo decidí por diversión. demostrar la reversibilidad del proceso y aprenda cómo generar JavaScript (más precisamente, Asm.js) a partir de código de máquina. Se eligió QEMU para el experimento y algún tiempo después se escribió un artículo sobre Habr. En los comentarios me aconsejaron rehacer el proyecto en WebAssembly e incluso dejarlo yo mismo. casi terminado De alguna manera no quería el proyecto... El trabajo avanzaba, pero muy lentamente, y ahora, recientemente, apareció en ese artículo. comentario sobre el tema "¿Cómo terminó todo?" En respuesta a mi respuesta detallada, escuché "Esto suena como un artículo". Bueno, si puedes, habrá un artículo. Quizás alguien lo encuentre útil. De él, el lector aprenderá algunos datos sobre el diseño de los servidores de generación de código QEMU, así como también cómo escribir un compilador Just-in-Time para una aplicación web.

Tareas

Como ya había aprendido cómo "de alguna manera" portar QEMU a JavaScript, esta vez decidí hacerlo sabiamente y no repetir viejos errores.

Error número uno: bifurcar desde el punto de liberación

Mi primer error fue bifurcar mi versión de la versión 2.4.1. Entonces me pareció una buena idea: si existe una versión puntual, entonces probablemente sea más estable que el simple 2.4, y más aún la rama. master. Y como planeaba agregar una buena cantidad de mis propios errores, no necesitaba los de nadie más. Probablemente así resultó. Pero aquí está la cuestión: QEMU no se queda quieto, y en algún momento incluso anunciaron una optimización del código generado en un 10 por ciento: "Sí, ahora me voy a congelar", pensé y me derrumbé. Aquí debemos hacer una digresión: debido a la naturaleza de un solo subproceso de QEMU.js y al hecho de que el QEMU original no implica la ausencia de subprocesos múltiples (es decir, la capacidad de operar simultáneamente varias rutas de código no relacionadas, y no solo “usar todos los núcleos”) es fundamental para ello, las funciones principales de los hilos tuve que “desactivarlas” para poder llamar desde afuera. Esto creó algunos problemas naturales durante la fusión. Sin embargo, el hecho de que algunos de los cambios de la rama master, con el que intenté fusionar mi código, también fueron seleccionados en la versión puntual (y por lo tanto en mi rama) y probablemente tampoco habrían agregado comodidad.

En general, decidí que todavía tiene sentido tirar el prototipo, desmontarlo en piezas y construir una nueva versión desde cero basada en algo más nuevo y ahora desde master.

Error número dos: metodología TLP

En esencia, esto no es un error, en general, es solo una característica de crear un proyecto en condiciones de completo malentendido tanto de "¿dónde y cómo moverse?", como en general "¿llegaremos allí?". En estas condiciones programación torpe Era una opción justificada, pero, naturalmente, no quería repetirla innecesariamente. Esta vez quería hacerlo sabiamente: confirmaciones atómicas, cambios de código conscientes (y no "encadenar caracteres aleatorios hasta que se compila (con advertencias)", como dijo una vez Linus Torvalds sobre alguien, según Wikiquote), etc.

Error número tres: meterse al agua sin conocer el vado

Todavía no me he deshecho completamente de esto, pero ahora he decidido no seguir el camino de menor resistencia y hacerlo de la “forma adulta”, es decir, escribir mi backend de TCG desde cero, para no tener que decir más tarde: "Sí, esto es, por supuesto, lentamente, pero no puedo controlarlo todo; así es como se escribe TCI..." Además, esto inicialmente parecía una solución obvia, ya que genero código binario. Como dicen, "Gante reunióу, pero no ese”: el código es, por supuesto, binario, pero el control no se le puede simplemente transferir: debe insertarse explícitamente en el navegador para su compilación, lo que da como resultado un determinado objeto del mundo JS, que aún necesita ser compilado. ser salvo en alguna parte. Sin embargo, en arquitecturas RISC normales, hasta donde tengo entendido, una situación típica es la necesidad de restablecer explícitamente el caché de instrucciones para el código regenerado; si esto no es lo que necesitamos, entonces, en cualquier caso, está cerca. Además, en mi último intento, aprendí que el control no parece transferirse a la mitad del bloque de traducción, por lo que realmente no necesitamos interpretar el código de bytes desde ningún desplazamiento, y simplemente podemos generarlo desde la función en TB. .

Ellos vinieron y patearon

Aunque comencé a reescribir el código en julio, un efecto mágico pasó desapercibido: generalmente las cartas de GitHub llegan como notificaciones sobre respuestas a problemas y solicitudes de extracción, pero aquí, de repente mencionar en el hilo Binaryen como backend de qemu en el contexto, "Él hizo algo así, tal vez diga algo". Estábamos hablando de usar la biblioteca relacionada de Emscripten. binario para crear WASM JIT. Bueno, dije que tienes una licencia Apache 2.0 allí, y QEMU en su conjunto se distribuye bajo GPLv2, y no son muy compatibles. De repente resultó que una licencia puede ser arreglarlo de alguna manera (No lo sé: tal vez cambiarlo, tal vez una doble licencia, tal vez algo más...). Esto, por supuesto, me hizo feliz, porque en ese momento ya había mirado de cerca formato binario WebAssembly, y de alguna manera me sentí triste e incomprensible. También había una biblioteca que devoraría los bloques básicos con el gráfico de transición, produciría el código de bytes e incluso lo ejecutaría en el propio intérprete, si fuera necesario.

Luego hubo más письмо en la lista de correo de QEMU, pero se trata más bien de la pregunta: "¿Quién lo necesita de todos modos?" Y es de repente, resultó que era necesario. Como mínimo, puedes reunir las siguientes posibilidades de uso, si funciona más o menos rápido:

  • Lanzar algo educativo sin ninguna instalación.
  • virtualización en iOS, donde, según los rumores, la única aplicación que tiene derecho a generar código sobre la marcha es un motor JS (¿es cierto?)
  • demostración de mini-OS: de un solo disquete, integrado, todo tipo de firmware, etc.

Funciones de tiempo de ejecución del navegador

Como ya dije, QEMU está vinculado al subproceso múltiple, pero el navegador no lo tiene. Bueno, es decir, no... Al principio no existía en absoluto, luego apareció WebWorkers; hasta donde tengo entendido, esto es un subproceso múltiple basado en el paso de mensajes. sin variables compartidas. Naturalmente, esto crea problemas importantes al portar código existente basado en el modelo de memoria compartida. Luego, bajo presión pública, se implementó bajo el nombre SharedArrayBuffers. Se introdujo gradualmente, celebraron su lanzamiento en diferentes navegadores, luego celebraron el Año Nuevo y luego Meltdown... Después de lo cual llegaron a la conclusión de que la medición del tiempo es tosca o tosca, pero con la ayuda de la memoria compartida y un hilo incrementando el contador, es todo lo mismo funcionará con bastante precisión. Entonces deshabilitamos el subproceso múltiple con memoria compartida. Parece que luego lo volvieron a encender, pero, como quedó claro en el primer experimento, hay vida sin él y, si es así, intentaremos hacerlo sin depender de subprocesos múltiples.

La segunda característica es la imposibilidad de 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 administrada por la máquina virtual JS. Al parecer, ¿cuál es el problema, ya que decidimos gestionar los flujos anteriores de forma totalmente manual? El hecho es que la E/S de bloque en QEMU se implementa mediante corrutinas, y aquí es donde las manipulaciones de pila de bajo nivel serían útiles. Afortunadamente, Emscipten ya contiene un mecanismo para operaciones asincrónicas, incluso dos: asincronizar и Emterprete. El primero funciona a través de una hinchazón significativa en el código JavaScript generado y ya no es compatible. La segunda es la "forma correcta" actual y funciona mediante la generación de código de bytes para el intérprete nativo. Funciona, por supuesto, lentamente, pero no sobrecarga el código. Es cierto que el soporte de corrutinas para este mecanismo tenía que aportarse de forma independiente (ya había corrutinas escritas para Asyncify y había una implementación de aproximadamente la misma API para Emterpreter, solo era necesario conectarlas).

Por el momento, todavía no he logrado dividir el código en uno compilado en WASM e interpretado usando Emterpreter, por lo que los dispositivos de bloque aún no funcionan (ver en la próxima serie, como dicen...). Es decir, al final deberías obtener algo como esto divertido en capas:

  • E/S de bloque interpretadas. Bueno, ¿realmente esperabas NVMe emulado con rendimiento nativo? 🙂
  • Código QEMU principal compilado estáticamente (traductor, otros dispositivos emulados, etc.)
  • código de invitado compilado dinámicamente en WASM

Características de las fuentes QEMU.

Como probablemente ya habrás adivinado, el código para emular arquitecturas invitadas y el código para generar instrucciones de la máquina host están separados en QEMU. De hecho, es incluso un poco más complicado:

  • hay arquitecturas invitadas
  • есть aceleradores, concretamente, KVM para virtualización de hardware en Linux (para sistemas invitados y host compatibles entre sí), TCG para generación de código JIT en cualquier lugar. A partir de QEMU 2.9, apareció soporte para el estándar de virtualización de hardware HAXM en Windows (Detalles)
  • Si se utiliza TCG en lugar de virtualización de hardware, entonces tiene soporte de generación de código independiente para cada arquitectura de host, así como para el intérprete universal.
  • ... y en torno a todo esto: periféricos emulados, interfaz de usuario, migración, reproducción de grabaciones, etc.

Por cierto, ¿sabías que? QEMU puede emular no solo toda la computadora, sino también el procesador para un proceso de usuario separado en el kernel del host, que es utilizado, por ejemplo, por el fuzzer AFL para instrumentación binaria. ¿Quizás a alguien le gustaría migrar este modo de operación de QEMU a JS? 😉

Como la mayoría del software libre de larga data, QEMU se construye mediante la llamada configure и make. Digamos que decides agregar algo: un backend de TCG, implementación de subprocesos, algo más. No se apresure a sentirse feliz/horrorizado (subraye según corresponda) ante la perspectiva de comunicarse con Autoconf; de hecho, configure QEMU aparentemente está escrito por sí mismo y no se genera a partir de nada.

WebAssembly

Entonces, ¿qué es esto llamado WebAssembly (también conocido como WASM)? Este es un reemplazo de Asm.js y ya no pretende ser un código JavaScript válido. Por el contrario, es puramente binario y optimizado, e incluso simplemente escribir un número entero en él no es muy simple: para que sea compacto, se almacena en el formato LEB128.

Es posible que haya oído hablar del algoritmo de repetición de bucles para Asm.js: esta es la restauración de instrucciones de control de flujo de "alto nivel" (es decir, bucles if-then-else, etc.), para las cuales están diseñados los motores JS, desde el LLVM IR de bajo nivel, más cercano al código de máquina ejecutado por el procesador. Naturalmente, la representación intermedia de QEMU se acerca más a la segunda. Parecería que aquí está, en código de bytes, el final del tormento... ¡Y luego están los bloques, if-then-else y loops!..

Y esta es otra razón por la que Binaryen es útil: naturalmente puede aceptar bloques de alto nivel cercanos a los que se almacenarían en WASM. Pero también puede producir código a partir de un gráfico de bloques básicos y transiciones entre ellos. Bueno, ya he dicho que oculta el formato de almacenamiento WebAssembly detrás de la conveniente API C/C++.

TCG (generador de código pequeño)

TCG fue original backend para el compilador de C. Entonces, aparentemente, no pudo resistir la competencia con GCC, pero al final encontró su lugar en QEMU como mecanismo de generación de código para la plataforma host. También hay un backend de TCG que genera un código de bytes abstracto, que el intérprete ejecuta inmediatamente, pero decidí evitar usarlo esta vez. Sin embargo, el hecho de que en QEMU ya sea posible habilitar la transición al TB generado a través de la función tcg_qemu_tb_exec, me resultó muy útil.

Para agregar un nuevo backend TCG a QEMU, necesita crear un subdirectorio tcg/<имя архитектуры> (en este caso, tcg/binaryen), y contiene dos archivos: tcg-target.h и tcg-target.inc.c и prescribir se trata de configure. Puedes poner otros archivos allí, pero, como puedes adivinar por los nombres de estos dos, ambos estarán incluidos en alguna parte: uno como un archivo de encabezado normal (está incluido en tcg/tcg.h, y ese ya está en otros archivos de 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.

Al decidir que dedicaría demasiado tiempo a investigaciones detalladas sobre cómo funciona, simplemente copié los "esqueletos" de estos dos archivos de otra implementación de backend, indicándolo honestamente en el encabezado de la licencia.

Expediente tcg-target.h contiene principalmente configuraciones en el formulario #define-s:

  • cuántos registros y qué ancho hay en la arquitectura de destino (tenemos tantos como queremos, tantos como queremos; la pregunta es más sobre qué generará el navegador en un código más eficiente en la arquitectura "completamente de destino"). ...)
  • alineación de las instrucciones del host: en x86, e incluso en TCI, las instrucciones no están alineadas en absoluto, pero voy a colocar en el búfer de código no instrucciones en absoluto, sino punteros a las estructuras de la biblioteca Binaryen, así que diré: 4 bytes
  • qué instrucciones opcionales puede generar el backend: incluimos todo lo que encontramos en Binaryen, dejamos que el acelerador divida el resto en otras más simples
  • ¿Cuál es el tamaño aproximado de la caché TLB solicitada por el backend? El hecho es que en QEMU todo es serio: aunque existen funciones auxiliares que realizan la carga/almacenamiento teniendo en cuenta la MMU invitada (¿dónde estaríamos sin ella ahora?), guardan su caché de traducción en forma de estructura, la cuyo procesamiento es conveniente para incrustar directamente en bloques de transmisión. La pregunta es, ¿qué compensación en esta estructura se procesa de manera más eficiente mediante una secuencia pequeña y rápida de comandos?
  • aquí puede modificar el propósito de uno o dos registros reservados, habilitar la llamada a TB a través de una función y, opcionalmente, describir un par de pequeños inline-funciones como flush_icache_range (pero este no es nuestro caso)

Expediente tcg-target.inc.c, por supuesto, suele ser mucho más grande y contiene varias funciones obligatorias:

  • inicialización, incluidas restricciones sobre qué instrucciones pueden operar y en qué operandos. Copiado descaradamente por mí desde otro backend
  • función que toma una instrucción de código de bytes interno
  • También puedes poner funciones auxiliares aquí, y también puedes usar funciones estáticas desde tcg/tcg.c

Para mí, elegí la siguiente estrategia: en las primeras palabras del siguiente bloque de traducción, escribí cuatro indicaciones: una marca de inicio (un valor determinado en las proximidades 0xFFFFFFFF, que determinó el estado actual del TB), contexto, módulo generado y número mágico para la depuración. Al principio la marca se colocó en 0xFFFFFFFF - nDonde n - un pequeño número positivo, y cada vez que se ejecutó a través del intérprete aumentó en 1. Cuando alcanzó 0xFFFFFFFE, se realizó la compilación, el módulo se guardó en la tabla de funciones, se importó a un pequeño "lanzador", desde donde se realizó la ejecución. tcg_qemu_tb_exec, y el módulo se eliminó de la memoria QEMU.

Parafraseando a los clásicos, “Crutch, cuánto se entrelaza en este sonido para el corazón del proger…”. Sin embargo, el recuerdo se estaba escapando por alguna parte. Además, ¡era memoria administrada por QEMU! Tenía un código que, al escribir la siguiente instrucción (bueno, es decir, un puntero), eliminaba aquella cuyo enlace estaba antes en este lugar, pero esto no ayudó. En realidad, en el caso más simple, QEMU asigna memoria al inicio y escribe allí el código generado. Cuando se agota el búfer, el código se descarta y el siguiente comienza a escribirse en su lugar.

Después de estudiar el código, me di cuenta de que el truco con el número mágico me permitía no fallar en la destrucción del montón al liberar algo incorrecto en un búfer no inicializado en la primera pasada. ¿Pero quién reescribe el búfer para omitir mi función más adelante? Como aconsejan los desarrolladores de Emscripten, cuando tuve un problema, porté el código resultante a la aplicación nativa, configuré Mozilla Record-Replay en él... En general, al final me di cuenta de una cosa simple: para cada bloque, a struct TranslationBlock con su descripción. Adivina dónde... Así es, justo antes del bloque justo en el buffer. Al darme cuenta de esto, decidí dejar de usar muletas (al menos algunas), simplemente tiré el número mágico y transfirí las palabras restantes a struct TranslationBlock, creando una lista enlazada individualmente que se puede recorrer rápidamente cuando se restablece el caché de traducción y liberar memoria.

Quedan algunas muletas: por ejemplo, punteros marcados en el búfer de código; algunos de ellos son simplemente BinaryenExpressionRef, es decir, observan las expresiones que deben colocarse linealmente en el bloque básico generado, parte es la condición para la transición entre BB y parte es hacia dónde ir. Bueno, ya hay bloques preparados para Relooper que deben conectarse según las condiciones. Para distinguirlos, se supone que todos están alineados por al menos cuatro bytes, por lo que puede usar con seguridad los dos bits menos significativos para la etiqueta, solo debe recordar eliminarlos si es necesario. Por cierto, estas etiquetas ya se utilizan en QEMU para indicar el motivo de la salida del bucle TCG.

Usando binario

Los módulos de WebAssembly contienen funciones, cada una de las cuales contiene un cuerpo, que es una expresión. Las expresiones son operaciones unarias y binarias, bloques que constan de listas de otras expresiones, flujo de control, etc. Como ya dije, el flujo de control aquí está organizado precisamente como ramas de alto nivel, bucles, llamadas a funciones, etc. Los argumentos de las funciones no se pasan a la pila, sino explícitamente, como en JS. También existen variables globales, pero no las he usado, así que no te hablaré de ellas.

Las funciones también tienen variables locales, numeradas desde cero, de tipo: int32/int64/float/double. En este caso, las primeras n variables locales son los argumentos pasados ​​a la función. Tenga en cuenta que, aunque todo aquí no es del todo de bajo nivel en términos de flujo de control, los números enteros aún no llevan el atributo "firmado/sin firmar": el comportamiento del número depende del código de operación.

En términos generales, Binaryen proporciona API C sencilla: creas un módulo, en el crear expresiones: unarias, binarias, bloques de otras expresiones, controlar el flujo, etc. Luego creas una función con una expresión como cuerpo. Si usted, como yo, tiene un gráfico de transición de bajo nivel, el componente Relooper lo ayudará. Según tengo entendido, es posible utilizar un control de alto nivel del flujo de ejecución en un bloque, siempre que no vaya más allá de los límites del bloque, es decir, es posible hacer que la ruta interna sea rápida/lenta. La ruta se bifurca dentro del código de procesamiento de caché TLB incorporado, pero sin interferir con el flujo de control "externo". Cuando liberas un relooper, sus bloques se liberan; cuando liberas un módulo, las expresiones, funciones, etc. asignadas a él desaparecen arena.

Sin embargo, si desea interpretar código sobre la marcha sin la creación y eliminación innecesaria de una instancia de intérprete, puede tener sentido poner esta lógica en un archivo C++ y desde allí administrar directamente toda la API C++ de la biblioteca, sin pasar por el código listo. envoltorios hechos.

Entonces, para generar el código que necesitas

// настроить глобальные параметры (можно поменять потом)
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, lo siento, esto es sólo para representar la escala, y los detalles están en la documentación.

Y ahora empieza el crack-fex-pex, algo como esto:

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,
          // ...
      }
  );
  // и вот уже у вас есть instance!
}, buf, sz);

Para conectar de alguna manera los mundos de QEMU y JS y al mismo tiempo acceder rápidamente a las funciones compiladas, se creó una matriz (una tabla de funciones para importar al iniciador) y las funciones generadas se colocaron allí. Para calcular rápidamente el índice, inicialmente se usó el índice del bloque de traducción de cero palabras, pero luego el índice calculado usando esta fórmula comenzó a encajar simplemente en el campo en struct TranslationBlock.

Por cierto, manifestación (actualmente con licencia turbia) Sólo funciona bien en Firefox. Los desarrolladores de Chrome fueron de alguna manera no estoy listo al hecho de que alguien querría crear más de mil instancias de módulos WebAssembly, por lo que simplemente asignó un gigabyte de espacio de direcciones virtuales para cada uno...

Eso es todo por ahora. Quizás haya otro artículo si alguien está interesado. Es decir, queda al menos solo hacer que los dispositivos de bloqueo funcionen. También podría tener sentido hacer que la compilación de los módulos WebAssembly sea asincrónica, como es habitual en el mundo JS, ya que todavía hay un intérprete que puede hacer todo esto hasta que el módulo nativo esté listo.

Finalmente un acertijo: ha compilado un binario en una arquitectura de 32 bits, pero el código, a través de operaciones de memoria, sube desde Binaryen, en algún lugar de la pila o en algún otro lugar de los 2 GB superiores del espacio de direcciones de 32 bits. El problema es que desde el punto de vista de Binaryen, esto es acceder a una dirección resultante demasiado grande. Cómo evitar esto?

A la manera del administrador

No terminé probando esto, pero lo primero que pensé fue "¿Qué pasa si instalé Linux de 32 bits?" Entonces la parte superior del espacio de direcciones estará ocupada por el kernel. La única duda es cuánto se ocupará: 1 o 2 Gb.

A la manera de un programador (opción para profesionales)

Soplemos una burbuja en la parte superior del espacio de direcciones. Yo mismo no entiendo por qué funciona, ahí ya debe haber una pila. Pero “somos practicantes: 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));
}

... es cierto que no es compatible con Valgrind, pero, afortunadamente, Valgrind en sí mismo saca a todos de allí de manera muy efectiva :)

Quizás alguien dé una mejor explicación de cómo funciona este código mío...

Fuente: habr.com

Añadir un comentario