Qemu.js con supporto JIT: puoi ancora girare il trito all'indietro

Qualche anno fa Fabrice Bellard scritto da jslinux è un emulatore PC scritto in JavaScript. Dopo di che ce n'era almeno di più X86 virtuale. Ma tutti loro, per quanto ne so, erano interpreti, mentre Qemu, scritto molto prima dallo stesso Fabrice Bellard, e, probabilmente, qualsiasi emulatore moderno che si rispetti, utilizza la compilazione JIT del codice ospite nel codice del sistema host. Mi è sembrato che fosse giunto il momento di implementare il compito opposto rispetto a quello risolto dai browser: la compilazione JIT del codice macchina in JavaScript, per il quale sembrava più logico eseguire il porting di Qemu. Sembrerebbe, perché Qemu, ci sono emulatori più semplici e intuitivi - lo stesso VirtualBox, per esempio - installati e funzionanti. Ma Qemu ha diverse caratteristiche interessanti

  • fonte aperta
  • capacità di lavorare senza un driver del kernel
  • capacità di lavorare in modalità interprete
  • supporto per un gran numero di architetture host e guest

Per quanto riguarda il terzo punto, ora posso spiegare che in realtà, in modalità TCI, non vengono interpretate le istruzioni stesse della macchina ospite, ma il bytecode ottenuto da esse, ma questo non cambia l'essenza - per costruire ed eseguire Qemu su una nuova architettura, se sei fortunato, è sufficiente un compilatore C: la scrittura di un generatore di codice può essere posticipata.

E ora, dopo due anni di piacevole armeggiare con il codice sorgente di Qemu nel tempo libero, è apparso un prototipo funzionante, in cui puoi già eseguire, ad esempio, il sistema operativo Kolibri.

Cos'è Emscripten

Al giorno d'oggi sono apparsi molti compilatori, il cui risultato finale è JavaScript. Alcuni, come Type Script, erano originariamente pensati per essere il modo migliore per scrivere per il web. Allo stesso tempo, Emscripten è un modo per prendere il codice C o C++ esistente e compilarlo in un formato leggibile dal browser. SU questa pagina Abbiamo raccolto molti port di programmi famosi: quiAd esempio, puoi guardare PyPy: a proposito, affermano di avere già JIT. In effetti, non tutti i programmi possono essere semplicemente compilati ed eseguiti in un browser: ce ne sono alcuni lineamenti, che però bisogna sopportare, poiché l'iscrizione sulla stessa pagina dice “Emscripten può essere usato per compilare quasi tutti portatile Codice C/C++ in JavaScript". Cioè, ci sono una serie di operazioni che hanno un comportamento non definito secondo lo standard, ma di solito funzionano su x86, ad esempio l'accesso non allineato alle variabili, che è generalmente proibito su alcune architetture. In generale , Qemu è un programma multipiattaforma e, volevo crederci, non contiene già molti comportamenti indefiniti: prendilo e compila, quindi armeggia un po' con JIT - e il gioco è fatto! Ma non è questo il caso...

Primo tentativo

In generale, non sono il primo a cui è venuta l’idea di portare Qemu su JavaScript. È stata posta una domanda sul forum ReactOS se ciò fosse possibile utilizzando Emscripten. Anche prima circolavano voci secondo cui Fabrice Bellard lo avrebbe fatto personalmente, ma stavamo parlando di jslinux, che, per quanto ne so, è solo un tentativo di ottenere manualmente prestazioni sufficienti in JS, ed è stato scritto da zero. Successivamente è stato scritto Virtual x86: per esso sono state pubblicate fonti non offuscate e, come affermato, il maggiore "realismo" dell'emulazione ha permesso di utilizzare SeaBIOS come firmware. Inoltre, c'è stato almeno un tentativo di porting di Qemu utilizzando Emscripten: ho provato a farlo coppia di prese, ma lo sviluppo, per quanto ho capito, è stato congelato.

