QEMU.js: ahora en serio y con WASM

Érase una vez, en aras de la risa, decidí probar la reversibilidad del proceso y aprender a generar JavaScript (o más bien, Asm.js) a partir del código de la máquina. QEMU fue elegido para el experimento, algún tiempo después se escribió un artículo sobre Habr. En los comentarios, me aconsejaron rehacer el proyecto en WebAssembly, y yo mismo no tenía ganas de abandonar el proyecto casi terminado ... El trabajo continuó, pero muy lentamente, y ahora, en ese artículo, apareció un comentario sobre el tema "Entonces, ¿cómo terminó?". A mi respuesta detallada, escuché "Se tira de un artículo". Bueno, si tiras, habrá un artículo. Tal vez alguien sea útil. A partir de él, el lector aprende algunos datos sobre la generación de código QEMU de backends del dispositivo, así como sobre cómo escribir un compilador Just-in-Time para una aplicación web.


Las tareas


Como ya aprendí a portar QEMU a JavaScript de alguna manera, esta vez se decidió hacerlo con prudencia y no repetir viejos errores.


Error número de veces: ramificación desde la liberación


Mi primer error fue bifurcar mi versión de la versión ascendente 2.4.1. Entonces me pareció una buena idea: si existe una liberación puntual, entonces es probable que sea más estable que un simple 2.4, y más aún, master rama master . Y como estaba planeando agregar una buena cantidad de mis errores, no necesitaba extraños en absoluto. Entonces probablemente sucedió. Pero aquí está la mala suerte: QEMU no se detiene, y en algún momento incluso anunciaron la optimización del código de porcentaje generado en 10. "Sí, ahora me estoy congelando", pensé y se interrumpió . Aquí tenemos que 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 subprocesamiento múltiple (es decir, es fundamental para que pueda operar varias rutas de código no relacionadas al mismo tiempo, en lugar de simplemente "enlazar todos los núcleos"), las funciones principales de los subprocesos tuvo que "salir" para poder llamar desde afuera. Esto creó algunos problemas naturales de fusión. Sin embargo, el hecho de que algunos de los cambios de la rama master , con la que traté de fusionar mi código, también se seleccionaron en la versión de punto (y, por lo tanto, en mi rama), probablemente tampoco agregaría conveniencia.


En general, decidí que de todos modos el prototipo tiene sentido tirar desmonte las piezas y cree una nueva versión desde cero basada en algo más nuevo y ahora desde el master .


Error número dos: metodología TLP


De hecho, esto no es un error, en general, es solo una característica de crear un proyecto en las condiciones de un malentendido completo de "¿dónde y cómo moverse?", Y en general "¿llegaremos allí?". En estas condiciones, la programación era una opción justificable, pero, por supuesto, no quería repetirla innecesariamente. Esta vez quería hacerlo sabiamente: confirmaciones atómicas, cambios deliberados de código (y no "encadenar caracteres aleatorios hasta que se compila (con advertencias)", como Linus Torvalds dijo una vez sobre alguien, si crees en Wikitatnik), etc.


Error número tres: no saber el vado para meterse en el agua


Todavía no me he librado completamente de esto, pero ahora decidí no seguir el camino de la menor resistencia y hacerlo "de una manera adulta", es decir, escribir mi backend TCG desde cero para que no diga más tarde: "Sí, Por supuesto, lentamente, pero no puedo controlarlo todo: TCI se escribe así ... ". Además, inicialmente esto parecía una solución obvia, ya que estaba generando código binario . Como dice el refrán, "Recogí Gante, pero no ese": el código es, por supuesto, binario, pero el control no puede transferirse a él de esa manera: necesita ser empujado explícitamente al navegador para su compilación, lo que resulta en un cierto objeto del mundo JS, que Todavía necesito guardar en alguna parte. Sin embargo, en normal Para las arquitecturas RISC, según tengo entendido, una situación típica es la necesidad de vaciar explícitamente la caché de instrucciones para el código regenerado; si esto no es lo que necesitamos, al menos está cerca. Además, desde mi último intento, aprendí que el control no parece transferirse a la mitad del bloque de traducción, por lo tanto, realmente no necesitamos que el bytecode se interprete desde ningún desplazamiento, y simplemente podemos generarlo por función en TB.


Vino y pateó


