Qemu.js con soporte JIT: aún puedes voltear la carne picada al revés

Hace unos años Fabrice Bellard escrito por jslinux es un emulador de PC escrito en JavaScript. Después de eso hubo al menos más x86 virtuales. Pero todos ellos, hasta donde yo sé, eran intérpretes, mientras que Qemu, escrito mucho antes por el mismo Fabrice Bellard y, probablemente, por cualquier emulador moderno que se precie, utiliza la compilación JIT del código invitado en el código del sistema host. Me pareció que era hora de implementar la tarea opuesta a la que resuelven los navegadores: la compilación JIT de código de máquina en JavaScript, para lo cual parecía más lógico portar Qemu. Parecería que Qemu tiene emuladores más simples y fáciles de usar, el mismo VirtualBox, por ejemplo, instalados y funcionando. Pero Qemu tiene varias características interesantes.

  • fuente abierta
  • capacidad de trabajar sin un controlador de kernel
  • Capacidad para trabajar en modo intérprete.
  • soporte para una gran cantidad de arquitecturas tanto de host como de invitado

Con respecto al tercer punto, ahora puedo explicar que, de hecho, en el modo TCI, no se interpretan las instrucciones de la máquina invitada en sí, sino el código de bytes obtenido de ellas, pero esto no cambia la esencia: para construir y ejecutar. Qemu en una nueva arquitectura, si tiene suerte, un compilador de C es suficiente; se puede posponer la escritura de un generador de código.

Y ahora, después de dos años de jugar tranquilamente con el código fuente de Qemu en mi tiempo libre, apareció un prototipo funcional en el que ya se puede ejecutar, por ejemplo, Kolibri OS.

¿Qué es Emscripten?

Hoy en día han aparecido muchos compiladores, cuyo resultado final es JavaScript. Algunos, como Type Script, fueron pensados ​​originalmente como la mejor manera de escribir para la web. Al mismo tiempo, Emscripten es una forma de tomar código C o C++ existente y compilarlo en un formato legible por el navegador. En En esta página Hemos recopilado muchos puertos de programas conocidos: aquíPor ejemplo, puedes mirar PyPy; por cierto, afirman que ya tienen JIT. De hecho, no todos los programas pueden compilarse y ejecutarse simplemente en un navegador; hay varios características, que sin embargo tienes que aguantar, ya que la inscripción en la misma página dice “Emscripten puede usarse para compilar casi cualquier portátil Código C/C++ a JavaScript". Es decir, hay una serie de operaciones que tienen un comportamiento no definido según el estándar, pero que generalmente funcionan en x86; por ejemplo, el acceso no alineado a variables, que generalmente está prohibido en algunas arquitecturas. En general , Qemu es un programa multiplataforma y, quería creer, y no contiene mucho comportamiento indefinido: tómalo y compílalo, luego modifica un poco con JIT, ¡y listo! Pero ese no es el caso...

Primer intento

En términos generales, no soy la primera persona a la que se le ocurre la idea de migrar Qemu a JavaScript. Hubo una pregunta en el foro de ReactOS si esto era posible usando Emscripten. Incluso antes, hubo rumores de que Fabrice Bellard hizo esto personalmente, pero estábamos hablando de jslinux, que, hasta donde yo sé, es solo un intento de lograr manualmente suficiente rendimiento en JS, y fue escrito desde cero. Más tarde, se escribió Virtual x86: se publicaron fuentes claras y, como se indicó, el mayor "realismo" de la emulación hizo posible utilizar SeaBIOS como firmware. Además, hubo al menos un intento de portar Qemu usando Emscripten; intenté hacer esto par de enchufes, pero el desarrollo, hasta donde tengo entendido, estaba congelado.