Quindi, sembrerebbe, ecco le fonti, ecco Emscripten: prendilo e compila. Ma ci sono anche librerie da cui dipende Qemu, e librerie da cui dipendono quelle librerie, ecc., e una di queste è libffi, da cui dipende la disinvoltura. Circolavano voci su Internet che ce n'era uno nella vasta raccolta di port di biblioteche per Emscripten, ma in qualche modo era difficile da credere: in primo luogo, non era destinato a essere un nuovo compilatore, in secondo luogo, era un livello troppo basso libreria da raccogliere e compilare in JS. E non è solo una questione di inserimenti in assembly: probabilmente, se lo giri, per alcune convenzioni di chiamata puoi generare gli argomenti necessari nello stack e chiamare la funzione senza di essi. Ma Emscripten è una cosa complicata: per rendere il codice generato familiare all'ottimizzatore del motore JS del browser, vengono utilizzati alcuni trucchi. In particolare, il cosiddetto relooping, un generatore di codice che utilizza l'LLVM IR ricevuto con alcune istruzioni di transizione astratte, cerca di ricreare if, loop, ecc. plausibili. Bene, come vengono passati gli argomenti alla funzione? Naturalmente, come argomenti delle funzioni JS, se possibile, non attraverso lo stack.

All'inizio c'era l'idea di scrivere semplicemente un sostituto di libffi con JS ed eseguire test standard, ma alla fine mi sono confuso su come creare i miei file header in modo che funzionassero con il codice esistente: cosa posso fare, come si suol dire: "I compiti sono così complessi? Siamo così stupidi?" Ho dovuto portare libffi su un'altra architettura, per così dire - fortunatamente, Emscripten ha sia le macro per l'assemblaggio in linea (in Javascript, sì - beh, qualunque sia l'architettura, quindi l'assembler), sia la capacità di eseguire il codice generato al volo. In generale, dopo aver armeggiato per un po' con i frammenti libffi dipendenti dalla piattaforma, ho ottenuto del codice compilabile e l'ho eseguito al primo test che ho trovato. Con mia sorpresa, il test ha avuto successo. Sbalordito dalla mia genialità - non è uno scherzo, ha funzionato dal primo lancio -, ancora non credendo ai miei occhi, sono andato a guardare nuovamente il codice risultante, per valutare dove scavare dopo. Qui sono impazzito per la seconda volta: l'unica cosa che ha fatto la mia funzione è stata ffi_call - questo ha segnalato una chiamata riuscita. Non c'era alcuna chiamata stessa. Così ho inviato la mia prima richiesta pull, che ha corretto un errore nel test che è chiaro a qualsiasi studente delle Olimpiadi: i numeri reali non dovrebbero essere confrontati con a == b e anche come a - b < EPS - devi anche ricordare il modulo, altrimenti 0 risulterà essere molto uguale a 1/3... In generale, mi è venuta in mente una certa conversione di libffi, che supera i test più semplici e con la quale glib è compilato: ho deciso che sarebbe stato necessario, lo aggiungerò più tardi. Guardando al futuro, dirò che, come si è scoperto, il compilatore non ha nemmeno incluso la funzione libffi nel codice finale.

Ma, come ho già detto, ci sono alcune limitazioni e, tra l'uso gratuito di vari comportamenti indefiniti, è nascosta una caratteristica più spiacevole: JavaScript in base alla progettazione non supporta il multithreading con memoria condivisa. In linea di principio questa può essere considerata una buona idea, ma non per il porting di codice la cui architettura è legata ai thread C. In generale, Firefox sta sperimentando il supporto dei lavoratori condivisi ed Emscripten ha un'implementazione pthread per loro, ma non volevo dipendere da questo. Ho dovuto sradicare lentamente il multithreading dal codice Qemu, ovvero scoprire dove sono in esecuzione i thread, spostare il corpo del ciclo in esecuzione in questo thread in una funzione separata e chiamare tali funzioni una per una dal ciclo principale.

Secondo tentativo

