Ähnlich wie die Global Descriptor Table müssen wir auch noch die Interrupt Descriptor Table anlegen. Aufmerksame Programmierer erinnern sich, dass wir zuvor die Interrupts deaktiviert haben mittels cli. Bevor wir diese reaktivieren können, müssen wir zwei Dinge erledigen:

  1. Interrupt Descriptor Table anlegen und aktivieren
  2. PIC ummappen und aktivieren

In diesem Tutorial werden wir den ersten Teil hiervon erledigen und die IDT anlegen.

Die IDT ist ähnlich wie die GDT und zeigt der CPU, an welchen Stellen im Speicher der Code steht, welcher ausgeführt werden soll, wenn Interrupts auftreten. Interrupts werden entweder von der CPU im Fehlerfall ausgelöst, von Hardware aufgerufen oder können auch von Software aufgerufen werden.

Bei x86 gibt es einige Interrupts, die von der CPU-Architektur spezifiziert und reserviert sind. Die übrigen können von Betriebssystem beliebig verwendet werden. Die genaue Spezifikation kann man sich im Handbuch von Intel ansehen (zum Erscheinen des Artikels Vol. 3A 6-1), hier nur eine kurze Liste:

  • 0: Divide Error
  • 1: Debug Exception
  • 2: NMI Interrupt
  • 3: Breakpoint
  • 4: Overflow
  • 5: Bound Range Exceeded
  • 6: Invalid Opcode
  • 7: Device not available (no math coprocessor)
  • 8: Double Fault
  • 9: Coprocessor Segment Overrun
  • 10: Invalid TSS
  • 11: Segment not present
  • 12: Stack-Segment Fault
  • 13: General Protection
  • 14: Page Fault
  • 15: Intel reserved
  • 16: x87 FPU Floating-Point Error
  • 17: Alignment Check
  • 18: Machine Check
  • 19: SIMD Floating Point Exception
  • 20: Virtualization Exception
  • 21 - 31: Intel reserved
  • 32 - 255: user defined (können vom OS benutzt werden)

Wie bereits gesagt ist die IDT recht ähnlich zur GDT, ein einzelner Eintrag in der IDT sieht so aus:

struct idt_entry {
    uint16_t base_lower;
    uint16_t selector;
    uint8_t unused;
    uint8_t type_attributes;
    uint16_t base_higher;
} __attribute__((packed));

Der Selektor verweist auf einen Eintrag in der GDT, die Basis (bestehend aus base_higher und base_lower) auf den Offset in diesem Speicherbereich. type_attributes besteht aus vier Teilen:

  • Bit 47: Present Bit
  • Bit 46, 45: Descriptor Privilege Level
  • Bit 44: Storage Segment
  • Bit 43 - 40: Gate Type

Wie bei der GDT gibt es erneut eine Struktur, die auf die Tabelle selbst verweist und die wir der CPU bekanntmachen müssen:

struct idt_description {
    uint16_t limit;
    uint32_t base;
} __attribute__((packed));

Die gesamte Datei idt.h sieht ziemlich analog zur gdt.h aus:

#ifndef IDT_H
#define IDT_H

#include <stdint.h>

#define IDT_ENTRIES 256

struct idt_entry {
    uint16_t base_lower;
    uint16_t selector;
    uint8_t unused;
    uint8_t type_attributes;
    uint16_t base_higher;
} __attribute__((packed));

struct idt_description {
    uint16_t limit;
    uint32_t base;
} __attribute__((packed));

struct idt_description idt_desc;
struct idt_entry idt[IDT_ENTRIES];

void idt_setup();

#endif

Als nächstes müssen wir einen Interrupt-Handler programmieren, auf den wir unsere IDT zeigen lassen können. Für Testzwecke wollen wir erstmal nur einen Handler, der uns in allen Fällen eine Meldung ausgibt und den Kernel anhält.

section .text
global int_handler
int_handler:
    mov ax, 0x10
    mov gs, ax
    mov dword [gs:0xB8000],') : '
    hlt

Ähnlich wie bei der GDT den Verweis auf den Assembler-Code uzm Installieren der GDT, verweisen wir auch diesmal wieder vom C-Code aus auf Assembler. Bei der GDT hätte man auch Inline-Assembler verwenden können, aber bei da es mehrere Zeilen waren und ein Sprung ins neue Segment nötig war, bot sich externes Assembler an. Bei Interrupts ist externes Assembler (außer man will Sonderfunktionen nutzen) notwendig, da der C-Compiler sonst Stack-Logik miteinbaut, die nicht zum Interrupt-Handler gehören.

Wir legen also wie bei der GDT zunächst wieder eine Funktion an, die die Struktur richtig befüllt (und die Adresse in higher und lower-Teil aufspaltet):

#include <stdint.h>
#include "idt.h"
void idt_set_entry(unsigned int pos, uint32_t base,
   uint16_t selector, uint8_t type_attributes)
{
    idt[pos].base_lower = base & 0xffff;
    idt[pos].selector = selector;
    idt[pos].unused = 0x0;
    idt[pos].type_attributes = type_attributes;
    idt[pos].base_higher = (base >> 16) & 0xffff;
}

Uns reicht zunächst mal ein Interrupt-Handler für alle Fälle, deshalb iterieren wir einfach und tragen an jeder Stelle in der IDT denselben Interrupt-Handler ein.

extern void int_handler();
void idt_setup()
{
    idt_desc.limit = IDT_ENTRIES * sizeof(struct idt_entry) - 1;
    idt_desc.base = (uint32_t) (uintptr_t) idt;
    
    for (int i = 0; i < IDT_ENTRIES; i++) {
        idt_set_entry(i, (uint32_t)(uintptr_t)int_handler, 0x8, 0x8e);
    }
    asm volatile("lidt (%0)" :: "m"(idt_desc));
    asm volatile("sti");
}

type_attributes = 0x8e setzt das Present-Flag in unserem Interrupt, verlangt vom aufrufenden Code Privilege Level = 0 (unser aktueller Kernel-Code ist in Level 0, Userspace-Code landet üblicherweise auf Level 3) und legt den Eintrag als 32-Bit-Interrupt-Gate fest.

Zu Testzwecken wollen wir nun einen Interrupt auslösen. Hierzu bietet sich der Divide Error (Division by zero) an. Wenn wir keine Compilerflags ändern wollen, bewerkstelligen wir dies am einfachsten durch Embedded Assembler direkt nach dem Setzen der Interrupt-Table.

asm volatile("mov 10, %ax");
asm volatile("divb 0");

Und wir sehen:

SOS nach einem Interrupt

Das Betriebssystem zu einem ähnlichen Stand wie nach diesem Artikel findet ihr auf Github (Commit eb694b28a47c4478292860b13e953b77025040cc).

Wer wirklich sichergehen will, dass wir den Interrupt 0 (Divide Error) erhalten, kann sich als Übung für jeden Interrupt einen eigenen Handler mit jeweils unterschiedlicher Ausgabe schreiben.