Emulator programmieren: Mit Software das Verhalten von Hardware nachbilden
In Hardware läuft alles parallel, in Software nicht. Wie kann man das emulieren? Und wie bremst man einen schnellen Prozessor zum langsamen Gameboy?
Die Programmteile zur Emulation von Hardwarekomponenten sind mehr oder weniger schnell programmiert. Wie kommt dann aber alles zusammen? Gar nicht so leicht, denn in einem Chip arbeiten alle Komponenten parallel. Gelegentlich beeinflussen sie einander, die meiste Zeit arbeiten sie aber unabhängig voneinander. Während sie unabhängig arbeiten, erledigen sie eine gewisse Menge ihrer jeweiligen Aufgaben.
- Emulator programmieren: Mit Software das Verhalten von Hardware nachbilden
- Eigene Prozessverwaltung
- Zeitsynchronisation
- Bild und Ton müssen stimmen
- Letzte Schritte und Fazit
Ein Emulator muss die zeitlichen Abhängigkeiten zwischen den Komponenten nachbilden, um die Funktion des Originalsystems überzeugend zu imitieren. Ein weiteres Problem ist, dass das emulierende System meist wesentlich leistungsfähiger ist als das emulierte. Wir nehmen hier wieder den Gameboy als Beispiel, der führt maximal einen Befehl pro Prozessortakt aus. Superskalare Prozessoren, wie sie seit Jahrzehnten in Computern und seit Jahren auch Smartphones üblich sind, können mehrere Befehle pro Takt ausführen. Zudem takten sie um ein Mehrtausendfaches höher.
Ein Spiel, das mehrere hundert Mal schneller läuft, als es sollte, macht aber wenig Spaß. Wir benötigen eine Lösung für die beiden zuvor genannten Probleme - und werden uns damit andere, neue Probleme einhandeln. Aber auch die lassen sich lösen.
Parallelität der Hardware
Ein Emulator muss zuerst einmal dafür sorgen, dass die zeitlichen Zusammenhänge zwischen den einzelnen Komponenten erhalten bleiben. Am besten verstehen lässt sich das an einem konkreten Beispiel. Die Grafikeinheit erzeugt zeilenweise das dargestellte Bild. Während des Großteils dieser Zeit hat die CPU keinen Zugriff auf den Grafikspeicher und kann entsprechend auch keine Änderungen vornehmen. Das ist erst am Ende der Zeile während des horizontalen Strahlrücklaufs (horizontal blank) möglich.
Dann kann das ausgeführte Programm Änderungen vornehmen, die sich auf die Grafik auswirken. Viele Spiele nutzen das für Effekte. Der Emulator muss sicherstellen, dass in beiden Phasen der Zeilenausgabe - während der Pixelausgabe und des Strahlrücklaufs - die CPU genauso viele Befehle ausführen kann, wie sie es auf einem Gameboy könnte. Problematischer als zu viele ausgeführte Befehle ist es, wenn der emulierte Prozessor weniger Befehle ausführen kann als die echte Hardware. Denn dann können Berechnungen noch nicht abgeschlossen sein und sich anders auswirken als beim Original.
Genau dafür haben wir doch viele Prozessorkerne! Oder?
Es mag intuitiv erscheinen, die Parallelität der Hardware zu erreichen, indem jede Komponente mittels Thread auf einem eigenen Prozessorkern emuliert wird. Threads zerlegen ein Programm in mehrere, parallel abzuarbeitende Teile auf, die allerdings noch immer im selben Adressraum liegen und so mit wenig Aufwand kommunizieren und Daten austauschen können. Theoretisch wäre es also denkbar, einfach jede Hardwarekomponente durch einen Prozessorkern abarbeiten zu lassen. Der Ansatz hat allerdings ein Problem: Die einzelnen Threads müssen sehr oft aktiviert werden und führen teils nur wenige Befehle aus.
Das funktioniert zwar, ist aber ziemlich ineffizient. Denn die Prozessverwaltung (Scheduling) eines Betriebssystems verteilt die Prozessorzeit aus Effizienzgründen nicht in beliebig kleinen Teilen (Zeitscheiben). Hierfür gibt es eine Untergrenze, bei Linux ist sie variabel und liegt im Bereich einiger Millisekunden. Keiner der Abläufe in der Gameboy-Hardware dauert so lang. Da zudem die emulierende Hardware wesentlich leistungsfähiger ist als die originale, benötigen die entsprechenden Programmteile weniger Zeit, als sie es in der Hardware täten.
Die einzelnen Threads warten also viel, und wenn ein Thread für einen kürzeren Zeitraum warten muss, als das Betriebssystem ihn anbietet, geschieht das mit busy waiting. Dabei verbringt der Prozess die Zeit bis zur nächsten Aktivierung in einer Warteschleife und belegt weiterhin die von ihm genutzten Ressourcen. Schlimmer noch: Da ihm vom Betriebssystem irgendwann der Prozessor weggenommen wird, läuft er dann eine ganze Weile gar nicht.
Die Synchronisation eines solchen Systems wäre der reinste Horror. Abgesehen davon nutzt es mehr Rechenzeit als benötigt wird und verhindert, dass Prozessorkerne heruntertakten oder in einen Stromsparmodus versetzt werden. Die Lösung des Problems ist eine eigene Prozessverwaltung für die sehr kurzen Ausführungsintervalle von teils wenigen Dutzend Mikrosekunden.
Eigene Prozessverwaltung |
Ja, das ist richtig, aber auf der Ebene dessen, was ich an Code schreibe, findet er nicht...