Ad un certo punto, è diventato chiaro che il problema era ancora lì e che spostare le stampelle a casaccio attorno al codice non avrebbe portato a nulla di buono. Conclusione: dobbiamo in qualche modo sistematizzare il processo di aggiunta delle stampelle. Pertanto, è stata presa la versione 2.4.1, che all'epoca era fresca (non la 2.5.0, perché, chissà, nella nuova versione ci saranno dei bug che non sono stati ancora rilevati, e ne ho abbastanza dei miei bug ), e la prima cosa è stata riscriverlo in modo sicuro thread-posix.c. Ebbene, altrettanto sicuro: se qualcuno tentava di eseguire un'operazione che portava al blocco, veniva immediatamente richiamata la funzione abort() - ovviamente questo non ha risolto tutti i problemi in una volta, ma almeno è stato in qualche modo più piacevole che ricevere tranquillamente dati incoerenti.

In generale, le opzioni di Emscripten sono molto utili per il porting del codice su JS -s ASSERTIONS=1 -s SAFE_HEAP=1 - rilevano alcuni tipi di comportamenti indefiniti, come le chiamate a un indirizzo non allineato (che non è affatto coerente con il codice per gli array tipizzati come HEAP32[addr >> 2] = 1) o chiamando una funzione con il numero sbagliato di argomenti.

A proposito, gli errori di allineamento sono un problema separato. Come ho già detto, Qemu ha un backend interpretativo “degenerato” per la generazione del codice TCI (tiny code interpreter), e per costruire ed eseguire Qemu su una nuova architettura, se sei fortunato, è sufficiente un compilatore C. "se sei fortunato". Sono stato sfortunato e si è scoperto che TCI utilizza l'accesso non allineato durante l'analisi del suo bytecode. Cioè, su tutti i tipi di ARM e altre architetture con accesso necessariamente livellato, Qemu compila perché hanno un normale backend TCG che genera codice nativo, ma se TCI funzionerà su di essi è un'altra questione. Tuttavia, come si è scoperto, la documentazione del TCI indicava chiaramente qualcosa di simile. Di conseguenza, al codice sono state aggiunte chiamate di funzione per la lettura non allineata, che sono state scoperte in un'altra parte di Qemu.

Distruzione dell'heap

Di conseguenza, l'accesso non allineato al TCI è stato corretto, è stato creato un loop principale, che a sua volta ha chiamato processore, RCU e alcune altre piccole cose. E così lancio Qemu con l'opzione -d exec,in_asm,out_asm, il che significa che è necessario dire quali blocchi di codice vengono eseguiti e anche al momento della trasmissione scrivere quale codice ospite era, quale codice host è diventato (in questo caso, bytecode). Si avvia, esegue diversi blocchi di traduzione, scrive il messaggio di debug che ho lasciato che RCU ora si avvierà e... si blocca abort() all'interno di una funzione free(). Armeggiando con la funzione free() Siamo riusciti a scoprire che nell'intestazione del blocco heap, che si trova negli otto byte che precedono la memoria allocata, invece della dimensione del blocco o qualcosa di simile, c'era spazzatura.

Distruzione dell'heap: che carino... In tal caso, c'è un rimedio utile: dalle stesse fonti (se possibile), assemblare un binario nativo ed eseguirlo sotto Valgrind. Dopo qualche tempo, il binario era pronto. Lo lancio con le stesse opzioni: si blocca anche durante l'inizializzazione, prima di raggiungere effettivamente l'esecuzione. È spiacevole, ovviamente - a quanto pare i sorgenti non erano esattamente gli stessi, il che non sorprende, perché configure ha individuato opzioni leggermente diverse, ma ho Valgrind - prima correggerò questo bug e poi, se sono fortunato , apparirà quello originale. Sto eseguendo la stessa cosa con Valgrind... Y-y-y, y-y-y, uh-uh, è iniziato, ha eseguito l'inizializzazione normalmente e ha superato il bug originale senza un solo avviso di accesso errato alla memoria, per non parlare delle cadute. La vita, come si suol dire, non mi ha preparato per questo: un programma che si blocca smette di bloccarsi quando viene avviato sotto Walgrind. Cosa fosse è un mistero. La mia ipotesi è che una volta in prossimità dell'istruzione corrente dopo un crash durante l'inizializzazione, gdb abbia mostrato lavoro memset-a con un puntatore valido utilizzando uno dei due mmx, se xmm registri, forse si è trattato di qualche errore di allineamento, anche se è ancora difficile da credere.

