Qemu.js con soporte JIT: la carne se puede volver a picar

Hace unos años, Fabrice Bellard escribió jslinux — un emulador de PC escrito en JavaScript. Después de eso, hubo al menos Virtual x86. Pero todos ellos, hasta donde yo sé, eran intérpretes, mientras que Qemu, escrito mucho antes por el mismo Fabrice Bellard, y probablemente cualquier emulador moderno de respeto, utiliza la compilación JIT del código huésped en código del sistema anfitrión. Me pareció que era el momento adecuado para implementar la tarea inversa a la que resuelven los navegadores: la compilación JIT del código máquina en JavaScript, para lo cual vi más lógico portar Qemu. Parece que, ¿por qué Qemu en particular? Hay emuladores más simples y amigables para el usuario, como VirtualBox, por ejemplo, que solo instalas y funcionan. Pero Qemu tiene varias características interesantes

  • código abierto
  • la capacidad de funcionar sin un controlador de kernel
  • la capacidad de funcionar en modo intérprete
  • compatibilidad con una gran cantidad de arquitecturas, tanto anfitrionas como huésped

Sobre el tercer punto, ahora ya puedo aclarar que, en realidad, en modo TCI no se interpretan las instrucciones máquina huésped propiamente dichas, sino el bytecode que se obtiene de ellas, pero eso no cambia la esencia: para compilar y ejecutar Qemu en una nueva arquitectura, si tienes suerte, solo necesitas un compilador C — la escritura del generador de código puede ser pospuesta.

Y así, después de dos años de trabajo meticuloso en mi tiempo libre sobre el código fuente de Qemu, apareció un prototipo funcional, en el que ya se puede ejecutar, por ejemplo, Kolibri OS.

Qué es Emscripten

Hoy en día hay muchos compiladores cuyo resultado final es JavaScript. Algunos, como TypeScript, fueron concebidos desde el principio como una forma mejor de programar para la web. Al mismo tiempo, Emscripten es una manera de tomar un código existente en C o C++, y compilarlo en un formato comprensible para el navegador. En esta página se han realizado bastantes puertos de programas conocidos: aquí, por ejemplo, se puede observar PyPy — por cierto, como se afirma, ya tienen JIT. De hecho, no se puede simplemente compilar y ejecutar cualquier programa en el navegador — hay una serie características, con los que hay que lidiar, aunque como dice el texto en esta misma página "Emscripten puede usarse para compilar casi cualquier portable Código C/C++ a JavaScript. Es decir, hay una serie de operaciones que son un comportamiento indefinido según el estándar, pero que generalmente funcionan en x86; por ejemplo, el acceso desalineado a variables, que en algunas arquitecturas está prohibido. En general, Qemu es un programa multiplataforma y, se quería creer, no contiene una gran cantidad de comportamientos indefinidos: ¡tómalo y compílalo, luego juega un poco con JIT, y listo! Pero no fue tan fácil...

Primer intento

En realidad, no soy el primero en tener la idea de portar Qemu a JavaScript. En el foro de ReactOS se preguntó si esto era posible con Emscripten. Hace tiempo, hubo rumores de que Fabrice Bellard lo había hecho personalmente, pero se trataba de jslinux, que, hasta donde sé, es un intento de lograr un rendimiento suficiente en JS desde cero. Más tarde se escribió Virtual x86; se publicaron los códigos fuente no ofuscados y, según se afirmaba, una mayor 'realidad' de la emulación permitió usar SeaBIOS como firmware. Además, hubo al menos un intento de portar Qemu utilizando Emscripten: eso fue lo que intentó hacer. socketpair, pero la desarrollo, según entendí, se detuvo.

Así que, en apariencia, aquí están los fuentes, aquí está Emscripten: tómalo y compílalo. Pero también hay bibliotecas de las que depende Qemu, y bibliotecas de las que dependen esas bibliotecas, etc., y una de ellas es libffi, de la cual depende glib. En internet había rumores de que en la gran colección de puertos de bibliotecas para Emscripten había una, pero resultaba difícil de creer: primero, no se podía compilar con el nuevo compilador, y segundo, es una biblioteca de bajo nivel como para simplemente tomarla y compilarla a JS. Y no solo se trata de las inserciones en ensamblador — probablemente, si se hace un esfuerzo, para algunas convenciones de llamadas se pueden formar los argumentos necesarios en la pila y llamar a la función sin ellas. Pero Emscripten es un asunto complicado: para que el código generado se vea familiar para el optimizador del motor JS del navegador, se utilizan ciertos trucos. En particular, el llamado relooping — el generador de código, utilizando el LLVM IR recibido con algunas instrucciones abstractas de saltos, intenta recrear ifs y bucles plausibles, etc. Y, ¿cómo se pasan los argumentos a las funciones? Por supuesto, como argumentos de funciones JS, es decir, en lo posible, no a través de la pila.