Entonces, al parecer, aquí están las fuentes, aquí está Emscripten: tómelo y compílelo. Pero también hay bibliotecas de las que depende Qemu, y bibliotecas de las que dependen esas bibliotecas, etc., y una de ellas es libfi, de lo que depende la simplismo. Había rumores en Internet de que había uno en la gran colección de versiones de bibliotecas para Emscripten, pero de alguna manera era difícil de creer: en primer lugar, no estaba destinado a ser un compilador nuevo, en segundo lugar, era un nivel demasiado bajo. biblioteca para simplemente recoger y compilar en JS. Y no se trata solo de inserciones de ensamblador; probablemente, si lo modifica, para algunas convenciones de llamada puede generar los argumentos necesarios en la pila y llamar a la función sin ellos. Pero Emscripten es algo complicado: para que el código generado le resulte familiar al optimizador del motor JS del navegador, se utilizan algunos trucos. En particular, el llamado relooping, un generador de código que utiliza el LLVM IR recibido con algunas instrucciones de transición abstractas, intenta recrear ifs, bucles, etc. plausibles. Bueno, ¿cómo se pasan los argumentos a la función? Naturalmente, como argumentos para funciones JS, es decir, si es posible, no a través de la pila.

Al principio, surgió la idea de simplemente escribir un reemplazo para libffi con JS y ejecutar pruebas estándar, pero al final me confundí acerca de cómo crear mis archivos de encabezado para que funcionaran con el código existente. ¿Qué puedo hacer? como dicen, “¿Son las tareas tan complejas? ¿Somos tan estúpidos?” Tuve que portar libffi a otra arquitectura, por así decirlo; afortunadamente, Emscripten tiene macros para ensamblaje en línea (en Javascript, sí, bueno, cualquiera que sea la arquitectura, entonces el ensamblador) y la capacidad de ejecutar código generado sobre la marcha. En general, después de jugar con fragmentos de libffi dependientes de la plataforma durante algún tiempo, obtuve un código compilable y lo ejecuté en la primera prueba que encontré. Para mi sorpresa, la prueba fue exitosa. Aturdido por mi genio (no es broma, funcionó desde el primer lanzamiento), yo, todavía sin creer lo que veía, fui a mirar el código resultante nuevamente para evaluar dónde profundizar a continuación. Aquí me volví loco por segunda vez; lo único que hizo mi función fue ffi_call - esto informó una llamada exitosa. No hubo ninguna llamada propiamente dicha. Entonces envié mi primera solicitud de extracción, que corrigió un error en la prueba que es claro para cualquier estudiante de la Olimpiada: los números reales no deben compararse como a == b e incluso como a - b < EPS - También es necesario recordar el módulo, de lo contrario 0 resultará ser muy igual a 1/3... En general, se me ocurrió un determinado puerto de libffi, que pasa las pruebas más simples y con el que simplista es compilado: decidí que sería necesario, lo agregaré más tarde. De cara al futuro, diré que resultó que el compilador ni siquiera incluyó la función libffi en el código final.

Pero, como ya dije, existen algunas limitaciones, y entre el uso gratuito de varios comportamientos indefinidos, se ha ocultado una característica más desagradable: JavaScript por diseño no admite subprocesos múltiples con memoria compartida. En principio, esto puede incluso considerarse una buena idea, pero no para portar código cuya arquitectura está ligada a subprocesos C. En términos generales, Firefox está experimentando con la compatibilidad con trabajadores compartidos y Emscripten tiene una implementación de pthread para ellos, pero no quería depender de ello. Tuve que eliminar lentamente los subprocesos múltiples del código Qemu, es decir, descubrir dónde se ejecutan los subprocesos, mover el cuerpo del bucle que se ejecuta en este subproceso a una función separada y llamar a dichas funciones una por una desde el bucle principal.

Segundo intento

En algún momento, quedó claro que el problema seguía ahí y que empujar al azar con muletas alrededor del código no conduciría a nada bueno. Conclusión: necesitamos sistematizar de alguna manera el proceso de agregar muletas. Por lo tanto, se tomó la versión 2.4.1, que era nueva en ese momento (no la 2.5.0, porque, quién sabe, habrá errores en la nueva versión que aún no se han detectado, y ya tengo suficientes errores propios). ), y lo primero fue reescribirlo de forma segura thread-posix.c. Bueno, es decir, igual de seguro: si alguien intentaba realizar una operación que provocara el bloqueo, la función se llamaba inmediatamente abort() - Por supuesto, esto no resolvió todos los problemas a la vez, pero al menos fue de alguna manera más agradable que recibir silenciosamente datos inconsistentes.