Aunque comencé a reescribir el código en julio, el Pendel mágico pasó desapercibido: por lo general, las cartas de GitHub vienen como notificaciones de respuestas a problemas y solicitudes de extracción , y luego, de repente , el Binaryen como un backend qemu en el contexto dice : "Aquí está- hizo algo así, tal vez dirá algo ". Se trataba de utilizar la biblioteca Binaryen relacionada con Emscripten para crear un WASM JIT. Bueno, dije que tienes una licencia de Apache 2.0 allí, y QEMU en su conjunto se distribuye bajo GPLv2, y no son muy compatibles. De repente resultó que la licencia podría corregirse de alguna manera (no lo sé: tal vez, cambiar, tal vez doble licencia, tal vez otra cosa ...). Esto, por supuesto, me hizo feliz, porque ya había mirado el formato binario WebAssembly varias veces en ese momento, y de alguna manera estaba triste e incomprensible para mí. Hubo una biblioteca aquí que engullirá los bloques básicos con el gráfico de transición, y emitirá el código de bytes, e incluso lo lanzará en el intérprete si es necesario.


Luego también había una carta en la lista de correo de QEMU, pero es más probable que se pregunte: "¿Quién la necesita?" Y, de repente , fue necesario. Como mínimo, puede reunir tales casos de uso si funciona de manera más o menos inteligente:


  • lanzar cualquier cosa que enseñe 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 el motor JS (¿es eso cierto?)
  • demostración de mini-OS: disco único, integrado, todo tipo de firmware, etc.

Características del tiempo de ejecución del navegador


Como dije, QEMU está vinculado a subprocesos múltiples, pero no está en el navegador. Bueno, es decir, como no ... Al principio no estaba allí en absoluto, luego apareció WebWorkers; según tengo entendido, esto es multihilo basado en el paso de mensajes sin variables mutuamente variables . Naturalmente, esto crea problemas importantes al portar código existente basado en un modelo de memoria compartida. Luego, bajo presión del público, se implementó bajo el nombre de SharedArrayBuffers . Poco a poco lo introdujeron, 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 grosera, no grosera, pero con la ayuda de la memoria compartida y un flujo que incrementa el contador, todavía es bastante preciso . Entonces desactivaron el subprocesamiento múltiple con memoria compartida. Parece que luego lo volvieron a encender, pero, como quedó claro desde 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 puede simplemente tomar, guardar el contexto actual y cambiar a uno nuevo con una nueva pila. La pila virtual de llamadas es administrada por la máquina virtual JS. Parecería, ¿cuál es el problema, ya que aún decidimos administrar los flujos anteriores de forma completamente manual? El hecho es que el bloque de entrada-salida en QEMU se implementa a través de las rutinas, y aquí las manipulaciones de la pila de bajo nivel nos serían útiles. Afortunadamente, Emscipten ya contiene un mecanismo para operaciones asincrónicas, incluso dos: Asyncify y Emterpreter . El primero funciona mediante una hinchazón significativa del código JavaScript generado y ya no es compatible. El segundo es el "camino correcto" actual y funciona a través de la generación de bytecode para su propio intérprete. Funciona, por supuesto, lentamente, pero no infla el código. Es cierto que el soporte de rutina para este mecanismo tenía que atribuirse por sí solo (ya había corutinas escritas en Asyncify y había una implementación de aproximadamente la misma API para Emterpreter, solo tenía que conectarlas).


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


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

Características de las fuentes QEMU


Como probablemente ya haya adivinado, el código de emulación para arquitecturas invitadas y el código para generar instrucciones de máquina host desde QEMU están separados. De hecho, hay incluso un poco más complicado:


  • hay arquitecturas invitadas
  • hay aceleradores , a saber, KVM para la virtualización de hardware en Linux (para sistemas invitados y host compatibles), TCG para la generación de código JIT en cualquier lugar. Comenzando con QEMU 2.9, apareció soporte para el estándar de virtualización de hardware HAXM en Windows ( detalles )
  • si se usa TCG, y no la virtualización de hardware, entonces tiene soporte separado para la generación de código para cada arquitectura de host, así como para un intérprete universal
  • ... y todo alrededor: periféricos emulados, interfaz de usuario, migración, grabación de repetición, 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 núcleo del host, que es utilizado, por ejemplo, por el fuzzer AFL para instrumentación de binarios. ¿Quizás alguien quiere portar este modo de operación QEMU a JS? ;)