Al principio, pensé simplemente en escribir una sustitución de libffi en JS y ejecutar las pruebas estándar, pero al final me perdí en cómo hacer mis archivos de encabezado para que funcionaran con el código existente — lo que hay que hacer, como se dice, "O las tareas son tan complejas, o nosotros somos tan tontos". Tuve que portar libffi a otra arquitectura, si se puede expresar así — afortunadamente, en Emscripten hay macros para ensamblador en línea (en JavaScript, sí — bueno, según la arquitectura, así es el ensamblador) y la posibilidad de ejecutar el código generado sobre la marcha. En resumen, después de manejar un tiempo con los fragmentos dependientes de la plataforma de libffi, obtuve un código que podía compilarse y lo ejecuté en la primera prueba que me encontré. Para mi sorpresa, la prueba fue exitosa. Sorprendido por mi genialidad — no es broma, funcionó a la primera — todavía sin creer lo que veía, volví a mirar el código resultante, evaluando dónde profundizar a continuación. Aquí me sorprendí nuevamente — lo único que hacía mi función ffi_call — fue informar sobre la exitosa llamada. No hubo ninguna llamada en sí. Así envié mi primera solicitud de extracción, corrigiendo un error obvio para cualquier olimpista en la prueba — no se deben comparar números reales como a == b ni siquiera como a - b < EPS — también hay que recordar el módulo, de lo contrario 0 será igual a 1/3... En general, he conseguido un puerto de libffi, que pasa las pruebas más simples, y con el que se compila glib — decidí que más adelante lo seguiré escribiendo. A modo de adelanto, diré que, como resultó, el compilador ni siquiera incluyó el código final de la función libffi.

Pero, como ya mencioné, hay algunas limitaciones, y entre el uso libre de diversos comportamientos indefinidos se coló una particularidad poco agradable: JavaScript por diseño no soporta la multihilo con memoria compartida. En principio, esto a menudo se puede considerar como una buena idea, pero no para portar código cuya arquitectura está basada en hilos de C. En general, en Firefox están experimentando con el soporte de shared workers, y la implementación de pthread no se ha dejado de lado en Emscripten, pero no quería depender de eso. Fue necesario eliminar poco a poco la multihilo del código de Qemu — es decir, encontrar dónde se inician los hilos, extraer el cuerpo del bucle que se ejecuta en ese hilo en una función separada, y llamar a tales funciones por turnos desde el bucle principal.

Segundo intento

En algún momento quedó claro que la situación no mejoraba, y que una distribución aleatoria de parches en el código no llevaría a nada bueno. Conclusión: hay que sistematizar el proceso de agregar parches. Por lo tanto, se tomó la versión más reciente en ese momento 2.4.1 (no 2.5.0, porque, quién sabe, tal vez queden errores no detectados en la nueva versión, y ya tengo suficientes errores propios), y lo primero que se hizo fue reescribirse de manera segura. thread-posix.c. Es decir, de manera segura: si alguien intentaba realizar una operación que conducía a un bloqueo, se llamaba inmediatamente a la función abort() — por supuesto, esto no solucionaba de inmediato todos los problemas, pero al menos es más agradable que recibir silenciosamente inconsistencias de datos.

En general, las opciones de Emscripten son de gran ayuda al portar código a JS. -s ASSERTIONS=1 -s SAFE_HEAP=1 — capturan algunos tipos de comportamiento indefinido como accesos a direcciones no alineadas (lo cual no se alinea con el código para typed arrays como HEAP32[addr >> 2] = 1) o llamar a una función con un número incorrecto de argumentos.

Por cierto, los errores de alineación son un tema aparte. Como ya mencioné, en Qemu hay un backend interpretador "degenerado" para la generación de código TCI (tiny code interpreter), y para compilar y ejecutar Qemu en una nueva arquitectura, si hay suerte, basta con un compilador C. Palabras clave "si hay suerte". A mí no me fue bien, y resultó que TCI al analizar su bytecode utiliza acceso no alineado. Es decir, en arquitecturas como ARM y otras con acceso alineado obligatorio, Qemu se compila porque hay un backend TCG normal que genera código nativo, pero si funcionará TCI en ellas es otra pregunta. Sin embargo, como resultó, en la documentación de TCI se indicaba algo similar. Al final, se añadieron llamadas a funciones para lecturas no alineadas, que fueron descubiertas en otra parte de Qemu.

