Aufgabe 2: Unterbrechungen
Normalerweise arbeitet ein System nicht einfach nur vor sich hin, sondern reagiert auf Ereignisse, beispielsweise von der Peripherie. Die Erkennung von solchen Ereignissen kann entweder durch regelmäßiges Abfragen in unserer Software – sogenanntes Polling – geschehen. Oder wir nutzen Unterbrechungen: Über eine spezielle Interruptleitung sorgt ein externes Ereignis dafür, dass die reguläre Abarbeitung in unserem Prozessor unterbrochen und eine spezielle Behandlungsroutine ausgeführt wird. Im Folgenden beleuchten wir dazu Unterbrechungen auf der x86 Architektur und wie deren Behandlung in StuBS umgesetzt wird.
Unsere Anwendung wird beispielsweise abgearbeitet, und sobald ein Unterbrechungsereignis eintritt wird der Zustand gesichert und in die Unterbrechungsbehandlungsroutine gewechselt. Nach der Abarbeitung wird der vorherige Zustand wiederhergestellt und die Abarbeitung der Anwendung transparent fortgesetzt.
Aber was ist der Zustand der Anwendung, welcher bei einer Unterbrechung auf jeden Fall gesichert werden muss? Mindestens natürlich die aktuelle Position des Instruktionszeigers, zu den danach wieder zurückgekehrt werden muss. Diesen sichert die CPU vor dem Einsprung in die Behandlungsfunktion automatisch auf dem Stack zusammen mit dem Statusregister flags
und – historisch bedingt – dem Code Segment (cs
) Register
Um nun aus der Behandlungsfunktion wieder zurückzukehren, muss die Instruktion iret
(bzw. bei 64 bit iretq
) aufgerufen werden, welche die Werte vom Stack wieder in die Register liest, und somit an der vorherigen Position des Instruktionszeigers fortsetzt. Diese Rückkehrinstruktion verwenden wir auch am Ende unserer Unterbrechungsbehandlung, welche wir somit in Assembler schreiben. Da wir jedoch aus guten Gründen eine Hochsprache bevorzugen, wollen wir den Assemblerteil nur so kurz wie möglich halten, und fügen dort einen Aufruf zu der Hochsprachenfunktion interrupt_handler
ein, welche wir im Falle von StuBS in C++ implementieren.
interrupt_entry
ist somit nur der Einsprungspunkt zu der eigentlichen Unterbrechungsbehandlung. Leider funktioniert der Code nun beim Übersetzen noch nicht so wie gewünscht, es wird stattdessen eine fehlende Referenz mit dem Funktionsnamen gemeldet. Wenn wir uns die übersetzte C++ Datei, in der wir diese interrupt_handler
Funktion implementiert haben, genauer anschauen, erkennen wir, das dort nur ein seltsames Symbol namens _Z17interrupt_handlerv
existiert. Wieso? Nun, in C++ kann es durch Funktionsüberladung vorkommen, dass es mehrere Funktionen mit demselben Namen aber unterschiedlichen Parametern gibt. Um dieses Problem zu adressieren, gibt es das Name Mangling, in welchem eben der Parameter mit "verwurschtet" wird, in unserem Fall ist es das v
am Ende, dass für void
– keine Parameter – steht. Die 17
am Anfang steht übrigens für die Länge des Funktionsnamens, entsprechend können hier auch Namespaces mit einbezogen werden. Da die genaue Umwandlung nicht im C++ Standard spezifiziert wurde und compilerspezifisch ist (auch wenn sich inzwischen ein defacto Standard entwickelt hat), können wir mit dem Schlüsselword extern "C"
eine Verknüpfung als C Symbol erzwingen, wodurch nun der Symbolname unseren Erwartungen entspricht und von unserer Assemblerroutine aufgerufen werden kann.
Das bedeutet, unsere Behandlungsroutine funktioniert – so lange unsere Anwendung keine Register verwendet. Von der CPU werden nur der Instruktionszeiger rip
und das Statusregister rflags
gesichert, aber unsere Anwendung verwendet sehr wahrscheinlich General Purpose Register wie rax
und rbx
. Um die Sicherung müssen wir uns entweder im Assemblercode selbst kümmern, oder aber auf den Übersetzer setzen.
Und da gilt es sich generell zu einigen, wie beim Aufruf von Funktionen die Register gesichert werden sollen. Prinzipiell hat man mehrere Möglichkeiten:
- Die aufrufende Funktion sichert alle Register, deren Inhalt sie im Anschluss noch benötigt.
- Die aufgerufene Funktion sichert alle Register, welche sie nachfolgend verändern wird. Oder
- Ein Hybrid aus beiden Varianten, ein Teil vom Aufrufer, der anderer Teil von der aufgerufenen Funktion – was gewisse Optimierungen beim Übersetzer ermöglicht.
Und dies ist auch was in der Praxis verwendet wird. Die Aufrufkonvention regelt dabei, welche Register von wem gesichert werden soll, für uns ist dabei die in der System V ABI (Application Binary Interface) für 64 bit beschriebene Version relevant.
Diese definiert die Aufteilung zum einen in flüchtige Register, auch bekannt als scratch oder caller-save Register, welche vom Aufrufer gesichert werden. Somit darf eine aufgerufene Funktion einfach die Werte von rax
und rcx
ändern, entsprechend werden diese Register natürlich vom Übersetzer in einfachen Funktionen bevorzugt verwendet. Bei den nicht-flüchtigen Register, non-scratch oder callee-save, muss hingegen die aufgerufene Funktion dafür Sorge tragen, dass diese am Ende wieder den korrekten Inhalt haben. Der Übersetzer wird diese nur bei großem Bedarf verwenden, und erstellt dann Code zum Sichern und Wiederherstellen.
Eine Besonderheit trifft unseren Einsprung zur Unterbrechungsbehandlung: Da der Aufruf zur Unterbrechungsbehandlung nicht vom Übersetzer generiert wird, werden auch die flüchtigen Register zuvor nicht gesichert – dass muss im besonderen Fall der Unterbrechungsbehandlung manuell gemacht werden! In unserem Fall müssen wir diese Sicherung dann in der interrupt_entry
implementieren: Vor dem Aufruf werden alle flüchtigen Register auf den Stack gesichert, nach dem Aufruf der Hochsprachenfunktion werden sie wiederhergestellt, bevor der Unterbrechungsbehandlung endet.
Für die Sicherung der nicht-flüchtigen Register muss von uns nichts weiter beachtet werden, sie wird in der Hochsprachenfunktion interrupt_handler
bei Bedarf durch den Übersetzer erledigt. Nun mag es manchmal hilfreich sein, die Inhalte der Register zu Debugzwecken lesen zu können. Da wir in Assembler das einfach auf den Stack legen, können wir eine Struktur erstellen, die exakt dieses Format hat – aufgrund der Wachstumsrichtung des Stacks (nach unten, in Richtung niedrigere Adressen) in umgekehrter Reihenfolge. Dies übergeben wir als Zeiger der Unterbrechungsbehandlung.
Aber wie funktioniert die Parameterübergabe eigentlich? Prinzipiell haben wir wieder mehrere Möglichkeiten Parameter können über den Stack übergeben werden, was auch bei x86 mit 32 bit üblich war. Alternativ können aber auch direkt Register verwendet werden, es wird sich pro Parameter ein push
und pop
auf den langsameren Speicher gespart, allerdings ist die Anzahl der Register deutlich begrenzt. Entsprechend bietet sich wieder eine hybride Lösung an, welche die ersten Parameter direkt in Register übergibt, und bei Bedarf dann die weiteren Parameter auf dem Stack. Dies wird auch gemäß der Konvention von System V ABI gemacht, sechs Parameter werden in General Purpose Registern übergeben. Außerdem können im Userspace noch die SSE Register verwendet werden, was aber in Betriebssystemkernen meist abgeschaltet ist. Und bei Bedarf wird eben der Stack verwendet, dabei werden die Parameter in umgekehrter Reihenfolge gepush
ed.
Mit diesem Wissen können wir nun auch die Parameterübergabe implementieren, da das Format der Struktur identisch mit dem Stack ist, reicht die Adresse des Stackzeigers nach dem push von r11
. Diesen kopieren wir in das Register von dem ersten Parameter, rdi
. In interrupt_handler
beinhaltet nun der Parameter die Adresse auf den gesicherten Zustand von r11
. Direkt dahinter liegen dann die anderen gesicherten Registerzustände, bis rcx
und rax
, jeweils im Abstand von 8 Bytes. Aber da die CPU auch selbst den am Anfang erwähnten minimalen Zustand auf dem Stack speichert, und wir zuvor keine Stackoperationen in unserer Routine machen – und in Assembler auch keinen Übersetzer haben, der hier etwas automatisch generieren könnte – wissen wir, das direkt hinter rax
der Instruktionszeiger rip
liegt. Somit können wir über unsere Struktur in der Behandlungsfunktion auch auf die von der CPU gesicherten Felder zugreifen.
Wir wissen nun wie wir zu unserer Unterbrechungsbehandlung kommen. Wenn nun jedoch verschiedene Geräte eine Unterbrechung auslösen können, wäre es hilfreich zu erfahren, wer diese ausgelöst hat. Auf unserer Architektur haben wir dafür einen 8 bit Vektor, können also zwischen 256 unterschiedlichen Arten von Unterbrechungen unterscheiden.
Die ersten 32 Vektoren sind für Traps reserviert, also synchrone Unterbrechungen. In dieser Veranstaltung werdet ihr vermutlich oft mit Vektor 13
(General Protection Fault) oder Vektor 6
(Invalid Opcode) in Berührung kommen, und zwar immer dann, wenn ihr einen fehlerhaften Speicherzugriff macht oder an einer falschen Stelle euer System fortsetzt. In der Nachfolgeveranstaltung Betriebssystemtechnik spielt dann der Page Fault eine größere Rolle. Zur Unterstützung wird bei manchen dieser Traps noch ein Fehlerwert auf dem Stack gepushed, Details verrät das Intel Handbuch.
Dabei sind nicht alle Trap Vektoren belegt, sondern zum Teil einfach für die Zukunft reserviert. Als Betriebssystementwickler verwenden wir jedoch für eigene Unterbrechungen nur die Vektoren ab 32
. Diese können wir entweder durch die Instruktion int
gefolgt von der Vektornummer in Software auslösen, oder eben durch entsprechend konfigurierte Hardware. Mit den Befehlen cli
und sti
haben wir zusätzlich die Möglichkeit, die Behandlung von Unterbrechungen temporär zu unterbinden und wieder zu erlauben. Dabei wird im Statusregister flags
ein entsprechendes Bit gesetzt.
Dies wird übrigens auch von der CPU entsprechend gesetzt, sodass beim Betreten unserer Unterbrechungsbehandlung Interrupts deaktiviert sind – und durch iretq
wieder aktiviert werden. Um nun unterschiedliche Geräte behandeln zu können, beispielsweise die Tastatur und den Timer, fügen wir den entsprechenden Code in unserer Hochsprachenfunktion ein, hier mittels switch
-case
-Anweisung. Diese unterscheidet anhand der Vektornummer das Gerät. Aber woher bekommen wir die Vektornummer? Dazu schreiben wir die hier dargestellte Einsprungsfunktion nun beispielsweise exklusiv nur für Vektor 6
, analog dazu erstellen wir 255 weitere Einsprungsfunktionen, eine für jeden Vektor. Da wir die Vektornummer für diese Einsprungsfunktion nun kennen, können wir diesen als zweiten Parameter übergeben, in rsi
. Und schon hat die Hochsprachenfunktion je nach Einsprungsfunktion die entsprechende Vektornummer.
Nächster Schritt: Übersetzen, assemblieren zu Objektdateien – und statisch Binden. Beim statischen Binden werden die Symbole zu Adressen aufgelöst. Dabei wird die Funktion interrupt_handler
aus der Objektdatei der Hochsprachenimplementierung im Binärabbild platziert, also auch die Speicheradresse welche sie bei der Ausführung des Kernels haben wird. In diesem Fall liegt die Funktion beispielsweise an 0x100 70f0
, also knapp über den ersten 16 Megabyte im Arbeitsspeicher.
Alle Aufrufe zu diesem Funktionssymbol – wie im call
aus der in Assembler geschriebenen Einsprungsfunktion – werden durch diese Adresse ersetzt. Schauen wir uns das am Beispiel von interrupt_entry_6
an, hier der zu den Instruktionen dazugehörige Maschinencode. Der Binder legt nun die Speicheradresse fest, die erste Instruktion, push rax
hat den Maschinencode 0x50
und ist der Beginn der Funktion – und damit auch die Adresse der Funktion.
Übrigens ist hier schön zu sehen, wie bei 0x100 0240
der Wert 06
– unsere Vektornummer – zu erkennen ist. Der Maschinencode 0xbe
steht für "Schiebe eine 32 bit Konstante in das Register `rsi`". Und da die x86 Architektur little Endian ist, also mit der kleinstwertigen Stelle beginnt, stehen dort nachfolgend die vier Bytes in der Reihenfolge 06 00 00 00
.
In der nachfolgenden Instruktion, unserem call
, sehen wir nicht wirklich die Adresse des Interrupt Handlers. Der Code e8
steht für einen relativen Sprung, um 0x6e a6
, berechnet ab dem nun gültigen Instruktionszeiger. Daraus ergibt sich unsere Zieladresse. Dies ist nun natürlich keineswegs schön zu lesen, und abgesehen von einem gewissen Linus Torvalds, der in seinen Anfangsjahren Assembler missverstanden hat und direkt diesen Maschinencode schrieb, bewegen wir uns eher nicht auf dieser Ebene.
Für uns ist die Assemblerebene eigentlich ausreichend. In StuBS verwenden wir dafür den Netwide Assembler, welcher noch einen Makro-Präprozessor mitbringt, also eine textuelle Vorverarbeitung bevor der eigentliche Assemblierungsschritt ausgeführt wird. Die Idee für die Einsprungsfunktion war, für jeden Vektor eine eigene interrupt_entry
anzubieten. Und da bietet sich nun der Präprozessor an, wir erstellen ein Makro-IRQ, welches einen Parameter bekommt, und zwar die Vektornummer. Dieser ist über Makrovariable %1
verfügbar und wird nun an den Funktionsnamen geklebt sowie als Konstante für die mov rsi
Instruktion. Durch dieses Makro können nun einfach mittels Schleife die 256 Einsprungsfunktionen erstellt werden, welche alle im darauf folgenden Schritt wie vorhin gezeigt in Maschinencode übersetzt und beim Binden mit einer Speicheradresse versehen werden. Somit haben wir nun unsere 256 Einsprungspunkte zur Unterbrechungsbehandlung.
Aber woher weiß die CPU, dass sie bei Vektor 6
die Adresse 0x100 0230
anspringen soll? Bei unserer Zielarchitektur gibt es dafür eine Struktur, den Interrupt Deskriptor, dieser definiert die Einsprungadresse, historisch bedingt – dank anfangs 16 bit – aufgeteilt in Offset low und high. Weitere Bits werden für die Konfiguration verwendet, beispielsweise ob der Eintrag überhaupt aktiv ist, oder den Typ: Das Task Gate steht für einen Hardware-Taskswitch, welcher heutzutage keinerlei Relevanz mehr hat – die Überbleibsel wie das TSS
(Task State Segment) aber noch sichtbar sind. Das Interrupt Gate hingegen ist wichtig für uns: Es sorgt dafür, dass beim Ausführen der Einsprungsfunktion Interrupts gesperrt sind, und automatisch nach iretq
wieder aktiv werden. Im Gegensatz zum Trap Gate, bei dem die Interrupts immer aktiv bleiben.
Wie müsste das nun bei unserer Einsprungsfunktion für Vektor 6
aussehen? Wir tragen die Adresse ein, aufgeteilt in die beiden Felder. Der Eintrag wird auf aktiv geschaltet, als Mode nehmen wir 64 bit und den Typ stellen wir immer auf Interrupt Gate, was es für uns etwas einfacher macht. Als Selektor, ein weiteres historisches Überbleibsel, wird das Kernel Codesegment gewählt, bei uns einfach die 8
. Daraus ergibt sich der Deskriptoreintrag.
Diesen erstellen wir nun auf die gleiche Weise für alle 256 Interrupts und legen es hintereinander in den Speicher, also in ein Array, unsere Interrupt Deskriptortabelle (IDT
), im Beispiel startend bei 0x101 e1c0
. Die eigentliche Einsprungsfunktion liegt natürlich auch weiterhin im Speicher. Auf dem x86 gibt es nun das idtr
Register, welches sowohl Startadresse der Tabelle als auch die Länge speichert, beides ist uns bekannt. Das Register kann mit der Instruktion lidt
geschrieben und mittels sidt
ausgelesen werden.
Beim Start des Betriebssystems schreiben wir dort entsprechend nach dem Erstellen der Deskriptortabelle die Adresse und Länge in das Register, in StuBS ist das in der idt.cc im Ordner machine implementiert. Denn mit diesem Register ermittelt nun die CPU bei einem Interrupt den Einsprungspunkt. Wenn beispielsweise Interrupt 6
eintritt, wird mithilfe der Basis und Vektornummer die Adresse des Eintrags in der Tabelle berechnet. Sofern diese innerhalb des Limits liegt, wird der Eintrag ausgewertet und die darin enthaltene Adresse der Einsprungsfunktion ausgelesen und angesprungen. Und diese führt nun unsere in der Hochsprache geschriebene Unterbrechungsbehandlung aus.