QEMU.js: agora serio e con WASM

Érase unha vez que decidín por divertirme demostrar a reversibilidade do proceso e aprende a xerar JavaScript (máis precisamente, Asm.js) a partir de código máquina. Elixiuse QEMU para o experimento, e un tempo despois escribiuse un artigo sobre Habr. Nos comentarios recomendáronme refacer o proxecto en WebAssembly, e mesmo saír case rematado Eu dalgún xeito non quería o proxecto... O traballo ía avanzando, pero moi lentamente, e agora, hai pouco apareceu nese artigo comentario sobre o tema "Entón, como rematou todo?" En resposta á miña resposta detallada, escoitei "Isto parece un artigo". Pois se podes, haberá un artigo. Quizais alguén lle resulte útil. A partir del, o lector aprenderá algúns datos sobre o deseño dos backends de xeración de código QEMU, así como como escribir un compilador Just-in-Time para unha aplicación web.

tarefas

Como xa aprendera a portar "dalgunha maneira" QEMU a JavaScript, esta vez decidiuse facelo con prudencia e non repetir vellos erros.

Erro número un: bifurcación desde o lanzamento do punto

O meu primeiro erro foi fornear a miña versión da versión upstream 2.4.1. Entón pareceume unha boa idea: se existe un punto de liberación, probablemente sexa máis estable que a simple 2.4, e máis aínda a rama. master. E como planeaba engadir unha boa cantidade dos meus propios erros, non necesitaba os de ninguén. Probablemente así resultou. Pero aquí está a cousa: QEMU non se queda parado, e nalgún momento incluso anunciaron a optimización do código xerado nun 10 por cento. "Si, agora voume conxelar", pensei e estropeei. Aquí temos que facer unha digresión: debido á natureza dun fío único de QEMU.js e ao feito de que o QEMU orixinal non implica a ausencia de fío múltiple (é dicir, a capacidade de operar simultaneamente varias rutas de código non relacionadas, e non só "usar todos os núcleos") é fundamental para iso, as funcións principais dos fíos tiña que "desactivalo" para poder chamar desde fóra. Isto xerou algúns problemas naturais durante a fusión. Con todo, o feito de que algúns dos cambios da rama master, co que tentei fusionar o meu código, tamén foron escollidos na versión de punto (e, polo tanto, na miña rama) probablemente non tería máis comodidade.

En xeral, decidín que aínda ten sentido tirar o prototipo, desmontalo para as pezas e construír unha nova versión desde cero baseada en algo máis fresco e agora de master.

Erro número dous: metodoloxía TLP

En esencia, isto non é un erro, en xeral, é só unha característica de crear un proxecto en condicións de total incomprensión tanto de "onde e como moverse?" e en xeral "chegaremos alí?" Nestas condicións programación torpe era unha opción xustificada, pero, naturalmente, non quería repetila innecesariamente. Desta vez quería facelo con prudencia: compromisos atómicos, cambios conscientes de código (e non "encadear caracteres aleatorios ata que se compila (con avisos)", como dixo unha vez Linus Torvalds sobre alguén, segundo Wikiquote), etc.

Erro número tres: meterse na auga sen coñecer o vado

Aínda non me liberei completamente disto, pero agora decidín non seguir o camiño de menor resistencia e facelo "como adulto", é dicir, escribir o meu backend de TCG desde cero, para non ter que dicir máis tarde: "Si, isto é, por suposto, lentamente, pero non podo controlar todo, así é como se escribe TCI..." Ademais, isto parecía inicialmente unha solución obvia, xa que Xero código binario. Como din, "Gante reuniuseу, pero non ese”: o código é, por suposto, binario, pero o control non se pode transferir simplemente a el; debe ser enviado explícitamente ao navegador para a súa compilación, resultando un determinado obxecto do mundo JS, que aínda debe ser gardado nalgún lugar. Non obstante, nas arquitecturas RISC normais, polo que entendo, unha situación típica é a necesidade de restablecer explícitamente a caché de instrucións para o código rexenerado; se isto non é o que necesitamos, entón, en calquera caso, está preto. Ademais, dende o meu último intento, aprendín que o control non parece transferirse ao medio do bloque de tradución, polo que realmente non necesitamos un bytecode interpretado desde ningún offset, e simplemente podemos xeralo desde a función en TB. .

Viñeron e patearon

