Qualche anno fa Fabrice Bellard — un emulatore PC scritto in JavaScript. Dopodiché ci sono stati almeno . Ma tutti loro, per quel che ne so, erano interpreti, mentre il Qemu, scritto molto prima dallo stesso Fabrice Bellard, e probabilmente qualsiasi emulatore moderno rispettabile, utilizza la compilazione JIT del codice guest nel codice del sistema host. Mi è sembrato il momento giusto per implementare il compito inverso rispetto a quello che risolvono i browser: la compilazione JIT del codice macchina in JavaScript, per cui sembrava più logico portare Qemu. Sembrerebbe, perché proprio Qemu, ci sono emulatori più semplici e user-friendly — come VirtualBox, per esempio — basta installarlo ed è pronto. Ma Qemu ha alcune caratteristiche interessanti
- sorgenti aperti
- la possibilità di funzionare senza driver kernel
- la possibilità di funzionare in modalità interprete
- supporto per un gran numero di architetture sia host che guest
Per quanto riguarda il terzo punto, ora posso già spiegare che in realtà in modalità TCI non vengono interpretate le stesse istruzioni macchina guest, ma il bytecode generato da esse, ma ciò non cambia il succo — per costruire e avviare Qemu su una nuova architettura, se va bene, è sufficiente un compilatore C — si può rimandare la scrittura del generatore di codice.
E così, dopo due anni di lavoro lento nel tempo libero sui sorgenti di Qemu, è comparso un prototipo funzionante in cui è già possibile avviare, per esempio, Kolibri OS.
Cos'è Emscripten
Al giorno d'oggi ci sono molti compilatori, il cui risultato finale è JavaScript. Alcuni, come TypeScript, sono stati originariamente pensati 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 un formato comprensibile dal browser. Su sono raccolti diversi port di programmi noti: , per esempio, si può dare un'occhiata a PyPy — tra l'altro, come affermato, hanno già un JIT. In realtà, non ogni programma può essere semplicemente compilato e avviato nel browser — ci sono una serie di , con cui si deve convivere, come recita l'iscrizione su questa stessa pagina "Emscripten può essere utilizzato per compilare quasi qualsiasi portable Codice C/C++ in JavaScript. In altre parole, ci sono una serie di operazioni che sono comportamenti indefiniti secondo lo standard, ma che di solito funzionano su x86 - per esempio, l'accesso non allineato alle variabili, che in alcune architetture è addirittura vietato. In generale, Qemu è un programma multipiattaforma e, si sperava, non contiene una grande quantità di comportamenti indefiniti - basta prendere e compilare, poi giocare un po' con JIT - ed è fatto! Ma non è andata così...
Primo tentativo
A dire il vero, non sono il primo a cui è venuta in mente l'idea di portare Qemu su JavaScript. Su un forum di ReactOS è stata posta la domanda se fosse possibile farlo con Emscripten. Ancora prima c'erano voci che Fabrice Bellard lo avesse fatto personalmente, ma si parlava di jslinux, che, per quanto ne so, è proprio un tentativo di ottenere manualmente delle prestazioni sufficienti su JS, scritto da zero. In seguito è stato scritto Virtual x86 - a cui sono stati resi disponibili i sorgenti non offuscati, e, come si affermava, una "maggiore 'realismo'" nell'emulazione ha reso possibile l'uso di SeaBIOS come firmware. Inoltre, c'è stata almeno un tentativo di portare Qemu con Emscripten - che qualcuno ha provato a fare , ma lo sviluppo, per quanto ho capito, è stato congelato.
Quindi, a prima vista, ecco i sorgenti, ecco Emscripten - basta prendere e compilare. Ma ci sono anche le librerie da cui dipende Qemu, e le librerie da cui dipendono quelle librerie, ecc., e una di esse è , da cui dipende glib. Si erano diffuse voci su internet riguardo a una grande collezione di porte delle librerie sotto Emscripten, ma era difficile crederci: prima di tutto, non compilava con il nuovo compilatore, e in secondo luogo, era una libreria troppo a basso livello per essere semplicemente presa e compilata in JS. E non si trattava solo di inserti in assembler: probabilmente, se ci si sforzava, per alcune convenzioni di chiamata si potevano formare gli argomenti necessari nello stack e chiamare la funzione anche senza di essi. Ma Emscripten è un'affare ingegnoso: affinché il codice generato appaia familiare per l'ottimizzatore del motore JS del browser, vengono utilizzati alcuni trucchi. In particolare, il cosiddetto relooping: il generatore di codice, partendo dall'IR LLVM ricevuto con alcune istruzioni astratte di salto, cerca di ricreare if plausibili, cicli, ecc. E come vengono passati gli argomenti alla funzione? Naturalmente, come argomenti delle funzioni JS, cioè il meno possibile tramite lo stack.
All'inizio avevo pensato di scrivere semplicemente una sostituzione di libffi in JS e di eseguire i test standard, ma alla fine mi sono confuso su come realizzare i miei file header affinché funzionassero con il codice esistente — come si suol dire, "O questi compiti sono così complicati, o noi siamo così stupidi". Ho dovuto portare libffi su un'altra architettura, se così si può dire: fortunatamente, 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. In generale, dopo aver lavorato un po' con i frammenti di libffi dipendenti dalla piattaforma, ho ottenuto un certo codice compilabile e l'ho eseguito su uno dei test a caso. Con mia sorpresa, il test è passato con successo. Meravigliato dalla mia genialità — non è uno scherzo, ha funzionato al primo colpo — ho, ancora non credendo ai miei occhi, guardato di nuovo il codice risultante per valutare dove approfondire. Qui sono rimasto di nuovo sorpreso: l'unica cosa che la mia funzione ffi_call faceva era segnalare una chiamata riuscita. Non c'è stata alcuna chiamata. Così ho inviato la mia prima pull request, correggendo un errore evidente a qualunque olimpionico nel test — i numeri reali non dovrebbero essere confrontati come a == b e nemmeno come a - b < EPS — non dimentichiamoci di un modulo, altrimenti 0 risulterà addirittura uguale a 1/3… In generale, ho realizzato una sorta di porta di libffi, che supera i test più semplici, e con cui si compila glib — ho deciso che, se sarà necessario, lo completerò in seguito. Anticipando un po', dirò che, come si è rivelato, nel codice finale della funzione libffi il compilatore non ha nemmeno incluso.
Ma, come ho già detto, ci sono alcune limitazioni e tra l'uso libero di vari comportamenti indefiniti si è infilata una caratteristica piuttosto spiacevole: JavaScript per progettazione non supporta la multithreading con memoria condivisa. In linea di principio, questo di solito può anche essere considerato un'ottima idea, ma non per il porting di codice la cui architettura è legata ai thread C. In effetti, in Firefox si stanno conducendo esperimenti per supportare i worker condivisi, e l'implementazione di pthread è presente in Emscripten, ma non volevo dipenderne. Ho dovuto lentamente estirpare la multithreading dal codice di Qemu — cioè cercare dove vengono avviati i thread, estrarre il corpo del ciclo che viene eseguito in questo thread in una funzione separata, e chiamare a turno tali funzioni dal ciclo principale.
Secondo tentativo
A un certo punto è diventato chiaro che la situazione era ferma e che un disperso riempire di impedenze il codice non avrebbe portato a nulla di buono. Morale: bisogna in qualche modo sistematizzare il processo di aggiunta di soluzioni temporanee. Per questo è stata presa la versione 2.4.1 (non 2.5.0, perché, chi lo sa, potrebbero esserci bug non ancora scoperti nella nuova versione, e io ho già abbastanza dei miei bug), e per prima cosa è stata riscritta in modo sicuro thread-posix.c. Chiaro, in modo sicuro: se qualcuno cercava di eseguire un'operazione che portava a un lock, veniva subito chiamata la funzione abort() — ovviamente, questo non risolveva immediatamente tutti i problemi, ma almeno era più piacevole che ricevere silenziosamente dati inconsistenti.
In generale, nel porting di codice su JS le opzioni di Emscripten -s ASSERTIONS=1 -s SAFE_HEAP=1 — catturano alcuni tipi di comportamento indefinito, come gli accessi a indirizzi non allineati (che non è affatto coerente con il codice per array tipizzati come HEAP32[addr >> 2] = 1) o la chiamata di una funzione con un numero errato di argomenti.
A proposito, gli errori di allineamento sono un argomento a parte. Come ho già detto, in Qemu c'è un backend per la generazione di codice TCI (tiny code interpreter) "degenere", e per compilare e avviare Qemu su una nuova architettura, se tutto va bene, è sufficiente un compilatore C. Parole chiave "se tutto va bene". A me non è andata bene, e si è scoperto che il TCI, mentre analizza il proprio bytecode, utilizza accesso non allineato. Cioè, su architetture come ARM e altre, dove è obbligatorio l'accesso allineato, Qemu viene compilato perché per esse esiste un normale backend TCG che genera codice nativo, ma se il TCI funzionerà su di esse è un'altra questione. Tuttavia, come si è rivelato, nella documentazione del TCI era esplicitamente indicato qualcosa di simile. Alla fine, nel codice sono state aggiunte chiamate a funzioni per la lettura non allineata, che sono state trovate in un'altra parte di Qemu.
Corruzione dell'heap
Alla fine, l'accesso non allineato nel TCI è stato corretto, è stato creato un ciclo principale che chiamava a turno il processore, RCU e alcune cosette. Ecco che avvio Qemu con l'opzione -d exec,in_asm,out_asm, che significa che bisogna indicare quali blocchi di codice vengono eseguiti, e nel momento della traduzione scrivere quale codice guest è stato, quale codice host è diventato (in questo caso, bytecode). Si avvia, esegue alcuni blocchi di traduzione, scrive il messaggio di debug che ho lasciato, che ora si avvierà RCU e… cade all'interno della abort() funzione free(). Attraverso un po' di indagine nella funzione free() si è scoperto che nell'intestazione del blocco heap, che si trova a otto byte dalla memoria allocata, al posto della dimensione del blocco o qualcosa di simile è presente spazzatura.
La distruzione del mucchio - che carino... In un caso del genere, c'è un metodo utile: raccogliere un binario nativo dagli stessi sorgenti, se possibile, e farlo girare sotto Valgrind. Dopo un po', il binario era pronto. Lo avvio con le stesse opzioni - si arresta di nuovo all'inizializzazione, senza arrivare realmente all'esecuzione. È spiacevole, ovviamente - a quanto pare, i sorgenti non erano proprio gli stessi, il che non sorprende, visto che il configure ha esaminato alcune opzioni diverse, ma ho Valgrind - prima risolvo questo bug e poi, se tutto va bene, apparirà anche l'originale. Avvio tutto come prima sotto Valgrind... Uhhh, è partito, l'inizializzazione è andata bene e ha continuato oltre il bug originale senza alcun avviso di accesso errato alla memoria, per non parlare dei crash. La vita non mi ha preparato a qualcosa del genere - un programma che crasha smette di farlo quando viene eseguito sotto Valgrind. Cosa fosse - è un mistero. La mia ipotesi è che poiché nei dintorni dell'istruzione corrente dopo il crash all'inizializzazione, gdb mostrava attività memset- con un puntatore valido utilizzando o mmx, o xmm registri, allora forse si trattava di un errore di allineamento, anche se è difficile crederlo.
Ok, Valgrind qui sembra non essere di aiuto. E qui è iniziato il momento più sgradevole: tutto, in un certo senso, sembra avviarsi, ma si blocca per motivi completamente sconosciuti a causa di un evento che potrebbe essere avvenuto milioni di istruzioni fa. Per molto tempo non è stato chiaro come affrontare la situazione. Alla fine, ho dovuto sedermi e fare il debug. La stampa di ciò che era stato riscritto nell'intestazione ha mostrato che sembra non essere un numero, ma piuttosto qualche dato binario. E, oh meraviglia, questa stringa binaria è stata trovata nel file del BIOS — cioè ora si poteva affermare con una certa sicurezza che si trattava di un overflow del buffer, e si capiva anche cosa veniva scritto in quel buffer. E poi, in un certo senso, in Emscripten, fortunatamente, non c'è randomizzazione dello spazio degli indirizzi, non ci sono buchi, quindi si può scrivere da qualche parte in mezzo al codice l'output dei dati usando il puntatore dal primo avvio, osservare i dati, controllare il puntatore e, se non è cambiato, ottenere informazioni per riflettere. Certo, il linking dopo ogni modifica richiede un paio di minuti, ma che ci si può fare. Alla fine è stata trovata una stringa specifica che copia il BIOS dal buffer temporaneo nella memoria guest — e, di fatto, nel buffer non c'era spazio sufficiente. 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 era la seguente: a volte può essere utile allineare l'indirizzo a una large page di dimensioni 2 MB, per fare ciò chiederemo a mmap inizialmente un po' di più, e poi restituiremo il superfluo tramite munmap. E se tale allineamento non è necessario, specificeremo invece 2 MB come risultato getpagesize() — mmap che emetterà comunque un indirizzo allineato... Quindi in Emscripten mmap si chiama semplicemente malloc, e questo, naturalmente, non allinea per pagina. In generale, un bug che mi ha dato fastidio per un paio di mesi è stato risolto con una modifica in due righe.
Caratteristiche della chiamata alle funzioni
E ora il processore sta eseguendo dei calcoli, Qemu non si blocca, ma lo schermo non si accende, e il processore si cicla rapidamente, a giudicare dall'output -d exec,in_asm,out_asm. È stata avanzata un'ipotesi: non arrivano le interruzioni del timer (o forse tutte le interruzioni). E in effetti, se si disattivano le interruzioni di una build nativa che per qualche motivo funzionava, si ottiene un quadro simile. Ma la soluzione non era affatto questa: il confronto dei tracciati forniti con l'opzione sopra indicata ha mostrato che i percorsi di esecuzione divergono molto presto. Qui bisogna dire che il confronto tra l'output registrato dal launcher emrun e l'output della build nativa non è un processo del tutto meccanico. Non so esattamente come il programma avviato nel browser si connetta a emrun, ma alcune righe nell'output risultano scambiate tra loro, quindi la differenza nel diff non è ancora un motivo per ritenere che i percorsi siano divergenti. Insomma, è diventato chiaro che, secondo le istruzioni ljmpl si passa a indirizzi diversi, e il bytecode generato è fondamentalmente diverso: in uno c'è un'istruzione per chiamare una funzione helper in C, nell'altro no. Dopo aver cercato su Google l'istruzione e aver esaminato il codice che trasmette queste istruzioni, è diventato evidente che, in primo luogo, direttamente prima di essa nel registro cr0 è stata effettuata una scrittura — anch'essa tramite un helper — che porta il processore in modalità protetta, e in secondo luogo, che la versione js non è mai passata alla modalità protetta. Il fatto è che un'altra particolare caratteristica di Emscripten è l'assenza di tolleranza per codice simile all'implementazione dell'istruzione call in TCI, che porta qualsiasi puntatore a funzione al tipo long long f(int arg0, .. int arg9) — le funzioni devono essere chiamate con il numero corretto di argomenti. In caso di violazione di questa regola, a seconda delle impostazioni di debug, il programma può terminare (il che è buono) o può chiamare una funzione completamente diversa (cosa che sarà triste da depurare). C'è anche una terza opzione: abilitare la generazione di wrapper che aggiungono/rimuovono argomenti, ma nel complesso questi wrapper occupano molto spazio, considerando che in realtà ho bisogno solo di poco più di cento wrapper. Solo questo è già piuttosto triste, ma si è rivelato esserci un problema più serio: nel codice generato delle funzioni-wrapper, gli argomenti venivano convertiti, solo che a volte la funzione con gli argomenti generati non veniva chiamata — proprio come nella mia implementazione di libffi. Cioè, alcuni helper semplicemente non venivano eseguiti.
Fortunatamente, in Qemu ci sono elenchi leggibili dalla macchina dei helper in forma di un file di intestazione come
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 ridefiniti in modo bizzarro i macro DEF_HELPER_n, e poi viene incluso helper.h. Fino al punto che il macro si espande nell'inizializzatore della struttura e nella virgola, per poi definire un array, e invece degli elementi — #include <helper.h> Di conseguenza, finalmente si è presentato l'occasione di provare a lavorare con la libreria , e è stato scritto uno script che genera esattamente quelle e solo quelle funzioni per cui è necessario.
E così, dopo questo il processore sembra essersi avviato. Sembra, perché lo schermo non si è mai inizializzato, anche se nella build nativa sono riuscito a provare memtest86+. Qui bisogna chiarire che il codice di I/O a blocchi di Qemu è scritto in coroutines. In Emscripten c'è una sua realizzazione piuttosto intricata, ma doveva ancora essere supportata nel codice di Qemu, mentre si può già debuggare il processore: Qemu supporta le opzioni -kernel, -initrd, -append, attraverso le quali è possibile caricare Linux o, ad esempio, memtest86+, senza utilizzare affatto dispositivi a blocchi. Ma c'è un problema: nella build nativa era possibile osservare l'output del kernel Linux sulla console con l'opzione -nographic, mentre dal browser non arrivava alcun output nel terminale da cui è stato avviato emrun, quindi non è chiaro: il processore non funziona o è il rendering grafico che non va. E poi mi è venuto in mente di aspettare un po'. Si è scoperto che "il processore non è in sleep, ma lampeggia lentamente", e dopo circa cinque minuti il kernel ha lanciato un insieme di messaggi sulla console e ha continuato a bloccarsi. È diventato chiaro che il processore, in generale, funziona, e si deve scavare nel codice che gestisce SDL2. Purtroppo non so utilizzare questa libreria, quindi a volte ho dovuto agire a caso. A un certo punto, sullo schermo è apparso il testo parallel0 su uno sfondo blu, il che ha suscitato alcune riflessioni. Alla fine si è scoperto che il problema era che Qemu apre più finestre virtuali in una sola finestra fisica, tra cui è possibile passare usando Ctrl-Alt-n: nella build nativa funziona, in Emscripten — no. Dopo aver eliminato le finestre extra con le opzioni -monitor none -parallel none -serial none e forzando il ridisegno dell'intero schermo a ogni fotogramma, tutto ha iniziato a funzionare improvvisamente.
Coroutines
Dunque, l'emulazione nel browser funziona, ma non si può avviare nulla di interessante con un disco unico, perché manca l'input-output a blocchi: è necessario implementare il supporto per le coroutine. In Qemu ci sono già diversi backend per coroutine, ma a causa delle peculiarità di JavaScript e del generatore di codice Emscripten non si può semplicemente prendere e iniziare a giocolare con gli stack. Sembrerebbe che "tutto sia perduto, il gesso si sta rimuovendo", ma gli sviluppatori di Emscripten hanno già pensato a tutto. È stato implementato in modo piuttosto divertente: chiamiamo sospettoso una chiamata a una funzione come emscripten_sleep e ad alcune altre, 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, allochiamo un contesto asincrono, e subito dopo la chiamata verifichiamo se si è verificata una chiamata asincrona e, se sì, salviamo tutte le variabili locali in questo contesto asincrono, indichiamo quale funzione restituire il controllo quando sarà necessario riprendere l'esecuzione, e usciamo dalla funzione corrente. Qui c'è spazio per esplorare gli effetti — per le esigenze di ripresa dell'esecuzione del codice dopo il ritorno da una chiamata asincrona, il compilatore genera "frammenti" della funzione, che iniziano dopo la chiamata sospetta — così: se ci sono n chiamate sospette, la funzione verrà frammentata circa n/2 volte — e questo è ancora, se non si considera che alla funzione originale è necessario aggiungere il salvataggio di parte delle variabili locali dopo ogni chiamata potenzialmente asincrona. Alla fine, si è dovuto persino scrivere un semplice script in Python, che per un dato insieme di funzioni particolarmente frammentate, che presumibilmente "non fanno passare l'asincronicità attraverso di esse" (cioè in esse non si verifica il rollback dello stack e tutto ciò che ho appena descritto), indica quali chiamate tramite puntatori in quali funzioni il compilatore deve ignorare affinché queste funzioni non siano trattate come asincrone. Altrimenti, i file JS da 60 MB iniziano ad essere decisamente eccessivi — almeno 30. Anche se, una volta stavo configurando uno script di build e accidentalmente ho eliminato le opzioni del linker, tra cui c'era anche -O3Avvio il codice generato e Chromium consuma tutta la memoria e si arresta. Poi ho accidentalmente guardato ciò che stava provando a caricare... Beh, cosa posso dire, anch'io mi sarei bloccato se mi chiedessero di studiare e ottimizzare un javascript di oltre 500 Mb.
Purtroppo, i controlli nel codice della libreria di supporto Asyncify non andavano molto d'accordo con longjmp- gli utilizzati nel codice del virtual processor, ma dopo una piccola patch che disabilita questi controlli e ripristina forzatamente i contesti come se tutto fosse a posto, il codice ha funzionato. E qui è iniziato l'insolito: a volte i controlli nel codice di sincronizzazione venivano attivati — quelli che terminano il codice in modo anomalo se, secondo la logica di esecuzione, dovrebbe bloccarsi — qualcuno stava cercando di acquisire un mutex già acquisito. Fortunatamente, non è stata una questione logica nel codice serializzato, ma ho usato la funzionalità standard del main loop fornita da Emscripten, ma a volte una chiamata asincrona ha completamente sviluppato lo stack e in quel momento si attivava setTimeout dal main loop — in questo modo, il codice entrava in iterazione del ciclo principale senza uscire dall'iterazione precedente. Ho riscritto con un ciclo infinito e emscripten_sleep, e i problemi con i mutex sono cessati. Il codice è diventato persino più logico — in fondo, non ho un certo 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: a volte l'esecuzione di Qemu terminava semplicemente in silenzio senza alcuna eccezione o errore. In quel momento, ho trascurato la questione, ma per anticipare dirò che il problema era il seguente: il codice delle coroutine, in realtà, non usa affatto setTimeout (o, per lo meno, non così spesso come si potrebbe pensare): la funzione emscripten_yield imposta semplicemente un flag di chiamata asincrona. Il problema è che emscripten_coroutine_next non è una funzione asincrona: all'interno controlla il flag, lo resetta e trasferisce il controllo dove necessario. Quindi, su di essa finisce lo sviluppo dello stack. Il problema risiedeva nel fatto che, a causa di un uso dopo la liberazione, che si manifestava con il pool di coroutine disabilitato poiché non ho copiato una riga importante di codice dal back-end delle coroutine esistente, la funzione qemu_in_coroutine restituiva true quando in realtà doveva restituire false. Questo portava a chiamate emscripten_yield, sopra il quale non c'era nulla nello stack emscripten_coroutine_next, lo stack si estendeva fino in cima, ma non c'era alcun setTimeout, come ho già detto, non veniva esposto.
Generazione di Codice JavaScript
Ecco, proprio ciò che avevo promesso "l'inversione della macinazione della carne". In realtà no. Certo, se esegui Qemu nel browser e all'interno di esso — Node.js, naturalmente, dopo la generazione del codice in Qemu otterremo un JavaScript completamente diverso. Ma tutto sommato, c'è comunque una sorta di trasformazione inversa.
Per cominciare, parliamo di come funziona Qemu. Mi scuso in anticipo: non sono un sviluppatore professionista di Qemu e le mie conclusioni potrebbero essere in parte errate. Come si suol dire, "l'opinione di uno studente non deve necessariamente coincidere con quella di un insegnante, con l'assiomatica di Peano e con il buon senso". Qemu ha un certo numero di architetture guest supportate e per ciascuna di esse c'è un catalogo simile a target-i386. Durante la compilazione è possibile specificare il supporto per più architetture guest, ma alla fine si otterrà semplicemente più binari. Il codice per supportare l'architettura guest, a sua volta, genera alcune operazioni interne di Qemu, che TCG (Tiny Code Generator) trasforma già in codice macchina per l'architettura host. Come indicato nel file readme presente nella cartella tcg, inizialmente era parte di un normale compilatore C, che poi è stato adattato per il JIT. Pertanto, ad esempio, l'architettura target in termini di questo documento — non è più l'architettura guest, ma l'architettura host. A un certo punto è apparso 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 un'architettura host specifica. In realtà, come indicato nella sua documentazione, questo interprete potrebbe non funzionare sempre bene come un generatore di codice JIT, sia in termini di velocità che 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 subito confuso tra i sorgenti e le non del tutto chiare descrizioni delle istruzioni del bytecode, quindi ho deciso di avvolgere l'interprete TCI. Questo ha immediatamente fornito alcuni vantaggi:
- nell'implementazione del generatore di codice si poteva 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
- In caso di modifica del codice generato (cosa che, a quanto pare, è possibile, considerando le funzioni con nomi che contengono la parola patch), dovrò invalidare il codice JS generato, ma almeno avrò qualcosa da cui ricominciare a rigenerarlo
Per quanto riguarda il terzo punto, non sono sicuro che il patching sia possibile dopo la prima esecuzione del codice, ma i primi due punti sono comunque sufficienti.
Inizialmente il codice veniva generato come un grande switch in base all'indirizzo dell'istruzione bytecode sorgente, ma poi, ricordando un articolo su Emscripten, l'ottimizzazione del JS generato e il relooping, ho deciso di generare un codice più leggibile, soprattutto perché empiricamente si è rivelato che l'unico punto d'ingresso nel blocco di traduzione è l'inizio. Detto, fatto, dopo un po' ho ottenuto un generatore di codice che genera codice con if (anche se senza cicli). Ma ecco, c'era un problema, si bloccava, restituendo un messaggio che l'istruzione era risultata di lunghezza errata. In questo caso, l'ultima istruzione a questo livello di ricorsione era brcond. Bene, aggiungerò un controllo identico nella generazione di questa istruzione sia prima della chiamata ricorsiva che dopo e... nessuno di essi è stato eseguito, ma dopo lo switch su assert siamo comunque andati in crash. Alla fine, esaminando il codice generato, ho capito che dopo lo switch il puntatore all'istruzione corrente 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, ed è diventato chiaro che il generatore di codice stava girando a vuoto. Ho dovuto controllare che non fossimo usciti dai confini del TB attuale, e se ci eravamo usciti, restituire l'indirizzo del prossimo TB con segno meno, in modo da poter continuare l'esecuzione. Inoltre, questo risolve il problema "quali funzioni generate invalidare se questo pezzo di bytecode è cambiato?" - è necessario invalidare solo quella funzione che corrisponde a questo blocco di traduzione. A proposito, anche se ho eseguito il debug tutto in Chromium (poiché utilizzo Firefox e mi è più facile usare un browser separato per gli 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
Quindi, il lavoro non è ancora finito, ma è arrivato il momento di pubblicare quello che ho finora. Il codice è a tratti spaventoso, visto che si tratta di un esperimento, e non è chiaro fin da subito cosa fare. Probabilmente, più avanti sarà opportuno fare dei normali commit atomic su una versione più moderna di Qemu. Per ora c'è un ramo su Git in formato blog: a ogni "livello" che è stato in qualche modo completato è stato aggiunto un commento dettagliato in russo. Questa articolo è in buona parte un riassunto dell'esito. git log.
Puoi provare tutto questo (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
- Esiste un modello per costruire altre architetture guest a 32 bit: puoi ammirare nel tuo browser il caricamento di Linux per l'architettura MIPS che si blocca.
Cosa si può fare ancora
- Accelerare l'emulazione. Anche in modalità JIT sembra funzionare più lentamente di Virtual x86 (ma potenzialmente c'è l'intero Qemu con molte più macchine e architetture emulate)
- Creare un'interfaccia decente — per essere onesti, non sono un grande sviluppatore web, quindi per ora ho modificato l'interfaccia standard di Emscripten come ho potuto
- Provare a far funzionare funzionalità più complesse di Qemu — rete, migrazione delle VM, ecc.
- UPD: Dovrò inviare al progetto principale di Emscripten i miei pochi risultati e bug report, come hanno fatto i portatori precedenti di Qemu e altri progetti. Grazie a loro per aver reso possibile sfruttare il loro contributo a Emscripten per il mio progetto.
Fonte: habr.com
