Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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));
    }
}
}