StuBS
Externe Interrupts in modernen PCs

Was ist ein Interrupt?

Programme auf Prozessoren laufen sequentiell ab. Ohne Interrupts wäre es notwendig, Geräte zu pollen, d.h. häufig nachzufragen, ob es neue Nachrichten gibt, bspw. neue Tastendrücke. Während das auf einem sehr einfachen System funktionieren kann, wird das unpraktisch bei sehr vielen Geräten, weil der Prozessor dann sehr viel Arbeitszeit darauf verwenden würde, um sinnlos nach Neuigkeiten zu fragen. Daher brauchen wir eine Möglichkeit, den Programmfluss zu unterbrechen, wenn ein Gerät Neuigkeiten oder einen Ausnahmezustand hat.

Die Möglichkeit ist durch Interrupts gegeben. Interrupts werden grundsätzlich durch den Prozessor ermöglicht. Dieser arbeitet im Normalfall so, dass er, vereinfacht gesehen, eine Instruktion lädt (Fetch), diese dekodiert (Decode) und ausführt (Execute). Als vierter Schritt wird jetzt zusätzlich geprüft, ob auf einer speziellen Leitung – der Interrupt-Leitung – ein Bit anliegt. Ist dies der Fall (2), springt der Prozessor an eine vorher definierte Stelle im Programmcode (3), wo die Interrupt-Service-Routine (ISR) zu finden ist. Die ISR löst den Ausnahmezustand der Hardware auf und kehrt dann in den Ursprungscode zurück ((4), bei x86 mit der Instruktion iret).

Genereller Ablauf einer Unterbrechung

Unterbrechungen können maskiert (d.h. ausgeschaltet) werden. In diesem Fall findet die oben beschriebene Überprüfung des Prozessors nicht statt und der Zustand der Interrupt-Leitung wird ignoriert. Interrupts werden allerdings zu einem gewissen Grad vom Interrupt-Controller zwischengespeichert werden. Die Maskierung wird durch das Interrupt-Enable-Flag (IE) im EFLAGS-Register (dem Statusregister auf x86) realisiert. Die Instruktion cli kann verwendet werden, um das IE-Flag auf Null zu setzen (maskiert) oder sti, um es wieder zu setzen (demaskiert). Alternativ kann am I/O-APIC, einem vorgeschalteten Interrupt-Controller, die entsprechende Leitung für einzelne Geräte deaktiviert werden, sodass keine Interrupts von diesen Geräten mehr durchkommen.

Man unterscheidet Interrupts (die von Geräten ausgelöst werden) und Exceptions (die als Ausnahmezustand von der CPU selbst ausgelöst werden). Letztere werden bspw. bei einer Division durch Null oder Page Faults ausgelöst und sind immer CPU-lokal. Von der CPU werden beide Arten, da sie den normalen Kontrollfluss unterbrechen, auf die gleiche Art und Weise gehandhabt, sind aber konzeptuell zu unterscheiden. Wir beschäftigen uns in BSB lediglich mit den (CPU-externen) Interrupts.

Prozessorsicht

Ist das IE-Flag gesetzt und ein Interrupt kommt, speichert der Prozessor einige Informationen auf den aktuellen Stack und wechselt in die Interrupt-Service Routine.

Um zu bestimmen, welche Interrupt-Service-Routine ausgeführt werden soll, sind Interrupts mit einer Nummer versehen, dem Interrupt-Vektor. Diese wird typischerweise vom Interrupt-Controller gesetzt. Der Vektor wird von der CPU verwendet, um einen Eintrag in einer speziellen Tabelle, der Interrupt-Descriptor-Table (IDT), auszuwählen. Der Prozessor wertet den Eintrag aus und bestimmt daraus die anzuspringende Code-Adresse. Nun wird die Rücksprungadresse in den normalen Kontrollfluss und einige Register (Zustandsinformationen, Threadkontext) auf dem Stack gespeichert und die ISR angesprungen.

Eine solche Routine kann nun weitere Register speichern, wird den Interrupt behandeln und beim Interrupt-Controller quittieren und letztlich mittels iret zurückkehren. Bei diesem Rücksprung lädt der Prozessor automatisch die vorher automatisch gespeicherten Werte vom Stack und kehrt somit in seinen alten Zustand zurück.

Sowohl die IDT als auch die ISRs müssen vom Betriebssystementwickler bereitgestellt werden. Wir haben das für StuBS schon für euch getan.

