Emulatore?! E' una cosa che si mangia?

Emulatore?! E' una cosa che si mangia?

Guida al funzionamento e alla realizzazione di un emulatore


lettura 15 min
11/12/2015
emulazione

Capita, alcune volte, di sentire l’esigenza di voler usare un programma o un gioco, magari realizzato solo per una piattaforma per esempio un vecchio GameBoy, ma di non possedere la piattaforma stessa, possederla ma non più funzionante o con dei problemi. La soluzione ovvia sarebbe quella di compare il GameBoy e il relativo gioco, ma tuttavia, per trovare un vecchio GameBoy servirebbe tempo e soprattutto sarebbe una spesa fatta magari per un solo gioco e senza la certezza che la vecchiaia dell’hardware presto sia la causa per cui qualcosa smetta di funzionare nuovamente. E allora come si può fare? Molti anni fa la mia prima idea fu quella di dire, ho un computer a disposizione con la potenza di almeno 700 GameBoy, possibile che non ci sia modo di utilizzarlo al suo posto? La risposta a questa domanda fu abbastanza semplice, un software di emulazione in combinazione a un file contente la copia dei dati contenuti nella cartuccia del gioco, che io possedevo invece solo come scatoletta di plastica soggetta all’usura degli anni. Una volta scaricato e installato tutto... magia! Il gioco funzionava alla perfezione e senza possibili guasti eccetto quelli legati allo sviluppo del software. Da subito questo mostra un potenziale enorme nel rendere multipiattaforma qualcosa legato non solo a un determinato sistema operativo ma anche ad un certo hardware. Questo concetto, assieme alla magia per cui tutto funziona senza problemi, mi ha sempre affascinato. Da informatico e conoscendo il principio di funzionamento di questo tipo di dispositivi potevo ipotizzare a grandi linee come tutto potesse funzionasse, ma con gli occhi di allora giocare era una prospettiva migliore rispetto a quella di rendere queste ipotesi realtà. Tuttavia il tempo passa e gli interessi cambiano notevolmente, ritrovandomi nei giorni scorsi faccia a faccia con la personale sfida di voler realizzare non solo un emulatore del GameBoy, il cui hardware è relativamente semplice, ma di farlo con tecnologia HTML5. Il risultato? Di certo positivo o questo articolo non sarebbe mai stato scritto.

Dopo questa “piccola” introduzione veniamo ora al sodo con l’aiuto di qualche piccolo esempio.

Cosa vuol dire emulare? Emulare significa riprodurre alla perfezione, mediante software, il comportamento di tutto l’hardware che compone un determinato dispositivo, permettendo così di eseguire una copia del software scritto originariamente per quell’hardware utilizzandolo come input del software di emulazione. Il software scritto originariamente per un certo hardware viene ottenuto tipicamente con una tecnica detta dumping, ovvero la copia “binaria” del contenuto del supporto contenente tale software su file, nel caso del GameBoy una cartuccia di gioco diventa un file detto ROM.

Dalla definizione si evince che la prima fondamentale necessità è quella di sapere quali sono i componenti utilizzati per realizzare il dispositivo, come sono collegati e come interagiscono tra di loro. Per fare ciò è necessario cercare approfonditamente della preziosissima documentazione (schemi, manuali, datasheet, ecc...) riguardanti sia il dispositivo che i singoli componenti. Ove non disponibile in quantità sufficiente della documentazione potrebbe essere necessario, possedendo le giuste competenze, analizzare fisicamente il dispositivo smontandolo. La mancanza di dati sufficienti potrebbe portare ad un emulazione incompleta, con errori o addirittura impossibile da realizzare, per questo motivo è bene documentarsi bene prima di cimentarsi in questa impresa. In secondo luogo bisogna essere certi di avere dei dump funzionanti dei software che si vogliono far emulare. Più software dumpati vengono usati per i test, più l’emulatore verrà testato in diverse situazioni e più sarà esente da bachi. Nel mio caso ho deciso di emulare un GameBoy di prima generazione che presenta un hardware molto semplice, esso infatti usa come processore lo SHARP LR35902, una via di mezzo tra uno Zilog Z80 ed un Intel 8080, queste le sue caratteristiche:

  • Il set di istruzioni si basa su quello di uno Z80 con alcune limitazioni, ha un estensione del set di istruzioni adottato anche dallo Z80 che permette la manipolazione diretta dei bit da registri e memoria (accessibile tramite l’opcode 0xCB)
  • Utilizza i registri dell’8080, i registri aggiuntivi presenti nello Z80 non sono stati utilizzati, per quanto riguarda il registro F non sono stati utilizzati tutti i bit come nell’8080 e nello Z80
  • Ha una frequenza di 4MHz (4194304Hz) il che lo rende più veloce rispetto ai 2MHz di un 8080 e ai 2.5Mhz di uno Z80
  • Utilizza un solo spazio degli indirizzi sia per accedere in memoria sia per accedere ai dispositivi di I/O, vista la mancanza dei registri IY e IX non esiste un indirizzamento di memoria “index+base”