Aínda que comecei a reescribir o código en xullo, un golpe máxico pasou desapercibido: normalmente as cartas de GitHub chegan como notificacións sobre respostas a problemas e solicitudes de extracción, pero aquí, de súpeto mención no fío Binaryen como backend de qemu no contexto, "Fixo algo así, quizais diga algo". Estivemos a falar de usar a biblioteca relacionada de Emscripten Binaryen para crear WASM JIT. Ben, dixen que tes alí unha licenza Apache 2.0 e QEMU no seu conxunto distribúese baixo GPLv2 e non son moi compatibles. De súpeto, descubriuse que unha licenza pode ser arranxalo dalgún xeito (Non sei: quizais cambialo, quizais dobre licenza, quizais outra cousa...). Iso, por suposto, fíxome feliz, porque daquela xa o fixera con atención formato binario WebAssembly, e eu estaba dalgún xeito triste e incomprensible. Tamén había unha biblioteca que devoraría os bloques básicos co gráfico de transición, producía o bytecode e ata executaría no propio intérprete, se fose necesario.

Despois houbo máis unha carta na lista de correo de QEMU, pero isto trata máis sobre a pregunta "Quen o necesita de todos os xeitos?" E é de súpeto, resultou que era necesario. Como mínimo, podes xuntar as seguintes posibilidades de uso, se funciona máis ou menos rápido:

  • lanzar algo educativo sen ningunha instalación
  • virtualización en iOS, onde, segundo os rumores, a única aplicación que ten dereito á xeración de código sobre a marcha é un motor JS (é isto certo?)
  • demostración de mini-OS: disquete único, integrado, todo tipo de firmware, etc...

Características do tempo de execución do navegador

Como xa dixen, QEMU está ligado ao multithreading, pero o navegador non o ten. Ben, é dicir, non... Ao principio non existía en absoluto, despois apareceu WebWorkers; polo que entendo, isto é multithreading baseado no paso de mensaxes. sen variables compartidas. Por suposto, isto crea problemas significativos ao portar código existente baseado no modelo de memoria compartida. Despois, baixo a presión pública, tamén se implantou baixo o nome SharedArrayBuffers. Foi introducindo pouco a pouco, celebráronse o seu lanzamento en diferentes navegadores, despois celebraron o Ano Novo, e despois Meltdown... Despois do cal chegaron á conclusión de que a medición do tempo é groseira ou groseira, pero coa axuda da memoria compartida e un fío que aumenta o contador, é igual funcionará con bastante precisión. Así que desactivamos o multithreading con memoria compartida. Parece que despois volveron a acendelo, pero, como quedou claro dende o primeiro experimento, hai vida sen el, e se é así, tentaremos facelo sen depender do multithreading.

A segunda característica é a imposibilidade de manipulacións de baixo nivel coa pila: non pode simplemente tomar, gardar o contexto actual e cambiar a un novo cunha nova pila. A pila de chamadas está xestionada pola máquina virtual JS. Parece que cal é o problema, xa que aínda decidimos xestionar os primeiros fluxos completamente manualmente? O feito é que o bloque de E/S en QEMU se implementa mediante corrutinas, e aquí é onde as manipulacións de pila de baixo nivel serían útiles. Afortunadamente, Emscipten xa contén un mecanismo para operacións asíncronas, incluso dous: Asincronizar и Intérprete. O primeiro funciona a través dun inchazo significativo no código JavaScript xerado e xa non é compatible. A segunda é a "forma correcta" actual e funciona mediante a xeración de bytecode para o intérprete nativo. Funciona, por suposto, lentamente, pero non incha o código. É certo, o soporte para as corrutinas para este mecanismo tiña que ser contribuído de forma independente (xa había corrutinas escritas para Asyncify e había unha implementación de aproximadamente a mesma API para Emterpreter, só precisaba conectalas).

Polo momento, aínda non conseguín dividir o código nun compilado en WASM e interpretado usando Emterpreter, polo que os dispositivos de bloque aínda non funcionan (ver na seguinte serie, como se di...). É dicir, ao final deberías conseguir algo así como esta cousa divertida en capas:

  • E/S de bloque interpretado. Ben, realmente esperabas NVMe emulado con rendemento nativo? 🙂
  • código QEMU principal compilado estáticamente (tradutor, outros dispositivos emulados, etc.)
  • código de convidado compilado dinámicamente en WASM

Características das fontes QEMU