En general, las opciones de Emscripten son muy útiles para migrar código a JS. -s ASSERTIONS=1 -s SAFE_HEAP=1 - detectan algunos tipos de comportamiento indefinido, como llamadas a una dirección no alineada (lo cual no es en absoluto coherente con el código para matrices escritas 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 dije, Qemu tiene un backend interpretativo “degenerado” para la generación de código TCI (tiny code interpreter), y para construir y ejecutar Qemu en una nueva arquitectura, si tienes suerte, un compilador de C es suficiente. "si tienes suerte". Tuve mala suerte y resultó que TCI utiliza acceso no alineado al analizar su código de bytes. Es decir, en todo tipo de arquitecturas ARM y otras con acceso necesariamente nivelado, Qemu compila porque tienen un backend TCG normal que genera código nativo, pero si TCI funcionará en ellos es otra cuestión. Sin embargo, resultó que la documentación de TCI indicaba claramente algo similar. Como resultado, se agregaron al código llamadas a funciones para lectura no alineada, que se descubrieron en otra parte de Qemu.

Destrucción del montón

Como resultado, se corrigió el acceso no alineado a TCI, se creó un bucle principal que a su vez llamaba al procesador, RCU y algunas otras cosas pequeñas. Y entonces lanzo Qemu con la opción -d exec,in_asm,out_asm, lo que significa que debe decir qué bloques de código se están ejecutando y también en el momento de la transmisión escribir qué código de invitado era, en qué código de host se convirtió (en este caso, código de bytes). Se inicia, ejecuta varios bloques de traducción, escribe el mensaje de depuración que dejé de que RCU ahora se iniciará y... falla abort() dentro de una función free(). Jugando con la función free() Logramos descubrir que en el encabezado del bloque del montón, que se encuentra en los ocho bytes que preceden a la memoria asignada, en lugar del tamaño del bloque o algo similar, había basura.

Destrucción del montón: qué lindo... En tal caso, existe un remedio útil: desde (si es posible) las mismas fuentes, ensamblar un binario nativo y ejecutarlo en Valgrind. Después de un tiempo, el binario estaba listo. Lo ejecuto con las mismas opciones: falla incluso durante la inicialización, antes de llegar a la ejecución. Es desagradable, por supuesto, aparentemente las fuentes no eran exactamente las mismas, lo cual no es sorprendente, porque en la configuración busqué opciones ligeramente diferentes, pero tengo Valgrind; primero arreglaré este error y luego, si tengo suerte. , aparecerá el original. Estoy ejecutando lo mismo en Valgrind... Y-y-y, y-y-y, uh-uh, comenzó, pasó por la inicialización normalmente y superó el error original sin una sola advertencia sobre el acceso incorrecto a la memoria, sin mencionar las caídas. La vida, como dicen, no me preparó para esto: un programa que falla deja de fallar cuando se inicia con Walgrind. Lo que fue es un misterio. Mi hipótesis es que una vez cerca de la instrucción actual después de un bloqueo durante la inicialización, gdb mostró trabajo memset-a con un puntero válido usando cualquiera de los dos mmx, ya sea xmm registros, entonces tal vez fue algún tipo de error de alineación, aunque todavía es difícil de creer.

Bien, Valgrind no parece ayudar aquí. Y aquí comenzó lo más repugnante: todo parece incluso comenzar, pero falla por razones completamente desconocidas debido a un evento que podría haber sucedido hace millones de instrucciones. Durante mucho tiempo ni siquiera estuvo claro cómo abordarlo. Al final, todavía tuve que sentarme y depurar. Al imprimir con qué se reescribió el encabezado se demostró que no parecía un número, sino algún tipo de datos binarios. Y, he aquí, esta cadena binaria se encontró en el archivo BIOS, es decir, ahora era posible decir con razonable confianza que se trataba de un desbordamiento del búfer, e incluso está claro que se escribió en este búfer. Bueno, entonces algo como esto: en Emscripten, afortunadamente, no hay aleatorización del espacio de direcciones, tampoco hay agujeros, por lo que puede escribir en algún lugar en el medio del código para generar datos mediante el puntero desde el último lanzamiento, mire los datos, mire el puntero y, si no ha cambiado, piense en algo. Es cierto que después de cualquier cambio se tarda un par de minutos en vincularse, pero ¿qué puedes hacer? Como resultado, se encontró una línea específica que copiaba el BIOS del búfer temporal a la memoria del invitado y, de hecho, no había suficiente espacio en el búfer. Encontrar la fuente de esa extraña dirección de búfer resultó en una función qemu_anon_ram_alloc en archivo oslib-posix.c - la lógica era la siguiente: a veces puede resultar útil alinear la dirección a una página enorme de 2 MB de tamaño, para ello preguntaremos mmap primero un poquito más, y luego devolveremos el sobrante con la ayuda munmap. Y si dicha alineación no es necesaria, indicaremos el resultado en lugar de 2 MB getpagesize() - mmap seguirá dando una dirección alineada... Así que en Emscripten mmap solo llama malloc, pero por supuesto no se alinea en la página. En general, un error que me frustró durante un par de meses se corrigió mediante un cambio en de dos líneas.

Características de las funciones de llamada.

Y ahora el procesador cuenta algo, Qemu no falla, pero la pantalla no se enciende y el procesador rápidamente entra en bucles, a juzgar por la salida. -d exec,in_asm,out_asm. Ha surgido una hipótesis: las interrupciones del temporizador (o, en general, todas las interrupciones) no llegan. De hecho, si desenroscas las interrupciones del ensamblaje nativo, que por alguna razón funcionaron, obtienes una imagen similar. Pero ésta no fue la respuesta en absoluto: una comparación de las trazas emitidas con la opción anterior mostró que las trayectorias de ejecución divergieron muy pronto. Aquí hay que decir que la comparación de lo grabado usando el lanzador emrun La depuración de la salida con la salida del ensamblado nativo no es un proceso completamente mecánico. No sé exactamente cómo se conecta un programa que se ejecuta en un navegador emrun, pero algunas líneas en la salida resultan estar reorganizadas, por lo que la diferencia en la diferencia aún no es una razón para suponer que las trayectorias han divergido. En general, quedó claro que de acuerdo con las instrucciones ljmpl hay una transición a diferentes direcciones y el código de bytes generado es fundamentalmente diferente: uno contiene una instrucción para llamar a una función auxiliar, el otro no. Después de buscar en Google las instrucciones y estudiar el código que traduce estas instrucciones, quedó claro que, en primer lugar, inmediatamente antes en el registro cr0 Se realizó una grabación, también utilizando un asistente, que cambió el procesador al modo protegido y, en segundo lugar, que la versión js nunca cambió al modo protegido. Pero el hecho es que otra característica de Emscripten es su renuencia a tolerar código como la implementación de instrucciones. call en TCI, que cualquier puntero de función da como resultado el tipo long long f(int arg0, .. int arg9) - las funciones deben llamarse con el número correcto de argumentos. Si se viola esta regla, dependiendo de la configuración de depuración, el programa fallará (lo cual es bueno) o llamará a la función incorrecta (lo cual será triste depurar). También hay una tercera opción: habilitar la generación de contenedores que agregan o eliminan argumentos, pero en total estos contenedores ocupan mucho espacio, a pesar de que en realidad solo necesito un poco más de cien contenedores. Esto por sí solo es muy triste, pero resultó haber un problema más grave: en el código generado de las funciones contenedoras, los argumentos se convertían y convertían, pero a veces la función con los argumentos generados no se llamaba, bueno, como en mi implementación de libffi. Es decir, algunos asistentes simplemente no fueron ejecutados.

Afortunadamente, Qemu tiene listas de ayudas legibles por máquina en forma de 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 forma bastante divertida: primero, las macros se redefinen de la forma más extraña DEF_HELPER_ny luego se enciende helper.h. En la medida en que la macro se expande a un inicializador de estructura y una coma, y ​​luego se define una matriz, y en lugar de elementos - #include <helper.h> Como resultado, finalmente tuve la oportunidad de probar la biblioteca en el trabajo. analizando, y se escribió un script que genera exactamente esos contenedores para exactamente las funciones para las que son necesarios.

Y así, después de eso, el procesador pareció funcionar. Parece deberse a que la pantalla nunca se inicializó, aunque memtest86+ pudo ejecutarse en el ensamblado nativo. Aquí es necesario aclarar que el código de E/S del bloque Qemu está escrito en corrutinas. Emscripten tiene su propia implementación muy complicada, pero aún necesitaba ser compatible con el código Qemu, y ahora puedes depurar el procesador: Qemu admite opciones -kernel, -initrd, -append, con el que puedes arrancar Linux o, por ejemplo, memtest86+, sin utilizar ningún dispositivo de bloque. Pero aquí está el problema: en el ensamblado nativo se podía ver la salida del kernel de Linux a la consola con la opción -nographic, y no hay salida desde el navegador al terminal desde donde se inició emrun, no vino. Es decir, no está claro: el procesador no funciona o la salida de gráficos no funciona. Y entonces se me ocurrió esperar un poco. Resultó que "el procesador no está inactivo, sino que simplemente parpadea lentamente", y después de unos cinco minutos, el kernel arrojó un montón de mensajes a la consola y continuó colgándose. Quedó claro que el procesador, en general, funciona y necesitamos profundizar en el código para trabajar con SDL2. Desafortunadamente, no sé cómo usar esta biblioteca, por lo que en algunos lugares tuve que actuar al azar. En algún momento, la línea paralela0 apareció en la pantalla sobre un fondo azul, lo que sugirió algunas ideas. Al final, resultó que el problema era que Qemu abre varias ventanas virtuales en una ventana física, entre las cuales puedes cambiar usando Ctrl-Alt-n: funciona en la compilación nativa, pero no en Emscripten. Después de deshacerse de ventanas innecesarias usando opciones -monitor none -parallel none -serial none e instrucciones para volver a dibujar a la fuerza toda la pantalla en cada cuadro, todo funcionó de repente.

corrutinas

Entonces, la emulación en el navegador funciona, pero no se puede ejecutar nada interesante en un solo disquete, porque no hay E/S de bloque; es necesario implementar soporte para corrutinas. Qemu ya tiene varios backends de rutina, pero debido a la naturaleza de JavaScript y el generador de código Emscripten, no se puede simplemente comenzar a hacer malabarismos con las pilas. Parecería que “se acabó todo, se quita el yeso”, pero los desarrolladores de Emscripten ya se han encargado de todo. Esto se implementa de manera bastante divertida: llamemos sospechosa a una llamada de función como esta emscripten_sleep y varios otros que utilizan el mecanismo Asyncify, así como llamadas a punteros y llamadas a cualquier función donde uno de los dos casos anteriores pueda ocurrir más abajo en la pila. Y ahora, antes de cada llamada sospechosa, seleccionaremos un contexto asíncrono, e inmediatamente después de la llamada, comprobaremos si se ha producido una llamada asíncrona y, si es así, guardaremos todas las variables locales en este contexto asíncrono, indicaremos qué función para transferir el control a cuando necesitamos continuar la ejecución y salir de la función actual. Aquí es donde hay margen para estudiar el efecto despilfarro — para las necesidades de continuar la ejecución del código después de regresar de una llamada asincrónica, el compilador genera "stubs" de la función que comienza después de una llamada sospechosa, como esto: si hay n llamadas sospechosas, entonces la función se expandirá en algún lugar n/2 veces: esto sigue siendo, si no. Tenga en cuenta que después de cada llamada potencialmente asincrónica, debe agregar guardar algunas variables locales a la función original. Posteriormente, incluso tuve que escribir un script simple en Python, que, basado en un conjunto dado de funciones particularmente utilizadas en exceso, que supuestamente "no permiten que la asincronía pase a través de sí mismas" (es decir, la promoción de pila y todo lo que acabo de describir no trabajar en ellos), indica llamadas mediante punteros en qué funciones deben ser ignoradas por el compilador para que dichas funciones no sean consideradas asincrónicas. Y luego los archivos JS de menos de 60 MB son claramente demasiado, digamos al menos 30. Aunque, una vez estaba configurando un script ensamblador y accidentalmente descarté las opciones del vinculador, entre las que se encontraba -O3. Ejecuto el código generado y Chromium consume memoria y falla. Luego miré accidentalmente lo que estaba intentando descargar... Bueno, ¿qué puedo decir? Yo también me habría congelado si me hubieran pedido que estudiara y optimizara cuidadosamente un Javascript de más de 500 MB.

Desafortunadamente, las comprobaciones en el código de la biblioteca de soporte de Asyncify no fueron del todo compatibles con longjmp-s que se utilizan en el código del procesador virtual, pero después de un pequeño parche que desactiva estas comprobaciones y restaura a la fuerza los contextos como si todo estuviera bien, el código funcionó. Y entonces comenzó algo extraño: a veces se activaban comprobaciones en el código de sincronización, las mismas que bloquean el código si, según la lógica de ejecución, debería bloquearse, alguien intentó capturar un mutex ya capturado. Afortunadamente, esto resultó no ser un problema lógico en el código serializado: simplemente estaba usando la funcionalidad de bucle principal estándar proporcionada por Emscripten, pero a veces la llamada asincrónica desenvolvía completamente la pila y en ese momento fallaba. setTimeout desde el bucle principal; por lo tanto, el código ingresó a la iteración del bucle principal sin salir de la iteración anterior. Reescrito en un bucle infinito y emscripten_sleep, y los problemas con los mutex cesaron. El código se ha vuelto incluso más lógico; después de todo, de hecho, no tengo ningún código que prepare el siguiente cuadro de animación; el procesador simplemente calcula algo y la pantalla se actualiza periódicamente. Sin embargo, los problemas no terminaron ahí: a veces la ejecución de Qemu simplemente terminaba silenciosamente sin excepciones ni errores. En ese momento lo dejé, pero, de cara al futuro, diré que el problema era este: el código de rutina, de hecho, no usa setTimeout (o al menos no tan a menudo como podría pensar): función emscripten_yield simplemente establece el indicador de llamada asincrónica. El punto es que emscripten_coroutine_next no es una función asincrónica: internamente verifica la bandera, la restablece y transfiere el control a donde se necesita. Es decir, ahí termina la promoción del stack. El problema fue que debido al uso después de la liberación, que apareció cuando el grupo de rutinas estaba deshabilitado debido al hecho de que no copié una línea importante de código del backend de rutina existente, la función qemu_in_coroutine devolvió verdadero cuando en realidad debería haber devuelto falso. Esto provocó una llamada emscripten_yield, encima del cual no había nadie en la pila emscripten_coroutine_next, la pila se desdobló hasta la parte superior, pero no setTimeout, como ya dije, no fue exhibido.

Generación de código JavaScript

Y aquí, de hecho, está la promesa de “devolver la carne picada”. No precisamente. Por supuesto, si ejecutamos Qemu en el navegador y Node.js en él, entonces, naturalmente, después de generar el código en Qemu obtendremos un JavaScript completamente incorrecto. Pero aún así, algún tipo de transformación inversa.

Primero, un poco sobre cómo funciona Qemu. Perdóneme de inmediato: no soy un desarrollador profesional de Qemu y mis conclusiones pueden ser erróneas en algunos lugares. Como dicen, “la opinión del alumno no tiene por qué coincidir con la opinión del profesor, la axiomática de Peano y el sentido común”. Qemu tiene una cierta cantidad de arquitecturas invitadas compatibles y para cada una hay un directorio como target-i386. Al compilar, puede especificar compatibilidad con varias arquitecturas invitadas, pero el resultado serán solo varios archivos binarios. El código para soportar la arquitectura invitada, a su vez, genera algunas operaciones internas de Qemu, que el TCG (Tiny Code Generator) ya convierte en código de máquina para la arquitectura anfitriona. Como se indica en el archivo Léame ubicado en el directorio tcg, originalmente era parte de un compilador de C normal, que luego se adaptó para JIT. Por lo tanto, por ejemplo, la arquitectura de destino en términos de este documento ya no es una arquitectura invitada, sino una arquitectura anfitriona. En algún momento, apareció otro componente: Tiny Code Interpreter (TCI), que debería ejecutar código (prácticamente las mismas operaciones internas) en ausencia de un generador de código para una arquitectura de host específica. De hecho, tal y como indica su documentación, es posible que este intérprete no siempre funcione tan bien como un generador de código JIT, no sólo cuantitativamente en términos de velocidad, sino también cualitativamente. Aunque no estoy seguro de que su descripción sea completamente relevante.

Al principio intenté crear un backend TCG completo, pero rápidamente me confundí con el código fuente y una descripción no del todo clara de las instrucciones del código de bytes, así que decidí empaquetar el intérprete TCI. Esto dio varias ventajas:

  • Al implementar un generador de código, no puede mirar la descripción de las instrucciones, sino el código del intérprete.
  • puede generar funciones no para cada bloque de traducción encontrado, sino, por ejemplo, solo después de la centésima ejecución
  • Si el código generado cambia (y esto parece posible, a juzgar por las funciones con nombres que contienen la palabra parche), necesitaré invalidar el código JS generado, pero al menos tendré algo desde donde regenerarlo.

Con respecto al tercer punto, no estoy seguro de que sea posible parchear después de ejecutar el código por primera vez, pero los dos primeros puntos son suficientes.

Inicialmente, el código se generó en forma de un interruptor grande en la dirección de la instrucción de código de bytes original, pero luego, recordando el artículo sobre Emscripten, optimización del JS generado y repetición de bucles, decidí generar más código humano, especialmente porque empíricamente Resultó que el único punto de entrada al bloque de traducción es su Inicio. Dicho y hecho, después de un tiempo teníamos un generador de código que generaba código con ifs (aunque sin bucles). Pero mala suerte, se estrelló y dio un mensaje de que las instrucciones tenían una longitud incorrecta. Además, la última instrucción en este nivel de recursividad fue brcond. Bien, agregaré una verificación idéntica a la generación de esta instrucción antes y después de la llamada recursiva y... ninguna de ellas se ejecutó, pero después del cambio de afirmación aún fallaron. Al final, después de estudiar el código generado, me di cuenta de que después del cambio, el puntero a la instrucción actual se recarga desde la pila y probablemente se sobrescribe con el código JavaScript generado. Y así resultó. Aumentar el búfer de un megabyte a diez no condujo a nada y quedó claro que el generador de código estaba funcionando en círculos. Teníamos que comprobar que no habíamos ido más allá de los límites del TB actual y, si lo hacíamos, emitir la dirección del siguiente TB con un signo menos para poder continuar con la ejecución. Además, esto resuelve el problema "¿qué funciones generadas deberían invalidarse si este fragmento de código de bytes ha cambiado?" — sólo es necesario 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 los experimentos), Firefox me ayudó a corregir las incompatibilidades con el estándar asm.js, después de lo cual el código comenzó a funcionar más rápido en Cromo.

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