Destrucción de montículos

Al final, el acceso no alineado en TCI fue corregido, se hizo un bucle principal que llamaba secuencialmente al procesador, RCU y algunas cosas menores. Y aquí estoy ejecutando Qemu con la opción -d exec,in_asm,out_asm, lo que significa que se debe informar qué bloques de código se están ejecutando, y también durante la traducción escribir qué código de invitado había, qué código de host se convirtió (en este caso, bytecode). Se inicia, ejecuta varios bloques de traducción, escribe el mensaje de depuración que dejé, que ahora se iniciará RCU y… se cae por abort() dentro de la función free(). A través de la inspección de la función free() se logró determinar que en la cabecera del montículo, que se encuentra en los ocho bytes antes de la memoria asignada, en lugar del tamaño del bloque o algo similar, había basura.

Destruir el montón — qué bonito... En este caso, hay una herramienta útil: intentar, si es posible, compilar un binario nativo a partir de las mismas fuentes y ejecutarlo con Valgrind. Después de un tiempo, el binario estuvo listo. Lo ejecuto con las mismas opciones — falla aún en la inicialización, sin llegar a la ejecución en sí. Es desagradable, por supuesto — parece que las fuentes no eran exactamente las mismas, lo cual no es sorprendente, ya que configure detectó algunas opciones diferentes, pero tengo Valgrind — primero arreglaré este error, y luego, si tengo suerte, podría aparecer el original. Ejecuté todo lo mismo bajo Valgrind... Oh, oh, oh, ¡funcionó! Pasó la inicialización correctamente y siguió adelante sin ningún aviso sobre acceso incorrecto a la memoria, sin mencionar las caídas. Nunca me había preparado para esto — un programa que se cae deja de fallar cuando se ejecuta bajo Valgrind. ¿Qué fue eso? Un misterio. Mi hipótesis es que si en las cercanías de la instrucción actual después de la caída, gdb mostraba actividad memset-a con un puntero válido utilizando ya sea mmx, o quizás xmm registros, entonces, posiblemente, fue algún tipo de error de alineación, aunque aún así es difícil de creer.

Ok, Valgrind aquí no parece ser de mucha ayuda. Y aquí empezó lo más complicado: todo, aparentemente, se ejecuta, pero falla por razones completamente desconocidas debido a un evento que pudo haber ocurrido millones de instrucciones atrás. Durante mucho tiempo, no supe ni por dónde empezar. Al final, tuve que sentarme y depurar. Al imprimir lo que se había sobrescrito en el encabezado, resultó que no parecía ser un número, sino más bien algún tipo de dato binario. Y, oh milagro, esta cadena binaria se encontró en el archivo del BIOS, es decir, ahora se podía decir con suficiente certeza que se trataba de un desbordamiento de búfer, y también era evidente qué se estaba escribiendo en ese búfer. A partir de ahí, como estaba en Emscripten, afortunadamente no hay aleatorización del espacio de direcciones, tampoco hay agujeros, así que se puede escribir en algún lugar a mitad del código para mostrar datos a través de un puntero de una ejecución anterior, observar los datos, mirar el puntero y, si no ha cambiado, obtener información para reflexionar. Es cierto que se tarda un par de minutos en enlazar después de cualquier cambio, pero qué se le va a hacer. Como resultado, se encontró una línea específica que copia el BIOS del búfer temporal a la memoria huésped, y efectivamente, no había suficiente espacio en el búfer. La búsqueda de la fuente de esa extraña dirección del búfer condujo a la función. qemu_anon_ram_alloc en el archivo oslib-posix.c La lógica ahí era la siguiente: a veces puede ser útil alinear la dirección a una página enorme de 2 MB, para eso pediremos mmap un poco más al principio, y luego devolveremos el exceso mediante munmap. Y si no se requiere dicha alineación, simplemente indicaremos en lugar de 2 MB el resultado getpagesize() — mmap que, de todos modos, devolverá la dirección alineada... Así que en Emscripten mmap simplemente se llama malloc, y este, por supuesto, no alinea por página. En general, el error que me frustraba durante un par de meses se resolvió con un cambio en dos las líneas.

Particularidades de la llamada a funciones

