Qemu.js con supporto JIT: alla fine si può riavvolgere il macinato

Qualche anno fa, Fabrice Bellard ha scritto jslinux — un emulatore di PC scritto in JavaScript. Dopo di ciò è apparso almeno Virtual x86. Ma, per quanto ne so, tutti erano interpreti, mentre Qemu, scritto molto prima dallo stesso Fabrice Bellard, e probabilmente qualsiasi emulatore moderno rispettabile, utilizza la compilazione JIT del codice guest in codice della macchina host. Mi è sembrato che fosse giunto il momento di realizzare il compito inverso rispetto a quello risolto dai browser: la compilazione JIT del codice macchina in JavaScript, per la quale si rivelava più logico portare Qemu. Sembrerebbe strano, perché proprio Qemu, ci sono emulatori più semplici e user-friendly — lo stesso VirtualBox, ad esempio — installi e funziona. Ma Qemu ha un paio di caratteristiche interessanti

  • codice sorgente aperto
  • possibilità di funzionare senza driver del kernel
  • possibilità di operare in modalità interprete
  • supporto a un ampio numero di architetture, sia host che guest

Riguardo al terzo punto, ora posso spiegare che in realtà nella modalità TCI non vengono interpretate le istruzioni macchina guest, ma il bytecode ottenuto, ma la sostanza non cambia — per costruire e avviare Qemu su una nuova architettura, se si è fortunati, basta un compilatore C — la scrittura del generatore di codice può essere rimandata.

E così, dopo due anni di lavoro lento nel tempo libero sul codice sorgente di Qemu, è apparso un prototipo funzionante in cui è già possibile avviare, ad esempio, Kolibri OS.

Cos'è Emscripten

Al giorno d'oggi sono emersi molti compilatori, il cui risultato finale è JavaScript. Alcuni, come TypeScript, sono stati originariamente concepiti come il modo migliore per scrivere per il web. Allo stesso tempo, Emscripten è un modo per prendere codice esistente in C o C++ e compilarlo in una forma comprensibile per il browser. Su questa pagina ci sono stati molti port di programmi noti: qui, ad esempio, si può vedere PyPy — a proposito, sembra che abbiano già JIT. In realtà, non è possibile compilare e avviare qualsiasi programma semplicemente in un browser — ci sono una serie di le caratteristiche, con cui si deve confrontare, comunque, come dice l'iscrizione su questa stessa pagina 'Emscripten può essere usato per compilare quasi qualsiasi portabile codice C/C++ in JavaScript'. Cioè, esistono una serie di operazioni che sono comportamenti indefiniti secondo lo standard, ma di solito funzionano su x86 — ad esempio, l'accesso non allineato alle variabili, che su alcune architetture è completamente vietato. In generale, Qemu è un programma multipiattaforma e, si sperava, non contiene una grande quantità di comportamenti indefiniti — prendi e compila, poi lavora un po' con JIT e il gioco è fatto! Ma non è andato proprio così...

Primo tentativo

In generale, non sono il primo a cui è venuta in mente l'idea di portare Qemu su JavaScript. Sul forum di ReactOS era stata posta la domanda se fosse possibile farlo con Emscripten. Ancora prima, si era diffusa la voce che Fabrice Bellard lo avesse fatto personalmente, ma si parlava di jslinux, che, per quanto ne so, è proprio un tentativo manuale di ottenere prestazioni sufficienti su JS, ed è stato scritto da zero. In seguito è stato scritto Virtual x86 — a esso sono stati pubblicati i sorgenti non offuscati, e come è stato affermato, una maggiore 'realismo' dell'emulazione ha permesso di utilizzare SeaBIOS come firmware. Inoltre, ci sono state almeno un paio di tentativi di portare Qemu usando Emscripten — una di esse è stata socketpair, ma lo sviluppo, per quanto ho capito, è stato sospeso.

