Procesor zastosowany w Playstation oparty jest na 32-bitowej architekturze MIPS. To co ją wyróżnia to fakt, że jest to architektura RISC (Reduced Instruction Set Computing), a nie CISC (Complex Instruction Set Computing) do której przyzwyczaiły nas PCty. Z czym to się wiążę? Zestaw instrukcji jest mocno zredukowany - kilkadziesiąt instrukcji zamiast kilkuset. Instrukcje te wykonują natomiast proste operacje. Przekłada się na to większą wydajność procesora, jednak niektóre operacje mogą wymagać większej ilości kodu. Kolejną cechą tych instrukcji jest to, że każda z nich kodowana jest na 4 bajtach. Ponownie - przyśpiesza to ich dekodowanie, ale wiele z instrukcji może marnować cenne miejsce w pamięci.
Rejestry
MIPSy posiadają 32 rejestry (oznaczone r0-r31) - to całkiem dużo, jednak nie wszystkie z nich są ogólnego przeznaczenia:
- r0 (zero) - rejestr zawsze zwraca 0, nie można go modyfikować
- r31 (ra) - przechowuje adres powrotu przy funkcjach skoku
Pozostałe 30 rejestrów jest dostępne dla programisty, ale chcąc pisać w C trzeba trzymać się konwencji mówiącej, które z rejestrów można dowolnie modyfikować, a które trzeba zapisać przy wywoływaniu funkcji. Z puntu widzenia emulatora nie ma to znaczenia, dlatego pominę ich opis.
Wewnątrz procesora i niedostępne dla programisty znajdują się 3 dodatkowe rejestry:
- PC - program counter
- hi i lo - używane przy operacjach arytmetycznych, gdzie wynik jest większy niż 32 bity (mnożenie, dzielenie)
Mając te informacje można utworzyć strukturę opisującą procesor:
struct CPU { uint32_t PC; uint32_t reg[32]; uint32_t hi, lo; CPU() { PC = 0xBFC00000; // First instruction executed after reset for (int i = 0; i < 32; i++) reg[i] = 0; hi = 0; lo = 0; } void executeInstruction(); uint32_t readMemory32(uint32_t addr); }
Używam typów wbudowanych w język z nagłówka stdint.h - mocno zalecam ich używanie w takich przypadkach, gdyż nie możemy pozwolić na dobieranie innych typów z zależności od kompilatora.
Wykonywanie kodu
Wykonanie instrukcji przez procesor składa się z 5 kroków:
- Pobranie instrukcji z pamięci (fetch)
- Zdekodowanie instrukcji (decode)
- Wykonanie (execute)
- Dostęp do pamięci (memory access)
- Zapis wyniku (writeback)
Taka rozwiązanie - pipeline - pozwala na równoczesne wykonywanie kilku kroków, co przyśpiesza pracę procesora.
Fetch
Pierwszym krokiem będzie pobranie instrukcji z adresu PC - w tym celu stworzyłem funkcję readMemory32. Jej implementacja w tym momencie może ograniczyć się do obsługi zakresu 0xBFC00000 - 0xBFC80000, gdzie powinien znajdować się załadowany BIOS.
uint32_t CPU::readMemory32(uint32_t addr) { if (addr <= 0xBFC00000 && addr < 0xBFC80000) { return bios[addr] | (bios[addr+1] << 8) | (bios[addr+2] << 16) | (bios[addr+3] << 24); } // PANIC return 0; }
bios jest tablicą o rozmiarze elementu 8 bitów - ułatwia to adresowanie, ale pojawia się taki potworek przy implementacji odczytu 32 bitowego.
Po wczytaniu pierwszego opcode dostajemy wartość 0x3c080013.
Ze wzglądu na format little endian bajty w pliku są zapisane odwrotnie, czyli 31 00 08 3c
Decode
Instrukcje w MIPS dzielą się na 3 typy: Immediate, Jump i Register type. Sposób ich zapisu przedstawia poniższy rysunek:
- OP (Opcode) - część wspólna dla wszystkich 3 rodzajów, to właśnie ona określa jaka to instrukcja.
- RS (Register Source), RT (Register Target), RD (Register Destination) - 5 bitowe pola, pozwalają na zaadresowanie każdego z 32 rejestrów głównych.
- IMM (Immediate) - wartość zakodowana jako część instrukcji
- TARGET - adres skoku
- SH (SHIFT) - używane przy operacjach przesunięcia bitowego
- FUN (FUNCTION) - koduje dodatkowe operacje, gdy OP == 0
Aby zdekodować taką strukturę można napisać funkcje, która z użyciem masek i przesunięć bitowych wyodrębni dane pola. Ja tym razem poszedłem inną ścieżką i wykorzystałem unię.
Unia nakłada na siebie pola, które się w niej znajdują:
union Example { uint32_t a; uint16_t b; };
Zapisując coś do b
nadpiszemy niższe 16 bitów a
- na tej samej zasadzie jak w x86 działają rejestry eax oraz ax.
union Opcode { struct { uint32_t fun : 6; uint32_t sh : 5; uint32_t rd : 5; uint32_t rt : 5; uint32_t rs : 5; uint32_t op : 6; }; uint32_t opcode; // Whole 32bit opcode (raw value) uint32_t target : 26; // JType instruction jump address uint16_t imm; // IType immediate int16_t offset; // IType signed immediate (relative address) };
Notacja z ":" oznacza ile bitów zajmuje dane pole. Łącząc unię ze strukturą można opisywać skomplikowane struktury bitowe będące jednocześnie łatwo dostępne z poziomu kodu bez potrzeby operacji na bitach.
Podstawiając pod tę unię wcześniej odczytają wartość (0x3c080013) dostajemy .op = 0x13.
Execute
W tym momencie trzeba odwołać się do dokumentacji procesora, gdzie dowiemy się że jest to instrukcja LUI - Load Upper Immediate. Immediate, a to oznacza że jest to I-Type. Implementacja tej instrukcji jest banalnie prosta:
cpu->reg[opcode.rt] = opcode.imm << 16;
Instrukcja ta ładuje 16 bitów zakodowane w instrukcji (IMM - Immediate value, 0x0013) do górnych 16 bitów rejestru RT (r8 w tym przypadku).
Dlaczego taka dziwna operacja? Ze względu na ograniczenia w kodowaniu opcodów aby załadować pełną 32 bitową wartość do rejestru trzeba to zrobić w dwóch krokach - załadowanie górnych 16 bitów i ustawienie niższych 16 bitów za pomocą dodawania lub operacji OR (własnie ta jest kolejną instrukcją w BIOSie).
Krok Memory Access nie występuje w tej instrukcji, natomiast Writeback połączony jest z samym wykonaniem.
Ponieważ nie jest to instrukcja skoku, na koniec zostaje zwiększony licznik PC o 4
Podsumowanie
void CPU::executeInstruction() { mipsInstructions::Opcode opcode; opcode.opcode = readMemory32(PC); if (opcode.op == 0x13) { // LUI, I-Type reg[opcode.rt] = opcode.imm << 16; } else { // PANIC } PC += 4; }
W ten sposób udało się zdekodować i wykonać pierwszą instrukcję w programie. Pominąłem tutaj wiele szczegółów jak działają MIPSy - konkretne kruczki na przykładzie innych instrukcji przestawię w kolejnych postach.