keyboard - PS/2 keyboard driver
Header: src/kernel/include/kernel/keyboard.h
Source: src/kernel/arch/i386/drivers/keyboard.c
A layered PS/2 keyboard driver. It handles IRQ 1, decodes scan-code set 1
(including e0/e1 prefixes and make/break separation), tracks modifier
state, delivers cooked bytes to focused tasks, supports raw diagnostic mode,
and provides deterministic in-kernel injection hooks for kbtest.
The keyboard and mouse share the 8042 controller: a shared i8042 module
(i8042.c) owns the port handshake, and the IRQ 1 handler forwards any AUX
(mouse) byte it drains to mouse_feed_byte (see mouse), so the
mouse driver doesn’t depend on this one. The modifier tracker also
recognises the Ctrl+Alt+Shift+P chord, which raises a KPANIC on purpose
for testing the fail-safe panic (debug).
This document describes the post-rewrite driver landed for slice #5 of the
feat/tty-multitasking follow-up roadmap. It replaces the older single-
state-flag implementation that suffered from “sticky e0”, an unsynchronised
SPSC ring race, and a tearing slot table - see “Why the rewrite” below.
Pipeline
PS/2 controller (port 0x60/0x64)
│
▼
keyboard_irq_handler
│ (drains controller, AUX/error filtering)
▼
decoder_feed
│ (state machine: NORMAL / AFTER_E0 / AFTER_E1A / AFTER_E1B)
▼
apply_modifier ──── tracks L/R shift, ctrl, alt; caps-lock toggle
│
▼ (mostly make events; break events update state and stop)
on_make
│ (Alt+Fn → vtty_switch; Ctrl+C SIGINT;
│ raw mode may deliver function/modifier sentinels)
▼
translate_make
│ (US QWERTY tables; Ctrl+letter → control code;
│ arrows → KEY_ARROW_* sentinels)
▼
kb_route
│
▼
┌──────────┴──────────┐
▼ ▼
per-task SPSC ring global fallback ring
(kb_slots[i]) (kb_buf[256])
Each stage is a pure function of its input plus the small amount of decoder state (state machine + modifier flags). Make and break events are strictly separated: a break (top bit set on the post-prefix byte) only updates modifier state and never reaches the translator.
Public API
| Function | Purpose |
|---|---|
keyboard_init() |
Register IRQ1 handler; drain any pre-existing controller queue. |
keyboard_getchar() |
Blocking single-byte read for the calling task (cooperatively yields). |
keyboard_poll() |
Non-blocking single-byte read; returns 0 if queue is empty. |
keyboard_set_focus(task) |
Set the task that receives input. NULL routes to the global ring. |
keyboard_send_to(task, c) |
Inject a byte directly into a task’s ring (used by vtty_switch for KEY_FOCUS_GAIN). |
keyboard_release_task(task) |
Free a task’s slot and clear focus if it points at the task, on exit. |
keyboard_set_raw(on) |
Enable diagnostic raw mode for focused tools such as kbtester. |
keyboard_inject_key(kc, shift, ctrl, alt) |
Test hook: inject one synthetic key into the live input path. |
keyboard_inject_text(text) |
Test hook: type a synthetic string into the live input path. |
keyboard_test_driver() |
Scripted in-guest keyboard/app-tab test driver used by ./run.sh kbtest. |
The public API still uses char so existing consumers (if (c == KEY_ARROW_UP) ...)
continue to compile unchanged. The producer pipeline is unsigned char end
to end - see “Sentinel safety” below.
Sentinel byte values (in keyboard.h)
| Sentinel | Byte | Notes |
|---|---|---|
KEY_ARROW_UP/DOWN/LEFT/RIGHT |
0x80–0x83 | Outside Ctrl+letter range (0x01–0x1A) |
KEY_F1..F12 |
0x84–0x90 except 0x88 | Function-key sentinels; cooked mode intercepts Alt+F1–F4 for VT switching |
KEY_FOCUS_GAIN |
0x88 | Sent by vtty_switch to the newly-focused task |
KEY_SHIFT_DOWN, KEY_CTRL_DOWN, KEY_ALT_DOWN, KEY_CAPS_TOGGLE, KEY_SUPER_DOWN, KEY_MENU_DOWN |
0x91–0x96 | Raw-mode modifier diagnostics |
KEY_PAGE_UP, KEY_PAGE_DOWN |
0x97–0x98 | Extended navigation sentinels |
KEY_CTRL_C |
0x03 | Plain ASCII ETX; Ctrl+C also sends SIGINT to the focused task |
Decoder state machine
The decoder is a four-state Mealy machine driven by raw PS/2 scancode set 1 bytes:
| State | Input | Action |
|---|---|---|
| any | 0xE0 |
→ DEC_AFTER_E0 |
| any | 0xE1 |
→ DEC_AFTER_E1A |
| any | 0x00 0xAA 0xEE 0xFA 0xFE 0xFF |
→ DEC_NORMAL (controller status, not a keystroke) |
DEC_NORMAL |
b |
emit kc = b & 0x7F, is_break = b >> 7 |
DEC_AFTER_E0 |
b |
emit kc = KC_EXT(b & 0x7F), is_break = b >> 7; drop fake-shift padding (PrintScreen) |
DEC_AFTER_E1A |
b |
→ DEC_AFTER_E1B (consume first half of Pause) |
DEC_AFTER_E1B |
b |
→ DEC_NORMAL (consume second half of Pause) |
The state machine cannot livelock: every input either advances or terminates
the current sequence, and an unexpected 0xE0/0xE1 cleanly restarts the
prefix rather than poisoning the next byte. This is the single most
important fix relative to the previous driver, where a single “extended”
flag was set on 0xE0 and a lost byte could leave it sticky indefinitely.
PrintScreen “fake shifts”
PrintScreen press/release sends e0 2a e0 37 / e0 b7 e0 aa. The
2a/aa bytes are a backwards-compatibility hack from the original AT
keyboard and would, if interpreted literally, toggle real shift state on
every PrintScreen. We detect them in DEC_AFTER_E0 (post-prefix value
== KC_LSHIFT) and drop them silently.
Modifier tracking
Left and right modifiers are tracked independently:
mod_lshift, mod_rshift → mod_shift = lshift | rshift
mod_lctrl, mod_rctrl → mod_ctrl = lctrl | rctrl
mod_lalt, mod_ralt → mod_alt = lalt | ralt
mod_caps (toggled on press only)
This avoids the classic bug where holding LShift, pressing and releasing RShift, and continuing to hold LShift causes shift to silently turn off.
The compound mod_* fields are recomputed after every press/release event,
so translate_make only ever has to read one flag per modifier.
SPSC ring buffers
Each task gets a 64-byte ring (kb_slots[i].buf); the global fallback ring
is 256 bytes. Both rings use uint8_t head/tail counters and a power-of-two
size so wraparound is exact: (head - tail) mod 256 gives the occupancy.
Memory ordering protocol
Producer (IRQ side): Consumer (task side):
write data[head & MASK] load head
smp_wmb() ◄── pairs ──► smp_rmb()
publish head++ read data[tail & MASK]
smp_wmb()
publish tail++
On x86 / x86_64 TSO, smp_wmb() and smp_rmb() are compiler-only barriers
(no fence instructions emitted). On a hypothetical weakly-ordered port they
would expand to dmb ishst / dmb ishld or equivalents. Modeled on Linux’s
<asm/barrier.h> so a future port can swap in arch-specific barriers
without touching the driver logic.
READ_ONCE / WRITE_ONCE (modeled on Linux’s <linux/compiler.h>) are
used for any field read or written across context boundaries, to defeat
compiler hoisting and tear-introducing optimisations.
Slot table - lock-free lookup, locked CAS mutation
kb_slots[KB_TASK_SLOTS] is a fixed array. owner == NULL means the slot
is free. Slots are never compacted - once a task is registered, its
slot index is stable for the task’s lifetime.
- Lookup (
slot_lookup): walks the array with__atomic_load_nonowner. Lock-free, safe from any context including IRQ. - Registration (
slot_register): fast path is a lock-free lookup; slow path takeskb_slots_lockand does a CAS-based claim of the firstNULLslot. SMP-safe - concurrent registrations resolve via CAS. - Release (
keyboard_release_task): clears focus before nulling the owner pointer, so the IRQ never routes a byte to a released slot.
This is the same shape as Linux’s many fixed-table-with-RCU-lookup patterns, simplified for our environment where we don’t yet have RCU.
SMP readiness
Makar is currently UP, but the design is correct under SMP and will not require revisits when a second CPU comes online:
- All cross-context shared state uses
__atomic_*builtins orREAD_ONCE/WRITE_ONCE. There are no naked accesses to a field that’s modified by another context. - Two spinlocks serialise mutation paths:
kb_io_lockaround the controller drain (so two CPUs can’t tear the PS/2 byte stream).kb_slots_lockaround slot registration/release (so two CPUs can’t race-claim the same slot).
- Both spinlocks are IRQ-safe (
pushfl; clion acquire;popflon release), so a CPU holding the lock cannot deadlock against its own IRQ handler trying to take the same lock. kb_focusedis pointer-sized, naturally aligned, and always accessed via__atomic_load_n/__atomic_store_nwith acquire/release semantics - so a future SMP runtime always sees a fully-published owner pointer for the current focus.
On UP, the spinlocks reduce to a single CAS on the fast path and an unconditional store on release - i.e. effectively free.
Sentinel safety (signed-char hazard)
The KEY_* sentinels live in 0x80..0x88. Stored as a signed char
they sign-extend to 0xFFFFFF80.. when widened to int, which breaks any
comparison that goes via int - e.g. c >= 0x80 evaluates `(int)(char)0x80
= (int)0x80
, i.e.-128 >= 128`, i.e. false.
Equality testing between two char-typed operands survives the widening
because both sides see the same bit pattern, which is why
if (c == KEY_ARROW_UP) ... continues to work in the shell. But the moment
a value is widened (putchar(c), printf("%d", c), c >= 0x20) the
sentinel is corrupted.
The driver enforces this discipline by:
- Keeping the producer pipeline
unsigned charend-to-end - scancodes, keycodes, ring storage, translator output. - Performing the conversion to
charat exactly two places:keyboard_getchar()andkeyboard_poll(), on the return value. - Documenting the sentinel byte values so consumers can decide whether to widen or not.
The public API now returns unsigned char, and the sentinel macros are
explicitly cast to unsigned char, which avoids the old failure mode where
((char)0x80) widened to a negative int and failed comparisons in users that
stored input as unsigned char.
Ctrl+C and signals
Ctrl+C is no longer a global flag consumed by shell code. On a Ctrl+C make event, the keyboard driver:
- routes the byte value
0x03(KEY_CTRL_C) through the normal input queue; - sends
SIGINTto the currently focused task withsig_send().
The signal subsystem owns default termination. Shell tasks install SIG_IGN
for their own prompt so Ctrl+C aborts the current line without killing the
shell. When the shell has launched a ring-3 child, the child is the task that
receives focus and the kernel default action can terminate it. This avoids the
old race where shell_cmd_apps.c had to poll keyboard_sigint_consume() while
also waiting for child state changes.
User-installed signal handlers are recorded by the signal subsystem, but full
ring-3 handler invocation still requires a trampoline and sigreturn. Until
that lands, default and ignored dispositions are the reliable behavior.
Raw mode
keyboard_set_raw(1) suspends cooked shortcuts for diagnostic tools:
- Alt+F1-F4 stop switching VTs and can be observed as key events;
- modifier press events are delivered as
KEY_*_DOWNsentinels; - F1-F12 are delivered as function-key sentinels;
- Ctrl+C still routes
0x03and sendsSIGINT, so raw tools can still be exited normally.
kbtester uses raw mode to show key activity directly. It disables raw mode in
its cleanup path so the next focused shell gets cooked behavior again.
Test hooks and kbtest
The driver has two different test interfaces:
| Hook family | Purpose |
|---|---|
keyboard_test_begin/feed/drain/end/reset |
Isolated decoder/ring tests used by in-kernel ktest; focus is temporarily cleared so bytes land in the global fallback ring. |
keyboard_inject_key/text and keyboard_test_driver |
Live in-guest scenario tests used by ./run.sh kbtest; these drive the shell and app focus paths like a user would. |
The live test driver is what makes kbtest headless-friendly. Instead of QEMU
HMP sendkey events, the kernel runs scripted scenarios against the real input
routing path, including app-tab behavior. ./run.sh kbtest gui keeps the QEMU
display visible while running the same kernel driver; plain ./run.sh kbtest
runs headless.
Controller hygiene
On every IRQ:
- Read
0x64status; bail ifOBFis clear. - Read
0x60data - this acks the byte to the controller. - If
AUXBis set the byte is from the mouse channel; discard. - Filter controller-internal status bytes (
0x00,0xAA,0xEE,0xFA,0xFE,0xFF) - they are responses to commands, never keystrokes. - Loop up to 16 times so a runaway controller can’t livelock the kernel, but stacked-up bytes from a previously-lost IRQ are still drained.
keyboard_init() also drains any leftover bytes the controller may have
queued before our handler was registered.
Why the rewrite
Three classes of latent bug in the previous driver:
-
Sticky
e0-extended_keywas a single bit set on every0xE0byte and cleared only by the next non-prefix byte. A lost byte (typematic burst, lost EOI, emulator hiccup) left it sticky and silently corrupted the next normal scancode. New decoder is a full state machine and re-issuing0xE0cleanly restarts the prefix. -
SPSC ring race - IRQ producer wrote
slot->buf[head]then incrementedheadwith no barrier between the two stores. Under-O2the compiler was free to reorder, and a consumer that observed the newheadbefore the new byte landed would read stale ring memory. This matched observed sporadic single-character noise that was not correlated to real keystrokes. -
Slot-table tearing -
kb_find_or_register()mutated the slot array andkb_nslotsfrom task context with no synchronisation. An IRQ1 firing mid-mutation walked a partial table and either dereferenced garbage or routed to the wrong task. New design is fixed-slot + CAS, and the IRQ side never observes a partial state.
Source map
| File | Role |
|---|---|
src/kernel/include/kernel/keyboard.h |
Public API, sentinel byte definitions |
src/kernel/arch/i386/drivers/keyboard.c |
Driver implementation (this document) |
src/kernel/arch/i386/proc/vtty.c |
Calls keyboard_set_focus / keyboard_send_to for TTY switching |
src/kernel/arch/i386/shell/shell.c |
Consumes keyboard_getchar; observes arrow / focus / Ctrl-C sentinels |
src/kernel/arch/i386/proc/vix.c |
Editor; consumes arrow sentinels |
src/kernel/arch/i386/proc/signal.c |
Receives Ctrl+C as SIGINT for the focused task |
src/kernel/arch/i386/shell/shell_cmd_apps.c |
Waits for ring-3 children; child termination is signal-driven |
Future work
- Right modifier sentinels.
RAlt(AltGr) andRCtrlare tracked in the modifier state but no key currently distinguishes them from their left counterparts at the translator layer. Add when an international layout / dead-key support lands. - Home / End / Insert / Delete sentinels. Some extended navigation keys are decoded internally but are not all delivered as public sentinels yet.
- NumLock / ScrollLock LEDs. Requires a controller write path
(
keyboard_send_command) and a small command queue. - Ring-3 signal trampolines. Ctrl+C already creates
SIGINT; invoking user-installed handlers still needs a user-mode trampoline andsigreturn.