Keyboard Input
Keyboard input is the most complex feature of KFS_1. It requires five separate concepts to work together correctly:
- Interrupts — Hardware signals that stop CPU execution
- IDT — Interrupt Descriptor Table (tells the CPU where handlers are)
- PIC — Programmable Interrupt Controller (manages interrupt routing)
- Scan codes — Raw keyboard codes that need translation
- 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
- Key is pressed → Keyboard hardware sends an electrical signal
- PIC receives signal → Routes it to the CPU as IRQ1
- CPU stops current execution → Saves state (registers, instruction pointer)
- CPU looks up handler → Checks the IDT for the IRQ1 entry
- Handler runs → Reads the key, processes it
- CPU restores state → Returns to whatever it was doing
PS/2 Keyboard Hardware
The keyboard communicates via port I/O:
| Port | Purpose |
|---|---|
0x60 | Data port — read scan codes here |
0x64 | Status 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 usescallwith 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 normalretwon’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