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!
Wstęp
Chip-8 jest niskopoziomowym interpretowalnym językiem programowania. Został stworzony w latach 70., 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, iż mamy do wykorzystania 32 wiersze i w każdym z nich mieszczą się 62 piksele, 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.
[0, 0] ■■■■■■■■■■■■■■…■ [0, 63]
■ ■
■ ■■ ■■ ■
■ ■■■■■■ ■
■ ■■ ■■ ■
■ ■ ■■ ■ ■
… …
[31, 0] ■■■■■■■■■■■■■■…■ [63, 31]
Na ekranie możemy umieszczać obrazki (ang. sprites) o szerokości 8 i wysokości choćby 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.
HEX | Binarnie | Ekran |
0x66 0x7E 0xC3 0x99 | 01100110 01111110 11000011 10011001 | ■■ ■■ ■■■■■■ ■■ ■■ ■ ■■ ■ |
Przykładowy obrazek złożony z 4 wierszy
Może zdarzyć się, iż 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.
0x000 – początek pamięci
… pusto …
0x200 – początek programu
… tu umieszczamy program …
0xFFF – koniec pamięci
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 (można je zapisać 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 = 12Chcą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 z opisami
- 00E0 – instrukcja czyszcząca ekran poprzez zgaszenie wszystkich pikseli
- 1NNN – skok do komendy znajdującej się na adresie NNN (kolejna wykonana instrukcja zostanie pobrana ze wskazanego miejsca w pamięci)
- 6XNN – zapisz w rejestrze X wartość NN
- 7XNN – dodaj do zawartości rejestru X wartość NN
- ANNN – zapisz do rejestru indeksowego wartość NNN
- 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, dodatkowo rejestr 0xF zmieni wartość na 0x1, gdy zgasimy już zapalony piksel (jest to wykorzystywane do wykrywania kolizji w grach)
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 choćby 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 (w Polsce popularniejszy był klon pod nazwą Pegasus).
Gotową implementację emulatora wraz z instrukcjami opisanymi w artykule i ROM z programem IBM Logo znajdziesz tutaj: https://github.com/jpawlak96/Chip-8/tree/ibm_logo (repozytorium zawiera również pełną implementację Chip-8 na głównej gałęzi).