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.
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 Controller | Hardware IRQ Line | Default Vector (Real Mode) | Remapped Vector (Protected 64-bit) | Attached Peripheral |
|---|---|---|---|---|
| Master PIC | IRQ 0 | 0x08 (Collision!) | 32 (0x20) | System Timer (PIT / Scheduler) |
| Master PIC | IRQ 1 | 0x09 | 33 (0x21) | PS/2 Keyboard Controller |
| Master PIC | IRQ 2 | 0x0A | 34 (0x22) | Cascaded Line to Slave PIC |
| Master PIC | IRQ 3–7 | 0x0B–0x0F | 35–39 | Serial Ports / Sound Cards |
| Slave PIC | IRQ 8 | 0x70 | 40 (0x28) | Real-Time Clock (RTC) |
| Slave PIC | IRQ 12 | 0x74 | 44 (0x2C) | PS/2 Mouse Controller |
| Slave PIC | IRQ 14–15 | 0x76–0x77 | 46–47 | Primary & 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
0x08through0x0F. - Slave PIC (IRQs 8–15) to interrupt vectors
0x70through0x77.
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
0x08is 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 (
0x20to0x27for IRQs 0–7). - Slave PIC Offset: Vector 40 (
0x28to0x2Ffor 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 scancode0x1E). - When a key is released, the keyboard sends a Break code (e.g., releasing
'A'produces0x9E, which is0x1E | 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:
- Initialize the cascaded 8259 PIC controllers.
- 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:
- Every keystroke triggers IRQ 1.
- The CPU jumps to our
keyboard_interrupt_handler. - The scancode is read from I/O port
0x60and decoded. - The character renders immediately to the VGA screen!
Architectural Summary
| Hardware Component | I/O Port / Vector | Rust Abstraction |
|---|---|---|
| Master PIC | Ports 0x20, 0x21 (Offset 32) | pic8259::ChainedPics |
| Slave PIC | Ports 0xA0, 0xA1 (Offset 40) | Cascaded over IRQ 2 |
| System Timer | IRQ 0 (Vector 32) | Periodic heartbeat for scheduling |
| PS/2 Keyboard | IRQ 1 (Vector 33, Port 0x60) | pc-keyboard::Keyboard |
| Interrupt Flag | EFLAGS bit 9 | x86_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.
Want to implement this architecture in your business?
Speak directly with our technical team to schedule an engineering audit and deployment review.