Oltre al processore troviamo RAM di sistema, RAM video, un Joypad, il display LCD, il controller dell’LCD, un altoparlante con il relativo amplificatore con ingresso per le cuffie, una porta per la connessione con altri dispositivi esterni e un socket per inserire le cartucce di gioco, che sono a loro volta composte da altri componenti a seconda del tipo di cartuccia, esistono cartucce di vario tipo a seconda delle esigenze di spazio, memoria aggiuntiva, RTC (Real Time Clock) e così via.

Dettagli sulle specifiche dell’hardware: Game Boy Technical Data Schemi di collegamento dei vari componenti: DMG Schematics Game Boy CPU Manual: GBCPUman.pdf Game Boy Programming Manual: gb-programming-manual.pdf

Ora che tutto è pronto per partire non resta che iniziare a sviluppare il nostro emulatore prendendo in analisi, per ora, solo processore e RAM, senza i quali nulla potrebbe funzionare, gli altri componenti saranno trattati in seguito per evitare di dilungarci troppo. Il processore ha lo scopo di analizzare ed eseguire le istruzioni definite nel suo set di istruzioni (instruction set) leggendole dalla memoria, questa operazione è chiamata “Fetch, Decode, Execute [Link alla definizione di Wikipedia]”. Il processore ha anche il compito di gestire la comunicazione con memora e dispositivi ad esso connessi mediante lo spazio degli indirizzi.

Le istruzioni sono formate da un opcode (operation code) sempre presente e da uno o più operandi opzionali, ogni istruzione può quindi avere una lunghezza variabile, anche il tempo di esecuzione di ogni istruzione varia, andando così a influire sul tempo di esecuzione di un programma. Ogni istruzione viene estratta dalla memoria all’indirizzo indicato dal registro PC (Program Counter), decodificata e recuperati gli eventuali operandi opzionali, a questo punto il processore può finalmente eseguire l’istruzione. L’esecuzione può necessitare di aree per la lettura e la scrittura dei dati, le principali sono:

  • I registri interni: contengono varie tipologie di dati e sono ad accesso diretto, la loro dimensione può essere di 8 bit, 16 bit, 32bit, ecc. Le più comuni tipologie di registro sono: i registri speciali tra cui il PC (Program Counter) e lo SP (Stack Pointer), i registri di stato o di flag il cui scopo è contenere i risultati di operazioni precedenti e in base ad esse eseguire operazioni o salti condizionati durante l’esecuzione del programma, registri aritmetici per contenere i dati per le operazioni matematiche, registri di appoggio, ecc. L’elenco completo dei registri è direttamente dipendente dell’architettura dei vari processori e presente nella relativa documentazione.
  • La memoria: contiene tutti i dati in modo sequenziale e permette un accesso casuale alle singole celle, in genere ogni cella ha una dimensione di 8 bit e più celle adiacenti possono essere utilizzate assieme per ottenere dimensioni di 16 bit, 32 bit, ecc. Varie aree di memoria possono essere isolate per essere dedicate a compiti diversi (istruzioni del programma, uscita video, uscita audio, dati generici, ecc.), l’accesso alle singole celle di memoria è regolato attraverso lo spazio degli indirizzi. Seppur la memoria può essere semplificata vedendola come una serie di celle dove memorizzare e leggere dei dati, non va però commesso l’errore di pensare che ogni indirizzo punti sempre ad un area di un dispositivo di memorizzazione temporaneo, infatti molti indirizzi puntano ad altri dispositivi in grado di essere scritti inviando così dei comandi e riletti su un altro indirizzo, avendo così un output diverso a seconda del comando inviato precedentemente, oppure inviando dati che poi vengono interpretati dall’hardware connesso per produrre output come nel caso del video. Esistono anche aree che non possono essere scritte ma solo lette per ricevere input come per esempio da pulsanti di un game pad ecc. La logica con cui la memoria viene indirizzata è definita generalmente nel manuale di sviluppo del dispositivo da emulare.