Ok, Valgrind non sembra essere d'aiuto qui. E qui è iniziata la cosa più disgustosa: tutto sembra addirittura iniziare, ma si blocca per ragioni assolutamente sconosciute a causa di un evento che sarebbe potuto accadere milioni di istruzioni fa. Per molto tempo non è stato nemmeno chiaro come avvicinarsi. Alla fine, dovevo ancora sedermi ed eseguire il debug. Stampando ciò con cui è stata riscritta l'intestazione è stato mostrato che non sembrava un numero, ma piuttosto una sorta di dato binario. Ed ecco, questa stringa binaria è stata trovata nel file BIOS, cioè ora è possibile dire con ragionevole certezza che si trattava di un overflow del buffer, ed è persino chiaro che è stato scritto in questo buffer. Bene, allora qualcosa del genere: in Emscripten, fortunatamente, non c'è randomizzazione dello spazio degli indirizzi, non ci sono nemmeno buchi, quindi puoi scrivere da qualche parte nel mezzo del codice per ottenere i dati tramite puntatore dall'ultimo lancio, guarda i dati, guarda il puntatore e, se non è cambiato, ottieni spunti di riflessione. È vero, ci vogliono un paio di minuti per collegarsi dopo qualsiasi modifica, ma cosa puoi fare? Di conseguenza, è stata trovata una riga specifica che copiava il BIOS dal buffer temporaneo alla memoria ospite e, in effetti, non c'era abbastanza spazio nel buffer. Trovare la fonte di quello strano indirizzo buffer ha prodotto una funzione qemu_anon_ram_alloc in archivio oslib-posix.c - la logica era questa: a volte può essere utile allineare l'indirizzo ad una pagina enorme di 2 MB, per questo chiederemo mmap prima un po' di più, poi restituiremo l'eccesso con l'aiuto munmap. E se tale allineamento non è richiesto, indicheremo il risultato invece di 2 MB getpagesize() - mmap fornirà comunque un indirizzo allineato... Quindi in Emscripten mmap semplicemente chiama malloc, ma ovviamente non si allinea alla pagina. In generale, un bug che mi ha frustrato per un paio di mesi è stato corretto con una modifica due linee.

Caratteristiche delle funzioni di chiamata

