Startseite > Wilkommen > Logbuch > Lass uns die CPU treffen
Lass uns die CPU treffen
Samstag 4. Februar 2023, von
Alle Fassungen dieses Artikels: [Deutsch] [English] [français]
Der Neo Geo Pocket verwendet einen Toshiba-Prozessor, den TLCS900H, als Hauptprozessor. Für den Sound verwendet es einen Z80 (den gleichen Prozessor wie der Game Boy), aber das ist nicht das Thema dieses Artikels.
Also beschloss ich, ein Programm in Assembler für diese Konsole zu erstellen, um mich mit der Prozessorarchitektur vertraut zu machen, mit dem Ziel, einen C-Compiler auf diese Architektur zu portieren. Um dies zu tun, muss ich mein Assembler-Programm wie ein C-Programm betrachten. Es wäre einfach, daran herumzubasteln, aber wenn ich jetzt genau definiere, wie sich zum Beispiel die Funktionen gegenseitig aufrufen, kann ich leichter erkennen, ob meine Methodik funktioniert, wenn ich sie als Compiler implementieren will.
In Assembler arbeiten wir mit dem, was die Prozessorarchitektur zur Verfügung stellt. In unserem Fall ist der TLCS900H ziemlich originell, da er "Fenster" von Registern hat, d.h. er hat 4 Fenster von 4 32-Bit-Registern, plus 4 ständig zugängliche Register (von denen eines speziell für den Stapelzeiger verwendet wird). In der Dokumentation von Toshiba werden sie als Bänke bezeichnet, aber ich gehe zurück zu der Art und Weise, wie der GCC diese Art von Architektur nennt (für die Zukunft!). Assembler-Instruktionen können nur auf einem Fenster gleichzeitig laufen (nicht ganz richtig, aber ich vereinfache), plus auf den 4 permanenten Registern. Und die Dokumentation des Neo Geo Pocket gibt an, dass Fenster Nummer 3 (sie sind von 0 bis 3 nummeriert...) für Systemfunktionen reserviert ist.
Wir haben also 3 Fenster zur Verfügung, und es ist verlockend zu versuchen, diese zu nutzen, um den Code und insbesondere die Funktionsaufrufe zu optimieren. In der Dokumentation des Neo Geo Pocket wird vorgeschlagen, die Fenster je nach Bedarf aufzuteilen - z.B. normaler Code hat ein Fenster, Interrupts benutzen ein anderes usw. Man könnte aber auch sagen, dass diese Fenster dazu dienen, die Datenmenge zu begrenzen, die zwischen dem Prozessor und dem Arbeitsspeicher übertragen wird.
Zur Erläuterung. Stellen wir uns einen Prozessor mit 2 Registern A und B vor. Alle Berechnungen des Prozessors verwenden diese beiden Register. Wenn eine Funktion aufgerufen wird, muss der Inhalt der 2 Register irgendwo gespeichert werden, da die Funktion auch diese 2 Register für ihre Berechnungen benötigt! Die ursprünglichen Werte werden dann wiederhergestellt, wenn die Funktion beendet ist und die Ausführung an den Aufrufer zurückgeht. Normalerweise verwenden wir einen so genannten Stapel, um die Werte zu speichern. Aber die Übertragung von Informationen zwischen dem Prozessor und dem Speicher ist sehr langsam - im Idealfall müssten wir das nicht tun...
Und genau hier können unsere Fenster interessant sein. Wenn die Hauptfunktion Fenster 0 verwendet, könnte sie eine Unterfunktion aufrufen, und wenn diese aufgerufen wird, wechseln wir zu Fenster 1. Auch die Register von Fenster 0 müssen nicht gespeichert werden! Und wenn die Unterfunktion selbst eine Unter-Unterfunktion aufruft, schalten wir auf Fenster 2 um.
Sie sehen schnell das Problem: Was passiert, wenn Sie 3 Ebenen von Unterfunktionen haben? Oder 4, oder 5...?
Wir können uns drei Arten von Antworten vorstellen:
- ach ja, diese Fenster sind lästig, ignorieren wir sie und benutzen wir einfach Fenster 0 und verwenden die anderen Fenster eventuell für spezielle Funktionen
- Wenn wir uns in Fenster 2 befinden, schalten die folgenden Funktionsaufrufe in einen "Stack"-Modus wie bei einem Standardprozessor
- wenn man sich auf Fenster 2 befindet, speichert der nächste Funktionsaufruf die Register von Fenster 0; und dieses wird erst wiederhergestellt, wenn man zur Hauptfunktion zurückkehrt.
Jede Antwort hat ihren Vor- und Nachteil:
- sie ist die einfachste, aber nicht optimal
- sie ist die eleganteste, aber nicht optimal
- sie scheint die optimalste zu sein, aber sie erfordert, dass die Werte woanders als auf einem Stapel gespeichert werden (wir wissen ja nicht, wann wir wieder nach unten gehen, die Unterfunktionen können auch Werte auf dem Stapel speichern usw.)
Ich möchte Lösung 2 implementieren, aber auch einen Weg finden, sie optimal zu gestalten. Was ist das Problem bei Lösung 2? Das Problem ist, dass es die Funktionen sind, die am tiefsten im Baum liegen, die den Stack-Modus aufrufen, d.h. die Funktionen, die am kleinsten sind und am häufigsten aufgerufen werden.
Eine Lösung 2b wäre, dass der "Stack"-Modus für Funktionsaufrufe bis zur Ebene N-3 verwendet wird und dass die letzten 3 Ebenen von Funktionsaufrufen den "Window"-Modus verwenden.
Diese Lösung bringt eine Einschränkung mit sich, mit der der Compiler vielleicht umgehen kann: Man muss den gesamten Funktionsbaum ausgewertet haben, bevor man entscheidet, welcher Modus für einen Funktionsaufruf zu verwenden ist. Eine "Blatt"-Funktion (die keine andere Funktion aufruft) wird immer im "Fenster"-Modus aufgerufen, ebenso eine Funktion, die nur Blattfunktionen aufruft, und ebenso Funktionen, die nur Blattfunktionen sowie Blattfunktionen aufrufen.
Was ist natürlich mit dem Fall, dass Funktionen sich selbst aufrufen? Rekursive Funktionen? In dem Fall, dass eine Funktion sich selbst aufruft, kann man selten im Voraus bestimmen, wie viele verschachtelte Aufrufe es geben wird. In diesem Fall muss der Compiler wohl in der Lage sein, zu erkennen, dass es eine Schleife von Aufrufen gibt (und der Baum ist dann kein Baum mehr, sondern ein Graph mit einem Zyklus). Die Lösung ist, dass in diesem Fall der "Stack"-Modus für alle Aufrufe verwendet wird.
In meinem Assemblerprogramm werde ich verschiedene Arten von Makros schreiben, um die Funktionsaufrufe zu standardisieren und sie so "aussehen" zu lassen.
Im Übrigen werden die 3 ständig zugänglichen Register verwendet, um die ersten 3 Parameter an die aufgerufene Funktion zu übergeben, die anderen Parameter werden auf dem Stack abgelegt. Und die aufgerufene Funktion wird ihre Rückgabe in dieselben drei Register legen, bevor sie die Hand an den Aufrufer zurückgibt. Der Aufrufer ist dafür verantwortlich, die Register vor dem Aufruf zu speichern (im Falle des "Stack"-Modus) und sie danach wiederherzustellen sowie den Rückgabestapel zu säubern, falls erforderlich. Die aufgerufene Partei ist dafür verantwortlich, den Eingabestapel (die Parameter, falls > 3) vor der Übergabe an den Aufrufer zu reinigen.
Übersetzt mit www.DeepL.com/Translator (kostenlose Version)