QEMU.js: ara seriós i amb WASM

Hi havia una vegada que vaig decidir per diversió demostrar la reversibilitat del procés i aprendre a generar JavaScript (més precisament, Asm.js) a partir del codi màquina. Es va triar QEMU per a l'experiment, i un temps després es va escriure un article sobre Habr. Als comentaris em van aconsellar que referís el projecte a WebAssembly, i fins i tot em deixés quasi acabat D'alguna manera no volia el projecte... La feina anava avançant, però molt lentament, i ara, fa poc va aparèixer aquest article comentari sobre el tema "Llavors, com va acabar tot?" En resposta a la meva resposta detallada, vaig sentir "Això sembla un article". Bé, si pots, hi haurà un article. Potser algú li serà útil. A partir d'ell, el lector aprendrà alguns fets sobre el disseny dels backends de generació de codi QEMU, així com com escriure un compilador Just-in-Time per a una aplicació web.

tasques

Com que ja havia après a "d'alguna manera" portar QEMU a JavaScript, aquesta vegada es va decidir fer-ho amb prudència i no repetir vells errors.

Error número u: bifurcació des del llançament del punt

El meu primer error va ser bifurcar la meva versió de la versió amunt 2.4.1. Aleshores em va semblar una bona idea: si existeix l'alliberament de punts, probablement és més estable que el simple 2.4, i encara més la branca. master. I com que tenia previst afegir una bona quantitat dels meus propis errors, no necessitava els de ningú més. Segurament així va resultar. Però aquí està la cosa: QEMU no s'atura, i en algun moment fins i tot van anunciar l'optimització del codi generat en un 10 per cent. "Sí, ara em congelaré", vaig pensar i em vaig trencar. Aquí hem de fer una digressió: a causa de la naturalesa d'un sol fil de QEMU.js i el fet que el QEMU original no implica l'absència de multiprocés (és a dir, la capacitat d'operar simultàniament diversos camins de codi no relacionats, i no només "utilitzar tots els nuclis") és fonamental per a això, les funcions principals dels fils les vaig haver de "desactivar" per poder trucar des de fora. Això va crear alguns problemes naturals durant la fusió. No obstant això, el fet que alguns dels canvis de la branca master, amb el qual vaig intentar fusionar el meu codi, també es van escollir en el llançament puntual (i, per tant, a la meva branca) probablement no hauria afegit comoditat.

En general, vaig decidir que encara té sentit llençar el prototip, desmuntar-lo per a les peces i construir una nova versió des de zero basada en quelcom més fresc i ara de master.

Error número dos: metodologia TLP

En essència, això no és un error, en general, és només una característica de la creació d'un projecte en condicions de total incomprensió tant de "on i com moure'ns?" i ​​en general "hi arribarem?" En aquestes condicions programació maldestra era una opció justificada, però, naturalment, no volia repetir-la innecessàriament. Aquesta vegada he volgut fer-ho amb prudència: compromesos atòmics, canvis de codi conscients (i no “encadenar caràcters aleatoris fins que es compile (amb avisos)”, com va dir alguna vegada Linus Torvalds sobre algú, segons Viquicita), etc.

Error número tres: entrar a l'aigua sense conèixer el gual

Encara no m'he desfet del tot d'això, però ara he decidit no seguir el camí de la menor resistència i fer-ho de la "manera dels grans", és a dir, escriure el meu backend de TCG des de zero, per no haver de dir més tard: "Sí, això és, per descomptat, lentament, però no puc controlar-ho tot, així és com està escrit TCI..." A més, això inicialment semblava una solució òbvia, ja que Genero codi binari. Com diuen, “Gant es va reunirу, però no aquell": el codi és, per descomptat, binari, però el control no es pot transferir-hi simplement; s'ha d'enviar explícitament al navegador per a la seva compilació, donant lloc a un determinat objecte del món JS, que encara necessita ser guardat en algun lloc. Tanmateix, a les arquitectures RISC normals, pel que entenc, una situació típica és la necessitat de restablir explícitament la memòria cau d'instruccions per al codi regenerat; si això no és el que necessitem, en qualsevol cas, està a prop. A més, des del meu darrer intent, vaig aprendre que el control sembla que no es transfereix al centre del bloc de traducció, per la qual cosa no necessitem el bytecode interpretat des de cap desplaçament, i simplement el podem generar des de la funció de TB. .