Como la mayoría de los programas gratuitos de larga data, QEMU se crea a través de una llamada para configure y make . Supongamos que decide agregar algo: un backend TCG, una implementación de subproceso, algo más. No se apresure a alegrarse / horrorizarse (subraye según sea necesario) la posibilidad de comunicarse con Autoconf; de hecho, la configure en QEMU parece ser autoescrita y no hay nada para generar.


Montaje web


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


Es posible que haya escuchado sobre el algoritmo de relooping para Asm.js: restaurar las instrucciones de control de flujo de ejecución de "alto nivel" (es decir, if-then-else, bucles, etc.) bajo las cuales los motores JS se sintonizan desde el IR LLVM de bajo nivel, 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. Parece que aquí está, bytecode, el final del tormento ... ¡Y luego los bloques, if-then-else y loops! ..


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


TCG (Generador de código minúsculo)


TCG fue originalmente un backend para el compilador de C. Luego, aparentemente, no pudo soportar la competencia con GCC, pero al final encontró su lugar en 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 el intérprete ejecuta inmediatamente, pero decidí irme esta vez. Sin embargo, el hecho de que QEMU ya tenga la capacidad de permitir la transición a la TB generada a través de la función tcg_qemu_tb_exec fue muy útil para mí.


Para agregar un nuevo backend TCG a QEMU, debe crear un subdirectorio tcg/< > (en este caso, tcg/binaryen ), y hay dos archivos en él: tcg-target.h y tcg-target.inc.c y registrar todo Esto es configure . Puede colocar otros archivos allí, pero, como puede adivinar por los nombres de estos dos, ambos se incluirán en alguna parte: uno como un archivo de encabezado normal (se incluirá en tcg/tcg.h , y ese ya estará en otros archivos en los directorios tcg , accel y no solo), el otro solo como fragmento de código en tcg/tcg.c , pero tiene acceso a sus funciones estáticas.


Habiendo decidido que pasaría demasiado tiempo en los procedimientos detallados, cómo funciona, simplemente copié los "esqueletos" de estos dos archivos de otra implementación de back-end, honestamente indicando esto en el encabezado de la licencia.


El tcg-target.h contiene principalmente configuraciones en forma de #define s:


  • cuántos registros y qué ancho tiene la arquitectura de destino (tenemos, por mucho que queramos, hay tantos, la pregunta es más que lo que generará el navegador en un código más eficiente en una arquitectura "completamente objetivo" ...)
  • alineación de las instrucciones del host: en x86 y en TCI, las instrucciones no se alinean en absoluto, pero voy a poner 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: active todo lo que encontremos en Binaryen, deje que el acelerador divida el resto en otros más simples
  • qué tamaño aproximado de la caché TLB es solicitado por el backend. El hecho es que en QEMU todo es serio: aunque hay funciones auxiliares que cargan / almacenan teniendo en cuenta el MMU invitado (¿y dónde ahora sin él?), Guardan su caché de traducción en forma de estructura, cuyo procesamiento es conveniente para incrustar directamente a los bloques de traducción. La pregunta es, ¿qué compensación en esta estructura es manejada más eficientemente por una secuencia pequeña y rápida de comandos?
  • aquí puede cambiar el propósito 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 funciones en inline como flush_icache_range (pero este no es nuestro caso)

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


  • inicialización, que indica, entre otras cosas, restricciones sobre qué instrucción con qué operandos pueden trabajar. Insolentemente copiado por mí de otro backend
  • función que acepta una instrucción de bytecode interno
  • aquí puede poner funciones auxiliares, y también aquí puede usar funciones estáticas de tcg/tcg.c

Para mí, elegí la siguiente estrategia: en las primeras palabras del siguiente bloque de transmisión, escribí cuatro punteros: la marca de inicio (un cierto valor en la vecindad de 0xFFFFFFFF , que determinó el estado actual de TB), el contexto, el módulo generado y el número mágico para la depuración. Primero, la etiqueta se configuró en 0xFFFFFFFF - n , donde n es un número positivo pequeño, y cada vez a través del intérprete aumentó en 1. Cuando llegó a 0xFFFFFFFE , se produjo la compilación, el módulo se almacenó en la tabla de funciones, importada en un pequeño "lanzador", en el que la ejecución dejó tcg_qemu_tb_exec , y el módulo se eliminó de la memoria QEMU.