Quindi, sembrerebbe, ecco i sorgenti, ecco Emscripten — prendi e compila. Ma ci sono anche librerie da cui dipende Qemu, e librerie da cui dipendono quelle librerie, ecc., ed una di esse è libffi, da cui dipende glib. Si sentivano voci su internet che in una grande collezione di porting di librerie per Emscripten ci fosse anche essa, ma era difficile crederci: da un lato, non si compilava con la nuova libreria, dall'altro, è una libreria troppo a basso livello per essere semplicemente compilata in JS. E non si tratta solo di inserti in assembler — probabilmente, se ci si sforza, per alcune convenzioni di chiamata si possono formare gli argomenti necessari senza di essi e chiamare una funzione. Solo che Emscripten è un affare complicato: affinché il codice generato appaia familiare all'ottimizzatore del motore JS del browser, vengono utilizzati alcuni trucchi. In particolare, il relooping: il generatore di codice cerca di ricreare if plausibili, cicli, ecc. dall'IR LLVM ottenuto con alcune istruzioni astratte di salto. E come vengono passati gli argomenti nelle funzioni? Naturalmente, come argomenti delle funzioni JS, cioè, per quanto possibile, non tramite lo stack.

All'inizio l'idea era semplicemente sostituire libffi con JS e far girare i test di routine, ma alla fine mi sono perso nel modo di creare i miei file header affinché funzionassero con il codice esistente — come si suol dire, "o le sfide sono troppo complesse, o noi siamo troppo stupidi". Ho dovuto portare libffi su un'altra architettura, se così si può esprimere — per fortuna, in Emscripten ci sono sia macro per l'assembly inline (in JavaScript, sì — beh, quale architettura, tale è l'assembler), sia la possibilità di eseguire codice generato al volo. Dunque, dopo aver lavorato un po' con i frammenti dipendenti dalla piattaforma di libffi, ho ottenuto un codice compilabile e l'ho testato con il primo test disponibile. Con mia sorpresa, il test ha avuto successo. Sbalordito dalla mia genialità — non era uno scherzo, ha funzionato al primo tentativo — sono andato a dare un'altra occhiata al codice ottenuto, per valutare dove approfondire. Qui sono rimasto di nuovo scioccato — l'unica cosa che la mia funzione ffi_call ha riportato è stata la riuscita della chiamata. La chiamata stessa non è mai avvenuta. Così ho inviato la mia prima pull request, che correggeva un errore evidente a qualsiasi partecipante alle olimpiadi nel test — i numeri reali non dovrebbero essere confrontati come a == b e neppure come a - b < EPS — bisognerebbe ricordare di considerare anche il modulo, altrimenti 0 risulta molto uguale a 1/3… Insomma, ho ottenuto una sorta di port di libffi, che passa i test più semplici e con cui glocompilerà glib — ho pensato che, se necessario, avrei potuto scrivere ulteriormente in seguito. Anticipando il tutto, dirò che, come si è rivelato, il codice finale della funzione libffi il compilatore non l'ha nemmeno incluso.

Ma, come ho già accennato, ci sono alcune limitazioni, e tra l'uso libero di vari comportamenti indefiniti si è infilata una particolarità un po' più fastidiosa — JavaScript per design non supporta il multithreading con memoria condivisa. In linea di principio, è una cosa che si può anche considerare un'idea non male, ma non per il porting di codice che è legato a thread in C. In effetti, in Firefox sono in corso esperimenti per supportare i worker condivisi, e l'implementazione di pthread è presente in Emscripten, ma non volevo dipendere da questo. Ho dovuto lentamente sradicare il multithreading dal codice di Qemu — cioè, cercando dove vengono avviati i thread, estraendo il corpo del ciclo che viene eseguito in quel thread in una funzione separata e chiamando alternativamente tali funzioni dal ciclo principale.

Secondo tentativo

Ad un certo punto è diventato chiaro che non mi sarei mosso da nessuna parte, e che una sistematica distribuzione di toppa non avrebbe portato a nulla di buono. Conclusione: è necessario sistematizzare in qualche modo il processo di aggiunta di riparazioni. Pertanto, è stata presa la versione fresca 2.4.1 (non 2.5.0, perché, chissà, potrebbero esserci ulteriori bug non risolti della nuova versione, e a me basta e avanza l'imprevisti), e come primo passo ho riscritto in modo sicuro thread-posix.c. Insomma, come in modo sicuro: se qualcuno provava a eseguire un'operazione che portava a un blocco, veniva immediatamente chiamata la funzione abort() — ovviamente, questo non risolveva subito tutti i problemi, ma almeno era più piacevole che ricevere silenziosamente dati inconsistenti.

In generale, nel porting del codice su JS aiutano molto le opzioni di Emscripten -s ASSERTIONS=1 -s SAFE_HEAP=1 — catturano alcune forme di comportamento indefinito come accessi a indirizzi non allineati (che non corrisponde affatto al codice per gli array tipizzati come HEAP32[addr >> 2] = 1) o chiamate di funzione con un numero errato di argomenti.

A proposito, gli errori di allineamento sono un tema a parte. Come ho detto, in Qemu c'è un backend interpretante "degenerato" per la generazione di codice TCI (tiny code interpreter), e per compilare e avviare Qemu su una nuova architettura, se tutto va bene, basta un compilatore C. Le parole chiave "se tutto va bene". A me non è andata bene, e si è rivelato che TCI utilizza accesso non allineato per elaborare il proprio bytecode. Quindi su varie architetture ARM e altre che richiedono accesso allineato, Qemu si compila perché per loro esiste un backend TCG normale che genera codice nativo, ma se TCI funzionerà su quelle architetture è ancora un mistero. Tuttavia, come si è scoperto, nella documentazione su TCI era indicato qualcosa di simile. Alla fine, nel codice sono state aggiunte chiamate a funzioni per la lettura non allineata, che sono state scoperte in un'altra parte di Qemu.

Distruzione della heap

Alla fine, l'accesso non allineato in TCI è stato corretto, è stato creato un ciclo principale che chiamava alternativamente il processore, RCU e qualche dettaglio minore. Ed ecco che avvio Qemu con l'opzione -d exec,in_asm,out_asm, indicando che è necessario comunicare quali blocchi di codice vengono eseguiti e, nel momento della traduzione, quali codice guest era, quale codice host è diventato (in questo caso, bytecode). Viene avviato, esegue alcuni blocchi di traduzione, scrive il messaggio di debug che ho lasciato, avvisando che RCU sta per avviarsi e... fallisce per abort() all'interno della funzione free(). Scoprendo la funzione free() si è riusciti a determinare che nell'intestazione del blocco della heap, che si trova otto byte prima della memoria allocata, al posto della dimensione del blocco o qualcosa di simile si trovava del rumore.

La corruzione della heap — che simpatico... In tal caso, c'è uno strumento utile: compilare un binario nativo dagli stessi sorgenti (se possibile) e farlo girare sotto Valgrind. Dopo un pò, il binario è pronto. Lo avvio con le stesse opzioni — fallisce ancora all'inizializzazione, non arrivando nemmeno all'esecuzione. Sgradevole, naturalmente — sembra che i sorgenti non fossero esattamente gli stessi, il che non sorprende, visto che configure ha scoperto alcune opzioni diverse, ma ho Valgrind — prima risolvo questo bug, e poi, se tutto va bene, affronterò l'originale. Eseguo tutto sotto Valgrind... Oh-oh-oh, yay, ha avviato, ha passato l'inizializzazione e è continuato oltre il bug originale senza alcun avviso su accessi errati alla memoria, senza contare i crash. Per qualcosa del genere, la vita non mi ha preparato, come si dice — un programma in errore smette di cadere quando viene eseguito sotto Valgrind. Cos'era — un mistero. La mia ipotesi è che dato che nelle vicinanze dell'istruzione attuale, dopo il crash durante l'inizializzazione, gdb mostrava l'utilizzo memset-a con un puntatore valido utilizzando o mmx, o xmm registri, forse si trattava di un errore di allineamento, anche se ci credo poco.

O-key, qui Valgrind non sembra essere d'aiuto. E qui comincia la parte più fastidiosa — tutto sembra avviarsi, ma fallisce per motivi assolutamente ignoti a causa di eventi che potrebbero essere avvenuti milioni di istruzioni fa. Ci è voluto molto tempo per capire da dove cominciare. Alla fine, sono dovuto sedermi e fare debugging. La stampa dell'intestazione scritta ha mostrato che sembrava più simile a dati binari che a un numero. E, meraviglia, questa stringa binaria è stata trovata nel file del BIOS — quindi ora si poteva dire con sufficiente certezza che si trattava di un overflow di buffer, e si capiva cosa veniva scritto in quel buffer. E poi è andata così — in Emscripten, fortunatamente, non ci sono randomizzazioni dello spazio degli indirizzi, non ci sono anche bug, quindi si può scrivere da qualche parte a metà del codice per visualizzare i dati tramite un puntatore dall'esecuzione precedente, guardare i dati, controllare il puntatore, e, se non è cambiato, ottenere informazioni da riflettere. Tuttavia, il collegamento dopo ogni modifica richiede un paio di minuti, ma che ci vuoi fare. Alla fine, è stata trovata la stringa specifica che copiava il BIOS dal buffer temporaneo nella memoria guest — e, in effetti, non c'era abbastanza spazio nel buffer. La ricerca della fonte di quell'indirizzo strano del buffer ha portato alla funzione qemu_anon_ram_alloc nel file oslib-posix.c — la logica lì era questa: a volte può essere utile allineare l'indirizzo su una huge page di 2 MB, per questo chiederemo mmap un po' di più, e poi restituiremo l'eccedenza tramite munmap. E se tale allineamento non è necessario, indicheremo invece 2 MB, il risultato getpagesize()mmap restituirà comunque un indirizzo allineato... Così, in Emscripten mmap si chiama semplicemente malloc, e questo, naturalmente, non allinea in base alla pagina. In generale, il bug che mi ha infastidito per un paio di mesi si è risolto con una modifica nelle due righe.

Caratteristiche della chiamata delle funzioni

E ora il processore sta calcolando qualcosa, Qemu non crolla, ma lo schermo non si accende, e il processore si blocca rapidamente, a giudicare dall'output -d exec,in_asm,out_asm. È emersa un'ipotesi: non arrivano le interruzioni del timer (o comunque tutte le interruzioni). E in effetti, se si disattivano le interruzioni dalla build nativa, che per qualche motivo funzionava, si ottiene un quadro simile. Ma la soluzione non era affatto questa: il confronto dei tracciamenti emessi con l'opzione sopra menzionata ha mostrato che le traiettorie di esecuzione divergono molto presto. Qui va detto che il confronto dell'output di debug registrato con l'output della build nativa — non è affatto un processo meccanico. Non so esattamente come il programma eseguito nel browser si collega a , ma alcune righe nell'output risultano scambiate, quindi una differenza nel diff — non è comunque un motivo per ritenere che le traiettorie si siano separate. In generale, è diventato chiaro che per l'istruzione l'uscita di debug con l'output della build nativa non è esattamente un processo meccanico. Non so esattamente come il programma eseguito nel browser si connetta a , ma alcune righe nell'output risultano scambiate, quindi una differenza nel diff — non è comunque un motivo per ritenere che le traiettorie si siano separate. In generale, è diventato chiaro che per l'istruzione, ma alcune righe nell'output sembrano essere spostate, quindi una differenza nel diff non è ancora motivo per considerare che le traiettorie siano divertere. In generale, è chiaro che secondo le istruzioni ljmpl si verifica un passaggio tra indirizzi diversi e il bytecode generato è fondamentalmente diverso: in uno c'è un'istruzione di chiamata a una funzione helper C, nell'altro no. Dopo aver cercato le istruzioni e studiato il codice che le trasmette, è diventato chiaro che, da un lato, direttamente prima di essa nel registro cr0 veniva effettuata una scrittura - anche questa tramite helper - che porta il processore in modalità protetta, e dall'altro lato, che la versione js non è mai passata in modalità protetta. La questione è che un'altra caratteristica di Emscripten è che non tollera codice come l'implementazione dell'istruzione call in TCI, che qualsiasi puntatore a funzione viene portato al tipo long long f(int arg0, .. int arg9) le funzioni devono essere chiamate con il numero corretto di argomenti. Se questa regola non viene rispettata, a seconda delle impostazioni di debug il programma può andare in crash (cosa buona), oppure può chiamare una funzione completamente diversa (cosa triste da debuggare). C'è anche una terza opzione: attivare la generazione di wrapper che aggiungono/rimuovono argomenti, ma in totale questi wrapper occupano davvero molto spazio, mentre effettivamente mi servirebbero solo poco più di un centinaio di wrapper. Questo di per sé è piuttosto triste, ma si è rivelato un problema più serio: nel codice generato delle funzioni wrapper gli argomenti venivano convertiti, solo che la funzione con gli argomenti generati non veniva qualche volta chiamata - proprio come nella mia implementazione di libffi. Cioè alcuni helper semplicemente non venivano eseguiti.

Fortunatamente, in Qemu ci sono elenchi leggibili dalle macchine di helper sotto forma di un file di intestazione simile a

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

Vengono utilizzati in modo piuttosto divertente: prima vengono sovrascritti in modo bizzarro i macro DEF_HELPER_n, e poi viene incluso helper.h. Fino al punto in cui il macro si espande in un inizializzatore di struttura e una virgola, e poi viene definito un array, e invece degli elementi - #include <helper.h> Alla fine, finalmente si è presentata l'opportunità di provare a lavorare con la libreria pyparsing, ed è stato scritto uno script che genera gli wrapper esattamente per quelle e solo quelle funzioni per cui sono necessari.

E così, dopo questo, il processore sembra aver funzionato. Sembra, perché lo schermo non è mai stato inizializzato, anche se nella build nativa sono riuscito a lanciare memtest86+. Qui bisogna chiarire che il codice dell'input/output a blocchi di Qemu è scritto in coroutine. In Emscripten c'è una propria implementazione piuttosto complessa, ma occorreva ancora sostenerla nel codice di Qemu, e il processore può essere debuggato già ora: Qemu supporta le opzioni -kernel, -initrd, -append, con le quali è possibile caricare Linux o, per esempio, memtest86+, senza utilizzare dispositivi a blocchi. Ma ecco il problema: nella build nativa era possibile vedere l'output del kernel Linux sulla console con l'opzione -nographic, ma dal browser non arrivava alcun output nel terminale da cui era stato avviato , ma alcune righe nell'output risultano scambiate, quindi una differenza nel diff — non è comunque un motivo per ritenere che le traiettorie si siano separate. In generale, è diventato chiaro che per l'istruzione, non si sapeva. Cioè non era chiaro: il processore non funziona o l'output grafico. Poi mi è venuta in mente l'idea di aspettare un po'. Si è scoperto che "il processore non dorme, sta solo lampeggiando lentamente", e dopo circa cinque minuti il kernel ha strappato un pacchetto di messaggi sulla console e ha cominciato a bloccarsi di nuovo. È diventato chiaro che il processore, in generale, funziona, e si devono esplorare il codice di lavoro con SDL2. Purtroppo non so come usare questa libreria, quindi a tratti ho dovuto agire a caso. A un certo punto sullo schermo è apparsa la riga parallel0 su sfondo blu, il che suggeriva alcune riflessioni. Alla fine si è scoperto che il problema era che Qemu apre più finestre virtuali in un'unica finestra fisica, tra cui si può passare con Ctrl-Alt-n: nella build nativa funziona, in Emscripten - no. Dopo aver eliminato le finestre superflue con le opzioni -monitor none -parallel none -serial none e specificando di ridisegnare forzatamente l'intero schermo a ogni fotogramma, tutto ha iniziato a funzionare improvvisamente.

Coroutine

Quindi, l'emulazione nel browser funziona, ma non si può avviare nulla di interessante monocartella, perché non ci sono input/output a blocchi: bisogna implementare il supporto per le coroutine. Qemu ha già diversi backend di coroutine, ma a causa delle particolarità di JavaScript e del generatore di codice Emscripten non si può semplicemente iniziare a jonglare con gli stack. Sembrerebbe che "tutto sia perduto, i gessi vengono tolti", ma gli sviluppatori di Emscripten si sono già presi cura di tutto. È implementato in modo piuttosto divertente: perché non chiamiamo la funzione con un invito sospetto come emscripten_sleep e di alcuni altri che utilizzano il meccanismo Asyncify, così come le chiamate tramite puntatori e le chiamate a qualsiasi funzione, dove più in basso nello stack può verificarsi uno dei due casi precedenti. E ora, prima di ogni chiamata sospetta, evidenzieremo il contesto async e subito dopo la chiamata verificheremo se è avvenuta una chiamata asincrona, e se sì, salveremo tutte le variabili locali in quel contesto async, indicheremo a quale funzione passare il controllo quando sarà necessario continuare l'esecuzione, e usciremo dalla funzione corrente. Qui c'è spazio per esplorare l'effetto. spezzettamento — per il proseguimento dell'esecuzione del codice dopo il ritorno da una chiamata asincrona, il compilatore genera "frammenti" di funzione, che iniziano dopo la chiamata sospetta — così: se ci sono n chiamate sospette, la funzione sarà spezzettata circa n/2 volte — questo è ancora senza considerare che nella funzione originale è necessario aggiungere, dopo ogni chiamata potenzialmente asincrona, il salvataggio di parte delle variabili locali. Successivamente, è stato necessario scrivere un semplice script in Python che, dato un insieme di funzioni particolarmente frammentate che, presumibilmente, "non lasciano passare l'asincronicità attraverso di esse" (cioè in esse non si attiva il rollback dello stack e tutto ciò che ho appena descritto), indica quali chiamate tramite puntatori in quali funzioni devono essere ignorate dal compilatore, affinché quelle funzioni non siano considerate come asincrone. In fondo, file JS di 60 MB sono decisamente troppo — almeno 30. Anche se, una volta ho impostato uno script di build e ho accidentalmente eliminato le opzioni del linker, tra cui c'era anche -O3. Avvio il codice generato e Chromium consuma memoria e si blocca. Poi ho guardato casualmente a ciò che stava cercando di caricare... Beh, cosa posso dire, anche io mi sarei bloccato se mi fosse stato chiesto di studiare e ottimizzare conscienziosamente del JavaScript su 500+ MB.

Purtroppo, i controlli nel codice della libreria di supporto Asyncify non andavano proprio d'accordo con longjmp- che vengono 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 qui è iniziato qualcosa di strano: a volte si attivavano i controlli nel codice di sincronizzazione — quegli stessi che terminano forzatamente il codice se logicamente deve bloccarsi — qualcuno stava cercando di acquisire un mutex già acquisito. Per fortuna, non era un problema logico nel codice serializzato — semplicemente stavo usando la funzionalità standard del ciclo principale fornita da Emscripten, ma a volte una chiamata asincrona ribaltava completamente lo stack, e in quel momento scattava setTimeout del ciclo principale — in questo modo, il codice entrava in un'iterazione del ciclo principale senza uscire dall'iterazione precedente. L'ho riscritto con un ciclo infinito e emscripten_sleep, e i problemi con i mutex sono finiti. Il codice è diventato persino più logico — infatti, in sostanza, non ho un codice che prepara il prossimo fotogramma di animazione — semplicemente il processore calcola qualcosa e lo schermo si aggiorna periodicamente. Tuttavia, i problemi non si sono fermati qui: a volte l'esecuzione di Qemu si chiudeva silenziosamente senza alcuna eccezione o errore. In quel momento ho smesso di pensarci, ma, anticipando, dirò che il problema era questo: il codice delle coroutine, in realtà, non utilizza setTimeout (o almeno, non così spesso come si potrebbe pensare): la funzione emscripten_yield semplicemente imposta un flag di chiamata asincrona. Il punto è che emscripten_coroutine_next non è una funzione asincrona: all'interno di essa controlla il flag, lo reimposta e passa il controllo dove necessario. Cioè su di essa il rollback dello stack si conclude. Il problema era che a causa di un use-after-free, che si manifestava quando il pool di coroutine era disabilitato perché non avevo copiato una riga di codice importante dal backend delle coroutine esistenti, la funzione qemu_in_coroutine restituiva true quando in realtà avrebbe dovuto restituire false. Questo portava a chiamare emscripten_yield, sopra la quale nello stack non c'era emscripten_coroutine_next, lo stack si sviluppava fino in cima, ma nessun setTimeout, come ho già detto, veniva impostato.

Generazione di codice JavaScript

Ecco, quindi, la tanto attesa "riavvolgimento della carne macinata". In realtà no. Certamente, se si esegue Qemu nel browser e al suo interno — Node.js, allora, ovviamente, dopo la generazione del codice in Qemu otterremo un JavaScript completamente diverso. Ma comunque, che piaccia o no, si tratta di una qualche forma di trasformazione inversa.

Iniziamo con una breve spiegazione su come funziona Qemu. Vorrei scusarmi in anticipo: non sono un sviluppatore professionista di Qemu e le mie conclusioni potrebbero essere a volte errate. Come si suol dire, "l'opinione di uno studente non è necessariamente in accordo con quella dell'insegnante, l'assiomatica di Peano e il buon senso". Qemu supporta un certo numero di architetture guest e per ciascuna esiste una directory come target-i386. Durante la compilazione, è possibile specificare il supporto per più architetture guest, ma il risultato sarà semplicemente un insieme di binari. Il codice per supportare l'architettura guest, a sua volta, genera alcune operazioni interne di Qemu, che TCG (Tiny Code Generator) trasforma in codice macchina per l'architettura host. Come si afferma nel file readme presente nella directory tcg, inizialmente questo era parte di un normale compilatore C, successivamente adattato per JIT. Pertanto, ad esempio, l'architettura target in termini di questo documento non è più quella guest, ma quella host. A un certo punto è emerso un altro componente: il Tiny Code Interpreter (TCI), che deve eseguire il codice (praticamente le stesse operazioni interne) in assenza di un generatore di codice per una specifica architettura host. In realtà, come descritto nella sua documentazione, questo interprete potrebbe non funzionare sempre allo stesso modo del generatore di codice JIT, non solo in termini di velocità, ma anche di qualità. Anche se non sono sicuro che la sua descrizione sia completamente attuale.

Inizialmente ho cercato di realizzare un backend TCG completo, ma mi sono confuso rapidamente tra i sorgenti e la descrizione poco chiara delle istruzioni del bytecode, quindi ho deciso di incapsulare l'interprete TCI. Questo ha subito portato a diversi vantaggi:

  • nella realizzazione del generatore di codice si poteva fare riferimento non alla descrizione delle istruzioni, ma al codice dell'interprete
  • era possibile generare funzioni non per ogni blocco di traduzione incontrato, ma ad esempio solo dopo la centesima esecuzione
  • in caso di modifica del codice generato (e ciò, a quanto pare, è possibile, visto le funzioni con nomi che contengono la parola patch) dovrò invalidare il codice JS generato, ma almeno avrò qualcosa da cui rigenerarlo.

Non sono sicuro che il patching sia possibile dopo che il codice è stato eseguito per la prima volta, ma i primi due punti sono già sufficienti.

Inizialmente il codice veniva generato come un grande switch sulla posizione dell'istruzione originale del bytecode, ma poi, ricordando un articolo su Emscripten, sull'ottimizzazione del JS generato e il relooping, ho deciso di generare un codice più umano, anche perché empiricamente si è dimostrato che l'unico punto di ingresso in un blocco di traduzione è il suo inizio. Detto fatto, dopo un po' è stato creato un generatore di codice, capace di generare codice con if (anche se senza cicli). Ma c'era un problema: falliva, mostrando che l'istruzione era di una lunghezza imprecisa. E così, l'ultima istruzione a questo livello di ricorsione era brcond. Va bene, aggiungerò un kontrol identico nella generazione di questa istruzione prima e dopo la chiamata ricorsiva e… nessuna delle due è stata eseguita, ma dopo lo switch sull'assert è riuscito a crollare. Alla fine, studiando il codice generato, ho capito che dopo lo switch il puntatore sull'istruzione attuale viene ricaricato dallo stack e, probabilmente, sovrascritto dal codice JavaScript generato. E così è stato. Aumentare il buffer da un megabyte a dieci non ha portato a nulla e si è capito che il generatore di codice girava in tondo. Ho dovuto controllare che non fossimo usciti dai limiti dell'attuale TB e, se lo fossimo, dare l'indirizzo del TB successivo con segno negativo, in modo da poter continuare l'esecuzione. Inoltre, questo risolve il problema di "quali funzioni generate invalidare se questo piccolo pezzo di bytecode è stato modificato?" — è necessario invalidare solo quella funzione che corrisponde a questo blocco di traduzione. A proposito, sebbene avessi effettuato il debug tutto in Chromium (poiché uso Firefox e mi è più facile utilizzare un browser separato per esperimenti), Firefox mi ha aiutato a correggere le incompatibilità con lo standard asm.js, dopo di che il codice ha iniziato a funzionare più velocemente in Chromium.

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

Così, il lavoro non è ancora concluso, ma mi sono stancato di perfezionare segretamente questo progetto incompleto. Perciò ho deciso di pubblicare ciò che ho fino ad ora. Il codice è un po' spaventoso in alcune parti, poiché è un esperimento e non è chiaro in anticipo cosa fare. Probabilmente, in seguito varrà la pena di organizzare commit atomici normali sopra una versione più moderna di Qemu. Nel frattempo, c'è un ramo nel git in formato blog: a ogni "livello" completato, ho aggiunto un commento dettagliato in italiano. Pertanto, questo articolo è, in larga misura, una rielaborazione delle conclusioni. git log.

È possibile provare tutto questo qui (attenzione, traffico).

Cosa funziona già adesso:

  • Funziona il processore virtuale x86
  • C'è un prototipo funzionante di generatore di codice JIT da codice macchina a JavaScript
  • C'è un'implementazione per assemblare altre architetture guest a 32 bit: puoi già ammirare Linux per l'architettura MIPS che si sta bloccando nel browser durante il caricamento

Cosa si può ulteriormente fare

  • Accelerare l'emulazione. Anche in modalità JIT sembra funzionare più lentamente di Virtual x86 (anche se c'è un intero Qemu con molte più macchine emulate e architetture)
  • Creare un'interfaccia normale — personalmente non sono un grande sviluppatore web, quindi per ora ho modificato l'interfaccia standard di Emscripten come ho potuto
  • Provare a eseguire funzioni più complesse di Qemu — rete, migrazione VM, ecc.
  • UPD: Dovrò inviare a upstream Emscripten i miei pochi risultati e report di bug, come hanno fatto i precedenti portatori di Qemu e di altri progetti. Grazie a loro per aver reso possibile l'utilizzo implicito del loro contributo in Emscripten nel mio progetto.

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS 🔥 Acquista hosting affidabile per siti web con protezione DDoS, server VPS VDS | ProHoster