Van venir i van donar puntades

Tot i que vaig començar a reescriure el codi al juliol, una puntada màgica va passar desapercebuda: normalment les cartes de GitHub arriben com a notificacions sobre respostes a problemes i sol·licituds d'extracció, però aquí, de sobte menció al fil Binaryen com a backend de qemu en el context, "Va fer una cosa així, potser dirà alguna cosa". Estàvem parlant d'utilitzar la biblioteca relacionada d'Emscripten Binaryen per crear WASM JIT. Bé, he dit que tens una llicència d'Apache 2.0 allà, i QEMU en conjunt es distribueix sota GPLv2, i no són gaire compatibles. De sobte va resultar que una llicència pot ser arreglar-ho d'alguna manera (No ho sé: potser canviar-ho, potser doble llicència, potser una altra cosa...). Això, és clar, em va fer feliç, perquè en aquell moment ja m'havia mirat de prop format binari WebAssembly, i jo estava d'alguna manera trist i incomprensible. També hi havia una biblioteca que devoraria els blocs bàsics amb el gràfic de transició, produïa el bytecode i, fins i tot, l'executaria al mateix intèrpret, si calia.

Després hi va haver més una carta a la llista de correu QEMU, però això és més sobre la pregunta: "Qui ho necessita de totes maneres?" I és així de sobte, va resultar que era necessari. Com a mínim, podeu reunir les següents possibilitats d'ús, si funciona més o menys ràpidament:

  • llançant alguna cosa educativa sense cap instal·lació
  • virtualització a iOS, on, segons els rumors, l'única aplicació que té dret a la generació de codi sobre la marxa és un motor JS (és cert?)
  • demostració de mini-OS: un sol disquet, integrat, tot tipus de microprogramari, etc...

Funcions de temps d'execució del navegador

Com ja he dit, QEMU està lligat al multithreading, però el navegador no en té. Bé, és a dir, no... Al principi no existia gens, després va aparèixer WebWorkers, pel que entenc, això és multithreading basat en el pas de missatges sense variables compartides. Naturalment, això crea problemes importants a l'hora de portar el codi existent basat en el model de memòria compartida. Després, sota la pressió pública, també es va implantar amb el nom SharedArrayBuffers. Es va anar introduint a poc a poc, van celebrar el seu llançament en diferents navegadors, després van celebrar l'Any Nou, i després Meltdown... Després de la qual cosa van arribar a la conclusió que la mesura del temps és tosca o grossa, però amb l'ajuda de la memòria compartida i un fil augmentant el comptador, és igual funcionarà amb força precisió. Així que vam desactivar el multithreading amb memòria compartida. Sembla que després el van tornar a encendre, però, com va quedar clar des del primer experiment, hi ha vida sense ell, i si és així, intentarem fer-ho sense dependre del multithreading.