Como probablemente xa adiviñaches, o código para emular arquitecturas convidadas e o código para xerar instrucións da máquina host están separados en QEMU. De feito, é aínda un pouco máis complicado:

  • hai arquitecturas convidadas
  • ten aceleradores, a saber, KVM para virtualización de hardware en Linux (para sistemas convidados e host compatibles entre si), TCG para a xeración de código JIT en calquera lugar. A partir de QEMU 2.9, apareceu soporte para o estándar de virtualización de hardware HAXM en Windows (os detalles)
  • se se usa TCG e non virtualización de hardware, entón ten soporte de xeración de código separado para cada arquitectura host, así como para o intérprete universal
  • ... e arredor de todo isto: periféricos emulados, interface de usuario, migración, reprodución de gravacións, etc.

Por certo, sabías: QEMU pode emular non só todo o ordenador, senón tamén o procesador para un proceso de usuario separado no núcleo host, que é usado, por exemplo, polo fuzzer AFL para a instrumentación binaria. Quizais alguén quere trasladar este modo de operación de QEMU a JS? 😉

Como a maioría do software libre de longa data, QEMU constrúese a través da chamada configure и make. Digamos que decides engadir algo: un backend de TCG, implementación de fíos, algo máis. Non te apresures a estar contento/horrorizado (subliña o caso) ante a perspectiva de comunicarte con Autoconf; de feito, configure A QEMU é aparentemente autoescrita e non se xera a partir de nada.

montaxe web

Entón, que é esta cousa chamada WebAssembly (tamén coñecido como WASM)? Este é un substituto de Asm.js, que xa non pretende ser código JavaScript válido. Pola contra, é puramente binario e optimizado, e incluso simplemente escribir un número enteiro nel non é moi sinxelo: para a compacidade, gárdase no formato LEB128.

Quizais escoitou falar sobre o algoritmo de relooping para Asm.js: esta é a restauración de instrucións de control de fluxo de "alto nivel" (é dicir, if-then-else, bucles, etc.), para as que están deseñados os motores JS, desde o LLVM IR de baixo nivel, máis próximo ao código máquina executado polo procesador. Por suposto, a representación intermedia de QEMU está máis próxima á segunda. Parece que aquí está, bytecode, o fin do tormento... E despois hai bloques, if-then-else e loops!..

E esta é outra razón pola que Binaryen é útil: pode aceptar naturalmente bloques de alto nivel próximos ao que se almacenaría en WASM. Pero tamén pode producir código a partir dun gráfico de bloques básicos e transicións entre eles. Ben, xa dixen que esconde o formato de almacenamento WebAssembly detrás da conveniente API C/C++.

TCG (Xerador de código pequeno)

GTC foi orixinalmente backend para o compilador C. Entón, ao parecer, non puido soportar a competencia con GCC, pero ao final atopou o seu lugar en QEMU como mecanismo de xeración de código para a plataforma host. Tamén hai un backend de TCG que xera algún bytecode abstracto, que o intérprete executa inmediatamente, pero decidín evitar usalo nesta ocasión. Non obstante, o feito de que en QEMU xa é posible habilitar a transición á TB xerada a través da función tcg_qemu_tb_exec, resultoume moi útil.

Para engadir un novo backend de TCG a QEMU, cómpre crear un subdirectorio tcg/<имя архитектуры> (neste caso, tcg/binaryen), e contén dous ficheiros: tcg-target.h и tcg-target.inc.c и rexistrarse trátase de todo configure. Podes poñer alí outros ficheiros, pero, como podes adiviñar polos nomes destes dous, ambos se incluirán nalgún lugar: un como ficheiro de cabeceira normal (está incluído en tcg/tcg.h, e ese xa está noutros ficheiros dos directorios tcg, accel e non só), o outro, só como fragmento de código tcg/tcg.c, pero ten acceso ás súas funcións estáticas.

Decidindo que dedicaría demasiado tempo a investigacións detalladas de como funciona, simplemente copiei os "esqueletos" destes dous ficheiros doutra implementación de backend, indicándoo honestamente na cabeceira da licenza.

arquivo tcg-target.h contén principalmente configuracións no formulario #define-s:

  • cantos rexistros e que ancho hai na arquitectura de destino (temos tantos como queremos, tantos como queiramos; a pregunta é máis sobre o que o navegador xerará en código máis eficiente na arquitectura "completamente de destino") ...)
  • aliñamento das instrucións do host: en x86, e mesmo en TCI, as instrucións non están aliñadas en absoluto, pero vou poñer no búfer de código non instrucións en absoluto, senón punteiros ás estruturas da biblioteca Binaryen, así que vou dicir: 4 bytes
  • que instrucións opcionais pode xerar o backend: incluímos todo o que atopamos en Binaryen, deixamos que o acelerador divida o resto en outros máis sinxelos.
  • Cal é o tamaño aproximado da caché TLB solicitada polo backend. O caso é que en QEMU todo é serio: aínda que hai funcións auxiliares que realizan carga/almacenamento tendo en conta a MMU convidada (onde estariamos sen ela agora?), gardan a súa caché de tradución en forma de estrutura, o cuxo procesamento é conveniente incorporar directamente en bloques de difusión. A pregunta é, que compensación nesta estrutura se procesa de forma máis eficiente mediante unha pequena e rápida secuencia de comandos?
  • aquí podes axustar o propósito dun ou dous rexistros reservados, habilitar a chamada a TB mediante unha función e, opcionalmente, describir un par de pequenos rexistros. inline- Funcións como flush_icache_range (pero este non é o noso caso)

