Negli ultimi tempi la quantità di "emulatori" di varia natura e utilizzati in vari campi va costantemente aumentando, così come l'interesse per questi programmi che ispirano sempre una qualche sensazione di stupore, di fronte a ciò che ha un carattere quasi di magia.
Affronteremo da un punto di vista teorico l'approccio all'emulazione, scendendo poi nel dettaglio delle implementazioni pratiche, con riferimento a emulatori reali del mondo attuale dell'informatica e dell'Amiga in particolare.
Definiamo prima cosa si intende precisamente per emulazione.
Emulare significa ricalcare i comportamenti di qualcunaltro o qualcosaltro; più tecnicamente si parla di emulazione quando un sistema (chiamiamolo A) è costituito e organizzato in maniera tale da comportarsi da un punto di vista funzionale come un altro distinto sistema (chiamiamolo B), almeno entro certi limiti. Il carattere peculiare dell'emulazione è l'aspetto funzionale, ossia si chiede che il sistema A sia utilizzabile come se si disponesse effettivamente del sistema B, entro i limiti già citati.
Parente stretto dell'emulazione è la simulazione, che si contraddistingue soprattutto per la finalità d'uso, che spesso è di verifica (debugging, prototyping) o valutazione (benchmarking, analisi del comportamento in situazioni critiche). Di fatto la differenza tra due concetti non è ampia e risiede per lo più nella definizione dei "certi limiti" di cui sopra.
L'emulazione ha la stragrande maggioranza delle sue applicazioni in ambito informatico, dato che i sistemi in gioco (elaboratori e simili) sono per loro natura caratterizzati dalla versatilità d'uso, essendo in grado di svolgere una grande varietà di compiti, se opportunamente programmati. Già agli albori dell'informatica si sapeva che una qualunque architettura può eseguire qualunque compito (a meno di mancanza di risorse), per cui anche i compiti che sono di solito eseguiti su altre architetture.
I sistemi in gioco sono di notevole complessità e spesso rappresentabili come strati funzionali sovrapposti; ciò che si vuole fare è tagliare via alcuni di questi strati e sostituirli con l'emulatore.
Tipici esempi sono:
1) emulazione di una CPU
2) emulazione di CPU e altro hardware di contorno (interfacce, controller)
3) emulazione di API
Nel caso 1) si vuole eseguire codice macchina per una particolare CPU su una CPU differente.
Nel caso 2) oltre a quanto previsto dal caso 1), si chiede una emulazione anche delle funzionalità di input/output ed altro, al fine di ottenere un elaboratore virtuale su A in grado di far funzionare buona parte del codice pensato per il sistema B.
Nel caso 3) si vuole realizzare una determinata interfaccia B, tipica di un altro sistema, su un substrato di tipo diverso.
A titolo di esempi, per il caso 1) possiamo citare:
- il codice che emula le CPU nel programma MAME
- gli emulatori di microcontrollori (più esattamente si tratta di simulatori)
Per il caso 2) invece la scelta è amplissima, si tratta di tutti quegli emulatori che permettono di far girare software di una macchina su un'altra: presa una coppia di macchine qualunque è molto probabile che esista almeno un emulatore per la prima che emuli la seconda, citiamone alcuni:
- Spapeshifter/Fusion
- PCTask
- UAE
- MAME
- Frodo
- PSEmu, Bleem, UltraHLE
- FX32
Per il caso 3) si possono citare:
- ppclibemu (emulazione di ppc.library su powerpc.library)
- WINE (emulazione della API di Windows su Linux)
- ixemul.library (emulazione della API Unix su AmigaOS)
In quest'ultimo caso si tratta di implementare un cosidetto "wrapper", ossia uno strato di conversione che si interfacci verso l'alto fornendo il front-end B e che esegua le richeste interfacciandosi verso il basso sul sistema A.
Soffermiamoci invece sul caso 1) (e sul 2) che ne è un'estensione).
In entrambi i casi bisogna innanzitutto definire bene cosa caratterizza lo stato della CPU B: si tratta spesso di registri e memoria (che va emulata in quanto ogni CPU ha bisogno di memoria per funzionare). Possiamo osservare che i registri non sono sostanzialmente diversi da locazioni di memoria generiche, per cui lo stato della CPU B è rappresentabile con uno o più array che contengono lo stato di queste memorie di vario tipo.
Per registri vanno intesi i registri generici, ma anche quelli "interni alla CPU", come PC (Program Counter), registri di condizione e flag vari, registri di stato e così via, fino a incontrare la barriera che segna il limite che si è deciso per l'emulatore: ad esempio di solito non va emulata la memoria cache, il pipelining, l'esecuzione superscalare o i cicli dei bus. Non è da escludere che questi ultimi aspetti vadano in alcuni casi emulati, nel caso si voglia ad esempio emulare non solo la funzionalità di B ma rispettarne anche le temporizzazioni, cioé conoscere come è distribuita nel tempo l'esecuzione delle varie operazioni; in questo caso però ci siamo spostati verso il campo della simulazione.
Limitandoci dunque al problema di eseguire codice di B sulla CPU A, essendo interessati solo ai risultati finali dell'elaborazione, che vogliamo siano gli stessi forniti da una CPU B reale, affrontiamo il problema delle risorse necessarie.
Come già detto, serve capacità di memorizzazione. Se si vuole emulare una CPU B collegata ad una certa quantità di RAM, è ovviamente necessario disporre di quella capacità di memoria (ma non è detto che si debba trattare di RAM fisica) più quella necessaria a definire lo stato, più naturalmente quella necessaria all'emulatore stesso sia in termini di codice sia di variabili interne del programma.
Se la memoria è sufficiente, allora è possibile ottenere l'emulazione, visto che anche la più semplice delle CPU (bastano operazioni logiche a due ingressi sui bit) può emulare la CPU B. Ciò significa in particolare che una CPU a 8 bit può benissimo emulare una CPU a 32 o 64 bit (ammesso di non avere problemi di memoria, che in questo caso possono diventare irrisolvibili).
A questo punto bisogna aprire il capitolo "prestazioni": è vero che si vuole giungere ai risultati a cui giungerebbe B, ma bisogna chiedersi anche in quanto tempo. Si desidera una velocità identica o superiore a quella di B in ogni condizione (funzionamento real-time) o si è disposti ad avere velocità inferiore in determinate circostanze (o in ogni circostanza), e in questo caso fino a che punto si è disposti ad allungare i tempi rispetto a quelli impiegati da B.
Nella maggior parte dei casi non è richiesta velocità pari, ma ci si attende un rallentamento, che sia però accettabile in riferimento ai tempi impiegati per compiere le elaborazioni che si vuole effettuare.
In base alle prestazioni richieste, si può implementare l'emulazione in maniera diversa, sacrificando velocità per ottenere semplicità o viceversa. Ovviamente non si può andare oltre un determinato limite intrinseco alle potenzialità delle CPU stesse.
In generale le prestazioni migliori ottenibili sono circa quelle della CPU A se eseguisse codice nativo analogo, ma nei casi reali bisogna attendersi un fattore di rallentamento che può arrivare a 10 ed oltre nel caso di implementazione non orientata alla ricerca disperata di velocità e nel caso che le CPU abbiano caratteristiche fondamentali discordanti (ad esempio big-endian contro little-endian). Naturalmente è opportuno scrivere emulatori in linguaggi che siano prestazionalmente efficienti, come C/C++ o persino asm; con linguaggi meno performanti il fattore di rallentamento può superare 10 e 100, il che non è detto che sia assolutamente inaccettabile, se la CPU A è molto più performante della CPU B (un emulatore 6502 su Alpha difficilmente sarà così lento da essere inutilizzabile).
Esistono sostanzialmente due modi diversi di affrontare la differenza tra i codici nativi della CPU A e B:
- interpretazione: si eseguono uno dopo l'altro le istruzioni di B, riconoscendole di volta in volta e eseguendo per ognuna una opportuna routine di codice di A che produca gli stessi risultati;
- ricompilazione: si trasforma il codice di B interamente o a pezzi in codice nativo di A, che poi si esegue direttamente.
Esamineremo più da vicino queste due strategie nella prossima parte.
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ù.