Pierwszym pomysłem na realizację emulatora był emulator konsoli Atari2600. Implementacja tego emulatora na komputerze PC działała całkiem sprawnie, jednak po przeniesieniu programu na mikrokontroler STM32F051R8T6 płynność działania spadła do poziomu uniemożliwiającego korzystanie z niego. Spowodowane było to zegarem o niskiej częstotliwości oraz małą ilością dostępnej pamięci RAM wykorzystywanego układu. Z pomocą przyszedł projekt konsoli Chip-8, który jest maszyną wirtualną stworzoną w celu ułatwienia procesu tworzenia i uruchamiania prostych gier na starszym sprzęcie (lata ’70 ubiegłego wieku).
W poniższym projekcie przedstawiony został proces tworzenia emulatora platformy do gier (Chip-8), zaczynając od implementacji w języku C++ pod systemem Windows, a następnie przenosząc projekt na mikrokontroler STM32F0. Platformą na jakiej został zrealizowany przedstawiony projekt jest zestaw STM32F0DISCOVERY oraz moduł wyświetlacza KamodTFT2.
Chip-8 jest prostą maszyną wirtualną. Charakteryzuje się on 8-bitowym procesorem dysponującym 4 kilobajtami pamięci. Do dyspozycji jest 16 rejestrów 8-bitowych oznaczanych od V0 do VF. Przy czym rejestr VF ma specjalne zastosowanie przy operacjach arytmetycznych. Dodatkowo do dyspozycji jest 16-bitowy rejestr indeksowy oznaczany literą I – służy on do adresowania pamięci.
Procesor posiada również stos, jednak umieszczony jest on poza pamięcią. Mieści on 16 dwubajtowych (16-bitowych) adresów. Wykorzystywany jest do przechowywania adresów powrotu przy wywoływaniu funkcji.
Maszyna dysponuje dwoma licznikami taktowanymi ze stałą częstotliwością 60 Hz. Liczniki te liczą w dół, dopóki nie osiągną wartości 0. Jeden z nich przeznaczony jest do generowania opóźnień (delay timer), natomiast drugi do generowania prostych dźwięków (sound timer).
Maszyna Chip-8 wyposażona jest w prosty monochromatyczny wyświetlacz o rozdzielczości 64×32 piksele. Procesor rysuje po nim używając duszków (sprite) będących obrazami o szerokości 8 pikseli i wysokości od 1 do 15 pikseli, które są nakładane na tło. Rysowanie odbywa się w specyficzny sposób, gdyż piksele wstawiane na ekran są za pomocą operacji XOR – umożliwia to wykrywanie kolizji, jednak niesie ze sobą przykry efekt – migotanie ekranu. W pierwszych 512 bajtach pamięci umieszczona jest tablica znaków 0-9 i a-f wyświetlanych na ekranie.
Chip-8 zawiera 35 instrukcji, każda o długości dwóch bajtów. Dokładniejsze ich omówienie zostanie przedstawione w dalszej części. Na rysunku 1 przedstawiono zrzut ekranu stworzonej aplikacji podczas jednej z rozgrywek.
Rys. 1. Działająca aplikacja
Tworzenie aplikacji dla komputera PC
W tym momencie, po zapoznaniu się z podstawami budowy Chip-8, można przejść do tworzenia emulatora tego procesora przeznaczonego dla komputera PC. Należy zaznaczyć, że ze względu na małą złożoność nie jest to trudny proces.
List. 1. Klasa reprezentująca obiekt procesora
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class CPU { private: // Pamięć uint8_t memory[0x1000]; // 4KB // Rejestry uint8_t V[16]; // V0-VF uint16_t I; // Rejest adresu uint16_t PC; // Licznik instrukcji uint16_t S[16]; // Stos uint8_t SP; // Wskaźnik stosu public: bool screen[32][64]; // Ekran bool keys[0xf]; // Przyciski } uint8_t – zmienna 8bitowa uint16_t – zmienna 16bitowa |
W pierwszym kroku została utworzona klasa reprezentująca obiekt procesora. W ramach deklaracji zostały zawarte wszystkie rejestry układu, pamięć oraz tablica reprezentującą wyświetlaną zawartość ekranu. W konstruktorze klasy wykonywane jest czyszczenie pola pamięci, wyświetlacza oraz stosu. Proces ten jest realizowany jest poprzez zapis do powyższych elementów wartości 0.
List. 2. Konstruktor klasy CPU
1 2 3 4 5 6 7 |
memset( memory, 0, 0x1000 ); // Czyścimy pamięć memset( screen, 0, 64*32 ); // ekran memset( S, 0, 16*2 ); // stos memcpy( memory , font, 16*5 ); // Kopiuję tablicę znaków PC = 0x200; // Wykonywanie programu zaczyna się od adresu 512 (200 heksadecymalnie) SP = 0; // Wskaźnik stosu I = 0; // Rejestr indeksowy |
Kolejnym krokiem jest wczytanie do pamięci procesora programu, który będzie wykonywany. W przypadku komputera PC proces ten realizowany jest przy użyciu funkcji fopen, która wczytuje zawartość pliku do pamięci procesora rozpoczynając od adresu 512.
Po stworzeniu szkieletu maszyny można przejść do tworzenia funkcji realizujących operację procesora. Składa się ona z trzech części:
- Pobranie instrukcji z pamięci.
- Dekodowanie instrukcji.
- Wykonanie.
Pierwszym działaniem jest pobranie instrukcji. Jak zostało zaznaczone wcześniej jedna instrukcja składa się z dwóch bajtów. Pierwsza część decyduje o rodzaju realizowanej operacji, natomiast druga jest parametrem dla wykonywanej funkcji. Należy zwrócić uwagę, że w zależności od operacji długość bitów określających operację może mieć różną długość. Operacja pobrania instrukcji z pamięci została przedstawiona na listingu poniżej.
List. 3. Pobranie instrukcji i parametru z pamięci układu
1 2 3 |
uint8_t opcode = memory[PC]; uint8_t data = memory[PC+1]; PC+=2; // zwiększamy licznik na następną instrukcję |
Kolejnym krokiem, po pobraniu instrukcji, jest jej zdekodowanie. Opis wszystkich instrukcji można znaleźć pod adresem http://en.wikipedia.org/wiki/Chip-8 (opcode table). Najłatwiejszą metodą realizacji jest zastosowanie instrukcji switch. W przedstawionym programie zastosowano inne podejście. Zamiast analizowania pełnych rozkazów przeprowadzane jest sprawdzenie stanu 4 starszych bitów pierwszego bajtu będącego rozkazem:
1 |
switch( (opcode>>4)&0xf ) // opcode – pierwszy bajt instrukcji, ta operacja wyodrębnia 4 najstarsze bity (7654 3210 -> .... 7654) |
W zależności od zawartości tych bitów wykonywane są odpowiednie działania. Przykładowo dla przypadku 0h należy sprawdzić drugi bajt instrukcji, gdyż w ten sposób mogą być zakodowane 3 różne instrukcje. Dekodowanie tych instrukcji zostało przedstawione na listingu poniżej.
List. 4. Dekodowanie przykładowych instrukcji
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
... case 0: // 00E0 - CLS // Wyczyść ekran if (data == 0xE0) // data – drugi bajt instrukcji { memset( screen, 0, 64*32 ); // ustawiam tablicę ekranu na zera } // 00EE - RET // Powrót z funkcji else if (data == 0xEE) { PC = Pop(); // ściągnij ze stosu adres powrotu } break; ... |
Podążając powyżej przedstawionym schematem działań należy opisać pozostałe operacje procesora.
List. 5. Dekodowanie oraz wykonanie najważniejszych operacji
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// 1nnn - JP addr // Skocz do adresu nnn case 0x1: PC = (((uint16_t)opcode&0xf)<<8 | data); // adres to niższa połówka pierwszego bajtu, oraz cały drugi bajt. Pierwszą część wyodrębniamy za pomocą operacji AND i przesunięcia i „sklejamy” je operacją OR. break; // 2nnn - CALL addr // Skocz do funkcji nnn case 0x2: Push(PC); // To samo co w poprzednim przypadku, jednak wcześniej wrzucamy na stos adres powrotu. PC = (((uint16_t)opcode&0xf)<<8 | data); break; // 3xkk - SE Vx, byte // Pomin następną instrukcję jeżeli rejestr Vx = wartość kk case 0x3: if ( V[ opcode&0xf ] == data ) PC+=2; // kk oznacza wartość w drugim bajcie, x to numer rejestru break; // 6xkk - LD Vx, byte // Przypisz rejestrowi Vx wartość kk case 0x6: V[ opcode&0xf ] = data; break; |
Warta uwagi jest operacja wyświetlania danych na ekranie przedstawiona na poniższym listingu.
List. 6. Dekodowanie oraz wykonanie operacji wyświetlającej zawartość ekranu
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
// Dxyn - DRW Vx, Vy, height // Wyświetl n bajtowy obrazek zaczynający się w pamięci pod adresem I<br>// w miejscu (Vx, Vy), ustaw VF, gdy wystąpi kolizja case 0xD: x = opcode&0xf; // Pozycja X na ekranie y = ((data&0xf0)>>4); // Pozycja Y na ekranie V[0xf] = 0; // Zerujemy rejestr Vf for ( int YL = 0; YL < (data&0xf); YL++ ) // Dla każdej lini w obrazku (sprite) { pixel = memory[ I + YL ]; // Pobieramy bajt z pamięci _y = (YL + V[y])%32; // Wyliczamy pozycje na ekranie (wykroczenie poza ekran wraca na jego początek) for ( int XL = 0; XL <8; XL++ ) // Dla każdego pikselu obrazka { if ( pixel & (0x80>>XL) ) // Jeżeli bit w obrazu jest ustawiony { _x = (XL + V[x])%64; // Oblicz pozycję x na ekranie (tak samo jak dla y) if ( screen[_y][_x] ) V[0xf] = 1; // Jeżeli wcześniej był na ekranie bit – ustawiamy kolizję screen[_y][_x] ^= 1; // rysujemy piksel na ekranie } } } break; |
Kod pozostałych funkcji nie został przedstawiony ze względu na fakt, że są to funkcje realizujące proste operacje arytmetyczne i logiczne. Trzy z nich pobierają stan wejść z klawiatury i działają analogicznie jak funkcja 3xkk (SE – pomiń jeżeli Vx = kk). W dołączonym kodzie źródłowym programu dla komputera PC można zapoznać się z pełnym programem, którego najważniejsze elementy zostały omówione powyżej.
Implementacja projektu na platformie STM32
W ramach implementacji na platformie STM32 należy zrealizować obsługę: instrukcji, licznika odliczającego 60 razy na sekundę, wejść z klawiatury do tworzonej maszyny i na końcu obsłużyć wyświetlanie obrazu. W ramach działania programu w pierwszym kroku tworzony jest obiekt klasy CPU, który realizuje wszystkie działania układu Chip-8.
Jak zostało wspominane wcześniej w ramach projektu został wykorzystany zestaw STM32F0DISCOVERY. Natomiast do wyświetlania zastosowano wyświetlacz KamodTFT2, którego sposób obsługi jest dobrze opisany na stronie STM32.eu w wielu projektach. Ogólno dostępna biblioteka została wzbogacona o dodatkowe poprawki służące przyśpieszeniu transferu danych do LCD oraz obsługujące czcionki do wyświetlania tekstu. Jako klawiaturę zastosowano 6 mikroprzełączników zmontowanych na płytce uniwersalnej podłączonej bezpośrednio pod piny mikroprocesora. Do przechowywania danych wstępnie planowano zastosowanie karty SD, jednak rozmiar plików wykonywalnych dla maszyny wirtualnej jest na tyle mały, że zrezygnowano z tego pomysłu. Zamiast tego użyto pamięci Flash z interfejsem SPI w postaci układu SST25VF080B. Na rysunku poniżej przedstawiono schemat połączonych ze sobą elementów.
Rys. 2. Schemat połączeń płytki STM32F0DISCOVERY z elementami zewnętrznymi
Aplikacja została zrealizowana przy wykorzystaniu środowiska ARM-MDK firmy Keil. Wynika to z faktu, że TrueStudio Lite w przypadku układów bazujących na rdzeniu CortexM0 ma ograniczenie tworzonego programu do 8 kB, które jest zbyt małą ilością pamięci dla przedstawionego emulatora. Jednak nie obyło się bez problemów także w przypadku Keila. Niestety w darmowej wersji nie wspiera on języka C++. Spowodowało to konieczność pisania programu w języku C i zrezygnowanie z klas na rzecz struktur i zastosowanie kilku sztuczek. Pierwszym etapem prac było stworzenie programu realizującego wewnętrzne operacje maszyny wirtualnej. Warto zwrócić tutaj uwagę na rozmiar stosu, gdyż przy początkowych pracach jego rozmiar ustawiony był na zbyt niską wartość, co w szczególnych przypadkach wywoływało obsługę wyjątku.
Kolejnym etapem było obsłużenie wyświetlacza TFT. Procedura ta została zrealizowana jako funkcja aktualizująca zawartość tablicy przedstawiającej zawartość wyświetlacza. W czasie jej wykonywania odbywa się także aktualizacja zawartości wyświetlanej na TFT. Kolejnym elementem jest funkcja odczytującą stan linii, do których podpięte są przyciski sterowania. Przy wyborze portu warto spojrzeć do dokumentacji, aby upewnić się czy dany pin nie jest wykorzystywany przez inne peryferia na płytce (jak dioda lub port JTAG/SWD). Przy tworzeniu projektu nie została zwrócona na to uwaga, co sprawiło duże problemu podczas debugowania programu. Do obsługi licznika maszyny wirtualnej został użyty licznik SysTick, który odlicza 500 razy na sekundę (z taką prędkością działa maszyna wirtualna). Dodatkowo w ramach licznika SysTick generowany jest przebieg licznika programowego z częstotliwością 60 Hz.
Fot. 3. Widok działającego układu
Do przechowywania plików z grami użyta została zewnętrzna pamięć Flash, z którą komunikacja odbywa się przez magistralę SPI. Dla wygody przechowywania plików w pamięci został przygotowany prosty system plików. Do przygotowania zawartości zapisywanej w pamięci Flash został przygotowany program dla komputera PC, który łączy ze sobą wszystkie dane i tworzy tablicę z danymi zawierającą informacje o ich rozmiarze, nazwie i położeniu w pamięci:
1 2 3 4 5 6 |
struct FileEntry { uint16_t id; // identyfikator pliku uint32_t offset; // położenie w pamięci uint16_t file_size; // rozmiar pliku unsigned char name[32]; // nazwa pliku } |
Samo działanie maszyny Chip-8 na mikrokontrolerze jest analogiczne do działania na komputerze PC. W przypadku mikrokontrolera dodano proste menu pozwalające wybrać konkretną grę.
Jakub Czekański