Jak napisać własny emulator Chip-8?
Czy zastanawiałeś się kiedyś nad tym, jak działa procesor? A może chciałeś stworzyć własny język programowania? No dobra, a co powiesz na swój własny emulator uruchamiający proste gry? Chip-8 łączy te wszystkie tematy!
Spis treści
Chip-8. Wstęp
Chip-8 jest niskopoziomowym interpretowalnym językiem programowania. Został stworzony w latach 70-tych, by ułatwić programowanie 8-bitowych komputerów. Z jego użyciem powstało mnóstwo prostych gier i programów.
W artykule postaram się przybliżyć zagadnienia związane z emulacją Chip-8 i zaciekawić tematem. Opiszę architekturę i implementację 6 instrukcji wymaganych do uruchomienia najprostszego programu wyświetlającego logo IBM, będącego w świecie Chip-8 synonimem „Hello, Word!”.
Specyfikacja
Specyfikacja emulatora jest bardzo prosta:
- 4kB pamięci RAM zawierającej instrukcje programu oraz jego dane (4096 adresów po 8 bitów),
- 16 rejestrów ogólnego przeznaczenia, będących czymś na wzór zmiennych (każdy po 8 bitów),
- Rejestr indeksu wykorzystywany przez różne komendy do wskazywania adresów w pamięci (12 bitów = 4096 adresów, czyli dokładnie tyle samo, co pamięci RAM),
- Licznik programu, czyli specjalny rejestr przechowujący adres komórki pamięci aktualnie wykonywanej instrukcji (12 bitów, tak jak w rejestrze indeksu umożliwia zaadresowanie całej pamięci)
Wyświetlacz
Wyświetlacz ma rozdzielczość 64 na 32 piksele i jest monochromatyczny. Oznacza to, że mamy do wykorzystania 32 wiersze po 64 komórki, które mogą zostać zapalone lub zgaszone. Piksel na pozycji [0, 0] znajduje się w lewym górnym rogu, a piksel [63, 31] w prawym dolnym.
Na ekranie możemy umieszczać obrazki (ang. sprites) o szerokości 8 i wysokości nawet 16 pikseli. Proces rysowania polega na przenoszeniu 8-bitowych wierszy z pamięci na ekran. W zależności od tego, czy bit jest w stanie wysokim (1) lub niskim (0), konkretny piksel zapala się lub gaśnie.
Może zdarzyć się, że rysowany obrazek będzie wystawać poza ekran lub miejsce, od którego będziemy go rysować, jest poza jego zakresem. W takim przypadku obrazek pojawi się po drugiej stronie ekranu (do implementacji tego wykorzystujemy operację reszty z dzielenia i szerokości lub wysokości ekranu).
Pamięć RAM
Chip-8 może zaadresować do 4096 komórek pamięci podzielonej na dwie sekcje:
- W sekcji adresów od 0x000 do 0x1FF znajdował się na dawnych 8-bitowych komputerach interpreter odpowiedzialny za wykonanie kodu znajdującego się w kolejnej sekcji. Obszar ten został zachowany w celu wstecznej kompatybilności i na ten moment można go zostawić pustym.
- W sekcji zaczynającej się od 0x200 i kończącej się wraz z końcem pamięci maszyny, czyli 0xFFF powinien znajdować się kod i dane wykonywanego programu. To tu powinno się przerzucić bajty z pliku ROM, który chcemy uruchomić na naszej wirtualnej maszynie.
Cykl pracy
Cykl pracy emulatora polega na wykonywaniu w nieskończoność trzech kroków (tak na marginesie, ten proces przypomina w uproszczeniu działanie procesora):
- Pobranie instrukcji z pamięci. Lokalizację w pamięci określa licznik programu.
- Dekodowanie instrukcji, czyli określenie tego co emulator powinien zrobić. W najprostszej implementacji będzie to instrukcja switch, która wybierze odpowiedni kod do wykonania.
- Wykonanie instrukcji, czyli proces aktualizacji stanów rejestrów, przeniesienia danych, wyświetlania pikseli na ekranie itp.
Wykonanie kodu
Komendy w Chip-8 mają 2 bajty długości (zapisujemy je przy pomocy 4 cyfr heksadecymalnych, np. 0x1F00). Z tego powodu w większości przypadków, po wykonaniu komendy, należy zwiększyć licznik programu o 2 (nie dotyczy to instrukcji skoku, opisanej w dalszej części artykułu).
Argumenty w instrukcjach są zaznaczone przy pomocy NNN, NN, N, X i Y. N i jego powtórzenia oznaczają wartość HEX przekazaną bezpośrednio, a X i Y oznaczają numer rejestru ogólnego przeznaczenia, z którego zostanie pobrana zawartość i spożytkowana w trakcie wykonywania instrukcji.
Instrukcje mają różną ilość argumentów (niektóre mają aż trzy, inne nie mają wcale) dlatego najlepiej jest je dekodować po przepuszczeniu przez maskę (wykorzystując binarną operację AND), która usunie argumenty na czas dekodowania. Wynik tej operacji bardzo prosto obsłużyć w instrukcji switch:
int opcode = 0x1F34; // dekodowana instrukcja switch (opcode & 0xF00) { case 0x100: … tu wykonujemy operacje … break; }
Dla instrukcji 0x1F34 wynik maskowania to 0x100 (0x1F34 & 0xF00 = 0x100)
Często argument znajduje się w środkowej części instrukcji, więc wyciągnięcie go przy pomocy maski może nie być wystarczające. W takim przypadku korzysta się z przesunięcia bitowego w prawo, w celu zmiany na docelową liczbę.
int opcode = 0x6C34; // dekodowana instrukcja int unmasked = opcode & 0x0F00; // unmasked = 0x0C00 = 3072 int result = unmasked >>> 8; // result = 0x000C = 12
Chcąc pobrać drugą cyfrę instrukcji, czyli 0xC (12 w systemie dziesiętnym) użyjemy maski 0x0F00, której wynikiem będzie 0x0C00 (liczba 3072). Następnie w celu usunięcia zbędnych zer po prawej stronie przesuwamy całość 8 razy w prawo (o 8 bitów, czyli >>> 8) uzyskując zaplanowany rezultat (operacja ta jest równoznaczna z ośmiokrotnym podzieleniem liczby przez dwa, ale jest dużo szybsza).
Lista instrukcji
- 00E0 – instrukcja czyszcząca ekran przez zgaszenie wszystkich pikseli
for (int y = 0; y < DISPLAY_HEIGHT; y++) { for (int x = 0; x < DISPLAY_WIDTH; x++) { display[x][y] = false; } }
- 1NNN – skok do komendy znajdującej się na adresie NNN (kolejna wykonana instrukcja zostanie pobrana ze wskazanego miejsca w pamięci)
programCounter = opcode & 0x0FFF;
- 6XNN – zapisz w rejestrze X wartość NN
int regNumber = (opcode & 0x0F00) >>> 8; register[regNumber] = opcode & 0x00FF;
- 7XNN – dodaj do zawartości rejestru X wartość NN
int regNumber = (opcode & 0x0F00) >>> 8; register[regNumber] += (opcode & 0x00FF);
- ANNN – zapisz do rejestru indeksowego wartość NNN
indexRegister = opcode & 0x0FFF;
- DXYN – wyświetl na ekranie N wierszy na pozycji zawartej w rejestrach X i Y, rejestr indeksowy powinien zawierać adres pamięci, od którego będą pobierane kolejne wiersze obrazka, dodatkowo rejestr 0xF zmieni wartość na 0x1, gdy zgasimy już zapalony piksel (jest to wykorzystywane do wykrywania kolizji w grach)
// pobranie z rejestrów X i Y początkowej pozycji obrazka // reszta z dzielenia dba by wartości nie wyszły poza zakres int xPos = register[(opcode & 0x0F00) >>> 8] % 64; int yPos = register[(opcode & 0x00F0) >>> 4] % 32; register[0xF] = 0; // iterowanie po N wierszach for (int row = 0; row < (opcode & 0x000F); row++) { // pobranie z pamięci wyświetlanego wiersza int spriteByte = memory[indexRegister + row]; // wyliczenie pozycji y na ekranie int yOffset = (yPos + row) % 32; // iterowanie po kolumnach (8 razy, ile bitów w bajcie) for (int column = 0; column < 8; column++) { // pobranie koloru piksela w kolumnie int color = spriteByte & (0x1 << (7 - column)); if (color > 0) { // wyliczenie pozycji x na ekranie int xOffset = (xPos + column) % 64; // zapalenie lub zgaszenie piksela if (display[xOffset][yOffset]) { display[xOffset][yOffset] = false; register[0xF] = 1; } else { display[xOffset][yOffset] = true; } } } }
Chip-8. Podsumowanie
Na ten moment to wszystko, choć pozostało jeszcze dużo do zrobienia. Do pełnej implementacji Chip-8 został jeszcze: stos, czcionki, timery, buzzer, obsługa klawiatury i pozostałe instrukcje (których jest w sumie 36).
Choć lista brakujących elementów może wydawać się spora, to najważniejsza część jest opisana w tym artykule. Większość z nich da się zaimplementować w jedno popołudnie. Po więcej informacji zachęcam Cię do poszukania w innych źródłach.
Jeśli zainteresuje Cię temat i postanowisz dokończyć emulator, możesz wykorzystać specjalne ROM-y testujące poprawną implementację instrukcji i całego środowiska.
Jeśli i to będzie mało, zastanów się nad stworzeniem własnej gry, dodaniem debuggera, czy nawet stworzenie przenośnej konsoli (np. na bazie Arduino). Możesz też iść na całość i zajrzeć do dokumentacji bardziej znanych konsol takich jak GameBoy lub NES.
Gotową implementację emulatora wraz z instrukcjami opisanymi w artykule i ROM z programem IBM Logo znajdziesz tutaj (repozytorium zawiera również pełną implementację Chip-8 na głównej gałęzi).