Dort leiten wir alle Einträge der IDT zu einer Wrapper-Funktion, die die Registerwerte, die nicht automatisch vom Prozessor gesichert werden, auf dem Stack speichert und die Vektornummer der Funktion interrupt_handler() übergibt (für Interessierte: den Code dazu findet ihr in der interrupt/handler.asm, interrupt_entry_%d). Der guardian() soll dann anhand der Vektornummer die passende Routine auswählen, um die Unterbrechung zu behandeln.

Interrupt Descriptor Table (IDT)

In Betriebssystembau ist die IDT bereits richtig vorinitialisiert und muss nicht editiert werden.

Im Protected-Mode (32 Bit) einer x86-CPU sind die Einträge der Interrupt-Descriptor-Table 8 Byte lang. Jeder dieser Einträge wird Gate genannt (Achtung: Diese Gates sind Teil von Intels interner Nomenklatur und haben nichts mit der Klasse Gate von StuBS zu tun).

Gates regeln die Art der Umschaltung zwischen Kontrollflüssen, die allerdings für verschiedene Anwendungsfälle subtil unterschiedlich sind. Intel-Prozessoren haben darum drei Typen von Gates:

  • das Task-Gate,
  • das Interrupt-Gate,
  • das Trap-Gate.

Der Typ des Gates ist mit 4 Bits innerhalb eines Gate-Eintrages kodiert.

Das Task-Gate wurde für eine Prozessumschaltung in der Hardware verwendet, spielt aber heutzutage keine Rolle mehr, weil Softwarelösungen für Taskumschaltung perfomanter ablaufen als die Umschaltung im Prozessor.

Die Interrupt- und die Trap-Gates verhalten sich effektiv identisch. Beide enthalten einen Zeiger auf eine Behandlungsroutine, in die beim Auftreten des entsprechenden Interrupts gesprungen wird. Außerdem enthält das Gate noch Informationen für das Intel-Rechtemanagement (siehe Intel-Ringe). In der Folge-Veranstaltung Betriebssystemtechnik wird eine Isolation zwischen dem Betriebssystem und seinen Prozessen mit den Intel-Ringen aufgebaut.

Das Trap-Gate führt dazu, dass die Interrupt-Service-Routine mit angeschalteten Interrupts läuft, wohingegen beim Interrupt-Gate beim Eintritt in die Unterbrechungsbehandlung die Interrupts im Prozessor maskiert sind. D.h. standardmäßig ist es möglich, dass Traps von anderen Interrupts (und Traps) verdrängt werden, wohingegen Interrupts nicht ohne weiteres von anderen Unterbrechungen verdrängt werden können.

In StuBS verwenden wir Interupt-Gates für die interruptrelevanten IDT-Einträge.

Ein Zeiger auf die IDT muss dem Prozessor bekannt gemacht werden, da sie an beliebiger Stelle im Speicher liegen kann. Das geschieht über eine eigene Instruktion lidt (load IDT), welche einen Zeiger als Parameter erhält. An dem Ziel des Zeigers wird eine 16-Bit-Länge (in Bytes), gefolgt von einem 32-Bit-Zeiger auf die IDT erwartet (ein sogenannter Fat-Pointer). Der Prozessor speichert sich diese Adresse im IDT-Register.

Wenn ein Interrupt auftritt, wird der gespeicherte Zeiger verwendet und mit dem Vektor ein Offset in die Tabelle gebildet. Der damit gefundene Eintrag in die IDT wird verwendet, um die Interrupt-Service-Routine zu finden und auszuführen.

Interrupt-Controller

Interrupts von mehreren (vorab in der Anzahl nicht zwangsläufig festgelegten) Geräten müssen irgendwie bei der CPU ankommen. Darum gibt es eine separate Hardware-Einheit, die Interrupts an die CPU zustellt: der Interupt-Controller.

Ursprüngliche IBM-PCs und Klonsysteme haben einen sehr einfachen Chip als Interrupt-Controller verwendet. Der PIC8259A (Programmable Interrupt Controller) hat lediglich 8 Eingänge, wodurch die Anzahl der verschieden Interrupt-Quellen sehr begrenzt war. Aktiviert eine der Quellen seine Leitung, wird die Ausgangsleitung an den Prozessor aktiviert, wodurch die CPU unterbrochen wird. Wenn mehr als 8 Eingänge benötigt wurden, konnte man die PIC8259A kaskadieren, d.h. mehrere hintereinanderschalten, aber auch das genügte nicht aus. Spätestens mit den Mehrkernsystemen wurden die PIC8259A von einer neuen Hardware abgelöst, dem APIC (Advanced Programmable Interrupt Controller).

Die APIC-Architektur besteht dabei aus mehreren Chips:

