Skip to content
All writing
Technical · 5 min

It Listens Now

Milestone 5 of my from-scratch Rust kernel: PS/2 keyboard input — the first time the machine reacts to the outside world instead of just talking at it. Remapping the PIC, enabling interrupts for the very first time, and learning why forgetting one EOI makes the keyboard die after exactly one keypress.

Contents

Milestone 5 — PS/2 keyboard input. The first time the machine reacts to the outside world instead of just talking at it.

The last milestone handled exceptions — faults the running code causes itself. This milestone handles the other kind: a hardware interrupt, fired by a device that has no relationship to whatever instruction the CPU happens to be on. It’s also the milestone where we enable interrupts (sti) for the very first time.

The one thing I wanted to understand here: how a hardware device (the keyboard) gets the CPU’s attention through the interrupt controller (PIC), versus the CPU-caused exceptions of the last milestone. “Done” as observable behavior: typing in the QEMU window echoes the characters to the screen, live.

The smallest version that teaches it: unmask only the keyboard IRQ (mask the timer and everything else), US layout, echo printable keys plus Enter and Backspace. No key repeat handling, no full keymap. And there’s a specific trap between working and accidentally working: the echo must be interrupt-driven — the CPU sits in a halt loop and only wakes to a keystroke. If I were polling the port in a loop I’d “see” keys too, but that wouldn’t be the lesson. The halt loop makes it honest: nothing happens until an interrupt fires.

The failure mode I flagged before starting, because it’s the one that stalls people for days: forgetting to send the PIC an end-of-interrupt (EOI) — after which it never delivers another IRQ and the keyboard “dies” after exactly one key. Also: not reading port 0x60, which leaves the keyboard controller unable to send the next byte.

The scoping decision

Keyboard input is core — the shell needs it — so it stays. But I reduced it deliberately: unshifted + shift only, US QWERTY, common keys. No Caps Lock LED, no alternate layouts, no key-repeat tuning. Those are polish, not lesson, and can come with the shell if ever wanted.

The design

Approach. Three small modules:

  • src/pic.rs — remap the two 8259 PICs so their IRQs land at vectors 0x20–0x2F instead of colliding with the CPU exception vectors 0–31, then mask everything except the keyboard line (IRQ1), and expose send_eoi.
  • src/keyboard.rs — read a scancode from port 0x60, track shift state, and translate scancode-set-1 make codes to characters.
  • src/port.rs — shared inb/outb (factored out of serial.rs, which now reuses them).

Then wire vector 0x21 (0x20 + IRQ1) in interrupt_dispatch to the keyboard handler + EOI, and in kernel_main call pic::init() and finally sti.

Machine-state preconditions. The IDT from the last milestone is loaded (its 256 stubs already cover 0x21). Interrupts are enabled only after the PIC is remapped and masked — enabling them earlier, while IRQs still point at exception vectors, would misfire a stray IRQ into a fault handler.

Edge cases. Break codes (key release, make | 0x80) must be ignored except for shift; the shift make/break codes (0x2A/0x36, 0xAA/0xB6) update state; keys with no printable mapping return nothing; EOI must be sent on every keyboard IRQ or delivery stops; interrupt gates keep IF cleared during the handler so no nested keyboard IRQ can reenter the writer lock.

What got built

  • src/port.rs, src/pic.rs, src/keyboard.rs as planned; serial.rs refactored onto the shared port helpers.
  • interrupts.rs gains a keyboard arm (read scancode → echo → send_eoi); the fatal catch-all still guards every other unexpected vector.
  • vga_buffer.rs gains backspace handling so erasing looks right.
  • kernel_main enables interrupts and drops into the halt loop, now genuinely waiting on the world.

What I verified — and what I didn’t

Assembles, compiles, and links clean, with the Multiboot header intact. In QEMU, the halt loop idles and typing echoes characters — Enter starts a new line, Backspace erases. CI’s headless boot exercises the same path (which at minimum proves the sti path doesn’t immediately fault). None of it has run on real hardware yet; when a real keystroke first echoes (or doesn’t), the debugging story lands here.

Takeaway for the next person

An exception is the CPU tapping itself on the shoulder; a hardware interrupt is someone else doing it, at a moment you don’t control. The PIC is the receptionist deciding which taps get through and in what order — and the EOI is you telling the receptionist “okay, send me the next one.” Forget that one courtesy and everything goes quiet after a single keypress.

Share LinkedInXBlueskyReddit