Nachdem wir uns in den letzten Tutorials mit der Interrupt Descriptor Table, dem Programmable Interrupt Controller und einer Textausgabe für unseren Kernel beschäftigt haben, wollen wir nun weitere Hardware-Interrupts verarbeiten - nämlich unsere Tastatureingaben.

Die Tastatur sendet bei jedem Tastendruck und bei jedem Loslassen einen Interrupt. Diesen soll anschließend ein Tastaturtreiber verarbeiten und die richtigen Tastenanschläge ermitteln. Vom Keyboard-Controller erhalten wir nämlich je nach Tastatur-Aktion mehrere Bytes als Code und jedes Byte wird als eigener Interrupt gesendet.

Scancodes Einlesen mit Zustandsautomat

Eine Lösung hierfür ist ist ein Zustandsautomat, in dem wir die bisherigen Bytes über den aktuellen Zustand darstellen. Da Scancode-Set 2 am weitesten unterstützt wird, will ich den gesamten Ablauf anhand dieses erklären.

Im Scancode-Set 2 wird der Druck einer normalen Taste durch ein Byte repräsentiert. Multimedia-Tasten bestehen aus einem führenden 0xe0 sowie einem weiteren Byte, also insgesamt zwei Bytes. Nach dem Lesen eines 0x0e-Bytes wechseln wir also in einen Zustand multimedia.

Ähnlich verhält es sich beim Loslassen einer Taste. Hier erhalten wir dasselbe Byte wie nach dem Drücken, jedoch geführt von einem 0xf0. Wird beim Drücken von Q bspw. 0x15 gesendet, so erhalten wir beim Loslassen 0xf0 0x15.

Beim Loslassen einer Multimedia-Taste schiebt sich das 0xf0-Byte in die Mitte der beiden Bytes vom Drücken der Taste. Wenn also 0xe0 0x11 für das Drücken von alt gr steht, dann steht 0xe0 0xf0 0x10 für das Loslassen selbiger Taste. Häufig liegen auch Sondertasten wie “Play” oder “Browser” im Multimedia-Bereich, allerdings sind meines Wissens nur wenige dieser Sondertasten standardisiert und können je nach Tastatur abweichen.

Wir können also festhalten, dass wir nach einem 0xf0 in einen Release-Zustand springen müssen. Um uns zu merken, ob wir vorher schon 0xe0 gesehen haben, jeweils in einen anderen.

Nun gibt es noch ein paar Spezialfälle an Tasten, die aus bis zu acht einzelnen Bytes bestehen, z.B. “Pause gedrückt”: 0xe1, 0x14, 0x77, 0xe1, 0xf0, 0x14, 0xf0, 0x77. Diese Taste ist auch insofern speziell, als es keinen Release-Befehl für sie gibt. Dies müssen wir beim Design unserer Architektur beachten.

Mit diesem Wissen können wir uns einen Endlichen Zustandsautomaten entwerfen. Dies ist mein Entwurf mit dem Finite State Machine Designer von Evan Wallace. Exemplarisch für die Scancodes mit sehr vielen Bytes habe ich lediglich die Taste print screen released eingezeichnet. pause pressed und die Übrigen gehen analog.

Endlicher Zustandsautomat für Scancode Set 2

byte valid bedeutet, dass das Byte tatsächlich einem Scancode zugeordnet ist. So ist bspw. 0x02 kein gültiger Scancode. In diesen Fällen landen wir in einem Fehlerzustand, den ich zur besseren Übersicht nicht eingezeichnet habe.

Umsetzung in C

Diesen Zustandsautomaten müssen wir nun in C überführen. Vorher sollten wir uns jedoch überlegen, was überhaupt passieren soll, wenn ein gültiger Scancode gelesen werden sollte. Um unseren Kernel flexibel zu halten, macht es natürlich wenig Sinn, direkt an dieser Stelle das Zeichen auf den Bildschirm zu schreiben. Immerhin wollen wir nicht nur Zeichen auf dem Bildschirm anzeigen, sondern - um in der Konsole zu bleiben - in Anwendungen wie vim auch die Steuerung des Cursors übernehmen. Oder später in Videospielen eine Figur steuern.

