Nella parte 1 abbiamo descritto cosa si intende per emulazione, le risorse necessarie per creare un emulatore e i due diversi approcci al problema della emulazione di una CPU B su una CPU A.
Nella parte 2 abbiamo preso in considerazione il primo dei due approcci, l'interpretazione, descrivendone la metodologia di implementazione e vantaggi e svantaggi della stessa.
La seconda delle due possibilità è chiamata ricompilazione.
Uno dei difetti più grandi dell'interpretazione è che per ogni singola istruzione di B da emulare si debbano eseguire una multitudine di sottoroutine, con continui salti, cosa che, in processori moderni, rallenta la CPU A.
Lo stratagemma che si può mettere in atto per migliorare la situazione è di lavorare in due fasi distinte: nella prima la CPU A crea frammenti di codice (eseguibili dalla CPU A stessa) che emulano l'effetto di un frammento del codice della CPU B; nella seconda la CPU A esegue il frammento di codice che ha precedentemente generato. In realtà così facendo si rallenta ancor più il tutto, visto che la prima fase corrisponde sostanzialmente all'interpretazione, con la differenza che, una volta stabilite le sottoroutine da chiamare non le si chiama direttamente, ma si genera in output una lista di istruzioni di chiamata, da eseguire nella seconda fase.
L'aspetto da cui si può trarre vantaggio è che la prima parte dipende solo dal codice della CPU B e non dai dati che la CPU B elabora in quella zona del suo codice; per cui la prima parte (traduzione) è dispendiosa circa quanto l'interpretazione, ma è possibile che si possa evitare in futuro di ritradurre lo stesso frammento di codice di B nel caso che il PC (program counter) di B ritorni ad eseguirlo. In altre parole l'output del lavoro di traduzione non viene distrutto subito dopo la fase di esecuzione, ma messo da parte nell'evenienza che possa essere riutilizzato; naturalmente per conservare queste informazioni l'emulatore utilizza memoria del sistema della CPU A.
Secondo quale criterio spezzettare il codice di B in frammenti? Sembrerebbe una decisione arbitraria, ma bisogna affrontare in questo momento un nuovo problema. L'ipotesi fondamentale alla base della traduzione di frammenti è che dopo la prima istruzione vada eseguita la seconda e poi la terza e così via: in realtà questo non è affatto garantito, alcune istruzioni possono modificare il flusso del PC (le istruzioni di salto condizionato e non, quelle di chiamata a sottoprocedure e quelle di ritorno da esse). Questa considerazione porta alla decisione di suddividere il codice di B isolando frammenti separati da istruzioni di salto, in modo che si possa schematizzare il tutto come esecuzione di un frammento e indicazione alla fine dello stesso del prossimo frammento da eseguire. E' come se si stesse raggruppando le istruzioni di B in macroistruzioni più complesse, tutte terminanti con un salto alla prossima macroistruzione.
Quando l'esecuzione di un frammento termina si osserva quale sia il prossimo (l'indirizzo della prima istruzione del medesimo è una buona scelta per identificare un frammento) e si controlla se sia già disponibile in versione tradotta. Se è necessaria la traduzione, la si effettua (e si mette da parte il risultato di questo lavoro), altrimenti si passa direttamente alla esecuzione. L'ideale sarebbe poter conservare tutte le traduzioni effettuate (potrebbero tornare utili), ma si impiegherebbe molta memoria. Si stabilisce allora quanta memoria si vuole dedicare a questo buffer e, quando il buffer è completamente pieno, si eliminano alcune dei frammenti tradotti per far posto ai nuovi; si tenta di fare ciò con intelligenza, scartando i frammenti che meno sono stati chiamati in causa nel recente passato, ad esempio si scarta sempre il frammento che è rimasto inutilizzato da più tempo, tra quelli presenti (algoritmo LRU). Si tratta di un consueto problema di caching.
Esaminiamo ora in che modo si può effettuare la traduzione.
Il metodo più semplice è di generare una lista di chiamate alle sottoroutine elementari che si usano nel caso dell'interpretazione. Naturalmente si cerca di particolareggiare le routine chiamate, evitando che debbano loro stesse prendere troppe decisioni (stiamo facendo questo proprio per allontanarci dai problemi dell'interpretazione). Se bisogna tradurre un'istruzione M68K "add d3,d2" (cioé somma d3 e d2 e metti il risultato in d2) si sfrutta l'informazione che i registri sono proprio il 3 e il 2 e non due generici registri x e y. Per lavorare sul registro 3 l'emulatore deve implicitamente prendere l'indirizzo a partire dal quale sono memorizzati i finti registri (per esempio 0x135670) e aggiungere il numero del registro (3) moltiplicato per la lunghezza di ognuno (per esempio 4 bytes), per ottenere l'indirizzo giusto (0x13567c); questo lavoro di calcolo deve essere eseguito nella fase di traduzione e non in quella di esecuzione, se si vuole ottenere buona velocità.
Un successivo passo è quello di evitare la sequenza di chiamate a sottoroutine e copiarle direttamente nell'output della traduzione; un'operazione analoga alla sostituzione di routine con macro nel linguaggio C. Il vantaggio è che la fase di esecuzione diventa più veloce, essendo eliminati i continui salti che tanto danno fastidio alle CPU più evolute. Lo svantaggio è che i frammenti tradotti sono più estesi e riempono più velocemente la memoria dedicata a fare da buffer: si riesce a cacharne un numero inferiore.
Se si osserva il risultato di un'operazione del tipo appena delineato, si trova che il risultante codice di A è di pessima qualità, nel senso che non assomiglia al codice che verrebbe creato da un essere umano o un compilatore dotati di una anche minima intelligenza. Ciò che manca in questo momento è lo sfruttamento della conoscenza del fatto che dopo l'istruzione "add d3,d2" (che significa d2=d2+d3) ci sia una "sub d5,d2" (cioé d2=d2-d5). Non ha ad esempio molto senso memorizzare il valore di d2 subito dopo la somma nella locazione 0x135678 (parte finale della add), rileggere subito dopo la locazione 0x135678 (parte iniziale della sub), eseguire la sottrazione e memorizzare d2 nella locazione 0x135678 (parte finale della sub). E' evidente che sia la scrittura che la lettura intermedie sono del tutto superflue. Un'altra cosa da osservare è che la istruzione add genera dei flag nel registro dei codici di condizione (overflow, segno, zero, ecc.) per cui lo stato dei flag va calcolato se si vuole emulare perfettamente la add. Però lo stesso fa la sub e i valori generati dalla sub sovrascrivono immediatamente quelli generati dalla add; in pratica non c'è alcun bisogno che la add esegua questo compito in quanto è impossibile che i risultati di condizione siano utilizzati (diversamente se al posto della sub ci fosse un salto condizionato). Notammo già nel caso dell'interpretazione che la generazione dei flag di condizione è molto onerosa e poterne fare a meno nella maggior parte dei casi è realmente un grosso vantaggio.
Con questà mentalità si possono trovare mille altre situazioni in cui si può evitare lavoro inutile o si può escogitare un modo diverso per ottenere gli stessi risultati: per esempio una sequenza (add d2,d2 / add d2,d2 / add d2,d2) ha come effetto finale quello di moltiplicare per 8 il valore di d2. Forse per la CPU A è più conveniente eseguire una moltiplicazione piuttosto che tre addizioni (può darsi che per B valesse il contrario). In questo momento si sta ragionando sostanzialmente di ottimizzazione nella generazione di codice, esattamente gli stessi problemi che i compilatori prendono in considerazione quando traducono un linguaggio di alto livello in codice macchina. Il campo dei compilatori e in particolare delle strategie di ottimizzazione è immenso e le possibilità molto estese. Si potrebbe ad esempio tornare sulla decisione iniziale di separare i frammenti basandosi sulle istruzioni che modificano il PC. Può essere che costrutti più complessi (contenenti anche salti) siano riconoscibili (per esempio si tratta di un loop eseguito un certo numero fisso di volte) e traducibili in maniera più efficace. Continuando su questa strada si può generalizzare fino a tradurre interi programmi, più che spezzoni, ma di norma non si arriva a tanto.
Tutti gli sforzi sono concentrati a rendere veloce la fase di esecuzione e rendere rara la fase di traduzione. La ricerca esasperata del primo obiettivo (effettuare una traduzione di ottima qualità) può portare ad una lentezza eccessiva della fase di traduzione, che, per quanto si cerchi di rendere rara, impegnerà comunque una parte del tempo della CPU A: bisogna evitare di spendere tanto tempo nel tentativo di ottimizzare a fondo, il guadagno potrebbe non essere sufficiente a coprire la spesa pagata in traduzione). Il secondo obiettivo si ottiene aumentando la memoria a disposizione del buffer. Taluni emulatori esasperano a tal punto la salvaguardia del contenuto del buffer che ne salvano il contenuto su disco se si esce dall'emulatore in una modalità che presuppone la ripresa dell'emulazione in un secondo tempo dal punto in cui si è giunti in quell'istante.
Una strategia molto efficace è quella di avere vari livelli di ottimizzazione per il traduttore (possiamo chiamarlo ormai compilatore). Si lavora normalmente con bassa ottimizzazione (traduzione veloce), ma si porta conto di quali frammenti di codice sono eseguiti più spesso (d'altra parte qualcosa di simile va comunque implementato per riconoscere i frammenti poco usati, candidati alla distruzione).
Quando un frammento viene eseguito frequentemente si decide di ritradurlo ottimizzando di più (giacché sembra che valga la pena spendere tempo a tradurre meglio) e così di seguito, fino a tentare tutte le strategie a disposizione nel caso di codice molto critico che è eseguito continuamente. In un certo senso è come se il buffer, che abbiamo prima assimilato a una cache, diventasse una cache a vari livelli, differenziati non dalla velocità con cui presentano i dati, ma dalla qualità dei dati presentati (i frammenti tradotti).
Un'ultima parola va spesa sul codice automodificante: quando si eseguono scritture in memoria bisogna controllare se si sta modificando il contenuto di un frammento disponibile in formato tradotto: in questo caso va eliminato dal buffer, in modo che in futuro venga ritradotto correttamente, se e quando sarà necessario. Un meccanismo simile è obbligatorio nella normale programmazione di alcune CPU (dopo aver modificato del codice vanno invalidate parzialmente o totalmente le cache).
Il vantaggio dell'emulazione tramite ricompilazione è che la velocità ottenibile è molto alta: è quasi come se il codice di B fosse in realtà normale codice di A.
Gli svantaggi risiedono invece nell'abbondante uso di memoria e nella notevole complessità di un emulatore che al proprio interno deve lavorare come un compilatore e usare strategie generali e trucchetti particolari per riuscire a generare traduzioni di qualità, ma che non deve spendere troppo tempo nel tentare di farlo.
Nella maggior parte dei casi reali si usa l'interpretazione, molto più facile da programmare e da debuggare. Si è costretti a ricorrere alla compilazione quando si tenta l'emulazione di una CPU B con potenza paragonabile alla CPU A. Gli emulatori compilativi sono abbastanza rari e si tratta spesso di prodotti commerciali, costosi e prodotti da chi ha molte esperienze nel campo dei compilatori. Negli ultimi tempi la loro diffusione va però crescendo, non solo nell'ambito commerciale.
Abbiamo ricordato in precedenza che spesso le CPU fisiche più moderne sono organizzate internamente come un emulatore, specie quelle che sono ancorate alla compatibilità con un set di istruzioni vetusto.
In questo caso si tratta sempre di emulatori interpretativi. All'inizio dell'anno 2000 è comparso per la prima volta un approccio di tipo compilativo e con ottimizzazione graduale in una nuova CPU (compatibile con un instruction set molto datato). E' probabile che questa strada diventi sempre più battuta negli anni futuri.