Y ya el procesador está realizando cálculos, Qemu no falla, pero la pantalla no se enciende y el procesador se está bloqueando rápidamente, según la salida. -d exec,in_asm,out_asmSe planteó la hipótesis de que no llegan las interrupciones del temporizador (o tal vez ninguna interrupción en absoluto). Y de hecho, si se desactivan las interrupciones de la compilación nativa que funcionaba por alguna razón, se obtiene un panorama similar. Pero la solución no estaba en esto: la comparación de trazas, emitidas con la opción mencionada anteriormente, mostró que las trayectorias de ejecución se desvían muy pronto. Aquí hay que mencionar que comparar la salida de depuración grabada con la salida de la compilación nativa no es un proceso del todo mecánico. emrun No sé exactamente cómo el programa ejecutado en el navegador se conecta con emrun, pero algunas líneas en la salida terminan cambiando de lugar, así que la diferencia en el diff no es necesariamente un indicativo de que las trayectorias se hayan desviado. En general, quedó claro que, según las instrucciones, se realiza un cambio a diferentes direcciones y que el bytecode generado es fundamentalmente diferente: en uno hay una instrucción de llamado a una función auxiliar de C, en el otro no. Tras buscar las instrucciones en Google y estudiar el código que las transfiere, quedó claro que, en primer lugar, se escritura directamente en el registro ljmpl , también con la ayuda de un auxiliar, que cambia el procesador a modo protegido, y en segundo lugar, que la versión js nunca entró en modo protegido. Y es que una de las características de Emscripten es su renuencia a tolerar código que implemente la instrucción en TCI, que convierte cualquier puntero a función al tipo long long f(int arg0, .. int arg9) call — las funciones deben ser llamadas con el número correcto de argumentos. Si se incumple esta regla, dependiendo de la configuración de depuración, el programa puede fallar (lo cual es bueno), o puede llamar a una función completamente diferente (lo cual será difícil de depurar). También hay una tercera opción: activar la generación de envoltorios que añadan/eliminen argumentos, pero en total estos envoltorios ocupan bastante espacio, dado que en realidad sólo necesito poco más de un centenar de envoltorios. Solo esto ya es bastante desalentador, pero surgió un problema más serio: en el código generado de las funciones envoltorio, los argumentos se convertían, se convertían, pero la función con los argumentos generados a veces no se llamaba — igual que en mi implementación de libffi. Es decir, algunos auxiliares simplemente no se ejecutaban. long long f(int arg0, .. int arg9) — las funciones deben ser llamadas con el número correcto de argumentos. Si se viola esta regla, dependiendo de la configuración de depuración, el programa puede fallar (lo cual es bueno) o puede llamar a una función completamente diferente (lo cual será difícil de depurar). También hay una tercera opción: activar la generación de envoltorios que añaden/eliminen argumentos, pero en total estos envoltorios ocupan mucho espacio, a pesar de que en realidad solo necesito un poco más de un centenar de envoltorios. Solo esto es bastante triste, pero hay un problema más serio: en el código generado de las funciones envoltorio, los argumentos se convertían, se convertían, pero a veces la función con los argumentos generados no se llamaba — exactamente como en mi implementación de libffi. Es decir, algunos helpers simplemente no se ejecutaban.

Afortunadamente, en Qemu hay listas de ayudantes legibles por máquina en forma de un archivo de encabezado como

DEF_HELPER_0(lock, void)
DEF_HELPER_0(unlock, void)
DEF_HELPER_3(write_eflags, void, env, tl, i32)

Se utilizan de manera bastante curiosa: primero, los macros se redefinen de la manera más extravagante DEF_HELPER_n, y luego se incluye helper.h. Hasta el punto en que el macro se expande en el inicializador de la estructura y la coma, y luego se define un array, y en lugar de elementos hay #include <helper.h> Como resultado, finalmente surgió la oportunidad de probar la biblioteca pyparsing, y se escribió un script que genera envolturas exactamente para aquellas funciones que son necesarias.