Parafraseando a los clásicos, "Crutch, cuánto proger se ha entrelazado en este sonido para el corazón ...". Sin embargo, el recuerdo estaba goteando en alguna parte. ¡Y fue un recuerdo administrado por QEMU! Tenía un código que, al escribir la siguiente instrucción (bueno, es decir, un puntero), eliminaba el enlace al que estaba en este lugar anteriormente, pero eso no ayudó. En realidad, en el caso más simple, QEMU asigna memoria al inicio y escribe el código generado allí. Cuando finaliza 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 la muleta con número mágico nos permitía no caer en la destrucción del montón, liberando algo mal en el búfer no inicializado en el primer paso. ¿Pero quién sobrescribe el búfer sin pasar por mi función más tarde? Como aconsejaron los desarrolladores de Emscripten, después de haber tenido un problema, volví el código resultante a la aplicación nativa, configuré Mozilla Record-Replay en él ... En general, como resultado, me di cuenta de una cosa simple: se asigna una struct TranslationBlock con su descripción para cada bloque. Adivina dónde ... Así es, justo en frente del bloque justo en el búfer. Al darme cuenta de esto, decidí atarlo con muletas (al menos algunas), y simplemente tiré el número mágico y transfirí las palabras restantes a la struct TranslationBlock , creando una lista de enlaces únicos que puede recorrer rápidamente al restablecer el caché de traducción y liberar memoria.


Quedaron algunas muletas: por ejemplo, punteros marcados en el búfer de código; algunos de ellos son simplemente BinaryenExpressionRef , es decir, miran expresiones que deben colocarse linealmente en la unidad base generada, parte, la condición de transición entre los WB, parte, a dónde ir. Bueno, ya hay bloques preparados para Relooper, que deben conectarse de acuerdo con las condiciones. Para distinguir entre ellos, se asume que todos están alineados con al menos cuatro bytes, por lo que puede usar con seguridad los dos bits inferiores de la etiqueta, solo necesita recordar eliminarlo si es necesario. Por cierto, tales etiquetas ya se usan en QEMU para indicar la razón para salir del ciclo TCG.


Usando Binaryen


Los módulos en WebAssembly contienen funciones, cada una de las cuales contiene un cuerpo que representa una expresión. Las expresiones son operaciones unarias y binarias, bloques que consisten en 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 a las funciones se pasan no en la pila, sino explícitamente, como en JS. Hay variables globales, pero no las utilicé, así que no hablaré sobre ellas.


Las funciones también tienen variables locales, numeradas desde cero, del tipo: int32 / int64 / float / double. Las primeras n variables locales son los argumentos pasados ​​a la función. Tenga en cuenta que aunque todo aquí no es del todo bajo en términos de flujo de control, los enteros aún no llevan el signo con signo / sin signo: cómo se comportará el número depende del código de operación.


En términos generales, Binaryen proporciona una C-API simple : crea un módulo, en él crea expresiones: unario, binario, bloques de otras expresiones, flujo de control, etc. Luego crea una función, cuyo cuerpo necesita para especificar una expresión. 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 el bloque, siempre que no supere los límites del bloque, es decir, es posible hacer una rama interna de ruta rápida / ruta lenta dentro del código de procesamiento de caché TLB incorporado, pero no hay interferencia . Cuando liberas relooper, sus bloques se liberan, cuando liberas un módulo, las expresiones, funciones, etc., asignadas en su arena desaparecen.


Sin embargo, si desea interpretar el código sobre la marcha sin la creación y eliminación innecesarias de la instancia del intérprete, puede tener sentido transferir esta lógica a un archivo C ++, y desde allí controlar directamente toda la biblioteca API de C ++, sin pasar por los contenedores terminados.


Por lo tanto, para generar el código, necesita


 //    (  ) 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); 

… — , , — .


--, :


 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); 

- QEMU JS , ( ), . , translation block, , struct TranslationBlock .


, ( ) Firefox. Chrome - , - WebAssembly, ...


. , , - . , . , WebAssembly , JS, , , .


: 32- , Binaryen, - - 2 32- . , Binaryen . ?


-

, « , 32- Linux?» . , : 1 2 Gb.


- ( )

. , — . « : , ...».


 // 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)); } 

… Valgrind-, , , , , Valgrind :)


, - , ...

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


All Articles