Entonces, el trabajo aún no está terminado, pero estoy cansado de perfeccionar en secreto esta construcción de largo plazo. Por eso, decidí publicar lo que tengo por ahora. El código da un poco de miedo en algunos lugares, porque se trata de un experimento y no está claro de antemano qué se debe hacer. Probablemente, entonces valga la pena emitir confirmaciones atómicas normales además de alguna versión más moderna de Qemu. Mientras tanto, en el Gita hay un hilo en formato blog: para cada “nivel” que se ha superado al menos de alguna manera, se ha añadido un comentario detallado en ruso. En realidad, este artículo es en gran medida un recuento de la conclusión. git log.

Puedes probarlo todo aquí (cuidado con el tráfico).

Lo que ya está funcionando:

  • Procesador virtual x86 en ejecución
  • Existe un prototipo funcional de un generador de código JIT desde código de máquina a JavaScript
  • Hay una plantilla para ensamblar otras arquitecturas invitadas de 32 bits: ahora mismo puede admirar Linux para la arquitectura MIPS que se congela en el navegador en la etapa de carga.

Qué más puedes hacer

  • Acelera la emulación. Incluso en modo JIT parece funcionar más lento que Virtual x86 (pero existe potencialmente un Qemu completo con una gran cantidad de hardware y arquitecturas emuladas)
  • Para crear una interfaz normal, francamente, no soy un buen desarrollador web, así que por ahora he rehecho el shell estándar de Emscripten lo mejor que puedo.
  • Intente iniciar funciones Qemu más complejas: redes, migración de VM, etc.
  • UPD: deberá enviar sus pocos desarrollos e informes de errores a Emscripten en sentido ascendente, como lo hicieron los portadores anteriores de Qemu y otros proyectos. Gracias a ellos por poder utilizar implícitamente su contribución a Emscripten como parte de mi tarea.

Fuente: habr.com

Añadir un comentario