Systems Engineering•2026-03-29•16 min read•Adoreka Systems Architecture Team

Building an Operating System in Rust: Part 4 - Hardware Timer Interrupts & Keyboard Drivers

Step-by-step tutorial on interfacing with the Intel 8259 PIC interrupt controller, programmable timers, and PS/2 scancode decoding in Rust.

HARDWARE TOPOLOGY: CASCADED 8259 PIC & PS/2 SCANCODE DECODER
REMAPPED VECTORS 32-47
HARDWARE INPUTSIRQ 0: System Timer~18.2Hz Clock TicksHeartbeat of OS SchedulerIRQ 1: PS/2 KeyboardPort 0x60 ScancodesKey Make/Break EventsCASCADED 8259 PICMaster PIC (Offset 32)Remapped from default 0x08 to 0x20Avoids collision with CPU exceptionsSlave PIC (Offset 40)Chained to Master IRQ 2Requires EOI AcknowledgementCPU EXECUTION1. Interrupt FiredCPU pauses user instructions2. Handler Decodespc-keyboard converts scancode3. EOI Signal SentPIC readied for next keystroke

Building an Operating System in Rust: Part 4 — Hardware Timer Interrupts & Keyboard Drivers

In Part 3: CPU Interrupts, IDT & Double Faults, we established a robust exception-handling infrastructure capable of catching CPU faults, breakpoints, and stack overflows. However, all of those exceptions were synchronous—they were triggered directly by CPU instructions executing inside the kernel.

In this fourth installment of our Building an Operating System in Rust series, we will transition to asynchronous hardware interrupts.

Hardware interrupts originate from external devices: the system clock, the keyboard controller, network interface cards, and storage disks. You will learn how the legacy Intel 8259 Programmable Interrupt Controller (PIC) works, how to remap PIC vectors away from CPU exception conflicts, how to handle timer interrupts (the foundation of multitasking preemptive schedulers), and how to decode raw PS/2 keyboard scancodes into printable characters.


1. The Intel 8259 Programmable Interrupt Controller (PIC)

On x86 hardware, external device interrupt lines cannot connect directly to the CPU's pins because the processor only has one or two physical interrupt pins. Instead, the motherboard routes hardware interrupt lines through an interrupt controller.

The classic PC architecture uses two cascaded Intel 8259 PIC chips:

PIC ControllerHardware IRQ LineDefault Vector (Real Mode)Remapped Vector (Protected 64-bit)Attached Peripheral
Master PICIRQ 00x08 (Collision!)32 (0x20)System Timer (PIT / Scheduler)
Master PICIRQ 10x0933 (0x21)PS/2 Keyboard Controller
Master PICIRQ 20x0A34 (0x22)Cascaded Line to Slave PIC
Master PICIRQ 3–70x0B–0x0F35–39Serial Ports / Sound Cards
Slave PICIRQ 80x7040 (0x28)Real-Time Clock (RTC)
Slave PICIRQ 120x7444 (0x2C)PS/2 Mouse Controller
Slave PICIRQ 14–150x76–0x7746–47Primary & Secondary ATA / IDE Disks
  • Master PIC: Handles Interrupt Requests (IRQs) 0 through 7. IRQ 2 is wired directly to the output of the Slave PIC.
  • Slave PIC: Handles IRQs 8 through 15.

2. The Vector Conflict Problem: Remapping the PIC

By default, the 8259 PIC is initialized in IBM-PC compatibility mode, mapping:

  • Master PIC (IRQs 0–7) to interrupt vectors 0x08 through 0x0F.
  • Slave PIC (IRQs 8–15) to interrupt vectors 0x70 through 0x77.

This default configuration creates a catastrophic conflict in protected 64-bit mode: Vectors 0x00 through 0x1F (0 to 31) are reserved by Intel for CPU exceptions! For instance:

  • Vector 0x08 is the Double Fault Exception.
  • But on an unconfigured PIC, the periodic Timer Tick (IRQ 0) also fires on vector 0x08!

If a timer interrupt fires, the CPU cannot distinguish between a routine clock tick and an unrecoverable double fault.