arquivo tcg-target.inc.c, por suposto, adoita ter un tamaño moito maior e contén varias funcións obrigatorias:

  • inicialización, incluíndo restricións sobre que instrucións poden operar sobre que operandos. Copiado descaradamente por min doutro backend
  • función que toma unha instrución interna de código de bytes
  • Tamén podes poñer aquí funcións auxiliares e tamén podes usar funcións estáticas de tcg/tcg.c

Para min, escollín a seguinte estratexia: nas primeiras palabras do seguinte bloque de tradución anotei catro indicadores: unha marca de inicio (un determinado valor nas proximidades). 0xFFFFFFFF, que determinou o estado actual da TB), contexto, módulo xerado e número máxico para a depuración. Ao principio colocouse a marca 0xFFFFFFFF - nonde n - un pequeno número positivo, e cada vez que se executaba a través do intérprete aumentaba en 1. Cando chegaba 0xFFFFFFFE, tivo lugar a compilación, o módulo foi gardado na táboa de funcións, importado nun pequeno "lanzador", no que a execución pasou desde tcg_qemu_tb_exec, e o módulo foi eliminado da memoria QEMU.

Parafraseando os clásicos, "Crutch, canto se entrelaza neste son para o corazón do proger...". Con todo, a memoria estaba a filtrarse nalgún lugar. Ademais, era memoria xestionada por QEMU! Tiven un código que, ao escribir a seguinte instrución (ben, é dicir, un punteiro), eliminaba aquela cuxa ligazón estaba neste lugar antes, pero isto non axudou. En realidade, no caso máis sinxelo, QEMU asigna memoria ao inicio e escribe alí o código xerado. Cando se esgota o búfer, o código bótase fóra e o seguinte comeza a escribirse no seu lugar.

Despois de estudar o código, decateime de que o truco co número máxico permitíame non fallar na destrución do montón ao liberar algo incorrecto nun búfer sen inicializar na primeira pasada. Pero quen reescribe o búfer para evitar a miña función máis tarde? Como aconsellan os desenvolvedores de Emscripten, cando me atopei cun problema, portei o código resultante de novo á aplicación nativa, configurei Mozilla Record-Replay nel... En xeral, ao final decateime dunha cousa sinxela: para cada bloque, a struct TranslationBlock coa súa descrición. Adiviña onde... É certo, xusto antes do bloque xusto no buffer. Ao entender isto, decidín deixar de usar muletas (polo menos algunhas), e simplemente tirei o número máxico e transferín as palabras restantes a struct TranslationBlock, creando unha lista ligada individualmente que se pode percorrer rapidamente cando se restablece a caché de tradución e liberar memoria.

Quedan algunhas muletas: por exemplo, os punteiros marcados no búfer de código; algúns deles son simplemente BinaryenExpressionRef, é dicir, miran as expresións que hai que poñer linealmente no bloque básico xerado, parte é a condición para a transición entre BB, parte é a onde ir. Ben, xa hai bloques preparados para Relooper que deben conectarse segundo as condicións. Para distinguilos, úsase a suposición de que están todos aliñados polo menos catro bytes, polo que pode usar con seguridade os dous bits menos significativos para a etiqueta, só cómpre lembrar de eliminalo se é necesario. Por certo, tales etiquetas xa se usan en QEMU para indicar o motivo para saír do bucle TCG.

Usando Binaryen

Os módulos de WebAssembly conteñen funcións, cada unha das cales contén un corpo, que é unha expresión. As expresións son operacións unarias e binarias, bloques formados por listas doutras expresións, fluxo de control, etc. Como xa dixen, o fluxo de control aquí organízase precisamente como ramas de alto nivel, bucles, chamadas de funcións, etc. Os argumentos das funcións non se pasan na pila, senón explícitamente, como en JS. Tamén hai variables globais, pero eu non as usei, así que non vos vou falar delas.

