Programming lesson
Tiny Machine Simulator in C: Eine Schritt-für-Schritt-Anleitung zur CPU-Emulation
Lerne, wie du einen Tiny Machine Simulator in C programmierst – inklusive Fetch/Execute-Zyklus, Instruction Memory, Data Memory und Steuerung der CPU-Register. Perfekt für CDA3103 Hausaufgaben und zum Verständnis der Rechnerarchitektur.
Einführung: Was ist ein Tiny Machine Simulator?
Stell dir vor, du könntest eine ganze CPU in einem C-Programm nachbauen – genau das ist die Aufgabe in CDA3103 Homework 3. Du simulierst eine Tiny Machine Architecture, bei der der Speicher in Instruction Memory (IM) und Data Memory (DM) aufgeteilt ist. Dein Simulator führt einen Fetch/Execute-Zyklus aus, liest Befehle aus einer Datei und zeigt den Zustand aller Register an. Klingt nach einem CPU-Simulator in C? Ist es auch! Und das Beste: Mit den richtigen Strukturen und einer klaren Schritt-für-Schritt-Logik wird es gar nicht so kompliziert.
Dieses Tutorial hilft dir, die Tiny Machine ISA zu verstehen und einen funktionierenden Simulator zu schreiben. Wir nutzen aktuelle Beispiele aus der Gaming-Welt und dem KI-Trend, um die Konzepte greifbar zu machen. Los geht's!
Die Architektur der Tiny Machine
Bevor wir code schreiben, müssen wir die Komponenten verstehen. Die Tiny Machine hat folgende Register und Speicher:
- Program Counter (PC): Zeigt auf die nächste Instruktion im Instruction Memory.
- Instruction Register (IR): Enthält die aktuell ausgeführte Instruktion.
- Memory Address Register (MAR, MAR2): Adressieren Instruction Memory bzw. Data Memory.
- Memory Data Register (MDR, MDR2): Halten Daten aus dem Speicher.
- Accumulator (A): Das zentrale Rechenregister.
- Instruction Memory (IM): Array von Instruktionen (struct mit opCode und address).
- Data Memory (DM): Integer-Array für Daten.
Ein CPU-Simulator in C muss all diese Teile abbilden. Der Fetch/Execute-Zyklus ist das Herzstück: Im Fetch-Schritt holt der PC die nächste Instruktion aus dem IM, speichert sie im IR und erhöht den PC. Im Execute-Schritt wird der Opcode ausgeführt – ähnlich wie eine KI-App Schritt für Schritt Daten verarbeitet.
Strukturen und globale Variablen
Wir definieren eine Struktur für Instruktionen und legen die Größen fest:
#define MAX_PROGRAM_SIZE 100
#define DATA_MEMORY_SIZE 10
typedef struct {
int opCode;
int deviceOrAddress;
} Instruction;
Instruction IM[MAX_PROGRAM_SIZE];
int DM[DATA_MEMORY_SIZE];
int PC, A, MAR, MAR2, MDR, MDR2, IR_OP, IR_ADDR, Run;Hier speichert IR_OP und IR_ADDR die Felder der aktuellen Instruktion. Du könntest auch ein struct für IR verwenden, aber getrennte Variablen sind einfacher. Der Programmierstil ist bewusst simpel gehalten – perfekt für CDA3103 Hausaufgaben.
Fetch/Execute-Zyklus implementieren
Der Zyklus läuft in einer while-Schleife, solange Run == 1. Zuerst der Fetch:
void fetch() {
MAR = PC;
PC = PC + 1;
MDR = IM[MAR];
IR_OP = MDR.opCode;
IR_ADDR = MDR.deviceOrAddress;
}Dann der Execute-Teil: Eine switch-Anweisung für die Opcodes. Jeder Befehl wird nach dem Schema aus der Aufgabenstellung umgesetzt. Hier ein Beispiel für LOAD und ADD:
void execute() {
switch(IR_OP) {
case 1: // LOAD
MAR2 = IR_ADDR;
MDR2 = DM[MAR2];
A = MDR2;
break;
case 2: // ADD
MAR2 = IR_ADDR;
MDR2 = DM[MAR2];
A = A + MDR2;
break;
// ... weitere Fälle
case 7: // END
Run = 0;
break;
case 8: // JMP
PC = IR_ADDR;
break;
case 9: // SKIPZ
if (A == 0) PC = PC + 1;
break;
}
}Beachte: Bei JMP und SKIPZ greifst du direkt auf PC zu. Der Befehlssatz ist klein, aber mächtig – wie die Basistechnologie einer KI, die mit wenigen Grundoperationen komplexe Aufgaben löst.
Einlesen der Befehle aus der Datei
Dein Programm bekommt einen Dateinamen als Kommandozeilenargument. Öffne die Datei, lies Paare von Integers und speichere sie in IM:
int main(int argc, char *argv[]) {
if (argc != 2) {
printf("Usage: %s <inputfile>\n", argv[0]);
return 1;
}
FILE *fp = fopen(argv[1], "r");
if (!fp) { perror("Error"); return 1; }
int pc = 0;
while (fscanf(fp, "%d %d", &IM[pc].opCode, &IM[pc].deviceOrAddress) == 2) {
pc++;
}
fclose(fp);
// Jetzt starte die Simulation
Run = 1;
PC = 0;
while (Run) {
printf("PC = %d | A = %d | DM = [", PC, A);
for (int i = 0; i < DATA_MEMORY_SIZE; i++) {
printf("%d%s", DM[i], (i < DATA_MEMORY_SIZE-1) ? ", " : "");
}
printf("]\n");
fetch();
execute();
// Bei IN: Benutzereingabe anfordern
if (IR_OP == 5) {
printf("Bitte Wert eingeben: ");
scanf("%d", &A);
}
// Bei OUT: Wert ausgeben
if (IR_OP == 6) {
printf("OUT: %d\n", A);
}
}
printf("Programm beendet.\n");
return 0;
}Dieser Code zeigt den Zustand vor der Ausführung jeder Instruktion – so wie im Beispiel der Aufgabenstellung. Der Simulator-Ablauf wird transparent und du siehst, wie sich PC, A und DM ändern. Das ist besonders hilfreich für Debugging in C und für das Verständnis der Rechnerarchitektur Grundlagen.
Vollständige Execute-Funktion
Hier ist die vollständige execute-Funktion für alle Opcodes:
void execute() {
switch(IR_OP) {
case 1: // LOAD
MAR2 = IR_ADDR;
MDR2 = DM[MAR2];
A = MDR2;
break;
case 2: // ADD
MAR2 = IR_ADDR;
MDR2 = DM[MAR2];
A = A + MDR2;
break;
case 3: // STORE
MAR2 = IR_ADDR;
MDR2 = A;
DM[MAR2] = MDR2;
break;
case 4: // SUB
MAR2 = IR_ADDR;
MDR2 = DM[MAR2];
A = A - MDR2;
break;
case 5: // IN
// Wird im Hauptprogramm behandelt
break;
case 6: // OUT
// Wird im Hauptprogramm behandelt
break;
case 7: // END
Run = 0;
break;
case 8: // JMP
PC = IR_ADDR;
break;
case 9: // SKIPZ
if (A == 0) PC = PC + 1;
break;
default:
printf("Unbekannter Opcode: %d\n", IR_OP);
Run = 0;
}
}Denk daran: Der Fetch-Schritt erhöht PC bereits, daher springt JMP direkt auf die Zieladresse. SKIPZ erhöht PC nochmal, wenn A == 0 – das überspringt die nächste Instruktion. Dieses Verhalten ist typisch für CPU-Emulation in C und ähnelt der Spielentwicklung, wo Sprungbefehle den Spielfluss steuern.
Ausgabe formatieren
Die Konsolenausgabe sollte wie im Beispiel aussehen. Achte darauf, dass du nach jeder Instruktion den Zustand ausgibst. Nutze printf mit klaren Trennern. Ein Tipp: Verwende if (IR_OP == 5) für die Eingabe, bevor du die nächste Instruktion holst. So bleibt die Reihenfolge korrekt: erst Zustand anzeigen, dann ausführen, dann bei IN/OUT interagieren.
Hier ein Ausgabe-Snippet:
PC = 0 | A = 0 | DM = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
Bitte Wert eingeben: 42
PC = 1 | A = 42 | DM = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
OUT: 42
PC = 2 | A = 42 | DM = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
...Beachte: Der PC zeigt auf die nächste Instruktion, nachdem Fetch ausgeführt wurde. Im Beispiel der Aufgabenstellung wird der Zustand vor der Ausführung gezeigt – das passt.
Häufige Fehler und Tipps
- Vergiss nicht, Run zu initialisieren: Setze
Run = 1vor der while-Schleife. - PC-Inkrementierung: Erhöhe PC im Fetch, nicht im Execute. Sonst stimmen die Adressen nicht.
- IN und OUT: Behandle sie im Hauptprogramm, nicht in execute, da sie Interaktion erfordern.
- Array-Grenzen prüfen: Stelle sicher, dass IM und DM nicht überlaufen. Verwende Konstanten für die Größe.
- Debugging: Gib Zwischenzustände aus – das hilft ungemein bei der Fehlersuche in C.
Trend-Beispiel: Gaming und KI
Stell dir vor, dein Tiny Machine Simulator ist die CPU einer Spielekonsole aus den 80ern. Der Fetch/Execute-Zyklus ist das Herz des Retro-Gaming: Jeder Befehl lässt Mario springen oder einen Gegner erscheinen. Oder denk an eine KI-App auf deinem Smartphone: Sie führt Millionen solcher Zyklen pro Sekunde aus, um Gesichter zu erkennen oder Texte zu übersetzen. Dein Simulator zeigt, was auf der untersten Ebene passiert – das ist Rechnerarchitektur zum Anfassen.
Fazit
Mit diesem Tutorial hast du die Grundlagen, um einen Tiny Machine Simulator in C zu schreiben. Du kennst jetzt den Aufbau der CPU, den Fetch/Execute-Zyklus und die Umsetzung des Befehlssatzes. Das ist nicht nur für CDA3103 Hausaufgaben nützlich, sondern auch für das Verständnis moderner Prozessoren. Viel Erfolg beim Programmieren!