E ora il processore conta qualcosa, Qemu non si blocca, ma lo schermo non si accende e il processore entra rapidamente in loop, a giudicare dall'output -d exec,in_asm,out_asm. È emersa un'ipotesi: gli interrupt del timer (o, in generale, tutti gli interrupt) non arrivano. E in effetti, se sviti le interruzioni dall'assemblea nativa, che per qualche motivo ha funzionato, otterrai un'immagine simile. Ma questa non è stata affatto la risposta: un confronto delle tracce emesse con l’opzione di cui sopra ha mostrato che le traiettorie di esecuzione divergevano molto presto. Qui va detto il confronto di quanto registrato utilizzando il launcher emrun il debug dell'output con l'output dell'assembly nativo non è un processo completamente meccanico. Non so esattamente come si connette un programma in esecuzione in un browser emrun, ma alcune linee nell'output risultano essere riorganizzate, quindi la differenza nella differenza non è ancora un motivo per presumere che le traiettorie siano divergenti. In generale, è diventato chiaro che secondo le istruzioni ljmpl c'è una transizione a indirizzi diversi e il bytecode generato è fondamentalmente diverso: uno contiene un'istruzione per chiamare una funzione di supporto, l'altro no. Dopo aver cercato su Google le istruzioni e studiato il codice che traduce queste istruzioni, è diventato chiaro che, in primo luogo, immediatamente prima nel registro cr0 è stata effettuata una registrazione, sempre utilizzando un aiutante, che ha commutato il processore in modalità protetta e, in secondo luogo, che la versione js non è mai passata in modalità protetta. Ma il fatto è che un'altra caratteristica di Emscripten è la sua riluttanza a tollerare codici come l'implementazione delle istruzioni call in TCI, che qualsiasi puntatore a funzione risulta in tipo long long f(int arg0, .. int arg9) - le funzioni devono essere chiamate con il numero corretto di argomenti. Se questa regola viene violata, a seconda delle impostazioni di debug, il programma si bloccherà (il che è positivo) o chiamerà la funzione sbagliata (il che sarà triste da eseguire il debug). C'è anche una terza opzione: abilitare la generazione di wrapper che aggiungono / rimuovono argomenti, ma in totale questi wrapper occupano molto spazio, nonostante in realtà mi servano solo poco più di un centinaio di wrapper. Questo da solo è molto triste, ma si è rivelato esserci un problema più serio: nel codice generato delle funzioni wrapper, gli argomenti venivano convertiti e convertiti, ma a volte la funzione con gli argomenti generati non veniva chiamata - beh, proprio come in la mia implementazione di libffi. Cioè, alcuni aiutanti semplicemente non sono stati giustiziati.

Fortunatamente, Qemu dispone di elenchi di helper leggibili dalla macchina sotto forma di file di intestazione come

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

Il loro utilizzo è piuttosto divertente: in primo luogo, i macro vengono ridefiniti nel modo più bizzarro DEF_HELPER_n, quindi si accende helper.h. Nella misura in cui la macro viene espansa in un inizializzatore di struttura e una virgola, quindi viene definito un array e invece degli elementi - #include <helper.h> Di conseguenza, ho finalmente avuto la possibilità di provare la biblioteca al lavoro pyparsinged è stato scritto uno script che genera esattamente quei wrapper per esattamente le funzioni per le quali sono necessari.

E così, dopo il processore sembrava funzionare. Sembra che lo schermo non sia mai stato inizializzato, sebbene memtest86+ sia stato in grado di funzionare nell'assembly nativo. Qui è necessario chiarire che il codice I/O del blocco Qemu è scritto in coroutine. Emscripten ha una sua implementazione molto complicata, ma necessitava comunque di essere supportato nel codice Qemu e ora puoi eseguire il debug del processore: Qemu supporta le opzioni -kernel, -initrd, -append, con il quale puoi avviare Linux o, ad esempio, memtest86+, senza utilizzare affatto dispositivi a blocchi. Ma ecco il problema: nell'assembly nativo si poteva vedere l'output del kernel Linux sulla console con l'opzione -nographice nessun output dal browser al terminale da cui è stato avviato emrun, non è venuto. Cioè, non è chiaro: il processore non funziona o l'output grafico non funziona. E poi mi è venuto in mente di aspettare un po'. Si è scoperto che "il processore non dorme, ma semplicemente lampeggia lentamente" e dopo circa cinque minuti il ​​kernel ha lanciato una serie di messaggi sulla console e ha continuato a bloccarsi. È diventato chiaro che il processore, in generale, funziona e dobbiamo approfondire il codice per lavorare con SDL2. Sfortunatamente non so come utilizzare questa libreria, quindi in alcuni punti ho dovuto agire a caso. Ad un certo punto, sullo schermo lampeggiava la linea parallel0 su sfondo blu, che suggeriva alcuni pensieri. Alla fine, si è scoperto che il problema era che Qemu apre diverse finestre virtuali in una finestra fisica, tra le quali puoi passare usando Ctrl-Alt-n: funziona nella build nativa, ma non in Emscripten. Dopo aver eliminato le finestre non necessarie utilizzando le opzioni -monitor none -parallel none -serial none e le istruzioni per ridisegnare forzatamente l'intero schermo su ciascun fotogramma, tutto ha funzionato all'improvviso.