Y así, después de esto, el procesador parece haber funcionado. Parece que sí, porque la pantalla nunca se inicializó, aunque en la compilación nativa se pudo ejecutar memtest86+. Aquí hay que aclarar que el código de entrada/salida por bloques de Qemu está escrito en corrutinas. En Emscripten hay una implementación bastante intrincada, pero aún había que respaldarla en el código de Qemu, y el procesador se puede depurar ya, porque Qemu admite opciones -kernel, -initrd, -append, con las que se puede cargar Linux o, por ejemplo, memtest86+, sin usar dispositivos de bloque. Pero aquí surge un problema: en la compilación nativa se podían observar las salidas del kernel de Linux en la consola con la opción -nographic, mientras que desde el navegador no se recibía ninguna salida en la terminal desde la que se había iniciado emrun, no llegaba. Es decir, no está claro: ¿el procesador no funciona o es la salida gráfica? Y luego se me ocurrió esperar un poco. Resultó que "el procesador no está dormido, simplemente parpadea lentamente", y después de cinco minutos, el núcleo lanzó un montón de mensajes a la consola y siguió bloqueándose. Quedó claro que el procesador, en general, está funcionando, y hay que escarbar en el código que trabaja con SDL2. Desafortunadamente, no sé usar esta biblioteca, así que en algunos momentos tuve que actuar a ciegas. En algún momento, una línea paralela0 apareció en la pantalla sobre un fondo azul, lo que insinuó algunas ideas. Al final resultó que todo se debía a que Qemu abre varias ventanas virtuales en una sola ventana física, entre las que se puede alternar con Ctrl-Alt-n: en la compilación nativa funciona, en Emscripten, no. Después de deshacerme de ventanas innecesarias con las opciones -monitor none -parallel none -serial none y al indicar la necesidad de redibujar toda la pantalla en cada cuadro, todo de repente funcionó.

Corrutinas

Así que la emulación en el navegador funciona, pero no se puede ejecutar nada interesante de disquete porque no hay entrada/salida por bloques; es necesario implementar soporte para corutinas. En Qemu ya hay varios backends de coroutine, pero debido a las particularidades de JavaScript y el generador de código Emscripten, no se puede simplemente tomar y empezar a hacer malabares con las pilas. Parecería que "todo está perdido, se quita el yeso", pero los desarrolladores de Emscripten ya se han encargado de todo. Se ha implementado de manera bastante divertida: ¿y si llamamos sospechosos a llamados de funciones como emscripten_sleep y varios otros que usan el mecanismo Asyncify, así como llamadas por punteros y llamadas a cualquier función donde más abajo en la pila pueda ocurrir uno de los dos casos anteriores? Y ahora, antes de cada llamada sospechosa, asignamos un contexto asíncrono, y justo después de la llamada verificamos si ocurrió una llamada asíncrona; si es así, guardamos todas las variables locales en este contexto asíncrono, indicamos a qué función pasar el control cuando necesitemos continuar la ejecución y salimos de la función actual. Ahí sí que hay campo para estudiar el efecto desintegración — para las necesidades de continuar la ejecución del código después de regresar de una llamada asíncrona, el compilador genera "fragmentos" de función que comienzan después de la llamada sospechosa: así, si hay n llamadas sospechosas, la función se dividirá aproximadamente n/2 veces; esto sin contar que en la función original se debe agregar, después de cada posible llamada asíncrona, la conservación de parte de las variables locales. Posteriormente, incluso tuve que escribir un sencillo script en Python, que, dado un conjunto de funciones especialmente fragmentadas, que, presuntamente, "no permiten la asincronía a través de ellas" (es decir, en ellas no se activa el desarrollo de la pila y todo lo que acabo de describir), indica qué llamadas por punteros deberían ser ignoradas por el compilador para que esos funciones no sean consideradas asíncronas. Porque archivos JS de 60 MB es claramente un exceso; que al menos sean 30. Aunque, una vez configuré un script de compilación y accidentalmente eliminé opciones del enlazador, entre las cuales estaba -O3. Estoy ejecutando el código generado y Chromium está consumiendo memoria y se cierra. Luego, accidentalmente miré lo que intentaba cargar... Bueno, ¿qué puedo decir? Yo también me quedaría atascado si me pidieran que revisara y optimizara un JavaScript de más de 500 MB.

