The IDT — Interrupt Descriptor Table
The IDT is a table of 256 entries that tells the CPU: “When interrupt N fires, call function at address X.”
Structure
IDT Entry (8 bytes)
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Copy, Clone)]
struct IdtEntry {
offset_low: u16, // Lower 16 bits of handler address
selector: u16, // Code segment selector (0x08 = kernel code)
zero: u8, // Always zero
type_attr: u8, // Gate type and privilege level
offset_high: u16, // Upper 16 bits of handler address
}
}
#[repr(C, packed)] — The CPU reads this structure directly from memory. It must have C layout with no padding. Without packed, Rust might insert alignment padding between fields and the CPU would read garbage.
Field Breakdown
offset_low + offset_high — Together they form the 32-bit address of the handler function. Split across the entry because that’s how the x86 IDT format was designed in the 1980s.
Handler at 0x00102ABC:
offset_low = 0x2ABC
offset_high = 0x0010
selector — Which code segment the handler runs in. 0x08 = kernel code segment (selector index 1, GDT entry 1). This is the segment GRUB sets up for us.
type_attr = 0x8E:
Bit 7: Present = 1 (entry is valid)
Bits 6-5: DPL = 0 (kernel privilege level)
Bits 4-0: Type = 0xE (32-bit interrupt gate)
Combined: 10001110 = 0x8E
An interrupt gate automatically disables interrupts (clears the IF flag) when the handler is called, and re-enables them on iretd.
Setting a Handler
#![allow(unused)]
fn main() {
fn set_handler(&mut self, handler: unsafe extern "C" fn()) {
let handler_addr = handler as usize;
self.offset_low = (handler_addr & 0xFFFF) as u16;
self.offset_high = ((handler_addr >> 16) & 0xFFFF) as u16;
self.selector = 0x08;
self.zero = 0;
self.type_attr = 0x8E;
}
}
The IDT Descriptor
To load the IDT, we need a small descriptor structure that tells the CPU where the table is and how big it is:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct IdtPointer {
limit: u16, // Size of IDT in bytes - 1
base: u32, // Physical address of IDT
}
}
limit = size_of::<Idt>() - 1 — The limit is the last valid byte offset, not the count.
Loading the IDT
#![allow(unused)]
fn main() {
let idt_ptr = IdtPointer {
limit: (core::mem::size_of::<Idt>() - 1) as u16,
base: core::ptr::addr_of!(IDT) as u32,
};
asm!(
"lidt [{}]",
in(reg) &idt_ptr,
options(readonly, nostack, preserves_flags)
);
}
lidt — Load Interrupt Descriptor Table. Takes a pointer to the descriptor structure. After this instruction, the CPU uses our IDT for all interrupts and exceptions.
Full Initialisation
#![allow(unused)]
fn main() {
pub fn init() {
unsafe {
// CPU exception handlers
IDT.entries[0].set_handler(divide_by_zero_handler);
IDT.entries[6].set_handler(invalid_opcode_handler);
IDT.entries[8].set_handler(double_fault_handler);
IDT.entries[13].set_handler(general_protection_fault_handler);
IDT.entries[14].set_handler(page_fault_handler);
// Default handler for everything else (catches timer, spurious IRQs)
for i in 1..256 {
if i != 0 && i != 6 && i != 8 && i != 13 && i != 14 && i != 33 {
IDT.entries[i].set_handler(default_interrupt_handler);
}
}
// Keyboard interrupt (IRQ1 → interrupt 33 after PIC remapping)
IDT.entries[33].set_handler(keyboard_interrupt_handler);
let idt_ptr = IdtPointer {
limit: (core::mem::size_of::<Idt>() - 1) as u16,
base: core::ptr::addr_of!(IDT) as u32,
};
asm!(
"lidt [{}]",
in(reg) &idt_ptr,
options(readonly, nostack, preserves_flags)
);
}
}
pub fn enable_interrupts() {
unsafe {
asm!("sti", options(nomem, nostack));
}
}
}