Coroutine

Quindi, l'emulazione nel browser funziona, ma non puoi eseguire nulla di interessante su un singolo floppy, perché non c'è I/O a blocchi: devi implementare il supporto per le coroutine. Qemu ha già diversi backend coroutine, ma a causa della natura di JavaScript e del generatore di codice Emscripten, non puoi semplicemente iniziare a destreggiarti tra stack. Sembrerebbe che “tutto sia andato via, l'intonaco venga rimosso”, ma gli sviluppatori di Emscripten si sono già occupati di tutto. Questa implementazione è piuttosto divertente: chiamiamo sospetta una chiamata di funzione come questa emscripten_sleep e molti altri che utilizzano il meccanismo Asyncify, nonché chiamate a puntatori e chiamate a qualsiasi funzione in cui uno dei due casi precedenti può verificarsi più in basso nello stack. E ora, prima di ogni chiamata sospetta, selezioneremo un contesto asincrono e, subito dopo la chiamata, controlleremo se si è verificata una chiamata asincrona e, in tal caso, salveremo tutte le variabili locali in questo contesto asincrono, indicheremo quale funzione per trasferire il controllo a quando è necessario continuare l'esecuzione e uscire dalla funzione corrente. È qui che c’è spazio per studiare l’effetto sperpero — per esigenze di continuazione dell'esecuzione del codice dopo il ritorno da una chiamata asincrona, il compilatore genera "stub" della funzione a partire da una chiamata sospetta — in questo modo: se ci sono n chiamate sospette, la funzione verrà espansa da qualche parte n/2 volte: questo è ancora, altrimenti tieni presente che dopo ogni chiamata potenzialmente asincrona, è necessario aggiungere il salvataggio di alcune variabili locali alla funzione originale. Successivamente, ho dovuto anche scrivere un semplice script in Python, che, sulla base di un dato insieme di funzioni particolarmente abusate che presumibilmente "non consentono il passaggio dell'asincronia" (ovvero, la promozione dello stack e tutto ciò che ho appena descritto non lo fanno lavorare in essi), indica chiamate tramite puntatori in cui le funzioni devono essere ignorate dal compilatore in modo che tali funzioni non siano considerate asincrone. E poi i file JS inferiori a 60 MB sono chiaramente troppi, diciamo almeno 30. Anche se, una volta stavo impostando uno script assembly e ho accidentalmente eliminato le opzioni del linker, tra cui c'era -O3. Eseguo il codice generato e Chromium consuma memoria e si blocca. Poi ho guardato per sbaglio cosa stava cercando di scaricare... Beh, cosa posso dire, anch'io mi sarei bloccato se mi avessero chiesto di studiare attentamente e ottimizzare un Javascript di oltre 500 MB.

Sfortunatamente, i controlli nel codice della libreria di supporto Asyncify non erano del tutto compatibili longjmp-s utilizzati nel codice del processore virtuale, ma dopo una piccola patch che disabilita questi controlli e ripristina forzatamente i contesti come se tutto andasse bene, il codice ha funzionato. E poi è iniziata una cosa strana: a volte venivano attivati ​​dei controlli nel codice di sincronizzazione - gli stessi che mandano in crash il codice se, secondo la logica di esecuzione, dovesse essere bloccato - qualcuno ha provato ad impossessarsi di un mutex già catturato. Fortunatamente, questo si è rivelato non essere un problema logico nel codice serializzato: stavo semplicemente utilizzando la funzionalità standard del ciclo principale fornita da Emscripten, ma a volte la chiamata asincrona scartava completamente lo stack e in quel momento falliva setTimeout dal ciclo principale: quindi, il codice è entrato nell'iterazione del ciclo principale senza uscire dall'iterazione precedente. Riscritto in un ciclo infinito e emscripten_sleepe i problemi con i mutex si sono interrotti. Il codice è diventato persino più logico - dopotutto, infatti, non ho del codice che prepari il successivo fotogramma dell'animazione - il processore calcola semplicemente qualcosa e lo schermo viene periodicamente aggiornato. Tuttavia, i problemi non si fermavano qui: a volte l’esecuzione di Qemu terminava semplicemente in modo silenzioso, senza eccezioni o errori. In quel momento ho rinunciato, ma, guardando avanti, dirò che il problema era questo: il codice coroutine, infatti, non utilizza setTimeout (o almeno non così spesso come potresti pensare): funzione emscripten_yield imposta semplicemente il flag di chiamata asincrona. Il punto è questo emscripten_coroutine_next non è una funzione asincrona: internamente controlla il flag, lo resetta e trasferisce il controllo dove serve. Cioè, la promozione dello stack finisce qui. Il problema era che a causa di use-after-free, che appariva quando il pool di coroutine era disabilitato perché non avevo copiato un'importante riga di codice dal backend di coroutine esistente, la funzione qemu_in_coroutine ha restituito true quando in realtà avrebbe dovuto restituire false. Ciò ha portato a una chiamata emscripten_yield, sopra il quale non c'era nessuno in pila emscripten_coroutine_next, la pila si è aperta fino in cima, ma no setTimeout, come ho già detto, non è stato esposto.

