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.