Possiamo iniziare a implementare il nostro emulatore andando a considerare i registri come delle variabili, la RAM come uno o più buffer di memoria o array e l’accesso allo spazio degli indirizzi come delle funzioni di lettura e scrittura che vanno a smistare le richieste in base all’indirizzo di memoria che viene richiesto.

Esempio (semplificato rispetto al codice reale, in pseudo linguaggio):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37class Processor { private byte A, B, C, D, E, F, H, L; private word PC, SP; private byte[] workRAM = new byte[1024 * 8]; private byte[] videoRAM = new byte[1024 * 8]; private byte readByte(int address) { if (address <= 0x7FFF || (address >= 0xA000 && address <= 0xBFFF)) { return cartridge.readByte(address); } else if (address >= 0x8000 && address <= 0x9FFF) { return videoRAM[address - 0x8000]; } else if (address >= 0xC000 && address <= 0xDFFF) { return workRAM[address - 0xC000]; } else if (address >= 0xE000 && address <= 0xFDFF) { return workRAM[address - 0xE000]; } else { ... } return 0; } private void writeByte(int address, byte value) { if (address <= 0x7FFF || (address >= 0xA000 && address <= 0xBFFF)) { cartridge.writeByte(address, value); } else if (address >= 0x8000 && address <= 0x9FFF) { videoRAM[address - 0x8000] = value; } else if (address >= 0xC000 && address <= 0xDFFF) { workRAM[address - 0xC000] = value; } else if (address >= 0xE000 && address <= 0xFDFF) { workRAM[address - 0xE000] = value; } else { ... } } }

Nel codice di esempio ci sono due funzioni ovvero la cartridge.readByte(address) e la cartridge.writeByte(address, value) che non sono state definite nell’esempio ed il cui compito e quello di simulare la cartuccia del gioco, esse funzionano nello stesso modo della readByte e della writeByte nella classe Processor, ma per il momento questa parte dell’emulatore rimarrà astratta per evitare di aggiungere complicazioni.

La seconda parte da implementare è il ciclo di fetch, decode, execute questo sarà implementato in una funzione il cui compito sarà quello di leggere dalla memoria un byte mediante la “readByte” all’indirizzo puntato dal registro PC ed incrementarlo, questo byte verrà poi decodificato mediante uno switch case del suo valore che ne decreterà il tipo di istruzione, a questo punto se necessario saranno letti altri operandi opzionali, posti dopo il byte dell’opcode nella memoria, per poi eseguire l’istruzione che andrà ad esempio a inserire informazioni nella memoria o nei registri o ad eseguire operazioni matematiche e così via, simulando così il funzionamento del processore stesso. In questa fase vengono anche incrementati i tick del processore in base ai cicli di clock richiesti dall’operazione andando a determinare il tempo di esecuzione.