Generazione del codice JavaScript

E qui, infatti, c'è la promessa di "riportare indietro la carne macinata". Non proprio. Naturalmente, se eseguiamo Qemu nel browser e Node.js al suo interno, naturalmente, dopo la generazione del codice in Qemu, otterremo JavaScript completamente sbagliato. Ma comunque, una sorta di trasformazione inversa.

Innanzitutto, qualcosa su come funziona Qemu. Per favore perdonami subito: non sono uno sviluppatore Qemu professionista e le mie conclusioni potrebbero essere errate in alcuni punti. Come si suol dire, “l’opinione dello studente non deve coincidere con l’opinione dell’insegnante, l’assiomatica e il buon senso di Peano”. Qemu ha un certo numero di architetture guest supportate e per ognuna esiste una directory come target-i386. Durante la compilazione, è possibile specificare il supporto per diverse architetture guest, ma il risultato saranno solo diversi file binari. Il codice per supportare l'architettura guest, a sua volta, genera alcune operazioni interne di Qemu, che il TCG (Tiny Code Generator) trasforma già in codice macchina per l'architettura host. Come indicato nel file readme situato nella directory tcg, questo era originariamente parte di un normale compilatore C, che è stato successivamente adattato per JIT. Pertanto, ad esempio, l'architettura di destinazione nei termini di questo documento non è più un'architettura ospite, ma un'architettura host. Ad un certo punto, è apparso un altro componente: Tiny Code Interpreter (TCI), che dovrebbe eseguire codice (quasi le stesse operazioni interne) in assenza di un generatore di codice per un'architettura host specifica. Infatti, come afferma la documentazione, questo interprete potrebbe non sempre funzionare bene come un generatore di codice JIT, non solo quantitativamente in termini di velocità, ma anche qualitativamente. Anche se non sono sicuro che la sua descrizione sia del tutto rilevante.

All'inizio ho provato a creare un backend TCG a tutti gli effetti, ma mi sono subito confuso nel codice sorgente e in una descrizione non del tutto chiara delle istruzioni del bytecode, quindi ho deciso di avvolgere l'interprete TCI. Ciò ha dato diversi vantaggi:

  • quando si implementa un generatore di codice, è possibile guardare non alla descrizione delle istruzioni, ma al codice dell'interprete
  • è possibile generare funzioni non per ogni blocco di traduzione incontrato, ma, ad esempio, solo dopo la centesima esecuzione
  • se il codice generato cambia (e questo sembra possibile, a giudicare dalle funzioni con nomi contenenti la parola patch), dovrò invalidare il codice JS generato, ma almeno avrò qualcosa da cui rigenerarlo

Per quanto riguarda il terzo punto, non sono sicuro che sia possibile applicare una patch dopo la prima esecuzione del codice, ma i primi due punti sono sufficienti.