Desafortunadamente, las verificaciones en el código de la biblioteca de soporte Asyncify no se llevaban muy bien con longjmp-s que se utilizan en el código del procesador virtual, pero después de un pequeño parche que desactiva estas verificaciones y recupera los contextos como si todo estuviera bien, el código funcionó. Y aquí comenzó lo extraño: a veces se activaban las verificaciones en el código de sincronización, esas que finalizan el código si lógicamente debería bloquearse, porque alguien intentaba capturar un mutex que ya estaba tomado. Afortunadamente, esto no fue un problema lógico en el código serializado; simplemente estaba utilizando la funcionalidad estándar del bucle principal proporcionada por Emscripten, pero a veces la llamada asíncrona deshacía completamente la pila, y en ese momento se activaba setTimeout del bucle principal; de este modo, el código entraba en una iteración del bucle principal sin salir de la iteración anterior. Lo reescribí en un bucle infinito y emscripten_sleep, y los problemas con los mutex cesaron. El código se volvió incluso más lógico, ya que, en esencia, no tengo un código que prepara el siguiente cuadro de animación; simplemente el procesador está calculando algo y la pantalla se actualiza periódicamente. Sin embargo, los problemas no se detuvieron ahí: a veces la ejecución de Qemu simplemente se cerraba silenciosamente sin ninguna excepción o error. En ese momento lo dejé estar, pero adelantándome diré que el problema era el siguiente: el código de las corutinas, en realidad, no utiliza setTimeout (o al menos, no tan a menudo como se podría pensar): la función emscripten_yield simplemente establece un indicador de llamada asíncrona. La clave está en que emscripten_coroutine_next no es una función asíncrona: dentro de sí misma, verifica el indicador, lo restablece y pasa el control a donde corresponde. Es decir, ahí es donde termina la expansión de la pila. El problema era que debido a un uso después de liberar, que se manifestaba al desactivar el grupo de corutinas porque no copié una línea clave de código del backend existente de las corutinas, la función qemu_in_coroutine devolvía true, cuando en realidad debería haber devuelto false. Esto resultaba en una llamada emscripten_yield, por encima de la cual no había nada en la pila emscripten_coroutine_next, la pila se extendía hasta la parte superior, pero no hubo setTimeout, como ya he mencionado, no se exhibió.

Generación de código JavaScript

Y aquí está, como prometí, el "retorno de la carne picada". En realidad, no. Desde luego, si ejecutas Qemu en el navegador, y dentro de él — Node.js, entonces, naturalmente, después de la generación de código en Qemu obtendremos un JavaScript completamente diferente. Pero aún así, hay algún tipo de transformación inversa.

Para empezar, un poco sobre cómo funciona Qemu. De antemano, pido disculpas: no soy un desarrollador profesional de Qemu y mis conclusiones pueden ser erróneas en algunos aspectos. Como se dice, "la opinión de un estudiante no tiene por qué coincidir con la de un profesor, la axiomatología de Peano y el sentido común". Qemu tiene una cierta cantidad de arquitecturas de invitados soportadas y cada una tiene un directorio como target-i386. Al compilar, se puede especificar soporte para varias arquitecturas de invitados, pero como resultado simplemente se obtendrán varios binarios. El código para soportar la arquitectura de invitado, a su vez, genera ciertas operaciones internas de Qemu, que TCG (Tiny Code Generator) convierte en código máquina de la arquitectura huésped. Como se afirma en el archivo readme que se encuentra en el directorio tcg, originalmente era parte de un compilador C convencional, que luego se adaptó para JIT. Por lo tanto, por ejemplo, la arquitectura objetivo en los términos de este documento ya no es la arquitectura de invitado, sino la arquitectura huésped. En algún momento, apareció otro componente: Tiny Code Interpreter (TCI), que debe ejecutar el código (prácticamente las mismas operaciones internas) en ausencia de un generador de código para una arquitectura huésped específica. De hecho, como se menciona en su documentación, este intérprete puede no funcionar siempre tan bien como lo hace el generador de código JIT, no solo en cantidad en términos de velocidad, sino también en calidad. Aunque no estoy seguro de que su descripción sea completamente actual.

Al principio intenté hacer un backend TCG completo, pero me perdí rápidamente en el código fuente y en la descripción poco clara de las instrucciones de bytecode, así que decidí envolver el intérprete TCI. Esto proporcionó varias ventajas de inmediato:

  • al implementar el generador de código, se podía mirar no en la descripción de las instrucciones, sino en el código del intérprete
  • se pueden generar funciones no para cada bloque de traducción encontrado, sino, por ejemplo, solo después de la centésima ejecución
  • en caso de que el código generado se modifique (lo cual, aparentemente, es posible, según las funciones con nombres que contienen la palabra patch), tendré que invalidar el código JS generado, pero al menos tendré de dónde regenerarlo

En cuanto al tercer punto, no estoy seguro de que el parcheo sea posible después de que el código se ejecute por primera vez, pero los dos primeros puntos son suficientes.

