L'emulazione (parte 2)


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.

La prima delle due possibilità è chiamata interpretazione.

L'esame delle istruzioni da eseguire e il conseguente aggiornamento dello stato della CPU B è simile al modo in cui vengono implementati alcuni linguaggi di alto livello: un loop esamina le istruzioni una alla volta, ed esegue una determinata routine.
In codice C si ha qualcosa di questo tipo:

while(running){
opcode=fetch();
decodeandexecute(opcode);
}

La fase di fetch è semplice, si tratta solo di manipolare il PC (Program Counter) della CPU emulata e leggere dalla memoria emulata. Questa fase viene eseguita sempre, per ogni istruzione emulata, ed è preferibile che sia ben ottimizzata perché può incidere pesantemente sulle prestazioni. La semplicità della funzione fetch() non pone particolari problemi implementativi.

La fase di decode deve smistare il flusso del programma a seconda dell'istruzione in oggetto: almeno la parte iniziale di questa funzione verrà eseguita ad ogni istruzione, poi il codice si dirama in base al codice operativo in esame. Questa fase è molto più complessa: un primo problema è come gestire tutte le possibili diramazioni.

Con un linguaggio di alto livello si tratta di utilizzare una lunga (forse lunghissima) serie di if o if/elseif/elseif/.../end oppure di affidarsi ad altri costrutti che hanno sostanzialmente lo stesso significato (istruzione switch del C). Spostandoci a livello più basso si può pensare a una batteria di routine già pronte, ognuna dedicata a un codice operativo, e uno smistamento effettuato tramite una tabella di indirizzi, oppure un indirizzo calcolato a partire dall'opcode.
Per esempio con 256 codici operativi si può preparare un vettore fisso di 256 elementi, ognuno dei quali è l'indirizzo della routine da eseguire. Altra possibilità, si somma un indirizzo base al codice operativo moltiplicato per un certo numero (maggiore della lunghezza massima delle routine): ad esempio se l'indirizzo base è 0x10000 (0x indica numeri in notazione esadecimale) e la lunghezza massima delle routine è 0x40 byte (64 in decimale), si piazza all'indirizzo 0x10000 la routine pertinente l'opcode 0, a 0x10040 quella per l'opcode 1, a 0x10080 per il 2, a 0x100c0 per il 3 e così via.
Da un punto di vista prestazionale quale è la soluzione migliore? Non si può rispondere, bisogna chiedersi se l'accesso a una tabella sia più o meno veloce di una moltiplicazione (magari per un numero potenza di due, che potrebbe essere più veloce se implementata con uno shift). Consideriamo anche le risorse occupate: possiamo permetterci di avere una tabella di 256 elementi? e se gli elementi sono milioni? il metodo del jump calcolato risparmia lo spazio per la tabella ma ha un inconveniente forse peggiore: se tutte le routine sono lunghe meno di 10 byte ed una sola è di 230 byte, bisogna distanziare tutte le routine di 230 (o più) byte, sprecando probabilmente molto più di quello che sarebbe costato una tabella.
Tornando alle prestazioni, quale effetto ha sulle cache l'uso di una tabella di dati, e quale è l'effetto di molte routine equispaziate in memoria? per dei motivi legati al funzionamento delle cache (su cui non ci dilunghiamo) avere tanti frammenti di codice a indirizzi che hanno alcuni bit meno significativi identici può portare a un peggioramento dell'efficacia del caching, con perdita di prestazioni. Piccoli risparmi possono venire dall'uso di salti di tipo "jump" invece delle chiamate di sottoroutine.

Ruolo fondamentale in questo frangente gioca l'estensione dell'insieme di codici operativi possibili; per una CPU a 8 bit ci sono 2^8=256 codici operativi possibili (di cui alcuni forse inutilizzati), con una CPU con codici operativi a 16 bit i casi sono 2^16=65536, se i bit sono 32 arriviamo a 4294967296 (oltre 4 miliardi) possibilità, e nel caso i bit siano 64 raggiungiamo quota 18446744073709551616 (oltre 18 miliardi di miliardi). Anche trascurando lo spazio occupato dalla tabella o lo spazio inutilizzato tra le routine, con opcode a 32 bit è praticamente realizzabile procedere in questa maniera? per di più, è sensato farlo? Per esempio un processore 680x0 può eseguire istruzioni di addizione tra registri, la sintassi assembler è "add.l dx,dy", che matematicamente significa "dy=dy+dx", con x e y numero di registro, variabile tra 0 e 7. Già solo per questo caso ci sono 64 combinazioni e esaminando meglio l'istruzione di addizione ci sono svariati altri casi, legati ai registri indirizzo (ax) e poi tante modalità di indirizzamento con tante varianti (una CPU 68020 o successiva ha modalità di indirizzamento spaventosamente potenti); centinaia e centinaia di varianti di una semplice addizione, e non dimentichiamoci che c'è poi la sottrazione, del tutto identica, tranne che per il calcolo effettuato.

