Übungsaufgabe #1: Systemaufrufe in StuBSmI

Ziel dieser Aufgabe ist es, das aus der Betriebssysteme-Übung bekannte OOStuBS durch Einführung von Systemcalls um eine Trennung der Privilegien von Kernel und Userspace zu erweitern. Dies stellt den ersten Schritt auf dem Weg zu StuBSmI (Studierenden-BetriebSystem mit Isolation) dar. Als Ausgangsbasis wird eine leicht angepasste Version von OOStuBS im Phabricator zur Verfügung gestellt.

Ausführen der Anwendungen auf Ring 3

Im ersten Schritt sollte das System zunächst so angepasst werden, dass der Code der Anwendungen stets auf Ring 3 ausgeführt und nur die Behandlung der Interrupts (insbesondere Zeitscheibenscheduling-Interrupts) auf Ring 0 stattfindet. Erst im zweiten Schritt wird dann eine Schnittstelle für Systemaufrufe eingeführt, die auch das synchrone Betreten des Kerns zur Ausführung privilegierter Operations ermöglicht.

Global Deskriptor Table erweitern

Bisher setzt OOStuBS für den Betrieb im Protected Mode eine Global Descriptor Table (GDT) auf, welche in zwei Einträgen die Code- und Daten-Segmente für Ring 0 beschreibt. Für einen Betrieb in Ring 3 müssen zwei weitere Einträge angelegt werden, die analog dazu den gleichen Zugriff von Ring 3 aus ermöglichen. Ein weiterer neuer Eintrag bildet den TSS-Deskriptor (Task State Segment), der im Wesentlichen kontrolliert, auf welchen Wert der Stackzeiger gesetzt wird, sobald eine Programmunterbrechung einen Wechsel auf Ring 0 auslöst. Die Strukturen dieser Deskriptoren sind im dritten Band des dreiteiligen IA-32-Entwicklerhandbuchs in den Abschnitten "Segment Descriptors" (3.4.5) und "Task Management Data Structures" (7.2.2) detailliert beschrieben.

Kernelstacks einführen

Die Ausführung von Ring-0-Code soll für jede Anwendung auf einem eigenen, vom normalen Stack getrennten Kernelstack stattfinden. Neben der entsprechenden Erweiterung der Thread-Klasse muss auch die Dispatcher-Klasse dahingehend angepasst werden, dass jeweils vor dem Einlasten einer anderen Anwendung der Kernelstackpointer dieser Anwendung im TSS gesetzt wird.

Initiales Verlassen von Ring 0

Um nun beim Einlasten des ersten Fadens den Ring 0 zu verlassen, der bisherige toc_settle() auf den Kernelstack angewendet werden. Zum anderen muss die dadurch angesprungene kickoff()-Funktion anstatt des direkten Aufrufs der virtuellen action()-Methode den Ringwechsel veranlassen, indem sie einen Stack aufbaut, der quasi vorgibt, durch einen Wechsel von 0 nach 3 entstanden zu sein, und durch eine iret-Instruktion schließlich über eine neue Trampolinfunktion kickoff_user() die action()-Methode der Anwendung auf Ring 3 betritt. Eine Beschreibung dieses Aufbaus ist im Intel-Handbuch unter "Exception and Interrupt Handling" (6.12) zu finden.

Systemaufrufschnittstelle

Für einen synchronen Weg zurück aus der Anwendungsebene auf Ring 0 soll nun im zweiten Teil die eigentliche Schnittstelle für Systemaufrufe eingeführt werden.

Ausnahmebehandlung

Das Auslösen eines Traps per int-Instruktion ist standardmäßig eine privilegierte Operation, die nicht ohne weiteres für Code auf Ring 3 zulässig ist und mit einem General Protection Fault quittiert würde. Im zugehörigen Eintrag in der Interrupt Descriptor Table kann jedoch für einzelne Vektoren (für Systemaufrufe beispielsweise Nummer 0x42) die Auslösung durch Usercode erlaubt werden (siehe wiederum Abschnitt 6.12 im Intel-Handbuch). Da ein Systemaufruf-Trap keine Geräteunterbrechung ist, muss der Systemeintrittspfad für den ausgewählten Interruptvektor angepasst werden. Um einen kontrollierten CPU Kontext des Prozesses zu haben, in dem sich die Systemaufrufparameter befinden, sichert man auch die flüchtigen Register (startup.asm) und erweitert die CPU Kontextstruktur (machine/cpu.h, cpu_context). Mit dieser Anpassung, kann ein Systemaufruf mit der regulären Plugbox+Gate-Konstruktion implementiert werden.

Parameterübergabe

Durch den Wechsel auf den Kernelstack bei Behandlung eines Systemaufruf-Traps ist die Übergabe von Parametern auf dem Stack wie bei normalen Funktionsaufrufen nicht möglich. Stattdessen müssen die Aufrufstümpfe (engl. stubs) so gestaltet sein, dass die übergebenen Parameter in Registern über den Privilegebenenwechsel hinweg “gehievt” werden. Passend dazu muss dann auch die Assembler-Behandlungsfunktion dasselbe umgekehrt machen, indem sie die Registerinhalte auf dem Stack (jetzt Kernelstack!) ablegt. Der Syscall Dispatcher wählt dann anhand eines identifizierenden Parameters die eigentliche, aufzurufende Implementierung aus.

Folgende Systemaufrufe sollen implementiert werden. (Die genaue Semantik darf nach eigenem Ermessen sinnvoll festgelegt werden.)

  • size_t write(int fd, const void *buf, size_t len, int x = -1, int y = -1)
  • size_t read(int fd, void *buf, size_t len)
  • void sleep(int ms)
  • int sem_init(int semid, int value)
  • void sem_destroy(int semid)
  • void sem_wait(int semid)
  • void sem_signal(int semid)

Es bietet sich an, den write-Syscall hinter einem O_Stream-kompatiblen Wrapper zu verbergen. Zur leichteren Fehlersuche empfiehlt sich, Makros für Assertions und Kernelpanics anzulegen, die den Fehlerort mit Hilfe von __LINE__, __FILE__ und __func__ anzeigen.