Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige
Lesedauer 21 Min.

Batteries not included

Retro-Hardware mit .NET in Software nachbilden: Von CPU-Instruktionssets, ­Memory-Maps und Timern bis hin zu Sprites und LCD-Ansteuerung.
Aufmacherbild_tbgb_dotnetpro_headline.jpg
© KI-generiert

Als C#-Entwickler ist man es gewohnt, Problemstellungen komfortabel auf einem hohen Abstraktionsgrad zu begegnen. Dabei hilft auch die Rechenleistung heutiger Maschinen, dank derer es für große Teile der Softwarelandschaft nahezu keine Rolle spielt, ob die gefundene Lösung mehr Rechenressourcen benötigt als unbedingt notwendig.

Trotzdem bleibt es spannend, sich mit der Hardware zu beschäftigen, auf der die Software läuft. Denn das Verständnis dafür, wie der Computer im Kern arbeitet, kann einem dabei helfen, Software zu schreiben, die nicht nur funktioniert, sondern auch effizient und ressourcenschonend ist.

Der 1989 von Nintendo veröffentlichte Game Boy ist ideal, um zeitlose, grundlegende Konzepte auf spaßige Weise zu verstehen. Denn er kann schließlich bereits alles, was Büro­computer im Wesentlichen heute noch tun: Rechnen und Grafiken erzeugen. In unserer Artikelserie werden wir daher diese Blackbox aus der eigenen Kindheit, die so viele Spiele zum Leben erweckt hat, öffnen und verstehen – und dann komplett in Software, so kompakt wie möglich, mit der besten Programmiersprache der Welt nachbauen.

Los geht’s. Welche Komponenten verwendet der Game Boy also eigentlich, und was müssen wir daher emulieren? Nur Speicher, eine PPU und eine CPU.

Der Speicher