Deshalb darf der Tastaturtreiber das eingelesene Zeichen den einzelnen Anwendungen lediglich irgendwie bekanntmachen. Hierfür gibt es zwei Varianten:

  • Der Tastaturtreiber hat eine Liste von Callback-Funktionen und ruft diese auf, wenn er eine neue Tastaturaktion bemerkt (Push- / Event-System)
  • Der Tastaturtreiber verwaltet eine Liste von aktuellen Tastenzuständen, die die Anwendungen abfragen können (Poll-System)

Ich habe mich für das Push-System entschieden, bei dem Events an Callbacks gesendet werden. Man kann aber auch beide Ansätze kombinieren. Das Poll-System alleine scheint mir relativ unpraktisch, weil dann jede Anwendung in möglichst häufigen Abständen nachschauen muss, ob es Änderungen an der Tastatur gibt. In einer Vorlesung habe ich mal gehört, dass bereits eine relativ kleine Verzögerung von wenigen hundert Millisekunden dem Anwender langsam vorkommt, d.h. wir müssten mehrmals pro Sekunde die Tastenliste prüfen.

Außerdem macht es wenig Sinn, die Scancodes selbst wieder rauszuschicken. Wir hatten ja große Probleme, weil diese aus einer ganz unterschiedlichen Anzahl von Bytes bestehen können. Deshalb legen sich die meisten Betriebssystemprogrammierer eine Liste von sogenannten Keycodes an. Dies ist auch wieder nur eine Zuordnung von Tasten auf Zahlen/Bytes, aber mit einheitlicher Bytelänge. Theoretisch kann man aber natürlich auch ganz abgefahrene Dinge tun und einen eigenen Keycode senden, sobald zwei Tasten gleichzeitig gedrückt werden (und dies als Spezialereignis handhaben).

Derzeit habe ich die Keycodes noch in ein enum geschrieben, will das aber ändern, damit ich die Bytegröße reduzieren kann:

// TODO: Switch to #define instead of enum, so that we can define
// bits individually and restrict to required length (8 bits)
// currently it's int always
enum keyboard_key {
    KEY_1 = 0, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9, KEY_0,

    KEY_Q, KEY_W, KEY_E, KEY_R, KEY_T,
    KEY_Y, KEY_U, KEY_I, KEY_O, KEY_P, KEY_A, KEY_S, KEY_D,
    KEY_F, KEY_G, KEY_H, KEY_J, KEY_K, KEY_L, KEY_Z, KEY_X,
    KEY_C, KEY_V, KEY_B, KEY_N, KEY_M,

    KEY_F1, KEY_F2, KEY_F3,
    KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9, KEY_F10, KEY_F11,
    KEY_F12,

    KEY_COMMA, KEY_DOT, KEY_SLASH,
    KEY_BACKSLASH, KEY_APOSTROPHE, KEY_SEMICOLON, KEY_MINUS, KEY_EQUALS,
    KEY_BACKSPACE, KEY_SPACE, KEY_ENTER, KEY_TAB, KEY_ESC,

    KEY_LSHIFT, KEY_RSHIFT, KEY_ALT, KEY_ALTGR, KEY_LCTRL,
    KEY_RCTRL, KEY_BACKTICK, KEY_RSQ_BRACKET, KEY_LSQ_BRACKET,
    KEY_CAPSLOCK, KEY_NUMLOCK, KEY_SCROLLLOCK,

    KEY_0_KP, KEY_1_KP, KEY_2_KP, KEY_3_KP, KEY_4_KP, KEY_5_KP, KEY_6_KP,
    KEY_7_KP, KEY_8_KP, KEY_9_KP,
    KEY_MINUS_KP, KEY_TIMES_KP, KEY_PLUS_KP, KEY_DOT_KP,

    KEY_UNUSED
};

Nun ist also die Taste 1 der Zahl 0 zugeordnet, die Taste 2 der Zahl 1 und so weiter.

Sobald diese Keycodes definiert sind, können wir uns auch eine Zuordnung von den Standard-Scancodes (also nicht multimedia) auf die Keycodes anlegen:

