Case Study 26-1: A PS/2 Keyboard Driver in Assembly
Interrupt-Driven Input for MinOS
A polling-based keyboard driver reads port 0x60 in a busy loop. An interrupt-driven driver sleeps until a key is pressed, then processes it. The difference in CPU utilization is the difference between 100% and ~0.001%. This case study builds the complete interrupt-driven PS/2 keyboard driver for MinOS.
The PS/2 Protocol
The PS/2 keyboard controller communicates through two I/O ports: - Port 0x60: Data port — read keyboard data here - Port 0x64: Status/command port — check controller status, send commands
When a key is pressed or released, the keyboard sends a scancode to the controller, which asserts IRQ1. The IRQ1 handler must read the scancode from port 0x60 before the controller sends another.
The controller status byte (port 0x64) has: - Bit 0 (OBF): Output Buffer Full — set when data is available at port 0x60 - Bit 1 (IBF): Input Buffer Full — set when controller is busy, do not write
The Scancode Set
Scancode Set 1 (the original IBM XT set, still the default) uses single-byte codes for most keys: - Make code (key press): 0x01–0x58 for the original 83-key keyboard - Break code (key release): make code with bit 7 set (make code | 0x80)
Some keys use extended scancodes prefixed with 0xE0 (arrow keys, Insert, Delete, etc.).
The Complete Driver
; minOS/drivers/keyboard.asm
; PS/2 keyboard driver with ring buffer and shift support
section .bss
; Ring buffer: 256 bytes, head and tail indices
kb_buffer: resb 256
kb_head: resb 1 ; write index
kb_tail: resb 1 ; read index (consumer reads from here)
shift_state: resb 1 ; 1 if any Shift key is pressed
section .data
; Scancode Set 1 → ASCII (unshifted)
; Index = scancode, value = ASCII (0 = non-printable or unmapped)
scancode_table_lower:
; 0 1 2 3 4 5 6 7
db 0, 27, '1', '2', '3', '4', '5', '6' ; 0x00
db '7', '8', '9', '0', '-', '=', 8, 9 ; 0x08 (8=BS, 9=Tab)
db 'q', 'w', 'e', 'r', 't', 'y', 'u', 'i' ; 0x10
db 'o', 'p', '[', ']', 13, 0, 'a', 's' ; 0x18 (13=Enter, 0=LCtrl)
db 'd', 'f', 'g', 'h', 'j', 'k', 'l', ';' ; 0x20
db 39, 96, 0, 92, 'z', 'x', 'c', 'v' ; 0x28 (39=', 96=`)
db 'b', 'n', 'm', ',', '.', '/', 0, '*' ; 0x30
db 0, ' ', 0, 0, 0, 0, 0, 0 ; 0x38 (LAlt, Space, CapsLk)
times 24 db 0 ; F1-F10, NumLk, ScrlLk, etc.
; Scancode Set 1 → ASCII (shifted)
scancode_table_upper:
db 0, 27, '!', '@', '#', '$', '%', '^'
db '&', '*', '(', ')', '_', '+', 8, 9
db 'Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I'
db 'O', 'P', '{', '}', 13, 0, 'A', 'S'
db 'D', 'F', 'G', 'H', 'J', 'K', 'L', ':'
db 34, '~', 0, '|', 'Z', 'X', 'C', 'V'
db 'B', 'N', 'M', '<', '>', '?', 0, '*'
db 0, ' ', 0, 0, 0, 0, 0, 0
times 24 db 0
%define SCANCODE_LSHIFT 0x2A
%define SCANCODE_RSHIFT 0x36
%define SCANCODE_LSHIFT_BREAK (0x2A | 0x80)
%define SCANCODE_RSHIFT_BREAK (0x36 | 0x80)
section .text
; keyboard_handler: IRQ1 interrupt handler
; No arguments. All registers preserved.
global keyboard_handler
keyboard_handler:
push rax
push rbx
push rcx
; Read scancode from keyboard data port
in al, 0x60
; Ignore extended prefix 0xE0 (simplification: skip extended scancodes)
cmp al, 0xE0
je .send_eoi
; Check for Shift press/release
cmp al, SCANCODE_LSHIFT
je .shift_pressed
cmp al, SCANCODE_RSHIFT
je .shift_pressed
cmp al, SCANCODE_LSHIFT_BREAK
je .shift_released
cmp al, SCANCODE_RSHIFT_BREAK
je .shift_released
; Check for key release (bit 7 set)
test al, 0x80
jnz .send_eoi ; ignore key releases (not shift)
; Translate scancode to ASCII
movzx rbx, al
cmp byte [shift_state], 0
jne .shifted
lea rcx, [scancode_table_lower]
jmp .lookup
.shifted:
lea rcx, [scancode_table_upper]
.lookup:
movzx rax, byte [rcx + rbx] ; look up ASCII
; Skip if non-printable (0)
test al, al
jz .send_eoi
; Add to ring buffer
call kb_buffer_put
jmp .send_eoi
.shift_pressed:
mov byte [shift_state], 1
jmp .send_eoi
.shift_released:
mov byte [shift_state], 0
.send_eoi:
; Send EOI to PIC1 to re-enable IRQ1
mov al, 0x20
out 0x20, al
pop rcx
pop rbx
pop rax
iretq
; kb_buffer_put: add ASCII byte in AL to ring buffer
; Returns: nothing. If buffer full, silently drops the character.
; Clobbers: nothing (saves all)
kb_buffer_put:
push rbx
push rcx
movzx rbx, byte [kb_head] ; current write position
mov rcx, rbx
inc rcx ; next write position
and rcx, 0xFF ; wrap (256-byte buffer)
cmp cl, [kb_tail] ; if next == tail, buffer is full
je .full
mov [kb_buffer + rbx], al ; write character
mov [kb_head], cl ; advance head
.full:
pop rcx
pop rbx
ret
; kb_buffer_get: read one character from ring buffer
; Returns: AL = character (0 if buffer empty)
global kb_buffer_get
kb_buffer_get:
movzx rax, byte [kb_tail]
cmp al, [kb_head] ; if tail == head, buffer empty
je .empty
movzx rax, byte [kb_buffer + rax] ; read character
movzx rcx, byte [kb_tail]
inc rcx
and rcx, 0xFF
mov [kb_tail], cl ; advance tail
ret
.empty:
xor al, al
ret
; kb_wait: wait until a key is available, return it in AL
; Busy-waits — in a real OS, you'd yield the CPU or use HLT
global kb_wait
kb_wait:
call kb_buffer_get
test al, al
jz kb_wait
ret
The Main Kernel Loop with Keyboard Echo
; In the MinOS kernel main function, after IDT and PIC setup:
kernel_main:
; ... [GDT, IDT, PIC setup, STI] ...
.main_loop:
; Wait for a keypress
hlt ; halt until next interrupt (saves power)
call kb_buffer_get ; try to get a character
test al, al
jz .main_loop ; no character yet, loop
; Echo the character to VGA
call vga_putchar ; al = character to print
; Handle Enter key
cmp al, 13 ; Enter
jne .main_loop
call vga_newline
jmp .main_loop
The HLT instruction halts the CPU until the next interrupt fires. This saves power compared to a busy loop and is the correct pattern for an idle kernel.
Testing in QEMU
# Build the MinOS image
nasm -f bin bootloader.asm -o boot.bin
nasm -f elf64 kernel.asm -o kernel.o
nasm -f elf64 keyboard.asm -o keyboard.o
ld -T linker.ld kernel.o keyboard.o -o kernel.bin
# Combine bootloader + kernel
cat boot.bin kernel.bin > minOS.img
dd if=minOS.img of=minOS.img bs=512 count=1 conv=notrunc
# Run in QEMU — keyboard input goes to the QEMU window
qemu-system-x86_64 -drive format=raw,file=minOS.img -display curses
# Or with a VGA window
qemu-system-x86_64 -drive format=raw,file=minOS.img
Press keys in the QEMU window and watch them appear on the VGA text screen. This is a complete, working keyboard subsystem — IRQ-driven, with a ring buffer, shift key tracking, and real scancode translation.
⚡ Performance Note: The ring buffer allows the interrupt handler to finish quickly (just store the character and send EOI) while the main loop processes characters at its own pace. This decoupling is essential: the interrupt handler must complete quickly to avoid blocking subsequent interrupts.