Im Unterschied zu heutigen Computern, die meist mit 48-Bit-Adressbussen ausgestattet sind, um damit riesige Mengen an Speicher ansteuern zu können, ist der Game Boy lediglich mit einem 16-Bit-Adressbus gesegnet. Mit diesen 16 Bit (die dem C#-Datentyp ushort entsprechen) kann er 8-bittige byte-­Werte in einem Adressbereich von 0 ... 65535 ansteuern, also exakt 64 KB.

Hardwareseitig verteilen sich diese 64 KB dabei auf mehrere Einzelchips, die verschiedene Funktionen abdecken. Das sind ROM, VRAM, WRAM und HRAM. Nicht alle diese Chips sind fest im Game Boy selbst verbaut, das ROM zum Beispiel steckt in der wechselbaren Spielecartridge und wird erst beim Einstecken zu einem Teil des Game-Boy-Speichers. Diese technischen Details sind für eine einfache Emulation aber uninteressant, und so können wir den gesamten Speicher für unsere Zwecke vereinfachend als ein einziges großes Byte-Array byte[0x10000] betrachten.

Listing 1: Darstellung der Register als C#-Felder
byte A, B, C, D, E, H, L;
ushort SP, PC;
ushort AF { get => (ushort)(F | (A << 8)); 
  set { F = (byte)value; A = (byte)(value >> 8); } }
ushort BC { get => (ushort)(C | (B << 8)); 
  set { C = (byte)value; B = (byte)(value >> 8); } }
ushort DE { get => (ushort)(E | (D << 8)); 
  set { E = (byte)value; D = (byte)(value >> 8); } }
ushort HL { get => (ushort)(L | (H << 8)); 
  set { L = (byte)value; H = (byte)(value >> 8); } }
 

PPU

Die PPU, die Picture Processing Unit, ist die Grafikeinheit des Game Boy. Sie ist für die Erzeugung der Bilder auf dem Bildschirm verantwortlich. Damit ist sie für die Emulation des Game Boy natürlich unverzichtbar, aber gleichzeitig für unseren Einstieg in diesem Artikel nicht notwendig. Den Umgang mit der PPU werden wir deshalb erst im zweiten Artikel dieser Serie in der dotnetpro-Heftausgabe 5/2026 genauer beleuchten.

CPU

Die Game-Boy-CPU ähnelt (mit vom Z80 abgeleiteten Erweiterungen) einem Intel 8080, dem Urvater heutiger x86-Prozessoren wie Ryzen und Intel Core. Sie taktet, versorgt durch lediglich vier AA-Batterien, mit satten 4 MHz. Mega-, nicht Gigahertz! ­Ihre Hauptaufgabe ist im Wesentlichen das stoische Abarbeiten von im Speicher liegenden Sequenzen binärer Anweisungen, sogenannten Opcodes (dazu weiter unten mehr). Diese Opcodes lassen sich beim Game Boy in fünf Aufgabengruppen einordnen:

  • Daten kopieren
  • Daten vergleichen
  • Daten addieren und subtrahieren
  • Daten mit Bit-Operationen verändern
  • (Anhand von Bedingungen) im Opcodestrom springen

 

Das ist überraschend wenig, dennoch lassen sich damit beliebig komplexe Programme schreiben.

CPU-Register

Die CPU enthält neben der notwendigen Logik zur Abarbeitung der Opcodes die 8-Bit-Register A, B, C, D, E, H und L, die man sich als CPU-lokale Variablen vom Typ byte vorstellen kann. Da sie sich innerhalb der CPU selbst befinden, muss die CPU für ihre Verwendung nicht auf externen Speicher ­zugreifen, was sie zu einem exzellenten und schnellen Zwischenspeicher für oft benötigte Werte macht, beispielsweise für Schleifenzähler oder Zwischenergebnisse. Mathematische Befehle wie zum Beispiel ADD addieren gar nur Werte auf das Register A und legen das Ergebnis anschließend auch dort ab. Die Registernutzung ist hier also zwingend. Bestimmte Befehle erlauben es, zwei 8-Bit-Register zusammengefasst als ein 16-Bit-Register, dann unter den Namen AF, BC, DE und HL, anzusprechen und sie so zum Beispiel als Speicheradresse zu verwenden.

Es gibt zusätzlich das spezielle Register SP, den sogenannten Stackpointer, den wir weiter unten genauer betrachten werden. Und das Register PC, genannt Programmcounter, das immer die Speicheradresse des nächsten Befehls enthält, den die CPU ausführen soll. Es lässt sich nicht direkt lesen oder schreiben, sondern wird durch die CPU verwaltet. Wir werden später sehen, wie.

Mit diesem Wissen lassen sich alle Register als C#-Felder darstellen, die zugehörigen Registerpaare als Properties, siehe Listing 1.

CPU-Flags

Als Flags werden einzelne Bits in einem Register bezeichnet, die von der CPU automatisch gesetzt oder gelöscht werden, um bestimmte Informationen über den Zustand der CPU oder das Ergebnis von Operationen zu speichern.

Die unteren 8 Bits des Registers AF, in der C#-Property von Listing 1 vereinfachend mit dem Platzhalter F bezeichnet, bilden das sogenannte Flag-Register. Es enthält vier Flags, deren Bedeutung Tabelle 1 erläutert.

Die restlichen Bits 0 bis 3 haben immer den Wert 0. Nur zwei der Flags, das Zero-Flag Z und das Carry-Flag C, werden tatsächlich von Programmen verwendet, um im Code bedingt, also abhängig von dem Ergebnis einer vorherigen Operation, zu einer anderen Stelle zu springen. Das Subtract-Flag N und das Half-Carry-Flag H dienen dagegen nur der internen Logik der CPU. 

Damit sind alle grundlegenden Konzepte der CPU und zugleich die wesentlichen Bausteine eines Computersystems abgedeckt. Es fehlt nur noch die Logik, welche die CPU dazu bringt, die Opcodes aus dem Speicher zu lesen und entsprechend ihrer Bedeutung auszuführen. Das ist das Thema des nächsten Abschnitts.

 

Tabelle 1: Flag-Register
 
FlagBitBedeutung
Z (Zero)7Ergebnis einer Operation ist 0
N (Subtract)6Letzte Operation war eine Subtraktion
H (Half-Carry)5Übertrag von Bit 3 auf Bit 4 bei der letzten Operation
C (Carry)4Übertrag von Bit 7 auf Bit 8 bei der letzten Operation
Tabelle 2: Beispiele aus der Opcode-Dokumentation
 
Assembly (Mnemonik)Opcode (hexadezimal)Bedeutung
LD B, 806 08Kopiere Wert 8 nach Register B
ADD A, B80Addiere Wert von Register B zu A
JMP 0xABCDC3 CD ABSpringe zu einer Adresse
NOP00Tue nichts (No Operation)

Maschinencode, Opcode und Assembler

Wie erzeugt man nun Programme, also ausführbare Opcode-Sequenzen, für eine CPU? Dafür gibt es mehrere Optionen. Echte Männer (mit zu viel Zeit) tun das per Hand, indem sie die hexadezimalen Opcodes für jeden Befehl im CPU-Handbuch nachschlagen und manuell eintragen. Andere, schon besser, erledigen es per Assembler, ein Tool, das immerhin Abfolgen von Befehlen einzeln aus ihrer einfachen, menschenlesbaren Textform, Mnemonik genannt, in ihren Op­code übersetzt. Der Rest der Welt nutzt dagegen einen Compiler, um den Quellcode aus einer Hochsprache in Opcodes zu übersetzen. Bei .NET ist dieser Compile-Prozess übrigens zweistufig: Erst übersetzt der C#-Compiler den C#-Code in MSIL-Opcodes (Microsoft Intermediate Language) für eine von Microsoft ersonnene, virtuelle CPU, die wohl niemand je in Silizium gießen wird, und schreibt ihn in eine DLL- oder EXE-Datei. Erst anschließend, beim Start der Anwendung, übersetzt der .NET-JIT diesen MSIL-Code final in die von der real verbauten CPU ausführbaren Opcodes.

Aber zurück zum Game Boy. Dessen Instruktionssatz umfasst 256 Befehlsvarianten. Assembler und Compiler für den Game Boy sind rar, aber die Dokumentation der Opcodes ist exzellent. Tabelle 2 zeigt einige Beispiele, um das Konzept zu veranschaulichen.

Die tatsächliche Länge der einzelnen Opcodes variiert. Das liegt daran, dass manche Befehle mehr Informationen benötigen als andere. Bei einer Addition etwa muss die CPU wissen, welchen Wert sie addieren soll. Wie lang genau jeder Befehl ist und welche Parameter er enthält, kann man der Dokumentation entnehmen.

Diese enthält auch eine weitere Angabe: die Anzahl der CPU-Zyklen pro Befehl. Diese wird später wichtig, um bei der Emulation die Zeit korrekt zu berücksichtigen und die ein­zelnen Komponenten des Game Boy (CPU, PPU et cetera) zu synchronisieren. Denn je nachdem, wie aufwendig die Arbeit ist, die der gerade ausgeführte Befehl verrichtet, braucht die CPU dafür mehr oder weniger Zeit. Ein Zyklus entspricht dabei der kleinsten Zeiteinheit, welche die CPU benötigt, um einen Teil eines Befehls auszuführen. Beim Game Boy entspricht ein Zyklus genau vier Takten. Befehle benötigen zu ihrer Abarbeitung bis zu sechs solcher Zyklen, aber immer mindestens einen Zyklus. Das bedeutet, dass die CPU mit ihren 4 MHz bis zu 1 Million Zyklen pro Sekunde ausführen kann, was wiederum bedeutet, dass sie bis zu 1 Million Befehle pro Sekunde ausführen könnte, wenn alle Befehle nur einen Zyklus benötigen würden.

Um die Abarbeitung einer beliebigen Opcode-Sequenz in C# zu simulieren, reicht im Grundsatz der Code aus Listing 2. Für jeden Opcode gibt es eine case-Anweisung, die seine eventuellen, auf den Opcode folgenden Parameter ausliest (und dabei den PC erhöht), die Logik des Opcodes implementiert und anschließend die Anzahl der Zyklen, die der Op­code benötigt, zum Zähler elapsedCycles addiert (den Fakt, dass elapsedCycles dadurch irgendwann überläuft, behandeln wir später). Ein einfaches Beispiel gefällig? Gerne.

Der wohl am einfachsten zu emulierende Befehl ist NOP („No Operation“), welcher mit dem Wert 0x00 codiert ist und der nichts tut, außer Zeit zu verbrauchen. Er ist kein vergessenes Überraschungsei, sondern wird in Programmen real verwendet, um für eine sehr kurze, definierte Zeitspanne von einem Zyklus eben nichts zu tun, also zu warten. Seine Implementierung ist denkbar einfach:

 

case 0x00: // NOP
  elapsedCycles++;

 

Listing 2: Opcode-Sequenz abarbeiten
var memory = new byte[0x10000]; // 64 KB
LoadProgramIntoMemory(memory); // zum Beispiel aus 
                               // einem ROM-File
var elapsedCycles = 0;

// main loop
while (true)
{
  var op = memory[PC++];
  switch (op)
  {
    case 0x00: ...
    case 0x01: ...
    ...
    default: throw new NotImplementedException();
  }
}
 
Listing 3: Implementierung des Opcodes 0x3D
void Dec(ref byte reg) => Sub(ref reg, 1, false, 
  false);
void Sub(ref byte reg, byte value, bool carry, 
    bool setCarry)
{
  var carryValue = carry ? 1 : 0;
  var tmp = reg - value - carryValue;
  FlagZ = (byte)tmp == 0;
  FlagN = true;
  FlagH = (reg & 0xF) - (value & 0xF) - 
    carryValue < 0;
  if (setCarry)
    FlagC = tmp < 0;
  reg = (byte)tmp;
}

case 0x3D: // DEC A [DEC r8]
  Dec(ref A);
  elapsedCycles++;
 

Hello World

Um möglichst früh den Schritt von der Theorie zur Praxis zu vollziehen, hilft es, kurze Testsequenzen in Assembler zu schreiben, diese mit RGBDS zu assemblieren und direkt mit der Emulation zu beginnen. Um die Korrektheit unserer Emulation zu prüfen, haben wir die Testsequenzen parallel in einen der weit verbreiteten Game-Boy-Emulatoren geladen und dort im Debugger beobachtet, ob die erwarteten Werte in den Flags und Registern stehen und die Abfolge der Befehle korrekt ist.

Wir werden uns daher jetzt der Emulation eines ersten kleinen Programms zuwenden, einem etwas ungewöhnlichen „Hello World“, das ohne Bildschirmausgabe auskommen muss. Hier der Assemblercode:

 

SECTION "Example",ROM0[$150]

ld a, 100       ; 0150: $3E $64
.loop dec a     ; 0152: $3D
jp nz, .loop    ; 0153: $C2 $52 $01

 

Das Schlüsselwort SECTION instruiert den Assembler, die darauffolgenden Befehle so in Opcodes zu übersetzen, dass diese im ROM0-Bereich des Speichers, ab der Adresse 0x0150, zum Liegen kommen. Diese Adresse ist ein üblicher Einsprungspunkt für das Hauptprogramm. Genau genommen beginnt der Game Boy nach dem Einschalten die Ausführung zwar schon bei 0x0100, hier befindet sich aber meist nur ein einzelner Sprungbefehl, der über den direkt anschließenden Cartridge-Headerbereich 0x0104-0x014F hin zur Adresse 0x0150 springt, wo dann unser Hauptprogramm liegt.

Die erste Zeile ld a, 100 (Load A) lädt die Konstante 100 in das Register A. Ihr generierter Opcode besteht aus zwei Bytes: 0x3E, dem Opcode für den Befehl LD A, n8, und 0x64, der hexadezimalen Darstellung von 100. Die Implementierung des Opcodes sieht wie folgt aus:

 

case 0x3E: // LD A, n8
  A = memory[PC++];
  elapsedCycles += 2;

 

Danach zeigt der Programmcounter PC bereits auf den nächsten Opcode, der an der Adresse 0x0152 beginnt. Der dortige Befehl DEC A (Decrement A) verringert den Wert von A um 1. Er ist mit dem Opcode 0x3D codiert, seine Implementierung zeigt Listing 3.

Jetzt stellt sich die Frage: Hätte ein einfaches A--; als Implementierung nicht gereicht? Nein, denn DEC verringert nicht nur den Wert von A, sondern setzt auch die CPU-Flags entsprechend dem Ergebnis. Da mehrere Subtraktions-Op­codes diese Logik benötigen, haben wir sie in der Funktion Sub gekapselt. Diese nimmt als Parameter das Register, das verändert werden soll, den Wert, um den es verändert werden soll, und zwei Flags, die angeben, ob der Wert zusätzlich um 1 verringert werden soll (carry) und ob das Carry-Flag entsprechend dem Ergebnis gesetzt werden soll (setCarry). In diesem Fall ist beides false, da DEC nur um 1 verringert und das Carry-Flag nicht beeinflusst.

Als Nächstes folgt der Befehl JP NZ, .loop (Jump if Not Zero), der mit den drei Bytes 0xC2, 0x52 und 0x01 codiert ist. Er prüft das Zero-Flag Z und springt zur Adresse 0x0152 zurück, wenn es nicht gesetzt ist. Das ist dann der Fall, wenn das Ergebnis der vorherigen Subtraktion durch DEC nicht 0 war. Listing 4 zeigt seine Implementierung. 

An den entstandenen Opcodes kann man zwei Dinge schön erkennen. Erstens, dass der Assembler uns die Arbeit abnimmt, aus dem Label .loop die effektive Adresse zu ermitteln. Und zweitens, dass die ermittelte Adresse 0x0152, wie alle 16-Bit-Werte und zur Byte-Order der CPU passend, in Little-Endian-Form codiert ist. Im Speicher erscheint also zuerst das niederwertige Byte 0x52, und dann folgt das höherwertige Byte 0x01. 

Wir haben uns der besseren Lesbarkeit halber für die Einführung ­einer Funktion ReadImmediateUShort entschieden, die wir auch in weiteren Opcodes verwenden werden, die ­alle ebenfalls 16-Bit-Konstanten aus dem Speicher laden. Sie liest die beiden auf den Opcode folgenden Bytes als 16-Bit-Wert aus dem Speicher und erhöht anschließend den PC um 2, damit er auf den nächsten Opcode zeigt.

An der Implementierung von JP NZ sieht man auch, dass bedingte Sprünge, je nachdem, ob die angegebene Bedingung erfüllt war oder nicht und damit der Sprung ausgeführt wurde oder nicht, unterschiedlich viele Zyklen benötigen. Die verfügbare Dokumentation des Prozessors führt für beide Fälle die Anzahl der jeweils benötigten Zyklen auf.

Was tut unser Hello World eigentlich? Es hätte eher den Namen „Sleep“ verdient, da es lediglich eine variable Zeitspanne wartet. Um diese tolle Funktion anderen Entwicklern zur Verfügung zu stellen (und um sie ehrlicherweise als krudes Vehikel zur Erklärung eines weiteren wichtigen Konzepts, nämlich dem Stack, zu verwenden) werden wir daraus im nächsten Abschnitt eine Funktion Sleep machen, welche die Anzahl der abzuarbeitenden Schleifendurchläufe als Parameter erhält.

 

Listing 4: Implementierung des Befehls JP NZ
ushort ReadImmediateUShort()
{
  var value = (ushort)(memory[PC] | 
    (memory[PC + 1] << 8));
  PC += 2;
  return value;
}

case 0xC2: // JP NZ, a16
  if (!FlagZ)
  {
    PC = ReadImmediateUShort();
    elapsedCycles += 4;
  }
  else
  {
    PC += 2;
    elapsedCycles += 3;
  }
 
Listing 5: Assemblercode für das „Hello World“
SECTION "Example",ROM0[$150]

ld a, 100       ; 0150: $3E $64
call Sleep      ; 0152: $CD $55 $01 - SP = 0xFFFC

Sleep:
.loop dec a     ; 0155: $3D
jr nz, .loop    ; 0156: $20 $FD
ret             ; 0158: $C9         - SP = 0xFFFE
 

Hello World mit Stack

Die Game-Boy-CPU verfügt, wie alle CPUs, über einen Stack, der hauptsächlich während Funktionsaufrufen und Rück­gaben verwendet wird. Technisch gesehen besteht der Stack lediglich aus dem bereits erwähnten CPU-Register SP (Stack-Pointer), das stets die Speicheradresse des zuletzt auf dem Stack abgelegten Elements enthält. Der initiale Wert von SP ist 0xFFFE, die Anwendung kann die Position des Stacks im Speicher aber frei wählen. Dazu schreibt sie, wenig überraschend, einfach einen anderen Wert in SP. Wichtig zu wissen ist, dass der Stack „nach unten“ wächst, sich der Wert von SP also mit jedem neu auf den Stack gelegten Element verringert. Der Stack wächst sozusagen wie ein Stalaktit „von der Decke herunter“ statt „vom Boden aufwärts“. Das mag überraschend sein, basiert allerdings auf der naheliegenden Annahme, dass sich die Anwendung und ihre Daten im unteren Bereich des Speichers aufhalten und man so einer Kollision mit dem Stack am leichtesten und ohne viel Verwaltungsaufwand entkommt.

In der Verwendung ähnelt der Stack der C#-Klasse Stack<ushort>. Die Anwendung kann mit PUSH-Anweisungen den Inhalt eines 16-Bit-Registers auf den Stack legen und anschließend mit POP wieder vom Stack nehmen und dabei in dasselbe oder in ein anderes Register zurückschreiben. Auf diesem Weg verwaltet die Anwendung den Stack explizit, um zum Beispiel den Inhalt von Registern zwischenzuspeichern. 

Es gibt aber auch Anweisungen, die den Stack implizit verändern. Wenn nämlich eine Funktion mit CALL aufgerufen wird, pusht die CPU automatisch den aktuellen Wert des Programmcounters PC (der zu diesem Zeitpunkt bereits auf den auf CALL folgenden Opcode zeigt) auf den Stack, damit sie nach der Ausführung der Funktion dorthin zurückkehren kann. Anschließend ersetzt sie den Inhalt von PC mit der Adresse der Zielfunktion. Ein irgendwann später folgendes RET (Return) holt einfach per POP den auf dem Stack gespeicherten Wert und schreibt ihn zurück in PC. Damit ist der Rücksprung abgeschlossen.

Der Code unseres Hello World ist im Wesentlichen der gleiche wie zuvor, wir haben nur die Logik der Schleife in eine Funktion Sleep ausgelagert, die wir mit einem CALL aufrufen. Und wir haben den absoluten Sprung JP als Mikrooptimierung durch ein relatives JR ersetzt. Listing 5 zeigt den Assemblercode, die Kommentare darin enthalten zusätzlich den aktuellen SP mit dem Wert, den dieser direkt nach der Ausführung des jeweiligen Befehls hat.

Der erste neue Befehl ist CALL, der mit dem Byte 0xCD codiert ist, gefolgt von einer 16-bittigen Adresse. Listing 6 zeigt seine Implementierung. Er pusht PC auf den Stack und ersetzt den Inhalt von PC mit der Adresse der Funktion Sleep, die damit als Nächstes ausgeführt wird. 

Der nächste neue Befehl ist JR NZ (Jump Relative if Not Zero), der mit den zwei Bytes 0x20 und 0xFD codiert ist. Listing 7 zeigt seine Implementierung. Er springt relativ zum aktuellen PC (der zu diesem Zeitpunkt bereits auf das nachfolgende RET zeigt) um die vorzeichenbehaftete Anzahl der Bytes, die im zweiten Byte angegeben ist, zurück oder vorwärts. In unserem Fall springt er um 3 Bytes zurück. Da JR schneller ist als das ihm ähnliche JP, wird es gern für kurze Sprünge in einem Zielbereich von -128 bis +127 Bytes verwendet. 

Damit ist unser zweites Beispiel-Programm bereits vollständig und korrekt emuliert.

Befehlsgruppen

Zum Glück muss man nicht alle Befehle einzeln implementieren, da sich unter ihnen Gruppen ausbilden, die sich nur in den verwendeten Quell- oder Zielregistern unterscheiden. Darunter fallen vor allem die LD-Befehle (Load) im Bereich 0x40 bis 0x7F, zusammen also bereits ein Viertel der 256 Befehle. Diese Befehle laden ein Datenbyte von einem Register in ein anderes oder von einem Register in den Speicher oder umgekehrt. Die Quelle und das Ziel kann man wunderbar mit einer Bitmaske aus dem Opcode selbst bestimmen:

 

private ref byte GetOperand(int opcode)
{
  switch (opcode & 0b111)
  {
    case 0: return ref B;
    case 1: return ref C;
    case 2: return ref D;
    case 3: return ref E;
    case 4: return ref H;
    case 5: return ref L;
    case 6: return ref memory[HL];
    default: return ref A;
  }
}

// main loop
while (true)
{
  var op = memory[PC++];
  if (op >= 0x40 && op < 0x80)   // 0x4x .. 0x7x = 
                                 // LD Block
  {
    ref var src = ref GetOperand(op);
    ref var dst = ref GetOperand(op >> 3);
    dst = src;
    // TODO: elapsedCycles entsprechend erhöhen
  } else
  switch (op)
  {
    ...
  }
}

 

Dasselbe Prinzip lässt sich auch auf den Funktionsblock 0x80 bis 0xBF anwenden, also ein weiteres Viertel aller Opcodes. In diesem liegen die Befehle ADD (Add), ADC (Add with Carry), SUB (Subtract), SBC (Subtract with Carry), AND (Logical AND), XOR (Logical XOR), OR (Logical OR) und CP (Com­pare). Auch hier lässt sich die Quelle der Operanden mit Get­Operand aus dem Opcode bestimmen. Das ist kein Zufall, sondern gutes Design der Hardware-Ingenieure, um die zugehörige CPU-Logik zu vereinfachen, wovon erfreulicherweise auch die Emulation profitiert:

 

if (op >= 0x80 && op < 0xC0)    // 0x8x .. 0xBx = 
                                // Math Block
{
  var arg = GetOperand(op);
  switch ((op >> 3) & 0b111)
  {
    case 0: // ADD A, arg
    case 1: // ADC A, arg
    case 2: Sub(ref A, arg, false, true); break; 
                                           // SUB A, r8
    case 3: Sub(ref A, arg, FlagC, true); break; 
                                           // SBC A, r8
    case 4: // AND A, arg
    case 5: // XOR A, arg
    case 6: // OR A, arg
    default: // CP A, arg
  }
  // TODO: elapsedCycles entsprechend erhöhen
}

 

Eine weitere, erklärungswürdige Opcode-Gruppe ist RST vec (Restart). Sie ruft eine Funktion an einer festen Adresse auf (je nach Opcode zum Beispiel 0x0000, 0x0008, 0x0010 et cetera) und ist durch ihre Codierung mit nur einem Byte und nur vier Zyklen Ausführungszeit ein schnelles Gegenstück zu einem CALL dorthin. Ihre Implementierung sieht folgendermaßen aus:

 

case 0xC7: // RST $00
case 0xD7: // RST $10
case 0xE7: // RST $20
case 0xF7: // RST $30
case 0xCF: // RST $08
case 0xDF: // RST $18
case 0xEF: // RST $28
case 0xFF: // RST $38
  PushToStack(PC);
  PC = (ushort)(op & 0b0011_1000);
  elapsedCycles += 4;

 

Die RST vec-Opcodes werden von Spielen meist verwendet, um sehr oft benötigte und kurze (schließlich folgt 8 Bytes später schon das nächste Sprungziel) Service-Routinen aufzurufen, nachdem ihr Code dort zuvor wie folgt platziert wurde:

 

SECTION "Service", ROM0[$0020]
; code der Routine, maximal 7 Bytes
ret

SECTION "Example",ROM0[$150]
rst $20 ; call Service

 

Listing 6: Implementierung des CALL-Befehls
private void PushToStack(ushort value)
{
  SP -= 2;
  memory[SP] = (byte)value;
  memory[SP + 1] = (byte)(value >> 8);
}

case 0xCD: // CALL a16
  var address = ReadImmediateUShort();
  PushToStack(PC);
  PC = address;
  elapsedCycles += 6; 
Listing 7: Implementierung des Befehls JR NZ
case 0x20: // JR NZ, e8
  if (!FlagZ)
  {
    var offset = (sbyte)memory[PC++];
    PC = (ushort)(PC + offset);
    elapsedCycles += 3;
  }
  else
  {
    PC++;
    elapsedCycles += 2;
  }
 

Bankswitching und andere Überraschungen

Während der Entwicklung haben wir eine komplette Nacht damit verbracht, einen Bug zu finden, der bei einem der vielen von uns getesteten Homebrew-Spiele dazu führte, dass es bei der Ausführung eines eigentlich nicht erlaubten Opcodes crashte. 

Am Ende stellte sich heraus, dass das Spiel viele Zyklen zuvor einen Schreibvorgang in den ROM-Bereich des Speichers ausführte, damit seinen eigenen Code modifizierte und dadurch schließlich eine falsche Adresse mit dem ungültigen Opcode ansprang. Ein Albtraum.

Was war passiert? Das Spiel wollte, völlig valide, Bankswitching nutzen und durch sein Schreiben der Cartridge einen ROM-Bankwechsel anweisen. Um zu verstehen, was das bedeutet, muss man sich zunächst noch einmal die Speicher­architektur des Game Boy anschauen. In den ersten 32 KB des Speichers, also von Adresse 0x0000 bis 0x7FFF, befindet sich der ROM-Bereich, der physisch auf der Cartridge steckt und die Spieldaten enthält. Dieser Bereich ist in zwei 16 KB große Bänke unterteilt, vergleiche Tabelle 3.

Sollte ein Spiel mehr als 32 KB groß sein, braucht es trotz dieser Beschränkung auf 32 KB eine Möglichkeit, auf alle seine Spieldaten zuzugreifen. Dazu kann es verändern, welche der Cartridge-seitig durchaus verfügbaren, aber dem Game Boy nicht direkt zugänglichen 16-KB-ROM-Bänke im Adressbereich der ROM-Bank 1 eingeblendet werden soll. Und genau dazu schreibt es die Nummer der gewünschten ROM-Bank in das Write-only-Register ROM Bank Number, das sich an der Adresse 0x2000-0x3FFF befindet. 

Sekunde … Register? Jein. Der Name ist etwas irreführend, da es sich nicht um ein klassisches CPU-Register wie A, B oder HL handelt, sondern um einen von vielen speziellen Speicherbereichen, die alle bestimmte Informationen über die Hardware zugänglich machen oder beim Schreiben eine bestimmte Logik auslösen. Nach dem Schreiben in ROM Bank Number zum Beispiel bedient die Cartridge alle Lesezugriffe im Bereich 0x4000-0x7FFF mit dem Inhalt der angegebenen Bank. 

Die Anzahl der in der Cartridge verfügbaren ROM-Bänke kann man übrigens dem Byte an der Speicheradresse 0x148 entnehmen, vergleiche die Angaben in Tabelle 4.

Das Problem war also, dass wir mit dieser Vorgehensweise Schreibvorgänge nicht korrekt behandelten und stattdessen einen normalen Schreibvorgang im Sinne von memory[0x2000] = value; ausführten, was den an der Stelle befindlichen Code des ROM überschrieb. Zur Abhilfe haben wir daher zwei Dinge verändert. Erstens wird der Code beim Start des Emulators nicht mehr in memory geladen, sondern in ein separates Array _rom mit einer dynamischen Größe. Und zweitens wurde jede Zuweisung der Art memory[addr] = ­value; durch den Aufruf eines Wrappers ersetzt, der die Ziel­adresse prüft und für spezielle Adressen die entsprechende Register-Logik ausführt (Listing 8).

Der Wrapper löst den ROM-Bank-Wechsel mit dem einfachen Setzen eines Offsets _romBankOneOffset, den wir auf Basis der angeforderten Banknummer berechnen und anschließend bei jedem Lesezugriff auf den ROM-Bereich berücksichtigen. Das hat natürlich den Nachteil, dass wir dafür nun auch alle Lesezugriffe durch einen passenden ReadMem-Wrapper schleusen müssen, der, sollte sich die angeforderte Adresse in der ROM-Bank 1 befinden, den Lesevorgang statt aus memory[addr] aus _rom[_romBankOneOffset + addr] bedient. Einfacher wäre es gewesen, die 16 KB der soeben ausgewählten Bank noch in WriteMem einfach aus _rom in den ROM-Bank-1-Speicherbereich von memory zu kopieren. Aber auch langsamer, da etliche Spiele den Bankwechsel sehr oft, Tausende Male pro Sekunde, durchführen und damit unnötig viel Zeit mit dem Kopieren verbringen würden.

 

Tabelle 3: Unterteilung des ROM-Bereichs

 

AdresseGrößeBeschreibung
0x0000–0x3FFF16 KBROM Bank 0 (fest)
0x4000–0x7FFF16 KBROM Bank 1–X (umschaltbar)
Tabelle 4: Anzahl der in der Cartridge verfügbaren ROM-Bänke

 

ROM Size CodeROM SizeAnzahl Bänke
0x0032 KB2 (Bank 0 + Bank 1)
0x0164 KB4
0x02128 KB8
………
0x088 MB512

DMA-Transfer

Durch die für das Bankswitching notwendige Einführung des WriteMem-Wrappers lässt sich auch gleich die Logik für einen weiteren wichtigen Hardwaremechanismus umsetzen, den DMA-Transfer.

Ein DMA-Transfer (Direct Memory Access) ist eine spezielle Art von Speichertransfer, der durch einen Schreibvorgang an die Adresse 0xFF46 ausgelöst wird. Er kopiert 160 Bytes von der angegebenen Quelladresse (die durch den Wert des geschriebenen Bytes multipliziert mit 0x100 bestimmt wird) in den OAM-Bereich (Object Attribute Memory) des Speichers. Der OAM genannte Bereich enthält die Attribute der Sprites, die auf dem Bildschirm angezeigt werden, und wird daher oft aktualisiert. Der Transfer dauert 160 Zyklen, was deutlich schneller ist, als die gleiche Menge an Daten mit normalen Lese- und Schreibvorgängen zu übertragen. Die Emulation muss zum Glück die Zyklenzahl nicht exakt nachbilden, da der Code des Spiels diese Zeitspanne bei Bedarf selbstständig aktiv abwartet. Seine Implementierung sieht wie folgt aus:

 

memory[0xFF46] = value;[
Array.Copy(memory, value << 8, memory, 0xFE00, 0xA0);

Interrupts

Ein Interrupt ist ein Signal, das die CPU dazu veranlasst, die aktuelle Programmausführung zu unterbrechen und stattdessen einen speziellen Code-Abschnitt, die sogenannte Interrupt-Service-Routine (ISR), auszuführen. 

Interrupts werden beim Game Boy verwendet, um auf Hardware-Ereignisse wie Timer, eine Zustandsänderung des Displays oder Benutzereingaben sofort zu reagieren, ohne dass das Programm dazu aktiv und kontinuierlich auf diese Ereignisse prüfen muss. 

Um die Interruptbehandlung in kritischen und vielleicht zeitsensiblen Codeabschnitten zu unterdrücken, lässt sie sich global mit den Befehlen EI (Enable Interrupts) und DI (Dis­able Interrupts) aktivieren oder deaktivieren. Daneben gibt es auch die Möglichkeit, gezielt nur einzelne Interrupttypen über ihre entsprechenden Bits im IE-Register (Interrupt En­able), einem Byte an der speziellen Adresse 0xFFFF, zu unterdrücken.

Vor der Ausführung eines jeden Befehls prüft die CPU, ob ein behandelbares Interrupt-Signal vorliegt, also ob das logische UND der beiden Register IF (Interrupt Flag) und IE (Interrupt Enable) ungleich 0 ist. Das Register IF, ein spezielles Byte an der Adresse 0xFF0F, enthält dabei, bitweise zu IE passend, die aktuell anliegenden Interrupt-Signale, die von der Hardware gesetzt wurden, als das zugehörige Ereignis eintrat. Zur korrekten Emulation werden die Interrupts in der Hauptschleife direkt vor der Abarbeitung der Opcodes behandelt (Listing 9).

Wird ein behandelbares Interrupt-Signal erkannt, wird zunächst IME auf false gesetzt, damit während der Ausführung der ISR keine weiteren Interrupts behandelt werden können. Anschließend wird von allen anliegenden Interrupts derjenige mit der höchsten Priorität bestimmt, das entsprechende Bit im IF-Register gelöscht und der aktuelle Wert von PC auf den Stack gepusht, damit die CPU nach der ISR-Ausführung das Programm an der ursprünglichen Stelle fortsetzen kann. 

Zum Schluss wird der Inhalt von PC mit einer der fünf ISR-Adressen (eine für jeden Interrupttyp) 0x40, 0x48, 0x50, 0x58 oder 0x60 ersetzt. Hat der dort liegende selbstgeschriebene Interrupt-Handler seine Arbeit beendet und möchte er zum normalen Programmablauf zurückkehren, führt er statt RET ein spezielles RETI (Return from Interrupt) aus. Das holt, wie RET, den Wert von PC vom Stack und setzt zusätzlich IME wieder auf true. Damit gilt die Interrupt-Behandlung als abgeschlossen:

 

case 0xD9: // RETI
  IME = true;
  PC = PopFromStack();
  elapsedCycles += 4;

 

Listing 8: Korrekte Umsetzung des Bankswitching-Schreibvorgangs
// Initialisierung
LoadProgramIntoMemory(_rom); // zum Beispiel aus einem
                             // ROM-File
byte _romBankOneMask = (byte)((2 << _rom[0x148]) - 1);
int _romBankOneOffset = 0;
Array.Copy(_rom, memory, 0x4000); // lade ROM Bank 0

private void WriteMem(int addr, byte value)
{
  if (addr is >= 0x2000 and < 0x4000)
  {
    // select ROM Bank Number
    value &= 0x1F;
    if (value == 0)
      value = 1;
    _romBankOneOffset = 
      ((value & _romBankOneMask) - 1) * 0x4000;
    return;
  } else if (...)
  {
    ...
    return;
  }
  memory[addr] = value;
}
 
Listing 9: Interrupt-Implementierung
bool IME; // Interrupt Master Enable, siehe EI und DI
byte IE => memory[0xFFFF];
byte IF { get => memory[0xFF0F]; set => memory[0xFF0F] 
  = value; }

// main loop
while (true)
{
  var interruptFlags = IF & IE;
  if (interruptFlags != 0)
  {
    // Behandle den Interrupt wenn IME aktiviert ist, 
    // verwende das niedrigste gesetzte Interrupt-Bit 
    // (höchste Priorität)
    if (IME)
    {
      IME = false;
      var bitNum = BitOperations.TrailingZeroCount(
        interruptFlags);
      IF ^= (byte)(1 << bitNum);
      PushToStack(PC);
      PC = (ushort)(0x40 + (bitNum * 8));
      elapsedCycles += 5;
      break;
    }
  }

  // normale Opcode-Abarbeitung
}
 

HALT-State

Um Batterie zu sparen, kann man die CPU mit dem Befehl HALT zeitweilig „schlafen legen“. Sie führt dann keine Befehle mehr aus, sondern wartet nur noch darauf, dass ein im IE-Register erlaubtes Interrupt-Signal eintrifft. Sobald das passiert, wacht die CPU wieder auf und fährt mit ihrer normalen Ausführung direkt nach HALT fort. 

In unserem Code wird der HALT-Zustand mit einem einfachen bool isHalted behandelt, das auf true gesetzt wird, sobald der HALT-Befehl ausgeführt wird.

 

case 0x76: // HALT
  isHalted = true;
  elapsedCycles++;
  break;

 

Solange isHalted den Wert true hat, werden keine Opcodes mehr ausgeführt, sondern nur noch der Zähler elapsedCycles wird erhöht, um der weiterhin ablaufenden Zeit Rechnung zu tragen. Sobald ein erlaubter Interrupt auftritt, wird isHalted auf false gesetzt, sodass die normale Befehlsabarbeitung wieder möglich ist.

 

// main loop
while (true)
{
  var interruptFlags = IF & IE;
  if (interruptFlags != 0)
  {
    isHalted = false;
    ...
  }

  if (isHalted)
  {
    elapsedCycles++;
    continue;
  }

  ...
}

 

Achtung: Wer an dieser Stelle aufmerksam war, hat erkannt, dass der Opcode von HALT mit dem Wert 0x76 im zuvor erwähnten LD-Block 0x40 bis 0x7F liegt. Seine Codierung entspräche damit eigentlich einem (natürlich sinnfreien) LD [HL], [HL], das aus diesem Grund umgewidmet wurde. Der Handler für den LD-Block ist also um diesen Spezialfall 

anzupassen.

Ausblick

Wie geht es weiter? Wir haben in diesem Artikel die wich­tigsten Grundlagen behandelt und eine Brücke von der Hardware zu C# geschlagen, konnten aber natürlich nur einen Bruchteil der insgesamt 256 Opcodes implementieren. Und selbst nach der Implementierung aller Opcodes kommt es auf weitere Details wie Timer, Joypad und Hardwareregister an, damit die Emulation korrekt funktioniert. Diese Teile werden wir in weiteren Artikeln behandeln.

Es gibt zusätzlich sehr gute Bücher und Online-Ressourcen zum Thema Game Boy, die wir hier nicht alle aufzählen können. Leider fanden wir bei unserer Suche keine, die das Thema aus Sicht eines .NET-Entwicklers angehen, was uns zu diesem Artikel veranlasst hat. Wer tiefer in die Hardware­materie einsteigen möchte, dem empfehlen wir die ausführliche Dokumentation der Game Boy Development Commu­nity [1] oder den Kauf entsprechender Fachliteratur. Es ist beeindruckend, wie viel Arbeit und Liebe in diese Ressourcen geflossen sind.

Alle im Artikel präsentierten Codeschnipsel sind (angepasste) Auszüge aus unserem als internes Spaß- und Schulungsprojekt gestarteten Emulator, den wir artikelbegleitend auf GitHub [2], in Stufen passend zum jeweiligen dotnetpro-Artikel, zur Verfügung stellen.

In der nächsten Heftausgabe werden wir die Grafikeinheit Schritt für Schritt nachbauen: Wie werden 8×8-Pixel-Kacheln zu spielbaren Welten zusammengesetzt? Warum ist der Render-Zyklus an das VBlank-Signal gekoppelt, und wie verhindert man, dass CPU- und PPU-Timing im Emulator aus dem Gleichgewicht geraten? 

Die Logik steht. Jetzt fehlt nur noch das Bild.

 

[1] Game Boy Development Community, https://gbdev.io/

[2] Beispielprojekt zum Artikel auf GitHub, https://github.com/twobrainsgmbh/dnmg

Neueste Beiträge

Lern! Dich! Schlau! - .NET Developer Conference 2026
Neu erworbenes Wissen, Austausch in den Gesprächen und hervorragendes Essen: Die DDC bietet Teilnehmenden ein Komplettpaket für die Fortbildung in .NET und KI.
6 Minuten
14. Okt 2026
KI in Anwendungen mit Microsoft Graph - Connected Apps mit .NET, Teil 4
Eine .NET-App verbindet Microsoft-365-Daten mit semantischer Suche und einem Sprachmodell und erzeugt daraus kontextbezogene Antworten.
9 Minuten
Vom vagen Ticket zum präzisen Prompt - Projektanforderungen an Entwickler in Zeiten von KI, Teil 2
Ein Jira-Ticket sagt: „Bewertungssystem implementieren.“ Ein Mensch liest und denkt den Kontext mit. Ein Agent nicht. Fünf Prompt-Patterns zeigen, wie der Weg vom Ticket zum funktionierenden Code kürzer wird, wenn man lernt, in Kontext statt in Code zu denken.
8 Minuten
22. Okt 2026

Das könnte Dich auch interessieren

Elektronische Schaltkreise im Browser simulieren - Simulation
Statt mit Steckfeld oder Lötkolben kann man auf dieser Website Schaltungen per Drag and Drop zusammenstellen und deren Verhalten testen.
2 Minuten
26. Jul 2018
Wenn Tests mitdenken
Wie KI-Agenten die Validierung von Embedded-Systemen neu definieren.
7 Minuten
FPGAs programmieren mit C/C++ - Hardware erzeugen mit C++
Komponenten als Hardware lassen sich nicht nur mit VHDL erzeugen, sondern auch mit weitverbreiteten Programmiersprachen wie C oder C++ – mit Vitis HLS als Werkzeug.
18 Minuten
14. Aug 2023
Anzeige
Anzeige
Anzeige
Anzeige
Anzeige