The Solution: Remap PIC Offsets to 32–47

To resolve this collision, we remap the PIC interrupt lines to an unused range in the IDT:

  • Master PIC Offset: Vector 32 (0x20 to 0x27 for IRQs 0–7).
  • Slave PIC Offset: Vector 40 (0x28 to 0x2F for IRQs 8–15).

Add the pic8259 crate to your Cargo.toml:

[dependencies]
pic8259 = "0.10.4"
pc-keyboard = "0.7.0"

Now, initialize and remap the PICs in src/interrupts.rs:

use pic8259::ChainedPics;
use spin::Mutex;

pub const PIC_1_OFFSET: u8 = 32;
pub const PIC_2_OFFSET: u8 = PIC_1_OFFSET + 8;

pub static PICS: Mutex<ChainedPics> =
    Mutex::new(unsafe { ChainedPics::new(PIC_1_OFFSET, PIC_2_OFFSET) });

3. Handling Hardware Timer Interrupts (IRQ 0)

Hardware timers provide periodic clock ticks at fixed frequencies (typically ~18.2 Hz by default, or configurable up to 1,000 Hz). In modern operating systems, timer interrupts are the heartbeat of the kernel: they update system uptime, trigger task context switches, and power preemptive multitasking.

Define the interrupt index enum in src/interrupts.rs:

#[derive(Debug, Clone, Copy)]
#[repr(u8)]
pub enum InterruptIndex {
    Timer = PIC_1_OFFSET,     // Vector 32
    Keyboard = PIC_1_OFFSET + 1, // Vector 33
}

impl InterruptIndex {
    pub fn as_u8(self) -> u8 {
        self as u8
    }

    pub fn as_usize(self) -> usize {
        usize::from(self.as_u8())
    }
}

Now, implement the timer interrupt handler:

extern "x86-interrupt" fn timer_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    // Print a dot to visualize the timer tick
    crate::print!(".");

    // Crucial: Send End of Interrupt (EOI) signal!
    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(InterruptIndex::Timer.as_u8());
    }
}

The Critical End of Interrupt (EOI) Signal

Notice the call to notify_end_of_interrupt. The 8259 PIC requires an explicit End of Interrupt (EOI) acknowledgement command from the kernel. If your handler fails to send the EOI signal, the PIC assumes the CPU is still processing the interrupt and will refuse to send any further hardware interrupts for the remainder of execution!


4. Registering Hardware Interrupts in the IDT

Update our global IDT configuration in src/interrupts.rs:

lazy_static! {
    static ref IDT: InterruptDescriptorTable = {
        let mut idt = InterruptDescriptorTable::new();
        idt.breakpoint.set_handler_fn(breakpoint_handler);
        unsafe {
            idt.double_fault.set_handler_fn(double_fault_handler)
                .set_stack_index(crate::gdt::DOUBLE_FAULT_IST_INDEX);
        }
        
        // Register Timer Interrupt (Vector 32)
        idt[InterruptIndex::Timer.as_usize()]
            .set_handler_fn(timer_interrupt_handler);

        // Register Keyboard Interrupt (Vector 33)
        idt[InterruptIndex::Keyboard.as_usize()]
            .set_handler_fn(keyboard_interrupt_handler);

        idt
    };
}

5. Reading PS/2 Keyboard Scancodes (IRQ 1)

Whenever a key on a physical PS/2 keyboard is pressed or released, the keyboard microcontroller transmits a serial data packet to the motherboard's 8042 keyboard controller chip. The controller places the received byte in its internal data buffer and raises IRQ 1.

The data byte is known as a scancode:

  • When a key is pressed, the keyboard sends a Make code (e.g., the 'A' key produces scancode 0x1E).
  • When a key is released, the keyboard sends a Break code (e.g., releasing 'A' produces 0x9E, which is 0x1E | 0x80).

To read the raw scancode byte from the keyboard controller, we read from CPU I/O Port 0x60:

use x86_64::instructions::port::Port;

extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    let mut port = Port::new(0x60);
    let scancode: u8 = unsafe { port.read() };

    crate::println!("Raw Scancode: 0x{:02X}", scancode);

    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
    }
}