Esempio (semplificato rispetto al codice reale, in pseudo linguaggio):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96private void writeByteMemory(ah, al, value) { writeByte((ah << 8) | al, value); } private byte readByteMemory(ah, al) { var r = readByte((ah << 8) | al); return r; } // FH, FC, FN ed FZ sono i flag definiti per comodità come booleani nella classe del processore private void add(b) { FH = (A & 0x0F) + (b & 0x0F) > 0x0F; A += b; FC = A > 255; A &= 0xFF; FN = false; FZ = A == 0; } private void step() { var opCode = readByte(PC++); // opCodeCyclesBase è un array che contiene il numero di clicli di base per ogni operazione, // la variabile opCodeCycles contiene il numero base di cicli per l’opcode Incrementare // la variabile nelle operazioni successive se sono richiesti cicli aggiuntivi var opCodeCycles = opCodeCyclesBase[opCode]; switch (opCode) { case 0x00: // NOP break; ... ... ... case 0x77: // LD (HL),A writeByteMemory(H, L, A); break; case 0x78: // LD A,B A = B; break; case 0x79: // LD A,C A = C; break; case 0x7A: // LD A,D A = D; break; case 0x7B: // LD A,E A = E; break; case 0x7C: // LD A,H A = H; break; case 0x7D: // LD A,L A = L; break; case 0x7E: // LD A,(HL) A = readByteMemory(H, L); break; case 0x7F: // LD A,A A = A; break; case 0x80: // ADD A,B add(B); break; case 0x81: // ADD A,C add(C); break; case 0x82: // ADD A,D add(D); break; case 0x83: // ADD A,E add(E); break; case 0x84: // ADD A,H add(H); break; case 0x85: // ADD A,L add(L); break; ... ... ... case 0xCB: // 0xCB è un set di istruzioni esteso, per cui va letto l’opcode successivo per identificare // l’operazione successiva ed in seguito verrà gestito come un altro switch di opcode var opCodeCB = readByte(PC++); opCodeCycles = opCodeCyclesCB[opCodeCB]; switch (opCodeCB) { ... ... ... } break; ... ... ... default: throw OpcodeNonImplementato; break; } ticks += opCodeCycles; }

La stesura di questa parte del codice può risultare molto lunga e laboriosa, è bene prestare attenzione a opcode che fanno una stessa operazione ma con parametri diversi, ed in questi casi implementare funzioni generiche in modo da non dover modificare il codice in più punti con il rischio di dimenticarsi pezzi. Bisogna anche fare attenzione a calcolare in modo corretto i cicli di clock, in quanto sono loro a definire il tempo di esecuzione dell’emulazione e di operazioni basate su di esso.

Un altra importante funzione che ha il processore è quella di ricevere interrupt ed eseguire operazioni in base ad esse non appena vengono ricevute, per fare questo è sufficiente aggiungere dei flag booleani all’interno delle variabili del nostro processore virtuale, nel mio caso ogni interrupt avrà 2 flag, la prima servirà a dire se tale interrupt è abilitato o meno, la seconda flag invece servirà per dire se l’interrupt è attivo va quindi gestito dalla nostra funzione di fetch, decode e execute, esisterà inoltre un altro flag per indicare se gli interrupt sono abilitati in generale o meno. La gestione è molto semplice, all’inizio della funzione “step” verranno messi una serie di if a cascata ordinati in base alla priorità dell’interrupt, la condizione dell’if controllerà che il flag di abilitazione e quello di attivazione dell’interrupt siano entrambe vere, se la condizione si verifica non verrà fatto altro che fare la stessa identica cosa che farebbe un processore, ovvero disabilitare le altre interrupt, salvare nello stack l’attuale indirizzo di PC e impostare PC all’indirizzo ove risiede il codice di gestione dell’interrupt. A questo punto tutto riprende la sua esecuzione come prima, ma senza poter ricevere alcun interrupt fino a quando non viene eseguita la tipica istruzione IRET o RETI (Interrupt RETurn), la quale andrà a recuperare dallo stack il vecchio indirizzo di PC e ripristinarlo dentro PC, una volta fatto questo riabiliterà gli interrupt e il programma ritornerà alla sua normale esecuzione.