// Mapping table from scancode set 2 standard keys (not multimedia)
// to the key codes
static enum keyboard_key s2_keycodes[256] = { 
    KEY_UNUSED, KEY_F9, KEY_UNUSED, KEY_F5, KEY_F3, KEY_F1, KEY_F2, KEY_F12,
    KEY_UNUSED, KEY_F10, KEY_F8, KEY_F6, KEY_F4, KEY_TAB, KEY_BACKTICK, KEY_UNUSED,
    KEY_UNUSED, KEY_ALT, KEY_LSHIFT, KEY_UNUSED, KEY_LCTRL, KEY_Q, KEY_1, KEY_UNUSED,
    KEY_UNUSED, KEY_UNUSED, KEY_Z, KEY_S, KEY_A, KEY_W, KEY_2, KEY_UNUSED,
    KEY_UNUSED, KEY_C, KEY_X, KEY_D, KEY_E, KEY_4, KEY_3, KEY_UNUSED,
    KEY_UNUSED, KEY_SPACE, KEY_V, KEY_F, KEY_T, KEY_R, KEY_5, KEY_UNUSED,
    KEY_UNUSED, KEY_N, KEY_B, KEY_H, KEY_G, KEY_Y, KEY_6, KEY_UNUSED,
    KEY_UNUSED, KEY_UNUSED, KEY_M, KEY_J, KEY_U, KEY_7, KEY_8, KEY_UNUSED,
    KEY_UNUSED, KEY_COMMA, KEY_K, KEY_I, KEY_O, KEY_0, KEY_9, KEY_UNUSED,
    KEY_UNUSED, KEY_DOT, KEY_SLASH, KEY_L, KEY_SEMICOLON, KEY_P, KEY_MINUS, KEY_UNUSED,
    KEY_UNUSED, KEY_UNUSED, KEY_APOSTROPHE, KEY_UNUSED, KEY_LSQ_BRACKET, KEY_EQUALS, KEY_UNUSED, KEY_UNUSED,
    KEY_CAPSLOCK, KEY_RSHIFT, KEY_ENTER, KEY_RSQ_BRACKET, KEY_UNUSED, KEY_BACKSLASH, KEY_UNUSED, KEY_UNUSED,
    KEY_UNUSED, KEY_UNUSED, KEY_BACKSPACE, KEY_UNUSED, KEY_UNUSED, KEY_1_KP, KEY_UNUSED, KEY_4_KP,
    KEY_7_KP, KEY_UNUSED, KEY_UNUSED, KEY_UNUSED, KEY_0_KP, KEY_DOT_KP, KEY_2_KP, KEY_5_KP,
    KEY_6_KP, KEY_8_KP, KEY_ESC, KEY_NUMLOCK, KEY_F11, KEY_PLUS_KP, KEY_3_KP, KEY_MINUS_KP,
    KEY_TIMES_KP, KEY_9_KP, KEY_SCROLLLOCK, KEY_UNUSED, KEY_UNUSED, KEY_UNUSED, KEY_F7
};

Und wenn wir diese Tabelle haben, ist es auch möglich einen Teil des obigen Zustandsautomaten zu realisieren. Für Zustandsautomaten gibt es auch wieder mehrere Umsetzungen in C. Ich habe mich für die Methode entschieden, bei der ein Funktionspointer auf den aktuellen Zustand zeigt. Jeder Zustand hat daher seine eigene Funktion und bei eintreffen eines neuen Bytes, wird diese Funktion mit dem Byte ausgeführt.

// All possible states for our keycode finite state machine
static void fsm_start(uint8_t);
static void fsm_release(uint8_t);

// FSM: Keep track of the current state of the state machine
static void (*current_state)(uint8_t) = fsm_start;

static void fsm_start(uint8_t byte) { 
    if (byte == 0xf0) {
        current_state = fsm_release;
    } else {
        if (s2_keycodes[byte] != KEY_UNUSED) {
            keyboard_fire_event(s2_keycodes[byte], false);
        }
    }
}

static void fsm_release(uint8_t byte) { 
    if (s2_keycodes[byte] != KEY_UNUSED) {
        keyboard_fire_event(s2_keycodes[byte], true);
    }

    current_state = fsm_start;
}

Für den obigen Code fehlt uns jetzt noch die Funktion keyboard_fire_event(), welche das neue Tastaturereignis an alle vorhandenen Callbacks sendet. Diese Funktion soll ein Event an alle Callbacks senden mit notwendigen und hilfreichen Informationen wie dem betroffenen Keycode, ob es ein Press- oder Release-Event war und dem ASCII-Symbol sowie der Aussage, ob der Keycode überhaupt darstellbar ist.