6. Type-Safe Scancode Decoding with pc-keyboard

Manually parsing every make code, break code, Shift key modifier, CapsLock toggle, and extended arrow key escape sequence is tedious and error-prone. We can use the robust pc-keyboard crate to translate raw scancodes into printable characters and key events:

use pc_keyboard::{layouts, DecodedKey, HandleControl, Keyboard, ScancodeSet1};
use spin::Mutex;

lazy_static! {
    static ref KEYBOARD: Mutex<Keyboard<layouts::Us104Key, ScancodeSet1>> =
        Mutex::new(Keyboard::new(
            ScancodeSet1::new(),
            layouts::Us104Key,
            HandleControl::Ignore
        ));
}

extern "x86-interrupt" fn keyboard_interrupt_handler(
    _stack_frame: InterruptStackFrame
) {
    let mut keyboard = KEYBOARD.lock();
    let mut port = Port::new(0x60);
    let scancode: u8 = unsafe { port.read() };

    if let Ok(Some(key_event)) = keyboard.add_byte(scancode) {
        if let Some(key) = keyboard.process_keyevent(key_event) {
            match key {
                DecodedKey::Unicode(character) => crate::print!("{}", character),
                DecodedKey::RawKey(key) => crate::print!("{:?}", key),
            }
        }
    }

    unsafe {
        PICS.lock()
            .notify_end_of_interrupt(InterruptIndex::Keyboard.as_u8());
    }
}

7. Enabling Hardware Interrupts: The sti Instruction

By default, when a CPU enters 64-bit long mode, hardware interrupts are masked (disabled). To start receiving clock ticks and keyboard strokes, we must:

  1. Initialize the cascaded 8259 PIC controllers.
  2. Execute the x86 sti (Set Interrupt Flag) instruction.

Update src/main.rs:

#[no_mangle]
pub extern "C" fn _start() -> ! {
    println!("Booting Adoreka OS Kernel v0.4.0...");

    // 1. Initialize CPU GDT & Exception Stacks
    gdt::init();

    // 2. Initialize Interrupt Descriptor Table
    interrupts::init_idt();

    // 3. Remap and Initialize 8259 PIC
    unsafe { interrupts::PICS.lock().initialize() };

    // 4. Enable CPU Hardware Interrupts (sti assembly instruction)
    x86_64::instructions::interrupts::enable();

    println!("Hardware interrupts enabled! Type on your keyboard:");

    // Keep CPU alive
    loop {
        x86_64::instructions::hlt(); // Halt instruction saves CPU power until next interrupt!
    }
}

Power Efficiency with the hlt Instruction

Notice the use of x86_64::instructions::hlt(). A naive loop {} consumes 100% of a CPU core in an idle spin loop. The x86 hlt instruction halts the processor until the next hardware interrupt arrives, reducing host CPU utilization and power consumption to virtually zero!

When you boot the kernel in QEMU and type on your keyboard:

  1. Every keystroke triggers IRQ 1.
  2. The CPU jumps to our keyboard_interrupt_handler.
  3. The scancode is read from I/O port 0x60 and decoded.
  4. The character renders immediately to the VGA screen!

Architectural Summary

Hardware ComponentI/O Port / VectorRust Abstraction
Master PICPorts 0x20, 0x21 (Offset 32)pic8259::ChainedPics
Slave PICPorts 0xA0, 0xA1 (Offset 40)Cascaded over IRQ 2
System TimerIRQ 0 (Vector 32)Periodic heartbeat for scheduling
PS/2 KeyboardIRQ 1 (Vector 33, Port 0x60)pc-keyboard::Keyboard
Interrupt FlagEFLAGS bit 9x86_64::instructions::interrupts::enable()

In Part 5: Paging, Virtual Memory & 4-Level Page Tables, we will tackle the most complex and critical subsystem of any modern operating system: 4-Level Hardware Paging and Virtual Memory Management.

PRODUCTION ARCHITECTURE REVIEW

Want to implement this architecture in your business?

Speak directly with our technical team to schedule an engineering audit and deployment review.

Start Project Discussion →