Schema der APIC Architektur auf Intel IA-32/AMD64
  • Jede CPU besitzt einen Local APIC (LAPIC), der Interruptanforderungen entgegennimmt und an den CPU-Kern weiterleitet. Über die LAPICs ist es in Multiprozessorsystem auch möglich, zwischen den CPUs Interprozessor-Interrupts (IPIs) zu versenden und so Signale an andere CPUs zu schicken.
  • Externe Interruptquellen wie z.B. Tastatur, Maus, Timer etc. sind an den I/O-APIC (IOAPIC) angeschlossen. Dieser besitzt dafür 24 Eingänge, die grundsätzlich gleichberechtigt sind. Er reagiert in FIFO-Reihenfolge auf mehrere externe Interrupts.

Alle APICs eines Systems (egal ob LAPIC oder I/O-APIC) kommunizieren je nach System mittels des Systembusses oder über einen dedizierten APIC-Bus. Für den*die Systemprogrammierer*in spielt das jedoch keine Rolle, da sich beide Implementierungen auf der Schnittstellen-Ebene identisch verhalten.

Der I/O-APIC besitzt eine Redirection-Table, bei der für jeden Eingangspin festgelegt werden kann, an welche Ziel-CPUs ein Interrupt zugestellt soll und welchen Vektor dieser haben soll. So können Geräten bestimmte Vektoren zugeordnet werden, auch wenn diese keine eigene Konfigurationsmöglichkeiten dafür haben. Weiter kann der Interrupt auf edge- oder leveltriggered gesetzt werden und einzeln im I/O-APIC ausmaskiert werden.

Wenn am I/O-APIC eine Interruptanforderung vorliegt, so wird anhand der Redirection-Table entschieden, an welche CPU der konfigurierte Vektor gesendet werden soll. Dieser wird dann durch den LAPIC der jeweiligen CPU an den eigentlichen Prozessorkern weitergeleitet. Dort wird die Unterbrechungsbehandlung gestartet.

Nach Durchführung der Behandlung muss das dem LAPIC quittiert werden. Das erfolgt durch ein Schreiben in ein spezielles Register des LAPICs (bei StuBS mit der Methode LAPIC::endOfInterrupt()). Erst wenn das passiert ist, wird er weitere Interruptanforderungen dieses Typs an die CPU zustellen.

Im Gegensatz zum I/O-APIC nimmt der LAPIC nun eine Priorisierung vor. Die Priorität eines Interrupts hängt dabei von seiner Vektornummer ab:

priority = vector / 16

Da die Vektoren 0 bis 31 reserviert sind, stehen die Prioritäten 2 bis 15 der Anwendung zur Verfügung. Die ersten 32 Vektoren (0 bis 31) sind für Ausnahmesituationen vergeben, welche lokal auf der CPU erzeugt werden. Dies kann bspw. eine Invalid Opcode oder eine Division by Zero-Ausnahme sein.

Register des I/O-APIC und Redirection-Table

Der LAPIC und auch der I/O-APIC wird über Register konfiguriert, die per memory-mapped-I/O angesprochen werden. Beim LAPIC sind alle internen Register in den physischen Adressraum des Prozessors eingeblendet. Die Register des I/O-APIC sind nicht direkt zugreifbar, sondern werden durch die beiden Register IOREGSEL (0xfec0 0000) und IOWIN (0xfec0 0010) ausgewählt (eine weitere Variante, um Hardware anzusprechen). In das Register IOREGSEL wird zunächst das gewünschte Register geschrieben, aus IOWIN kann dann der Wert ausgelesen werden bzw. geschrieben werden.

Registerdurchschaltung beim I/O APIC

Die Register des IO-APICs und ihre Beschreibung befindet sich in machine/ioapic_registers.h; für den Local APIC in machine/lapic_registers.h.

Redirection-Table

Die Redirection-Table hat 24 Einträge, die jeweils 64 Bit lang sind. Das Zugriffsregister IOWIN ist allerdings lediglich 32 Bit lang, sodass der Zugriff auf die Redirection-Table also in mehreren Schritten erfolgen muss. Die Bedeutung der Felder der Redirection Table im I/O-APIC kann in machine/ioapic_registers.h nachgelesen werden.

Die Initialisierung der LAPICs haben wir für euch schon erledigt. Ihr könnt also einfach die Klasse LAPIC verwenden, wenn ihr Funktionen der LAPICs benötigt. Die Zugriffsfunktionen für den I/O-APIC hingegen müssen von euch noch programmiert werden, indem ihr die Klasse IOAPIC vervollständigt.

Literatur