Inicialmente, el código se generó en forma de un gran switch basado en la dirección de la instrucción de bytecode, pero luego, recordando un artículo sobre Emscripten, la optimización del JS generado y el relooping, decidí generar un código más legible, especialmente porque empíricamente resultaba que el único punto de entrada en el bloque de traducción era su inicio. Dicho y hecho, después de un tiempo, obtuve un generador de código que generaba código con ifs (aunque sin ciclos). Pero, sorpresa, fallaba, arrojando un mensaje de que la instrucción había resultado ser de alguna longitud incorrecta. La última instrucción en este nivel de recursión fue brcond. Bien, agregaré una verificación idéntica en la generación de esta instrucción antes de la llamada recursiva y después y… ninguna de ellas se ejecutó, pero después del switch por assert, aún fallaron. Al final, tras examinar el código generado, entendí que después del switch el puntero a la instrucción actual se recargaba desde la pila y, probablemente, se sobrescribía con el código JavaScript generado. Así fue. Aumentar el búfer de un megabyte a diez no llevó a nada, y se hizo evidente que el generador de código estaba atrapado en un bucle. Tuve que verificar que no habíamos salido de los límites del TB actual, y si salimos, señalar la dirección del siguiente TB con signo negativo para poder continuar la ejecución. Además, esto resuelve el problema de "¿qué funciones generadas invalidar si este fragmento de bytecode ha cambiado?" — solo debemos invalidar la función que corresponde a este bloque de traducción. Por cierto, aunque depuré todo en Chromium (ya que uso Firefox y me resulta más fácil usar un navegador separado para experimentos), Firefox me ayudó a corregir incompatibilidades con el estándar asm.js, después de lo cual el código comenzó a funcionar más rápido en Chromium.

Ejemplo de código generado

Compiling 0x15b46d0:
CompiledTB[0x015b46d0] = function(stdlib, ffi, heap) {
"use asm";
var HEAP8 = new stdlib.Int8Array(heap);
var HEAP16 = new stdlib.Int16Array(heap);
var HEAP32 = new stdlib.Int32Array(heap);
var HEAPU8 = new stdlib.Uint8Array(heap);
var HEAPU16 = new stdlib.Uint16Array(heap);
var HEAPU32 = new stdlib.Uint32Array(heap);

var dynCall_iiiiiiiiiii = ffi.dynCall_iiiiiiiiiii;
var getTempRet0 = ffi.getTempRet0;
var badAlignment = ffi.badAlignment;
var _i64Add = ffi._i64Add;
var _i64Subtract = ffi._i64Subtract;
var Math_imul = ffi.Math_imul;
var _mul_unsigned_long_long = ffi._mul_unsigned_long_long;
var execute_if_compiled = ffi.execute_if_compiled;
var getThrew = ffi.getThrew;
var abort = ffi.abort;
var qemu_ld_ub = ffi.qemu_ld_ub;
var qemu_ld_leuw = ffi.qemu_ld_leuw;
var qemu_ld_leul = ffi.qemu_ld_leul;
var qemu_ld_beuw = ffi.qemu_ld_beuw;
var qemu_ld_beul = ffi.qemu_ld_beul;
var qemu_ld_beq = ffi.qemu_ld_beq;
var qemu_ld_leq = ffi.qemu_ld_leq;
var qemu_st_b = ffi.qemu_st_b;
var qemu_st_lew = ffi.qemu_st_lew;
var qemu_st_lel = ffi.qemu_st_lel;
var qemu_st_bew = ffi.qemu_st_bew;
var qemu_st_bel = ffi.qemu_st_bel;
var qemu_st_leq = ffi.qemu_st_leq;
var qemu_st_beq = ffi.qemu_st_beq;

function tb_fun(tb_ptr, env, sp_value, depth) {
  tb_ptr = tb_ptr|0;
  env = env|0;
  sp_value = sp_value|0;
  depth = depth|0;
  var u0 = 0, u1 = 0, u2 = 0, u3 = 0, result = 0;
  var r0 = 0, r1 = 0, r2 = 0, r3 = 0, r4 = 0, r5 = 0, r6 = 0, r7 = 0, r8 = 0, r9 = 0;
  var r10 = 0, r11 = 0, r12 = 0, r13 = 0, r14 = 0, r15 = 0, r16 = 0, r17 = 0, r18 = 0, r19 = 0;
  var r20 = 0, r21 = 0, r22 = 0, r23 = 0, r24 = 0, r25 = 0, r26 = 0, r27 = 0, r28 = 0, r29 = 0;
  var r30 = 0, r31 = 0, r41 = 0, r42 = 0, r43 = 0, r44 = 0;
    r14 = env|0;
    r15 = sp_value|0;
  START: do {
    r0 = HEAPU32[((r14 + (-4))|0) >> 2] | 0;
    r42 = 0;
    result = ((r0|0) != (r42|0))|0;
    HEAPU32[1445307] = r0;
    HEAPU32[1445321] = r14;
    if(result|0) {
    HEAPU32[1445322] = r15;
    return 0x0345bf93|0;
    }
    r0 = HEAPU32[((r14 + (16))|0) >> 2] | 0;
    r42 = 8;
    r0 = ((r0|0) - (r42|0))|0;
    HEAPU32[(r14 + (16)) >> 2] = r0;
    r1 = 8;
    HEAPU32[(r14 + (44)) >> 2] = r1;
    r1 = r0|0;
    HEAPU32[(r14 + (40)) >> 2] = r1;
    r42 = 4;
    r0 = ((r0|0) + (r42|0))|0;
    r2 = HEAPU32[((r14 + (24))|0) >> 2] | 0;
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    HEAPU32[1445309] = r2;
    HEAPU32[1445321] = r14;
    HEAPU32[1445322] = r15;
    qemu_st_lel(env|0, r0|0, r2|0, 34, 22759218);
if(getThrew() | 0) abort();
    r0 = 3241038392;
    HEAPU32[1445307] = r0;
    r0 = qemu_ld_leul(env|0, r0|0, 34, 22759233)|0;
if(getThrew() | 0) abort();
    HEAPU32[(r14 + (24)) >> 2] = r0;
    r1 = HEAPU32[((r14 + (12))|0) >> 2] | 0;
    r2 = HEAPU32[((r14 + (40))|0) >> 2] | 0;
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    HEAPU32[1445309] = r2;
    qemu_st_lel(env|0, r2|0, r1|0, 34, 22759265);
if(getThrew() | 0) abort();
    r0 = HEAPU32[((r14 + (24))|0) >> 2] | 0;
    HEAPU32[(r14 + (40)) >> 2] = r0;
    r1 = 24;
    HEAPU32[(r14 + (52)) >> 2] = r1;
    r42 = 0;
    result = ((r0|0) == (r42|0))|0;
    if(result|0) {
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    }
    HEAPU32[1445307] = r0;
    HEAPU32[1445308] = r1;
    return execute_if_compiled(22759392|0, env|0, sp_value|0, depth|0) | 0;
    return execute_if_compiled(23164080|0, env|0, sp_value|0, depth|0) | 0;
    break;
  } while(1); abort(); return 0|0;
}
return {tb_fun: tb_fun};
}(window, CompilerFFI, Module.buffer)["tb_fun"]