As funcións tamén teñen variables locais, numeradas desde cero, do tipo: int32 / int64 / float / double. Neste caso, as n primeiras variables locais son os argumentos pasados ​​á función. Teña en conta que, aínda que aquí non todo é de baixo nivel en canto ao fluxo de control, os enteiros aínda non levan o atributo "asinado/sen asinar": como se comporta o número depende do código da operación.

En xeral, Binaryen ofrece C-API simple: creas un módulo, nel crear expresións: unarios, binarios, bloques doutras expresións, controlar o fluxo, etc. Despois creas unha función cunha expresión como corpo. Se, coma min, tes un gráfico de transición de baixo nivel, o compoñente relooper axudarache. Polo que entendo, é posible usar un control de alto nivel do fluxo de execución nun bloque, sempre que non vaia máis alá dos límites do bloque, é dicir, é posible facer un camiño interno rápido / lento. ramificación do camiño dentro do código de procesamento da caché TLB incorporado, pero non para interferir co fluxo de control "externo". Cando liberas un relooper, os seus bloques son liberados, cando liberas un módulo, desaparecen as expresións, funcións, etc. area.

Non obstante, se queres interpretar código ao voo sen crear e eliminar unha instancia de intérprete innecesarias, pode ter sentido poñer esta lóxica nun ficheiro C++ e desde aí xestionar directamente toda a API de C++ da biblioteca, sen pasar por listo. confeccionaron envoltorios.

Entón, para xerar o código que precisa

// настроить глобальные параметры (можно поменять потом)
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);

... se esquecín algo, perdón, isto é só para representar a escala, e os detalles están na documentación.

E agora comeza o crack-fex-pex, algo así:

static char buf[1 << 20];
BinaryenModuleOptimize(MODULE);
BinaryenSetMemory(MODULE, 0, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
int sz = BinaryenModuleWrite(MODULE, buf, sizeof(buf));
BinaryenModuleDispose(MODULE);
EM_ASM({
  var module = new WebAssembly.Module(new Uint8Array(wasmMemory.buffer, $0, $1));
  var fptr = $2;
  var instance = new WebAssembly.Instance(module, {
      'env': {
          'memory': wasmMemory,
          // ...
      }
  );
  // и вот уже у вас есть instance!
}, buf, sz);

Para conectar dalgún xeito os mundos de QEMU e JS e ao mesmo tempo acceder ás funcións compiladas rapidamente, creouse unha matriz (unha táboa de funcións para importar ao iniciador) e colocáronse alí as funcións xeradas. Para calcular rapidamente o índice, utilizouse inicialmente o índice do bloque de tradución de palabra cero, pero despois o índice calculado mediante esta fórmula comezou a encaixar simplemente no campo en struct TranslationBlock.

By the way, programa demostrativo (actualmente cunha licenza turbia) só funciona ben en Firefox. Os desenvolvedores de Chrome foron dalgún xeito non está preparado ao feito de que alguén quere crear máis de mil instancias de módulos WebAssembly, polo que simplemente asignaron un gigabyte de espazo de enderezos virtuais para cada...

Iso é todo por agora. Quizais haxa outro artigo se alguén está interesado. É dicir, queda polo menos facer que os dispositivos de bloque funcionen. Tamén pode ter sentido facer asíncrona a compilación de módulos de WebAssembly, como é habitual no mundo JS, xa que aínda hai un intérprete que pode facer todo isto ata que o módulo nativo estea listo.

Por fin un enigma: compilaches un binario nunha arquitectura de 32 bits, pero o código, mediante operacións de memoria, sube desde Binaryen, nalgún lugar da pila ou noutro lugar dos 2 GB superiores do espazo de enderezos de 32 bits. O problema é que desde o punto de vista de Binaryen isto é acceder a un enderezo resultante demasiado grande. Como evitar isto?

A xeito de administrador

Non acabei probando isto, pero o meu primeiro pensamento foi "E se instalase Linux de 32 bits?" A continuación, a parte superior do espazo de enderezos será ocupada polo núcleo. A única dúbida é canto se ocupará: 1 ou 2 Gb.

A modo de programador (opción para practicantes)

Imos explotar unha burbulla na parte superior do espazo de enderezos. Eu mesmo non entendo por que funciona - alí xa debe haber unha pila. Pero "somos practicantes: todo nos funciona, pero ninguén sabe por que..."

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

... é certo que non é compatible con Valgrind, pero, afortunadamente, o propio Valgrind expulsa a todos de aí de forma moi efectiva :)

Quizais alguén dea unha mellor explicación de como funciona este meu código...

Fonte: www.habr.com

Engadir un comentario