Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Keyboard Input

Keyboard input is the most complex feature of KFS_1. It requires five separate concepts to work together correctly:

  1. Interrupts — Hardware signals that stop CPU execution
  2. IDT — Interrupt Descriptor Table (tells the CPU where handlers are)
  3. PIC — Programmable Interrupt Controller (manages interrupt routing)
  4. Scan codes — Raw keyboard codes that need translation
  5. ASCII conversion — Convert scan codes to printable characters

Get any one of these wrong and the result is either silence (keyboard does nothing) or an interrupt storm (keyboard fires thousands of times per second and the screen flickers into apparent oblivion). Both happened during development.

What Are Interrupts?

The kernel runs in a loop:

#![allow(unused)]
fn main() {
loop {
    unsafe { core::arch::asm!("hlt", options(nomem, nostack)); }
}
}

How does the keyboard tell the kernel that a key was pressed?

Bad solution: Polling

#![allow(unused)]
fn main() {
loop {
    if keyboard_has_data() {
        read_keyboard();
    }
}
}

Wastes CPU cycles constantly checking a port that rarely has data.

Good solution: Interrupts

#![allow(unused)]
fn main() {
loop {
    hlt();  // CPU sleeps until an interrupt fires
}

// Called automatically by hardware when a key is pressed
fn keyboard_interrupt_handler() {
    read_keyboard();
}
}

The CPU sleeps with hlt until the keyboard hardware sends a signal. The CPU wakes up, runs the handler, then goes back to sleep. Zero CPU wasted.

The Interrupt Lifecycle

  1. Key is pressed → Keyboard hardware sends an electrical signal
  2. PIC receives signal → Routes it to the CPU as IRQ1
  3. CPU stops current execution → Saves state (registers, instruction pointer)
  4. CPU looks up handler → Checks the IDT for the IRQ1 entry
  5. Handler runs → Reads the key, processes it
  6. CPU restores state → Returns to whatever it was doing

PS/2 Keyboard Hardware

The keyboard communicates via port I/O:

PortPurpose
0x60Data port — read scan codes here
0x64Status port — check if data is available

Scan Codes

When a key is pressed or released, the keyboard sends a scan code — a number identifying which physical key changed state.

  • Key press: Scan code < 128 (e.g., pressing ‘A’ sends 0x1E)
  • Key release: Scan code + 128 (e.g., releasing ‘A’ sends 0x9E)

We need to filter out releases and translate press codes to ASCII.

Scan Code to ASCII Table

#![allow(unused)]
fn main() {
static SCANCODE_TO_ASCII: [u8; 128] = [
    0,    27,  b'1', b'2', b'3', b'4', b'5', b'6',  // 0x00-0x07
    b'7', b'8', b'9', b'0', b'-', b'=', 8,   b'\t', // 0x08-0x0F
    b'q', b'w', b'e', b'r', b't', b'y', b'u', b'i', // 0x10-0x17
    b'o', b'p', b'[', b']', b'\n', 0,   b'a', b's', // 0x18-0x1F
    b'd', b'f', b'g', b'h', b'j', b'k', b'l', b';', // 0x20-0x27
    b'\'', b'`', 0,   b'\\', b'y', b'x', b'c', b'v',// 0x28-0x2F
    b'b', b'n', b'm', b',', b'.', b'/', 0,   b'*',  // 0x30-0x37
    0,    b' ', 0,   0,   0,   0,   0,   0,          // 0x38-0x3F
    // ... rest zeros (F-keys, numpad, etc.)
];
}

Index into this array with the scan code to get the ASCII character. Zero means “not a printable character.”

The Keyboard Handler

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_keyboard_handler() {
    unsafe {
        // ALWAYS read from port 0x60 — this clears the keyboard buffer
        let scancode = inb(KEYBOARD_DATA_PORT);

        // ALWAYS send EOI — this tells the PIC we handled the interrupt
        pic::send_eoi(1);

        // Filter out releases (scan code >= 128) and nulls
        if scancode >= 128 || scancode == 0 {
            return;
        }

        // Translate to ASCII
        let ascii = SCANCODE_TO_ASCII[scancode as usize];

        // Print if it's a printable character
        if ascii != 0 {
            crate::print!("{}", ascii as char);
        }
    }
}
}

Critical ordering: Read the port before sending EOI. The read clears the keyboard buffer and de-asserts the IRQ line. If you send EOI first, the PIC might fire another interrupt before you’ve cleared the source.

Why #[no_mangle] and extern "C"?

#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn rust_keyboard_handler()
}
  • #[no_mangle] — Keep the function name exactly as written. Without it, Rust mangles the name into something like _ZN3kfs2kb20rust_keyboard_handler17h... and the assembly wrapper can’t find it.
  • extern "C" — Use C calling convention. The assembly wrapper uses call with C-style stack discipline.

The Assembly Wrapper

The IDT stores function pointers to assembly wrappers, not Rust functions directly. The wrappers save and restore registers:

keyboard_interrupt_handler:
    cli                          ; Disable interrupts during handler
    pusha                        ; Save all general-purpose registers
    call rust_keyboard_handler   ; Call Rust handler
    popa                         ; Restore all registers
    sti                          ; Re-enable interrupts
    iretd                        ; Return from interrupt (32-bit)

Why not call Rust directly from the IDT?

  • iretd — Interrupt return. Restores the saved CPU state (EIP, CS, EFLAGS) from the stack. A normal ret won’t do this — it would crash.
  • pusha/popa — The Rust handler will use registers. We must save and restore them, otherwise we corrupt whatever was running when the interrupt fired.

Port I/O in Rust

Reading from a hardware port requires inline assembly:

#![allow(unused)]
fn main() {
unsafe fn inb(port: u16) -> u8 {
    let value: u8;
    core::arch::asm!(
        "in al, dx",
        out("al") value,
        in("dx") port,
        options(nomem, nostack, preserves_flags)
    );
    value
}
}

in al, dx — Read a byte from the port number in dx into the al register. The Rust compiler handles mapping port to dx and extracting the result from al.

Full Flow: Key Press to Screen

[1] You press 'A'
[2] Keyboard sends scan code 0x1E to port 0x60
[3] Keyboard sends electrical signal to PIC (IRQ1)
[4] PIC forwards IRQ1 to CPU as interrupt 33 (after remapping)
[5] CPU saves current state and looks up IDT entry 33
[6] CPU jumps to keyboard_interrupt_handler (ASM wrapper)
[7] ASM: cli, pusha
[8] ASM: calls rust_keyboard_handler
[9] Rust: reads 0x1E from port 0x60
[10] Rust: sends EOI to PIC
[11] Rust: SCANCODE_TO_ASCII[0x1E] = b'a'
[12] Rust: print!("a")
[13] ASM: popa, sti, iretd
[14] CPU restores saved state
[15] CPU returns to hlt loop