Conclusión

Así que el trabajo aún no está terminado, pero me ha cansado perfeccionar a escondidas este proyecto de larga duración. Por eso decidí publicar lo que tengo por ahora. El código es un poco aterrador en partes, ya que es un experimento, y no está claro de antemano qué se debe hacer. Probablemente, más adelante vale la pena crear commits atómicos normales sobre alguna versión de Qemu más moderna. Por ahora, hay una rama en git en formato de blog: a cada "nivel" que se ha pasado, se le ha añadido un comentario detallado en español. En esencia, este artículo es en gran medida un resumen de los resultados. git log.

Puedes probar todo esto aquí (cuidado, tráfico).

Lo que ya funciona ahora:

  • Funciona el procesador virtual x86
  • Hay un prototipo funcional de generador de código JIT de código máquina a JavaScript
  • Hay un esquema para construir otras arquitecturas huésped de 32 bits: puedes disfrutar ahora mismo viendo cómo se cuelga Linux para la arquitectura MIPS en el navegador durante la carga

Qué más se puede hacer

  • Acelerar la emulación. Incluso en modo JIT, parece que funciona más lento que Virtual x86 (aunque potencialmente hay todo un Qemu con muchas más piezas de hardware y arquitecturas emuladas)
  • Crear una interfaz normal — debo decir que no soy un gran desarrollador web, así que por ahora he adaptado la interfaz estándar de Emscripten como he podido
  • Intentar ejecutar funciones más complejas de Qemu — red, migración de VM, etc.
  • UPD: tendré que enviar mis escasos desarrollos y reportes de errores a Emscripten upstream, como lo hicieron anteriores portadores de Qemu y otros proyectos. Gracias a ellos por permitirme utilizar su contribución a Emscripten para mi tarea.

Fuente: habr.com

Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS 🔥 Compra un hosting fiable para sitios web con protección contra DDoS, servidores VPS VDS | ProHoster