13. Nov 2023
Lesedauer 6 Min.
System Calls
Das eigene Betriebssystem, Teil 12
Mithilfe von System Calls können Anwendungen Kernel-Funktionen nutzen.

W ie ein neuer virtueller Adressraum für Anwendungsprogramme erzeugt werden kann und wie Anwendungsprogramme von der FAT12-Partition geladen und ausgeführt werden, haben Sie in der zurückliegenden Folge [1] gesehen. Jetzt geht es darum, wie Anwendungsprogramme implementiert werden und wie diese mithilfe sogenannter System Calls mit dem Kernel zusammenarbeiten.Wie bereits in einer der vorangegangen Folgen erwähnt, bietet eine x64-CPU unterschiedliche Ringe an, in denen CPU-Instruktionen ausgeführt werden können. Klassischerweise bleibt hierbei der Ring 0 alleine für den Kernel reserviert und der Ring 3 für alle Anwendungsprogramme. Die Ringe 1 und 2 werden von modernen Betriebssystemen nicht benutzt. Bild 1 veranschaulicht diese Unterteilung.

Die vier Ringe: In Ring 0 läuft der Kernel, in Ring 3 die Anwendungen (Bild 1)
Autor
Anwendungsprogramme, die im Ring 3 ausgeführt werden, können allerdings wichtige CPU-Befehle (etwa in, out) nicht ausführen, da deren Ausführung nur im Ring 0 erlaubt ist. Auch der direkte Zugriff auf den Hauptspeicher ist tabu für Code, der im Ring 3 ausgeführt wird, da dieses Recht für Ring-0-Code reserviert ist. Daraus folgt, dass ein Anwendungsprogramm eine Kernel-Funktion wie etwa printf() nicht direkt aufrufen kann. Die CPU verbietet dies und löst bei einem Versuch eine Exception aus. Listing 1 zeigt eine einfache Anwendung dazu. Darin wird die Funktion printf() in einer Endlosschleife aufgerufen, welche die übergebene Zeichenkette auf dem Bildschirm ausgeben soll. Es handelt sich dabei jedoch nicht um die Kernel-Funktion printf(), die bereits in einer der ersten Folgen dieser Serie implementiert wurde. Das hat Folgen: Einerseits hat das Anwendungsprogramm überhaupt keinen Zugriff auf die Kernel-Funktion, da sich die Implementierung in einer anderen Binärdatei befindet. Und auf der anderen Seite hat die Anwendung im User Mode keinen Zugriff auf das Video-Memory und kann zudem die Assembly-Befehle in/out, durch die der Cursor auf dem Bildschirm bewegt wird, nicht ausführen.
Listing 1: Eine einfache Anwendung
<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">ProgramMain</span><span class="hljs-params">()</span></span><br/>{<br/> <span class="hljs-keyword">while</span> (<span class="hljs-number">1</span> == <span class="hljs-number">1</span>)<br/> {<br/> <span class="hljs-built_in">printf</span>(<span class="hljs-string">"Hello World from User Mode </span><br/><span class="hljs-string"> Program #1...\n"</span>);<br/> }<br/>}
Es stellt sich also die Frage, wie es ein Anwendungsprogramm schafft, überhaupt eine sinnvolle Aufgabe zu erledigen, wenn es nicht auf die Kernel-Funktionalitäten zugreifen kann. Die Antwort darauf sind die sogenannten System Calls.Ein Betriebssystem-Kernel stellt Anwendungen die benötigten Funktionalitäten in Form einer standardisierten Schnittstelle zur Verfügung.Die printf()-Funktion aus Listing 1 ist nichts anderes als ein Wrapper über eine System-Call-Funktion, die einen übergebenen String auf dem Bildschirm ausgibt. Der System Call wird vom Kernel entgegengenommen, und dieser führt dann intern die Kernel-Funktion printf() aus. Beim Aufruf eines System Calls werden die folgenden Schritte durchgeführt:
- Wechsel vom User Mode in Ring 3 in den Kernel Mode (Ring 0).
- Aufruf der entsprechenden Funktion im Kernel.
- Wechsel vom Kernel Mode (Ring 0) zurück in den User Mode (Ring 3).
Listing 3: System Calls verarbeiten
typedef struct <span class="hljs-type">SysCallRegisters</span><br/>{<br/> // <span class="hljs-type">Parameter</span> values<br/> unsigned long <span class="hljs-type">RDI</span>;<br/> unsigned long <span class="hljs-type">RSI</span>;<br/> unsigned long <span class="hljs-type">RDX</span>;<br/> unsigned long <span class="hljs-type">RCX</span>;<br/> unsigned long <span class="hljs-type">R8</span>;<br/> unsigned long <span class="hljs-type">R9</span>;<br/>} <span class="hljs-type">SysCallRegisters</span>;<br/>long <span class="hljs-type">SysCallHandlerC</span>(<br/> <span class="hljs-type">SysCallRegisters</span> *<span class="hljs-type">Registers</span>)<br/>{<br/> // <span class="hljs-type">The</span> <span class="hljs-type">SysCall</span> <span class="hljs-type">Number</span> is stored in the<br/> // register <span class="hljs-type">RDI</span><br/> int sysCallNumber = <span class="hljs-type">Registers</span>-&gt;<span class="hljs-type">RDI</span>;<br/> // printf<br/> if (sysCallNumber == <span class="hljs-type">SYSCALL_PRINTF</span>)<br/> {<br/> printf((char *)<span class="hljs-type">Registers</span>-&gt;<span class="hljs-type">RSI</span>);<br/> return 0;<br/> }<br/> // <span class="hljs-type">GetPID</span><br/> else if (sysCallNumber == <span class="hljs-type">SYSCALL_GETPID</span>)<br/> {<br/> <span class="hljs-type">Task</span> *state = (<span class="hljs-type">Task</span> *)<span class="hljs-type">GetTaskState</span>();<br/> return state-&gt;<span class="hljs-type">PID</span>;<br/> }<br/> // <span class="hljs-type">TerminateProcess</span><br/> else if (sysCallNumber == <br/> <span class="hljs-type">SYSCALL_TERMINATE_PROCESS</span>)<br/> {<br/> <span class="hljs-type">Task</span> *state = (<span class="hljs-type">Task</span> *)<span class="hljs-type">GetTaskState</span>();<br/> <span class="hljs-type">TerminateTask</span>(state-&gt;<span class="hljs-type">PID</span>);<br/> return 0;<br/> }<br/> return 0;<br/>}
Bei der Funktion SysCallHandlerAsm handelt es sich um eine Assembler-Funktion, die alle aktuellen Registerinhalte auf dem Stack zwischenspeichert, danach die C-Funktion SysCallHandlerC() aufruft und anschließend die Registerinhalte vom Stack wiederherstellt. In Listing 3 sehen Sie die Implementierung dieser C-Funktion, welche die verschiedenen System Calls abarbeitet.
Listing 2: IDT-Erweiterung für System Calls
<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">InitIdt</span><span class="hljs-params">()</span></span><br/>{<br/> <span class="hljs-comment">// ...</span><br/><span class="hljs-comment"> // The INT 0x80 can be raised from Ring 3</span><br/><span class="hljs-comment"> IdtSetGate(128, (unsigned long)SysCallHandlerAsm, </span><br/><span class="hljs-comment"> 0xE);</span><br/><span class="hljs-comment"> idtEntries[128].DPL = 3;</span><br/><span class="hljs-comment"> // Loads the IDT table into the processor </span><br/><span class="hljs-comment"> // register (Assembler function)</span><br/><span class="hljs-comment"> IdtFlush((unsigned long)&amp;idtPointer);</span><br/><span class="hljs-comment">}</span>
Wie Sie im Listing erkennen können, wird der Funktion SysCallHandlerC eine Referenz auf die Struktur SysCallRegisters übergeben, welche die sechs möglichen Parameter eines System Calls beinhaltet. Mehr als sechs Parameter werden aktuell nicht unterstützt. Hierbei wird die x64-Calling-Convention von Linux verwendet, welche die ersten sechs Parameter in den Registern RDI, RSI, RDX, RCX, R8 und R9 übergibt. Weitere Informationen zu dieser Calling Convention finden Sie unter [3].Die Nummer des System Calls wird immer als erster Parameter im Register RDI übergeben, auf dessen Basis anschließend die entsprechende Funktion im Kernel aufgerufen wird. Der Kernel-Funktion werden zusätzlich noch die erforderlichen Parameter aus den passenden Registern übergeben. Im Fall der Funktion printf() ist der darzustellende String als Zeiger im Register RSI enthalten.
System Calls: User-Mode-Implementierung
Nachdem Sie gesehen haben, wie der Kernel in der Lage ist System Calls abzuarbeiten, wird es nun darum gehen, wie eine Anwendung einen System Call durchführen kann.Entwickeln Sie Anwendungsprogramme für Betriebssysteme wie Windows oder Linux, benötigen Sie immer eine Referenz auf die sogenannte C Runtime Library. Diese Laufzeitbibliothek bietet eine Vielzahl unterschiedlicher, standardisierter Funktionen an, mit denen Sie schlussendlich Ihre Anwendungen implementieren können.Im Hintergrund verwendet die C Runtime Library immer System Calls, um ihre Funktionalitäten bereitstellen zu können. Klassische Beispiele dafür sind open(), read(), write(), malloc(), printf() und so weiter.Anschließend werden nun die ersten Funktionen implementiert, die System Calls im Kernel durchführen. Die Runtime Library wird statisch mit den Anwendungsprogrammen verlinkt, das heißt, dass jede Binärdatei eines Anwendungsprogramms den kompletten Code der Runtime Library enthält.Dynamische Runtime Libraries (wie DLL-Dateien unter Windows und Shared Object Files unter Linux) sind für den Anfang zu komplex, da hier zusätzlich ein sogenannter Dynamic Loader innerhalb des Betriebssystems benötigt werden würde.Im ersten Schritt möchte ich Ihnen deshalb zeigen, wie der System Call printf() funktioniert. In Listing 1 haben Sie bereits ein Anwendungsprogramm gesehen, das die Funktion printf() aufruft – diese ist aber Bestandteil der Laufzeitbibliothek (Runtime Library) und nicht die printf()-Funktion des Kernels, die Sie bereits kennen. Listing 4 zeigt die Definition dieser Runtime-Library-Funktion.Listing 4: printf() innerhalb der Runtime Library
#define SYSCALL_PRINTF <span class="hljs-number">1</span><br/><span class="hljs-keyword">void</span> printf(<span class="hljs-built_in">char</span> *<span class="hljs-built_in">string</span>)<br/>{<br/> SYSCALL1(SYSCALL_PRINTF, <span class="hljs-built_in">string</span>);<br/>}<br/><span class="hljs-built_in">long</span> SYSCALL1(<br/> <span class="hljs-keyword">int</span> SysCallNumber,<br/> <span class="hljs-keyword">void</span> *Parameter1)<br/>{<br/> <span class="hljs-keyword">return</span> SYSCALLASM1(<br/> SysCallNumber,<br/> Parameter1);<br/>}<br/>; Raises a SysCall<br/>SYSCALLASM1:<br/> INT <span class="hljs-number">0</span>x80<br/> RET
Wie Sie im Listing sehen, ruft printf() einfach die Funktion SYSCALL1() auf, deren Aufgabe es ist, einen System Call im Kernel auszuführen, der einen Parameter erwartet – im Fall von printf() ist das der Zeiger auf den String, der auf dem Bildschirm ausgegeben werden soll. Der Funktion SYSCALL1() wird im ersten Parameter die Nummer des System Calls übergeben – in diesem Fall ist das die Konstante SYSCALL_PRINTF, die den Wert 1 enthält.1Innerhalb von SYSCALL1() wird nun die Assembly-Funktion SYSCALLASM1 aufgerufen, die schlussendlich den Interrupt 0x80 auslöst. Die entsprechenden Parameter werden durch den C-Compiler anhand der x64-Calling-Convention in die Register RDI, RSI, RDX,RCX,R8 und R9 kopiert. Damit kann der System-Call-Handler innerhalb des Kernels die Parameter an die entsprechende Funktion im Kernel weiterreichen. Neben dem System Call printf() habe ich auch noch die beiden System Calls GetPID() und TerminateProcess() implementiert. Über GetPID() kann man die Process-ID des aktuell laufenden Prozesses ermitteln. Möchten Sie einen laufenden Prozess beenden, erledigt das die Funktion TerminateProcess() für Sie. Listing 5 zeigt, wie Sie die beiden Funktionen in einer Anwendung einsetzen können.
Listing 5: Weitere System Calls in Aktion
<span class="hljs-function"><span class="hljs-keyword">void</span> <span class="hljs-title">ProgramMain</span><span class="hljs-params">()</span></span><br/>{<br/> <span class="hljs-keyword">long</span> pid = GetPID();<br/> <span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = <span class="hljs-number">0</span>; i &lt; <span class="hljs-number">100</span>; i++)<br/> {<br/> <span class="hljs-built_in">printf</span>(<span class="hljs-string">"Hello World from User Mode </span><br/><span class="hljs-string"> Program #2 with PID "</span>);<br/> printf_long(pid, <span class="hljs-number">10</span>);<br/> <span class="hljs-built_in">printf</span>(<span class="hljs-string">"\n"</span>);<br/> }<br/> TerminateProcess();<br/>}
Fazit
Im Rahmen dieser und der vorangegangenen Folge [1] der Serie wurde gezeigt, wie Sie Anwendungsprogramme von der Festplatte laden und in einem eigenen virtuellen Adressraum ausführen. Da Anwendungsprogramme immer im Ring 3 der CPU ausgeführt werden, wurden System-Call-Funktionalitäten im Kernel implementiert, der immer im Ring 0 läuft.Aufbauend auf die System Calls wurden auch die ersten Schritte in Richtung einer eigenen Laufzeitbibliothek unternommen, welche die Entwicklung von Anwendungsprogrammen um einiges einfacher macht. Im kommenden Teil der Serie soll eine einfache Command Shell implementiert werden, mit deren Hilfe Anwendungen von der FAT12-Partition ausgeführt werden können.Fussnoten
- Klaus Aschenbrenner, Anwendungsprogramme, dotnetpro 11/2023, Seite 82 ff., http://www.dotnetpro.de/A2311DIYOS
- Difference between DPL and RPL in x86, http://www.dotnetpro.de/SL2312DIYOS1
- x64 Calling Convention, http://www.dotnetpro.de/SL2312DIYOS2