Nachdem wir zuletzt die IDT angelegt haben, wollen wir jetzt nicht mehr nur CPU-Interrupts, sondern auch andere Hardware-Interrupts empfangen. Diese gehen über den sogenannten Programmable Interrupt Controller. Dieser leitet die Interrupts von den einzelnen Hardware-Geräten an die CPU weiter.

Er ist standardmäßig jedoch so konfiguriert, dass es zu einer Kollision mit den Interrupts der x86-Spezifikation im Protected Mode kommt. Deshalb müssen wir ihn umprogrammeren, bevor wir ihn verwenden können.

Viele Freiheiten gibt es hier eigentlich nicht, wir müssen nur einem definierten Ablauf folgen.

Initialisierung

Genau genommen gibt es zwei PIC im Master-Slave-Modus, die jeweils 8 Interrupts signalisieren können. Kommuniziert wird pro PIC über einen Command- und einen Data-Port. Für den Master-PIC sind dies 0x20 (Command) und 0x21 (Data), für den Slave-PIC 0xa0 (Command) und 0xa1 (Data).

Die Initialisierung sieht folgendermaßen aus:

  1. ICW1 (Initialization Command Word 1) auf Port 0x20 schreiben
  2. ICW2 auf Port 0x21 schreiben (ICW2 = Offset der Interrupt-Nummern)
  3. Falls Bit D1 von ICW1 Null ist, schreibe ICW3 auf Port 0x20
  4. Schreibe ICW4 auf Port 0x21
  5. OCWs (Operation Control Word)

Zunächst einmal können wir die Adressen und die von uns exakt benötigten ICWs definieren:

#define PIC1_CMD 0x20
#define PIC1_DATA 0x21
#define PIC2_CMD 0xA0
#define PIC2_DATA 0xA1

#define ICW1 0x11 // ICW1 initialize and ICW4 is needed, cascading
                  // 8 byte interrupts vectors
#define ICW4 0x01 // x86 architecture, normal EOI, not buffered, sequential

Diese müssen wir dann in oben beschriebener Reihenfolge an die Geräte senden. ICW2 ist wie oben geschrieben der gewünschte Anfang der Interrupt-Nummern, kommt also im Idealfall von außen. ICW3 ist für die beiden Chips unterschiedlich, daher habe ich sie mal nicht extra definiert.

Im OSDev-Wiki wurden die ICWs etwas sauberer definiert, indem für jedes Bit ein #define angelegt wurde, sodass man diese hinterher leicht addieren kann. Außerdem wird dort vor dem Schreiben der PICs noch der aktuelle Status gesichert und später zurückgeschrieben. Letztere Idee wollen wir auch gleich übernehmen:

void pic_init(int pic1_pos, int pic2_pos) { 
    unsigned char a1, a2;                  
                         
    // save masks
    a1 = inb(PIC1_DATA);
    a2 = inb(PIC2_DATA);
                        
    outb(PIC1_CMD, ICW1);                                                  
    outb(PIC2_CMD, ICW1);                                                   
                                                                           
    outb(PIC1_DATA, pic1_pos);             
    outb(PIC2_DATA, pic2_pos);             
                                                                           
    outb(PIC1_DATA, 4); // Master PIC: There is a slave at IRQ2 (0000 0100)
    outb(PIC2_DATA, 2); // Slave PIC: cascade identity 0000 0010           
                                                                
    outb(PIC1_DATA, ICW4);
    outb(PIC2_DATA, ICW4);
                          
    // restore saved masks
    outb(PIC1_DATA, a1);  
    outb(PIC2_DATA, a2);
}

Diese Funktion können wir dann beim Hochfahren des Betriebssystems in etwa so aufrufen:

pic_init(0x20, 0x28);

Meines Wissens kamen die inb()- und outb()-Funktionen bisher noch nicht vor, daher müssen wir die auch noch definieren. Diese stellen im Grunde nur Wrapper um die gleichlautenden Assembler-Befehle dar:

static inline uint8_t inb(uint16_t port) {
    uint8_t ret;
    asm volatile("inb %1, %0" : "=a"(ret) : "Nd"(port));
    return ret;
}

static inline void outb(uint16_t port, uint8_t value) {
    asm volatile("outb %0, %1" :: "a"(value), "Nd"(port));
}

End of Interrupt

Wichtig für das Interrupt-Handling ist zudem noch, dass man dem PIC mitteilen muss, wenn man einen Interrupt erfolgreich abgearbeitet hat. Ist nur der Master-PIC in den Interrupt involviert, muss man nur diesen signalisieren. Kommt der Interrupt vom Slave-PIC, muss man beide signalisieren. Dies geht glücklicherweise extrem einfach, man muss lediglich 0x20 an den entsprechenden Command-Port senden.

; Slave PIC
mov al, 0x20
out 0xa0, al

; Master PIC
mov al, 0x20
out 0x20, al

Timer-Interrupt

Auf IRQ0 sendet der Interrupt Controller in Abständen von 55ms einen Timer-Interrupt. Nach unserer obigen Umprogrammierung wäre das also auf dem Interrupt-Vektor 0x20.

Diesen können wir im Folgenden behandeln und uns eine neue Anzeige aufs Display schreiben, z.B. eine ganz grobe Uhr. Ein Timer von 55ms bedeutet, dass der Timer ganz grob 18mal pro Sekunde aufgerufen wird. Um uns die Sache noch etwas einfacher zu machen, wollen wir zunächst auch mal keine Zahlen, sondern einfach nur wechselnde ASCII-Symbole. Es reicht also, wenn wir ein Byte durchzählen von 0 bis 256 und dieses auf den Ausgabespeicher schreiben.

global int_handler_32
int_handler_32:
mov ax, 0x10
mov gs, ax  ; set right data segment (should already be, but still)
xor ax, ax  ; make sure higher part of ax is zero
mov al, byte [gs:int_counter]  ; read the current count
inc al                       
mov byte [gs:int_counter], al  ; store back the incremented count
mov bl, 18  ; one second = 18 ticks
div bl
mov byte [gs:0xB8000], al  ; write the current second (= current ASCII symbol)
mov al, 0x20  ; interrupt is finished
out 0x20, al
iret

Diesen Interrupt müssen wir natürlich auch noch in der IDT registrieren:

extern void int_handler_32();

void idt_setup() {
    [...]
    idt_set_entry(32, (uint32_t)(uintptr_t)_handler_32, 0x8, 0x8e);
}

Und wenn alles richtig gemacht wurde, sollten oben links dann verschiedene Symbole im Takt blinken. In Bochs eventuell viel zu schnell (das kann passieren und ist normal), in QEMU sollte die Geschwindigkeit stimmen.

Den aktuellen Stand auf Github gibt es diesmal leider nicht, weil ich mit meiner eigenen Entwicklung bei den Interrupts in einer ganz anderen Situation bin. Demnächst machen wir in einem Tutorial mit Tastatureingaben weiter und dann sollte sich der Status wieder grob angleichen.