Wir benötigen also diese Struktur, eine Definition der Callbacks, eine Funktion, mit der man Callbacks registrieren kann, und die bereits bekannte Funktion keyboard_fire_event(). In einer Header-Datei führt das zu folgenden Deklarationen:

struct keyboard_event { 
    enum keyboard_key key;
    bool released; // true if released event
    char ascii;
    bool printable;
    // TODO: Add more useful fields
    // some people have ctrl_pressed, shift_pressed, and stuff here
};

void keyboard_init();
void keyboard_irq_handler();

void keyboard_add_callback(void (*callback)(struct keyboard_event));

void keyboard_fire_event(enum keyboard_key kbdkey, bool released);

In einer C-Datei implementieren wir natürlich diese ganzen Funktionen. Aber weil die Struktur auch den ASCII-Code des Keycodes und das Flag printable enthält, brauchen wir wieder einige Zuordnungstabellen, die uns diese Informationen für alle Keycodes liefern.

Insgesamt könnte die C-Datei dann so aussehen:

// This file contains general functions that are used as an interface
// to other parts of the kernel and to user space programs, and are used
// by all keyboard drivers (e.g. PS2 and others) the same

#include "keyboard.h"

static int keyboard_callbacks_pos = 0;
static void (*keyboard_callbacks[MAX_CALLBACK_COUNT])(struct keyboard_event);

// TODO: As long as we cannot allocate memory dynamically, use fixed one
static struct keyboard_event kbd_event;

static bool shift_active = false;

static bool keycode_is_printable[256] = {
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1,

    1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1, 1, 1, 1, 1,
    1, 1, 1, 1, 1, 1,

    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,

    1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0,

    0 ,0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0,

    1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1,

    0
};

static char keycode_to_char[256] = {
    '1', '2', '3', '4', '5', '6', '7', '8', '9', '0',
    'q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o' ,'p', 'a', 's', 'd', 'f',
    'g', 'h', 'j', 'k', 'l', 'z', 'x', 'c', 'v', 'b', 'n', 'm',

    ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',

    ',', '.', '/', '\\', '\'', ';', '-', '=', ' ', ' ', ' ', '\t', ' ',

    ' ', ' ', ' ', ' ', ' ', ' ', ' ', ']', '[', ' ', ' ', ' ',

    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '*', '+', '.',

    ' '
};

static char keycode_to_char_shift[256] = {
    '!', '@', '#', '$', '%', '^', '&', '*', '(', ')',
    'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O' ,'P', 'A', 'S', 'D', 'F',
    'G', 'H', 'J', 'K', 'L', 'Z', 'X', 'C', 'V', 'B', 'N', 'M',

    ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ',

    '<', '>', '?', '|', '"', ':', '_', '+', ' ', ' ', ' ', '\t', ' ',

    ' ', ' ', ' ', ' ', ' ', ' ', ' ', '}', '{', ' ', ' ', ' ',

    '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '-', '*', '+', '.',

    ' '
};

void keyboard_add_callback(void (*callback)(struct keyboard_event)) {
    keyboard_callbacks[keyboard_callbacks_pos] = callback;
    keyboard_callbacks_pos++;
}

void keyboard_fire_event(enum keyboard_key kbdkey, bool released) {
    if (kbdkey == KEY_LSHIFT || kbdkey == KEY_RSHIFT) {
        shift_active = !released;
    }

    kbd_event.key = kbdkey;
    kbd_event.released = released;

    // TODO: We should make sure that they always match?
    if (kbdkey < 256) {
        kbd_event.printable = keycode_is_printable[kbdkey];

        if (shift_active) {
            kbd_event.ascii = keycode_to_char_shift[kbdkey];
        } else {
            kbd_event.ascii = keycode_to_char[kbdkey];
        }
    }

    for (int i = 0; i < keyboard_callbacks_pos; i++) {
        keyboard_callbacks[i](kbd_event);
    }
}

Die Tastatur richtig initialisieren

Was wir jetzt bisher noch versäumt haben, ist es, der Tastatur überhaupt mitzuteilen, dass wir Untranslated Scancode Set 2 wollen.