Di questo passo, ci si rende conto che la strada dell'"esplosione totale" dei casi non è la più sensata. Tantissime parti sono simili o precisamente uguali e reimplementarle di continuo è la strada sbagliata per tanti motivi, sia quelli di pratica realizzabilità, sia quelli di gestibilità (come debuggare o modificare un codice ripetuto migliaia di volte in punti diversi?), sia per problemi prestazionali (un numero enorme di routine simili sicuramente farà crollare l'hit-rate, ossia l'efficacia del caching). D'altra parte nessuna CPU reale non elementare funziona internamente in questo modo.
Una strada più promettente è quella di decodificare le istruzioni per gradi, cioé riconoscendo alcuni gruppi di bit (bitfield) all'interno del codice operativo e diramando il flusso del codice di conseguenza. Ad esempio tutte le addizioni sono riconoscibili perché hanno alcuni bit nell'opcode con una particolare configurazione binaria. I bit rimanenti specificheranno altri dettagli, ad esempio il tipo di modalità di indirizzamento o il numero dei registri in gioco. La convenienza di quest'approccio risiede nel fatto che ci sarà una sola routine di addizione, che farà riferimento a sottoroutine di gestione dell'indirizzamento e dei registri. Ci sarà poi una sola routine di sottrazione, che potrà sfruttare le stesse sottoroutine accessorie di cui si avvale l'addizione. Naturalmente il discorso si estende agli altri operatori aritmetici, alle operazioni logiche e tanto altro. Sarà condivisa anche la gestione dei registri condizione e altri dettagli.
Il vantaggio fondamentale è che si guadagna in compattezza (con grandi benefici per il caching e le prestazioni), in chiarezza (il debugging diventa più agevole), nonché in eleganza (le scelte fatte dovrebbero essere sempre le più ragionevoli possibile). Ci sono svantaggi? l'esecuzione, per quanto più veloce, è comuque più contorta: per una singola istruzione si eseguono un gran numero di sottoroutine, con grande abbondanza di salti e chiamate a sottoprocedure, che pur avendo un buon impatto sulle prestazioni delle cache, non sono ben digerite dalle CPU più moderne, che preferiscono lunghi segmenti di codice sequenziale e non salti poco prevedibili in continuazione.

Per ottenere buone prestazioni bisogna eseguire le decodifica usando piccole tabelle ben mirate, che instradino velocemente l'esecuzione verso le routine giuste e bisogna poi curare molto le sottoroutine che vengono chiamate con maggiore frequenza: la routine di decodifica della modalità di indirizzamento viene utilizzata almeno una volta nella maggior parte delle istruzioni, lo stesso dicasi della routine di settaggio dei flag del registro di condizione, che tende ad essere molto più dispendiosa della routine di esecuzione vera e propria (cioé l'addizione).
Non c'è molto altro su cui si può agire, nel caso dell'interpretazione: l'esecuzione sarà sempre molto contorta e caratterizzata da un alto numero di salti, per cui le prestazioni non possono essere elevate. Architetture con bassa penalizzazione di salto e alta velocità della memoria (o grandi cache) sono più performanti in questo tipo di elaborazione.

Nella prossima parte vedremo quali vantaggi può portare un approccio di tipo ricompilazione e quali problemi comporta.

Roberto Ragusa

L'autore di quest'articolo ha scritto anni fa un emulatore di 68020/68030/68040/68060 in linguaggio C su Amiga come progetto per un esame universitario (ingegneria elettronica). L'emulatore comprende un disassembler/debugger integrato ed è in grado di eseguire software 68k che non chiami librerie di sistema: in particolare è in grado di far girare l'emulatore stesso (compilato per 68k) che esegue un altro programma, che può a sua volta anche essere l'emulatore stesso, e così via fino a 4-5 livelli di ricorsione o più.



Scritto da Roberto Ragusa
E-mail: robertoragusa@technologist.com