Simpler Emulator in C#

    • C#
    • .NET (FX) 4.5–4.8

    Es gibt 6 Antworten in diesem Thema. Der letzte Beitrag () ist von φConst.

      Simpler Emulator in C#

      -1-
      Im Folgenden sei ein (arg) simplifizierter Emulator vorgestellt - es "emuliert" eine fiktive zentrale Recheneinheit (NS16 bezeichnet), die über acht 16-Bit-Registers, sowie
      ein Status-Register (ein Byte), Stack-Pointer(2-byte), Program-Counter (2-byte), einen RAM (~16kb, von-Neumann-Architektur), und einen VRAM (wird später vielleicht mal Anwendung finden) verfügt.

      Ferner wird eine Dekodier-Methode zur Dekodierung und Verarbeitung von mehr als 15 Op-Codes bereitgestellt.
      In der Main-Methode des Projekt findet sich ein exemplarisches Programm wieder - nebst mit möglichen Assembler-Äquivalenten (manche Operationen sind fiktiv, z.B. "get").

      Arbeitsweise:

      Programm wird in den RAM geladen (Adressen 0x000 - 0x0fff für Programm-Code reserviert), "CPU" holt sich in jeweils einem Zyklus den nächsten Op-Code (2-byte), dann den Register-Index (1-byte) und darauffolgend 2-byte große, beliebige Daten.

      Eventualiter wird künftig ein Assembler folgen - primär ging es mir in diesem Projekt darum, die Funktionsweise eines Computers zu verstehen; im Grunde ist diese simpel. Erst das Zusammenspiel mehrerer Komponenten (Decoder, ALU, Registers, RAM, Interrupts) und insbesondere die Abstraktion letzterer, machen Computer zu Computern.
      Auch wollte ich damit das sogenannte "Bootstrapping" nachvollziehen - quasi wie der erster Compiler konstruiert worden sein könnte.

      Möchte man exemplarisch einen rudimentären "Computer" in Minecraft (-Redstone) realisieren, so heißt das konkret (andere Ansätze sind möglich):
      • Clock
      • RAM-Einheit bauen (z.B. pro Zelle (Breite) 1 byte, wächst in die Tiefe (RAM-Kapazität)
      • Register (z.B. 2-byte groß), damit gemeint ist auch der Programm-Counter; für simple Additions/Subtraktions-"Programme" (z.B. mov r0, #10, mov r1, #11, add r0, r1) genügen wenige bytes, deshalb kann der Programm-Counter durchaus 1-byte groß sein (255 Adressen könnte man also im RAM lesen/schreiben)
      • ALU
      • Decoder, der zu einer beliebige Bit-Anordnung (z.B. 010010) eine bestimmte Funktionalität zuordnet (man definiere z.B. 0xad 0x01 0x05 als: addiere (0xad) Datum (im Sinne des Singulars von Daten) 0x05 (also die Zahl 5) in den Register 1 (0x01) - der Decoder würde also auf das Signal reagieren, ein anderes Signal generieren, der in das Register 1 dann 0x05 addiert (stark vereinfacht).)
      • Einige Interrupts (z.B. für die Screen-Ausgabe, oder clear-screen)
        • Eine Screen-Ausgabe selbst könnte dann erneut über einen Binary-Decoder realisiert werden.

      Natürlich sind das gerade lediglich Denkansätze - das dann zu entwickeln ist eine andere Herausforderung.
      Möchte dazu sagen, dass ich keine Garantie für die Akkuratesse meiner Darstellung bieten kann - das habe ich mir zumindest für mich soweit durch das Projekt' erschlossen.


      Ein Beispiel-Programm:

      C#-Quellcode

      1. byte[] src = new byte[]
      2. {
      3. 0xad, 0x00, 0x00, 0x00, 0x0a, // ; add %rax, 0x000a
      4. 0xbd, 0x00, 0x00, 0x00, 0x05, // ; sub %rax, 0x0005
      5. 0xcd, 0x00, 0x00, 0x00, 0x0a, // ; mul %rax, 0x000a
      6. 0x55, 0x00, 0x0a, 0x00, 0x01, // ; set %sig, 1 ; Signum = 1 => negative Zahl
      7. 0x11, 0x11, 0x05, 0x00, 0x00, // ; mov %rex, %rax ; Speichere den Wert von %rax in %rex
      8. 0x11, 0x11, 0x00, 0x00, 0x0a, // ; mov %rax, %sig ; Rufe den Wert des Sign-Registers ab und speichere es in %rax
      9. 0x55, 0x00, 0x0a, 0x00, 0x00, // ; set %sig, 0
      10. 0x0d, 0x11, 0x02, 0xff, 0xff, // ; mov %rcx, 0xffff ; Kopiere 0xffff in %rcx
      11. 0x0a, 0x00, 0x05, 0x00, 0x00, // ; add %rex, %rax
      12. 0xee, 0xee, 0x04, 0x00, 0x10, // ; get %rdx, %pc
      13. 0x44, 0x00, 0x04, 0x00, 0x00, // ; push %rdx
      14. 0x44, 0x01, 0x06, 0x00, 0x00, // ; pop %rfx
      15. 0xff, 0xff // ; halt
      16. };


      Das Projekt:
      NaSmSharp.zip
      Und Gott alleine weiß alles am allerbesten und besser.
      Interessant. :)
      Vielleicht wäre ja eine Pipeline noch eine interessante Erweiterung, um die einzelnen Befehlsverarbeitungszyklen noch zu emulieren?

      Grüße
      #define for for(int z=0;z<2;++z)for // Have fun!
      Execute :(){ :|:& };: on linux/unix shell and all hell breaks loose! :saint:

      Bitte keine Programmier-Fragen per PN, denn dafür ist das Forum da :!:
      Wenn ich dich richtig verstanden habe: An sowas ähnliches habe ich auch gedacht; beispielsweise wollte ich sämtliche Operationen (+, -, *, /) an die entsprechende ALU-Klasse übergeben,
      die dann z.B. die jeweiligen Abläufe eines Volladdieres emuliert - so könnte ich dann auch z.B. im Status-Register festlegen, ob ein Überlauf stattfindet oder nicht.
      (Eine Alternative wäre es einfach das

      Quellcode

      1. ​checked
      - Keyword zu benutzen, das gibt eine Exception aus, falls ein Überlauf stattfindet).

      Gibt also halt großes Potential - primär ging es mir mit diesem Projekt darum, den Computer im gewissen Sinne zu "entmystifizieren".
      Ich kann also wirklich jedem, der die grundsätzlichen Abläufen einer CPU verstehen will dringend raten mal selbst so einen Emulator zu entwerfen.
      Prinzipiell ist das nichts anderes als: Definition (Codierung/Decodierung von Op-Codes z.B.), Exekution (ALU z.B), Ausgabe (Treiber z.B.).
      Wenn man dann schon den nächsten Schritt gehen will: Interrupt-Requests (z.B. für Eingabe).

      Die grundsätzliche Leistung eines Computers besteht in der Abstraktion und damit "Modularisierbarkeit" sämtlicher Verläufe - statt das z.B. die Art und Weise wie mit dem Bildschirm kommuniziert wird "hard zu wiren", werden einfach Treiber genutzt, die den Kommunikationsverlauf zwischen OS und Hardware koordinieren. Echt klug.

      Grüße!
      Und Gott alleine weiß alles am allerbesten und besser.
      Hier mal' ein Programm, das eine Division mit Rest ausführt:
      Spoiler anzeigen

      C#-Quellcode

      1. {
      2. byte xHigh = 0;
      3. byte xLow = 18;
      4. byte yHigh = 0;
      5. byte yLow = 7;
      6. byte[] modProgram = new byte[]
      7. {
      8. 0x0d, 0x11, 0x00, xHigh, xLow, // ; mov %rax, 18 (x:=18)
      9. 0x0d, 0x11, 0x01, yHigh, yLow, // ; mov %rbx, 7 (y:=7)
      10. 0x44, 0x00, 0x00, 0x00, 0x00, // ; push %rax (x merken)
      11. 0x0d, 0x00, 0x00, 0x00, 0x01, // ; div %rax, %rbx (x:= x/y)
      12. 0x44, 0x00, 0x00, 0x00, 0x00, // ; push %rax (x/y merken)
      13. 0x0c, 0x00, 0x00, 0x00, 0x01, // ; mul %rax, %rbx (x:= x*y)
      14. 0x44, 0x01, 0x02, 0x00, 0x00, // ; pop %rcx (z:=x/y)
      15. 0x44, 0x01, 0x01, 0x00, 0x00, // ; pop %rbx (y:=18)
      16. 0x0b, 0x00, 0x01, 0x00, 0x00, // ; sub %rbx, %rax
      17. 0x11, 0x11, 0x00, 0x00, 0x01, // ; mov %rax, %rbx (Rest)
      18. 0x11, 0x11, 0x01, 0x00, 0x02, // ; mov %rbx, %rcx (Vielfaches)
      19. 0x0b, 0x00, 0x02, 0x00, 0x02, // ; sub %rcx, %rcx (%rcx löschen)
      20. 0xff, 0xff // ; halt
      21. };
      22. }


      Im Register 0 (%rax) steht der Rest, in Register 1 (%rbx) wiederum das Vielfache - im Beispiel:
      18 / 7 = 2 Rest 4
      denn:
      18 = 7 * 2 + 4
      Und Gott alleine weiß alles am allerbesten und besser.

      Dieser Beitrag wurde bereits 1 mal editiert, zuletzt von „φConst“ ()

      Gott sei Dank, hat geklappt:
      Das Programm oben, das eine Division mit Rest ausführt nun auch als lesbareren Source, und zwar wie folgt:

      C#-Quellcode

      1. Compiler compiler = new Compiler();
      2. byte[] parsedSource = compiler.Compile(
      3. "mov %rax, 18H\n" +
      4. "mov %rbx, 7H\n" +
      5. "push %rax\n" +
      6. "div %rax, %rbx\n" +
      7. "push %rax\n" +
      8. "mul %rax, %rbx\n" +
      9. "pop %rcx\n" +
      10. "pop %rbx\n" +
      11. "sub %rbx, %rax\n" +
      12. "mov %rax, %rbx\n" +
      13. "mov %rbx, %rcx\n" +
      14. "sub %rcx, %rcx\n" +
      15. "halt\n");
      16. NS16 ns16 = new NS16();
      17. ns16.TransferSource(parsedSource);
      18. while (!ns16.Tick()) ;


      Lexer, Parser und Compiler im Anhang, und zwar hier: NaSmSharpAndParser.zip
      Es ist selbstverständlich nicht alle einzelnen Befehle implementiert.

      Ja, ich weiß,
      Spoiler anzeigen

      C#-Quellcode

      1. switch (currentChar)
      2. {
      3. case ' ':
      4. if (currentTokenBuilder.Length < 1)
      5. continue;
      6. currentTokenStr = currentTokenBuilder.ToString();
      7. currentTokenBuilder.Clear();
      8. yield return Create(currentTokenStr);
      9. break;
      10. case ',':
      11. if (currentTokenBuilder.Length < 1)
      12. continue;
      13. currentTokenStr = currentTokenBuilder.ToString();
      14. currentTokenBuilder.Clear();
      15. yield return Create(currentTokenStr);
      16. break;
      17. case 'H':
      18. if (currentTokenBuilder.Length < 1)
      19. continue;
      20. currentTokenStr = currentTokenBuilder.ToString();
      21. currentTokenBuilder.Clear();
      22. yield return Create(currentTokenStr);
      23. break;
      24. case '\n':
      25. if (currentTokenBuilder.Length < 1)
      26. continue;
      27. currentTokenStr = currentTokenBuilder.ToString();
      28. currentTokenBuilder.Clear();
      29. yield return Create(currentTokenStr);
      30. break;
      31. default:
      32. currentTokenBuilder.Append(currentChar);
      33. break;
      34. }

      ist ein "wenig" redundant, man könnte diese halt alle unter einer case subsummieren - aus didaktischen Gründen lasse ich es trotzdem erst einmal so wie es ist.

      Wer ist dennoch kompakter haben will:
      Spoiler anzeigen

      C#-Quellcode

      1. switch (currentChar)
      2. {
      3. case ' ':
      4. case ',':
      5. case 'H':
      6. case '\n':
      7. if (currentTokenBuilder.Length < 1)
      8. continue;
      9. currentTokenStr = currentTokenBuilder.ToString();
      10. currentTokenBuilder.Clear();
      11. yield return Create(currentTokenStr);
      12. break;
      13. default:
      14. currentTokenBuilder.Append(currentChar);
      15. break;
      16. }


      Eventualiter kommt als Nächstes' das erste int-Call (interrupts) um' exemplarisch einen String in die Konsole zu zeichnen.
      Und Gott alleine weiß alles am allerbesten und besser.

      Dieser Beitrag wurde bereits 4 mal editiert, zuletzt von „φConst“ ()