Inizialmente, il codice veniva generato sotto forma di un grande interruttore all'indirizzo dell'istruzione bytecode originale, ma poi, ricordando l'articolo su Emscripten, ottimizzazione del JS generato e relooping, ho deciso di generare più codice umano, soprattutto perché empiricamente si è scoperto che l'unico punto di ingresso nel blocco di traduzione è il suo Inizio. Detto fatto, dopo un po' avevamo un generatore di codice che generava codice con if (anche se senza loop). Ma sfortunatamente, si è bloccato, dando il messaggio che le istruzioni erano di lunghezza errata. Inoltre, l'ultima istruzione a questo livello di ricorsione è stata brcond. Ok, aggiungerò un controllo identico alla generazione di questa istruzione prima e dopo la chiamata ricorsiva e... nessuno di essi è stato eseguito, ma dopo il cambio di asserzione hanno comunque fallito. Alla fine, dopo aver studiato il codice generato, mi sono reso conto che dopo il cambio, il puntatore all'istruzione corrente viene ricaricato dallo stack e probabilmente viene sovrascritto dal codice JavaScript generato. E così è stato. L'aumento del buffer da un megabyte a dieci non ha portato a nulla ed è diventato chiaro che il generatore di codice funzionava in tondo. Dovevamo verificare di non oltrepassare i limiti dell'attuale TB e, in tal caso, indicare l'indirizzo del prossimo TB con un segno meno in modo da poter continuare l'esecuzione. Inoltre, questo risolve il problema “quali funzioni generate dovrebbero essere invalidate se questo pezzo di bytecode è cambiato?” — è necessario invalidare solo la funzione che corrisponde a questo blocco di traduzione. A proposito, anche se ho eseguito il debug di tutto in Chromium (poiché utilizzo Firefox ed è più facile per me utilizzare un browser separato per gli esperimenti), Firefox mi ha aiutato a correggere le incompatibilità con lo standard asm.js, dopodiché il codice ha iniziato a funzionare più velocemente in Cromo.

Esempio di codice generato

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"]

conclusione

Quindi il lavoro non è ancora finito, ma sono stanco di portare segretamente alla perfezione questa costruzione a lungo termine. Pertanto, ho deciso di pubblicare quello che ho per ora. Il codice in alcuni punti fa un po’ paura, perché si tratta di un esperimento e non è chiaro in anticipo cosa bisogna fare. Probabilmente, allora vale la pena emettere normali commit atomici sopra una versione più moderna di Qemu. Nel frattempo c'è un thread nella Gita in formato blog: per ogni “livello” che è stato almeno in qualche modo superato, è stato aggiunto un commento dettagliato in russo. In realtà, questo articolo è in larga misura una rivisitazione della conclusione git log.

Puoi provare tutto qui (attenzione al traffico).

Cosa funziona già:

  • Processore virtuale x86 in esecuzione
  • Esiste un prototipo funzionante di un generatore di codice JIT dal codice macchina a JavaScript
  • Esiste un template per assemblare altre architetture guest a 32 bit: in questo momento potete ammirare Linux per l'architettura MIPS che si blocca nel browser in fase di caricamento

Cos'altro puoi fare?

  • Accelera l'emulazione. Anche in modalità JIT sembra funzionare più lentamente di Virtual x86 (ma potenzialmente esiste un intero Qemu con molto hardware e architetture emulate)
  • Per creare un'interfaccia normale, francamente non sono un buon sviluppatore web, quindi per ora ho rifatto la shell standard di Emscripten nel miglior modo possibile
  • Prova ad avviare funzioni Qemu più complesse: networking, migrazione VM, ecc.
  • UPD: dovrai inviare i tuoi pochi sviluppi e segnalazioni di bug a Emscripten a monte, come hanno fatto i precedenti porter di Qemu e altri progetti. Grazie a loro per aver potuto utilizzare implicitamente il loro contributo a Emscripten come parte del mio compito.

Fonte: habr.com

Aggiungi un commento