La segona característica és la impossibilitat de manipulacions de baix nivell amb la pila: no podeu simplement agafar, desar el context actual i canviar-ne de nou amb una nova pila. La pila de trucades la gestiona la màquina virtual JS. Sembla ser, quin és el problema, ja que encara vam decidir gestionar els fluxos anteriors completament manualment? El fet és que el bloc d'E/S a QEMU s'implementa mitjançant corrutines, i aquí és on les manipulacions de la pila de baix nivell serien útils. Afortunadament, Emscipten ja conté un mecanisme per a operacions asíncrones, fins i tot dos: Asincronitzar и Emperter. El primer funciona mitjançant una inflor important al codi JavaScript generat i ja no és compatible. La segona és la "forma correcta" actual i funciona mitjançant la generació de bytecode per a l'intèrpret natiu. Funciona, per descomptat, lentament, però no augmenta el codi. És cert que el suport per a corrutines per a aquest mecanisme s'havia de contribuir de manera independent (ja hi havia corrutines escrites per a Asyncify i hi havia una implementació d'aproximadament la mateixa API per a Emterpreter, només calia connectar-les).

De moment, encara no he aconseguit dividir el codi en un de compilat en WASM i interpretat amb Emterpreter, de manera que els dispositius de bloc encara no funcionen (vegeu a la sèrie següent, com diuen...). És a dir, al final hauríeu d'obtenir alguna cosa com aquesta cosa divertida en capes:

  • E/S de bloc interpretada. Bé, realment esperaveu NVMe emulat amb un rendiment natiu? 🙂
  • codi QEMU principal compilat estàticament (traductor, altres dispositius emulats, etc.)
  • codi de convidat compilat dinàmicament a WASM

Característiques de les fonts QEMU

Com probablement ja heu endevinat, el codi per emular arquitectures convidades i el codi per generar instruccions de la màquina host estan separats a QEMU. De fet, és encara una mica més complicat:

  • hi ha arquitectures convidades
  • hi acceleradors, és a dir, KVM per a la virtualització de maquinari a Linux (per a sistemes host i host compatibles entre si), TCG per a la generació de codi JIT a qualsevol lloc. A partir de QEMU 2.9, va aparèixer el suport per a l'estàndard de virtualització de maquinari HAXM a Windows (els detalls)
  • si s'utilitza TCG en comptes de la virtualització de maquinari, té suport per a la generació de codi independent per a cada arquitectura d'amfitrió, així com per a l'intèrpret universal.
  • ... i al voltant de tot això: perifèrics emulats, interfície d'usuari, migració, reproducció de registres, etc.

Per cert, sabíeu: QEMU pot emular no només tot l'ordinador, sinó també el processador per a un procés d'usuari independent al nucli amfitrió, que s'utilitza, per exemple, pel fuzzer AFL per a la instrumentació binària. Potser algú li agradaria portar aquest mode d'operació de QEMU a JS? 😉

Com la majoria del programari lliure de llarga data, QEMU es construeix mitjançant la trucada configure и make. Suposem que decidiu afegir alguna cosa: un backend de TCG, implementació de fils, una altra cosa. No tingueu pressa per estar content/horroritzat (subratlleu si escau) davant la perspectiva de comunicar-vos amb Autoconf; de fet, configure El QEMU aparentment està escrit per si mateix i no es genera a partir de res.

WebAssembly

Aleshores, què és aquesta cosa anomenada WebAssembly (també conegut com WASM)? Aquest és un reemplaçament d'Asm.js, ja no pretén ser codi JavaScript vàlid. Al contrari, és purament binari i optimitzat, i fins i tot escriure-hi un nombre enter no és molt senzill: per a la compacitat, s'emmagatzema en el format LEB128.

És possible que hagis sentit a parlar de l'algoritme de relooping per a Asm.js: aquesta és la restauració d'instruccions de control de flux "d'alt nivell" (és a dir, if-then-else, bucles, etc.), per a les quals estan dissenyats els motors JS, des de el LLVM IR de baix nivell, més proper al codi màquina executat pel processador. Naturalment, la representació intermèdia de QEMU és més propera a la segona. Sembla que aquí està, bytecode, el final del turment... I després hi ha blocs, if-then-else i bucles!...

I aquesta és una altra raó per la qual Binaryen és útil: pot acceptar naturalment blocs d'alt nivell propers al que s'emmagatzemaria a WASM. Però també pot produir codi a partir d'un gràfic de blocs bàsics i transicions entre ells. Bé, ja he dit que amaga el format d'emmagatzematge WebAssembly darrere de la còmoda API C/C++.

TCG (generador de codi petit)

TCG era originàriament backend per al compilador C. Aleshores, pel que sembla, no va poder resistir la competència amb GCC, però al final va trobar el seu lloc a QEMU com a mecanisme de generació de codi per a la plataforma amfitriona. També hi ha un backend TCG que genera un bytecode abstracte, que l'intèrpret executa immediatament, però vaig decidir evitar-lo aquesta vegada. Tanmateix, el fet que a QEMU ja és possible habilitar la transició a la TB generada a través de la funció tcg_qemu_tb_exec, em va resultar molt útil.

Per afegir un nou backend de TCG a QEMU, heu de crear un subdirectori tcg/<имя архитектуры> (en aquest cas, tcg/binaryen), i conté dos fitxers: tcg-target.h и tcg-target.inc.c и prescriure es tracta de tot configure. Podeu posar-hi altres fitxers, però, com podeu endevinar pels noms d'aquests dos, tots dos s'inclouran en algun lloc: un com a fitxer de capçalera normal (s'inclou a tcg/tcg.h, i aquest ja es troba en altres fitxers dels directoris tcg, accel i no només), l'altre, només com a fragment de codi tcg/tcg.c, però té accés a les seves funcions estàtiques.

Decidint que dedicaria massa temps a investigacions detallades de com funciona, simplement vaig copiar els "esquelets" d'aquests dos fitxers d'una altra implementació de backend, indicant-ho honestament a la capçalera de la llicència.

expedient tcg-target.h conté principalment configuracions al formulari #define-s:

  • quants registres i quina amplada hi ha a l'arquitectura de destinació (tenim tants com volem, tants com vulguem; la pregunta és més sobre què es generarà en codi més eficient pel navegador a l'arquitectura "completament objectiu" ...)
  • alineació de les instruccions de l'amfitrió: a x86, i fins i tot a TCI, les instruccions no estan alineades en absolut, però posaré a la memòria intermèdia de codi no instruccions, sinó punters a estructures de biblioteca Binaryen, així que diré: 4 bytes
  • quines instruccions opcionals pot generar el backend: incloem tot el que trobem a Binaryen, deixem que l'accelerador divideixi la resta en altres més simples.
  • Quina és la mida aproximada de la memòria cau TLB sol·licitada pel backend. El cas és que a QEMU tot és seriós: encara que hi ha funcions d'ajuda que realitzen càrrega/emmagatzematge tenint en compte la MMU convidada (on estaríem ara sense ella?), guarden la seva memòria cau de traducció en forma d'estructura, la processament del qual és convenient incrustar directament als blocs de difusió. La pregunta és, quin desplaçament en aquesta estructura es processa de manera més eficient mitjançant una seqüència petita i ràpida d'ordres?
  • aquí podeu modificar el propòsit d'un o dos registres reservats, habilitar la trucada a TB mitjançant una funció i, opcionalment, descriure un parell de petits registres. inline-funcions com flush_icache_range (però aquest no és el nostre cas)

expedient tcg-target.inc.c, per descomptat, sol ser molt més gran i conté diverses funcions obligatòries:

  • inicialització, incloses les restriccions sobre quines instruccions poden operar amb quins operands. Copiat descaradament per mi des d'un altre backend
  • funció que pren una instrucció interna de codi de bytes
  • També podeu posar funcions auxiliars aquí i també podeu utilitzar funcions estàtiques des de tcg/tcg.c

Per a mi, vaig escollir l'estratègia següent: a les primeres paraules del següent bloc de traducció, vaig escriure quatre apunts: una marca d'inici (un cert valor al voltant 0xFFFFFFFF, que va determinar l'estat actual de la TB), el context, el mòdul generat i el número màgic per a la depuració. Al principi es va col·locar la marca 0xFFFFFFFF - nOn n - un petit nombre positiu, i cada vegada que s'executava a través de l'intèrpret augmentava en 1. Quan arribava 0xFFFFFFFE, va tenir lloc la compilació, el mòdul es va desar a la taula de funcions, importat a un petit "llançador", al qual va passar l'execució tcg_qemu_tb_exec, i el mòdul es va eliminar de la memòria QEMU.

Parafrasejant els clàssics, "Crutch, quant s'entrellaça en aquest so per al cor del proger...". Tanmateix, la memòria es filtrava en algun lloc. A més, era memòria gestionada per QEMU! Tenia un codi que, en escriure la següent instrucció (bé, és a dir, un punter), eliminava el que tenia l'enllaç abans en aquest lloc, però això no va ajudar. De fet, en el cas més senzill, QEMU assigna memòria a l'inici i escriu el codi generat allà. Quan el buffer s'esgota, el codi es llença i el següent comença a escriure's al seu lloc.

Després d'estudiar el codi, em vaig adonar que el truc amb el número màgic em va permetre no fallar en la destrucció de pila alliberant alguna cosa malament en un buffer no inicialitzat a la primera passada. Però, qui reescriu el buffer per evitar la meva funció més tard? Tal com aconsellen els desenvolupadors d'Emscripten, quan em vaig trobar amb un problema, vaig tornar a portar el codi resultant a l'aplicació nativa, vaig configurar-hi Mozilla Record-Replay... En general, al final em vaig adonar d'una cosa senzilla: per a cada bloc, a struct TranslationBlock amb la seva descripció. Endevina on... És així, just abans del bloc just al buffer. En adonar-me d'això, vaig decidir deixar d'utilitzar crosses (almenys algunes), i simplement vaig llençar el número màgic i vaig transferir les paraules restants a struct TranslationBlock, creant una llista enllaçada individualment que es pot recórrer ràpidament quan es restableix la memòria cau de traducció i alliberar memòria.

Queden algunes crosses: per exemple, punters marcats a la memòria intermèdia de codi; alguns d'ells són simplement BinaryenExpressionRef, és a dir, observen les expressions que s'han de posar linealment al bloc bàsic generat, una part és la condició per a la transició entre BB, una part és cap a on anar. Bé, ja hi ha blocs preparats per a Relooper que s'han de connectar segons les condicions. Per distingir-los, s'utilitza el supòsit que tots estan alineats almenys per quatre bytes, de manera que podeu utilitzar amb seguretat els dos bits menys significatius per a l'etiqueta, només cal que recordeu eliminar-lo si cal. Per cert, aquestes etiquetes ja s'utilitzen a QEMU per indicar el motiu per sortir del bucle TCG.

Utilitzant Binaryen

Els mòduls de WebAssembly contenen funcions, cadascuna de les quals conté un cos, que és una expressió. Les expressions són operacions unàries i binàries, blocs formats per llistes d'altres expressions, flux de control, etc. Com ja he dit, el flux de control aquí s'organitza precisament com a branques d'alt nivell, bucles, trucades de funcions, etc. Els arguments de les funcions no es passen a la pila, sinó de manera explícita, igual que a JS. També hi ha variables globals, però no les he fet servir, així que no us en parlaré.

Les funcions també tenen variables locals, numerades des de zero, de tipus: int32 / int64 / float / double. En aquest cas, les n primeres variables locals són els arguments passats a la funció. Tingueu en compte que, tot i que tot aquí no és del tot de baix nivell pel que fa al flux de control, els nombres enters encara no porten l'atribut "signat/sense signar": com es comporta el nombre depèn del codi d'operació.

En termes generals, Binaryen ofereix C-API simple: creeu un mòdul, en ell crear expressions: unàries, binàries, blocs d'altres expressions, control de flux, etc. A continuació, creeu una funció amb una expressió com a cos. Si, com jo, teniu un gràfic de transició de baix nivell, el component relooper us ajudarà. Pel que entenc, és possible utilitzar un control d'alt nivell del flux d'execució en un bloc, sempre que no superi els límits del bloc, és a dir, és possible fer un camí ràpid / lent intern. ramificació del camí dins del codi de processament de la memòria cau TLB integrat, però no per interferir amb el flux de control "extern". Quan alliberes un relooper, els seus blocs s'alliberen, quan alliberes un mòdul, desapareixen les expressions, funcions, etc. arena.

Tanmateix, si voleu interpretar codi sobre la marxa sense crear i suprimir innecessàriament una instància d'intèrpret, pot tenir sentit posar aquesta lògica en un fitxer C++ i des d'allà gestionar directament tota l'API C++ de la biblioteca, sense passar per embolcalls fets.

Així que per generar el codi que necessiteu

// настроить глобальные параметры (можно поменять потом)
BinaryenSetAPITracing(0);

BinaryenSetOptimizeLevel(3);
BinaryenSetShrinkLevel(2);

// создать модуль
BinaryenModuleRef MODULE = BinaryenModuleCreate();

// описать типы функций (как создаваемых, так и вызываемых)
helper_type  BinaryenAddFunctionType(MODULE, "helper-func", BinaryenTypeInt32(), int32_helper_args, ARRAY_SIZE(int32_helper_args));
// (int23_helper_args приоб^Wсоздаются отдельно)

// сконструировать супер-мега выражение
// ... ну тут уж вы как-нибудь сами :)

// потом создать функцию
BinaryenAddFunction(MODULE, "tb_fun", tb_func_type, func_locals, FUNC_LOCALS_COUNT, expr);
BinaryenAddFunctionExport(MODULE, "tb_fun", "tb_fun");
...
BinaryenSetMemory(MODULE, (1 << 15) - 1, -1, NULL, NULL, NULL, NULL, NULL, 0, 0);
BinaryenAddMemoryImport(MODULE, NULL, "env", "memory", 0);
BinaryenAddTableImport(MODULE, NULL, "env", "tb_funcs");

// запросить валидацию и оптимизацию при желании
assert (BinaryenModuleValidate(MODULE));
BinaryenModuleOptimize(MODULE);

... si he oblidat alguna cosa, ho sento, això és només per representar l'escala, i els detalls es troben a la documentació.

I ara comença el crack-fex-pex, una cosa així:

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

Per tal de connectar d'alguna manera els mons de QEMU i JS i al mateix temps accedir ràpidament a les funcions compilades, es va crear una matriu (una taula de funcions per importar al llançador) i les funcions generades s'hi van col·locar. Per calcular ràpidament l'índex, inicialment es va utilitzar l'índex del bloc de traducció de paraules zero, però després l'índex calculat mitjançant aquesta fórmula va començar a encaixar simplement al camp de struct TranslationBlock.

Per cert, manifestació (actualment amb una llicència tèrbola) només funciona bé al Firefox. Els desenvolupadors de Chrome ho eren d'alguna manera no està llest al fet que algú voldria crear més de mil instàncies de mòduls WebAssembly, de manera que simplement va assignar un gigabyte d'espai d'adreces virtuals per a cadascun...

Això és tot per ara. Potser hi haurà un altre article si algú està interessat. És a dir, queda almenys només fer que els dispositius de bloc funcionin. També podria tenir sentit fer asíncrona la compilació dels mòduls WebAssembly, com és habitual al món JS, ja que encara hi ha un intèrpret que pot fer tot això fins que el mòdul natiu estigui preparat.

Finalment un enigma: heu compilat un binari en una arquitectura de 32 bits, però el codi, mitjançant operacions de memòria, puja des de Binaryen, en algun lloc de la pila o en algun altre lloc dels 2 GB superiors de l'espai d'adreces de 32 bits. El problema és que, des del punt de vista de Binaryen, això és accedir a una adreça resultant massa gran. Com evitar això?

A la manera de l'administrador

No vaig acabar provant això, però el meu primer pensament va ser "I si instal·lés Linux de 32 bits?" Aleshores, la part superior de l'espai d'adreces estarà ocupada pel nucli. L'única pregunta és quant s'ocuparà: 1 o 2 Gb.

A la manera d'un programador (opció per a professionals)

Anem a bufar una bombolla a la part superior de l'espai d'adreces. Jo mateix no entenc per què funciona, allà ja hi ha d'haver una pila. Però "som practicants: tot ens funciona, però ningú sap per què..."

// 2gbubble.c
// Usage: LD_PRELOAD=2gbubble.so <program>

#include <sys/mman.h>
#include <assert.h>

void __attribute__((constructor)) constr(void)
{
  assert(MAP_FAILED != mmap(1u >> 31, (1u >> 31) - (1u >> 20), PROT_NONE, MAP_ANONYMOUS | MAP_PRIVATE, -1, 0));
}

... és cert que no és compatible amb Valgrind, però, afortunadament, el mateix Valgrind expulsa a tothom de manera molt efectiva :)

Potser algú donarà una millor explicació de com funciona aquest codi meu...

Font: www.habr.com

Afegeix comentari