Hierzu brauchen wir einige weitere Zeilen Code und unsere bereits erstellten IO-Funktionen. Zunächst mal müssen wir dem Keyboard-Controller das gewünschte Scancode Set mitteilen. Dazu schreiben wir 0xf0 auf Port 0x60 und anschließend die Nummer des Scancodes. Danach müssen wir noch die Translation deaktivieren. Das wird gemacht, indem man das 6. Bit des Comannd Byte löscht.

Um zudem in Zukunft auch auf andere Scancode Sets umsteigen zu können, habe ich gleich noch eine Liste von Startzuständen für jedes Scancode Set angelegt. Verwendet wird bisher aber nur Scancode Set 2.

#include <arch/x86/io.h>

// The state machine start states for each scancode set
// TODO: Currently scancode set 1 and 3 are missing and will trigger
// a fault interrupt
static void (*scancode_state_starts[3])(uint8_t) = { 0x0, fsm_start, 0x0 };

static void kbd_wait_outbuf() {
    uint8_t status;
    do {
        status = inb(0x64);
    } while ((status & 0x1) == 0);
}

static void kbd_wait_inbuf() {
    uint8_t status;
    do {
        status = inb(0x64);
    } while ((status & 0x2) != 0);
}

static void set_scancode(uint8_t scancode_nr) {
    if (scancode_nr < 1 || scancode_nr > 3)
        return;

    outb(0x60, 0xf0);
    kbd_wait_inbuf();
    outb(0x60, scancode_nr);
    kbd_wait_inbuf();
    
    // disable translation
    outb(0x64, 0x20);
    kbd_wait_outbuf();
    uint8_t status = inb(0x60);

    kbd_wait_inbuf();
    outb(0x64, 0x60);
    kbd_wait_inbuf();
    outb(0x60, status & 0x3f);

    current_state = scancode_state_starts[scancode_nr - 1];
}

void keyboard_init() {
    set_scancode(2);
}

Die Anbindung an Interrupts

Jetzt muss das ganze Scancode-Handling nur noch an die Interrupts gekoppelt werden, damit bei einem Interrupt auch etwas passieren kann. Dazu schreibt man sich einen Interrupt Handler, der im Falle eines Interrupts ausgeführt wird. Für unsere Tastatur sieht er so aus:

void keyboard_irq_handler() {
    uint8_t scancode;

    kbd_wait_outbuf();
    scancode = inb(0x60);
    current_state(scancode);
}

Weil der C-Compiler aber immer noch spezielle Funktionseintrittslogik hinzukompiliert, schreibt man die Eintrittspunkte für Interrupts meistens direkt in Assembler (mit speziellen Compiltermakros geht es auch in C). Wir brauchen also noch einen Assemblerabschnitt, der diese Funktion aufruft. Und dieser Assemblerabschnitt wird dann als Interrupt-Handler in der IDT registriert.

Zunächst also der Assembler-Code:

extern keyboard_irq_handler
global int_handler_33
int_handler_33:
mov ax, 0x10
mov gs, ax
mov dword [gs:0xB8000],'3 3 '
call keyboard_irq_handler
mov al, 0x20
out 0x20, al
iret

Und im IDT-Code brauchen wir noch diese Zeilen:

extern void int_handler_33();

void idt_setup() {
    [...]
    idt_set_entry(33, (uint32_t)(uintptr_t)&int_handler_33, 0x8, 0x8e);
    [...]
}

Den ganzen Spaß testen

Um das alles ausprobieren zu können, brauchen wir eigentlich nur noch einen Callback, der die Tastaturereignisse entgegennimmt und ausgibt und passen unsere main()-Funktion ein wenig an:

void print_keyboard(struct keyboard_event kbdevt) {
    char *vidmem = (char*) 0xb8000;

    if (kbdevt.printable && !kbdevt.released) {
        kprintc(kbdevt.ascii);
    }
}

void kernel_main() {
    gdt_setup();
    pic_init(0x20, 0x28);
    idt_setup();
    keyboard_init();

    keyboard_add_callback(print_keyboard);
}

Und schon können wir tippen!

Das Betriebssystem, nachdem "Hallo du, wie geht es dir!?" eingegeben wurde

Meinen aktuellen Stand des Betriebssystems findet ihr auf github (Commit: f4d64c45e2956dc72c7f3a46219665e8d9078866).