Esempio (semplificato rispetto al codice reale, in pseudo linguaggio):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48void interrupt(address) { interruptsEnabled = false; push(PC); PC = address; } void Return() { PC = readWord(SP); SP += 2; } void step() { ... if (interruptsEnabled) { if (vBlankInterruptEnabled && vBlankInterruptRequested) { vBlankInterruptRequested = false; interrupt(0x0040); } else if (lcdcInterruptEnabled && lcdcInterruptRequested) { lcdcInterruptRequested = false; interrupt(0x0048); } else if (timerOverflowInterruptEnabled && timerOverflowInterruptRequested) { timerOverflowInterruptRequested = false; interrupt(0x0050); } else if (serialIOTransferCompleteInterruptEnabled && serialIOTransferCompleteInterruptRequested) { serialIOTransferCompleteInterruptRequested = false; interrupt(0x0058); } else if (keyPressedInterruptEnabled && keyPressedInterruptRequested) { keyPressedInterruptRequested = false; interrupt(0x0060); } } ... switch (opCode) { ... ... ... case 0xD9: // RETI interruptsEnabled = true; Return(); break; ... ... ... } ... }

Ora è chiaro che per eseguire il processore basterà chiamare ciclicamente la funzione che esegue la fetch, decode e execute. Tuttavia manca ancora un pezzettino, ovvero quando il processore viene avviato si torva in uno stato dove alcuni suoi valori e indirizzi di memoria sono impostati ad un valore ben predefinito, in modo ad esempio da far partire il Program Counter a un determinato indirizzo in cui risiederà sempre l’inizio del programma da eseguire, nel mio caso l’indirizzo di memoria da cui partirà l’esecuzione del codice sarà sempre 0x0100.

Esempio (semplificato rispetto al codice reale, in pseudo linguaggio):

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46private void powerUp() { A = 0x01; B = 0x00; C = 0x13; D = 0x00; E = 0xD8; H = 0x01; L = 0x4D; FZ = true; FC = false; FH = true; FN = true; SP = 0xFFFE; PC = 0x0100; writeByte(0xFF05, 0x00); // TIMA writeByte(0xFF06, 0x00); // TMA writeByte(0xFF07, 0x00); // TAC writeByte(0xFF10, 0x80); // NR10 writeByte(0xFF11, 0xBF); // NR11 writeByte(0xFF12, 0xF3); // NR12 writeByte(0xFF14, 0xBF); // NR14 writeByte(0xFF16, 0x3F); // NR21 writeByte(0xFF17, 0x00); // NR22 writeByte(0xFF19, 0xBF); // NR24 writeByte(0xFF1A, 0x7F); // NR30 writeByte(0xFF1B, 0xFF); // NR31 writeByte(0xFF1C, 0x9F); // NR32 writeByte(0xFF1E, 0xBF); // NR33 writeByte(0xFF20, 0xFF); // NR41 writeByte(0xFF21, 0x00); // NR42 writeByte(0xFF22, 0x00); // NR43 writeByte(0xFF23, 0xBF); // NR30 writeByte(0xFF24, 0x77); // NR50 writeByte(0xFF25, 0xF3); // NR51 writeByte(0xFF26, 0xF1); // NR52 writeByte(0xFF40, 0x91); // LCDC writeByte(0xFF42, 0x00); // SCY writeByte(0xFF43, 0x00); // SCX writeByte(0xFF45, 0x00); // LYC writeByte(0xFF47, 0xFC); // BGP writeByte(0xFF48, 0xFF); // OBP0 writeByte(0xFF49, 0xFF); // OBP1 writeByte(0xFF4A, 0x00); // WY writeByte(0xFF4B, 0x00); // WX writeByte(0xFFFF, 0x00); // IE }

Arrivati a questo punto dovrebbe essere chiaro, a grandi linee, come funziona l’emulazione del processore. Ogni processore, pur avendo una logica costante nel modo di funzionare, è differente dal punto di vista delle funzioni, sia che siano quelle di base, sia che siano funzioni specifiche più avanzate, il che può rendere più o meno laborioso emulare il suo funzionamento. In questo caso si è scelto di emulare un processore molto semplice essendo sia abbastanza datato, sia uno dei modelli che hanno gettato le basi di funzionamento per quelli attuali e con un numero di istruzioni e funzioni abbastanza ridotte.