Interrupts and the IDT

The Interrupt Descriptor Table (IDT) is a data structure used by the x86 architecture to implement an interrupt vector table. The IDT is used by the processor to determine the correct response to interrupts and exceptions. ~ OSDev Wiki

The Interrupt Descriptor Table, or IDT, is a component of the x86 operating system. Interrupts are a mechanism that allows hardware and software to get the CPU's attention without requiring it to constantly check their status. Instead of constantly polling, a device or a program can simply "interrupt" the CPU when it needs something. Good examples is a keyboard press or a timer indicating that a time slice has passed.

The IDT can hold up to 256 entries, called vectors. Each entry points to an Interrupt Service Routine (ISR), which is the code that runs when a specific interrupt occurs. These 256 interrupts can be broadly categorized into two types.

Exceptions (CPU-Generated Interrupts)

These are triggered directly by the CPU when it detects a problem during program execution. The first 32 vectors (0-31) are reserved for these exceptions. Non all the 32 vectors are taken. There were only 17 when the i386 came out.

A classic example is vector 0: Divide-by-Zero. If your code attempts to divide a number by zero, the CPU hardware itself will stop execution and trigger interrupt 0. If this fault happens while the kernel (ring 0) is executing, it's usually an unrecoverable error. The OS has no choice but to panic the system.

Hardware Interrupts (External Interrupts)

These are the interrupts generated by hardware devices external to the CPU. Your keyboard, mouse, and system timer (RTC) all use hardware interrupts to communicate.

On older i386 systems, these devices don't talk to the CPU directly. Instead, they are connected to a controller chip called the 8259 PIC (Programmable Interrupt Controller). The PIC manages the hardware requests (IRQs) and signals the CPU.

The Common CPU Exceptions

When you write code to handle interrupts, you're mostly concerned with the first 32 vectors, which are reserved for CPU exceptions. Some of these push an error code onto the stack to provide more information about the fault, while others do not. Here is a list of the most common exceptions you will need to handle.

InterruptDescriptionError Code
0Divide errorNo
1Debug exceptionsNo
2Nonmaskable interruptNo
3BreakpointNo
4OverflowNo
5Bounds checkNo
6Invalid opcodeNo
7Coprocessor not availableNo
8Double faultYes (always 0)
9(reserved)-
10Invalid TSSYes
11Segment not presentYes
12Stack exceptionYes
13General protection faultYes
14Page faultYes
15(reserved)-
16Coprocessor errorNo
17-31(reserved)-

More information about each interrupt

Setup an IDT

Setting up the IDT as quite similair to the GDT. There are some minor difference. Each vector holds a function pointer to the function that should be called on a certain interrupt. Let's take a look on the Divide-By-Zero again:

// idt.c

typedef struct {
    interrupt_handler_e type;
    union {
        // Some functions receive an error_code to help identify the error.
        interrupt_handler regular;
        interrupt_handler_with_error with_error_code;
    } handler;
} interrupt_handler_entry_t;

const interrupt_handler_entry_t INTERRUPT_HANDLERS[17] = {
    {REGULAR, .handler.regular = divide_by_zero_handler},
    ... // The other functions
};

interrupt_descriptor_t idt_entries[IDT_ENTRY_COUNT];
descriptor_pointer_t idt_ptr;

void idt_set_gate(uint32_t num, uint32_t handler) {
    ...
}

void idt_init(void) {
    for (int32_t i = 0; i < 17; i += 1) {
        uint32_t handler = 0;

        if (INTERRUPT_HANDLERS[i].type == REGULAR) {
            handler = (uint32_t)INTERRUPT_HANDLERS[i].handler.regular;
        } else if (INTERRUPT_HANDLERS[i].type == WITH_ERROR_CODE) {
            handler = (uint32_t)INTERRUPT_HANDLERS[i].handler.with_error_code;
        }

        // Pass the index & address of the function to set it correctly in the IDT.
        idt_set_gate(i, handler);
    }

    // Set gates for the first 32 exceptions.
    // 0x08 is the kernel code segment selector. 0x8E are the flags for an interrupt gate.
    idt_ptr.limit = sizeof(entry_t) * IDT_ENTRY_COUNT - 1;
    idt_ptr.base = (uint32_t)&idt_entries;

    // Load the IDT using the lidt assembly instruction.
    __asm__ __volatile__("lidt %0" : : "m"(idt_ptr));
}
// exceptions.c

__attribute__((target("general-regs-only"), interrupt)) void
divide_by_zero_handler(registers_t *regs) {
    // The 'cs' register is on the stack as part of the interrupt frame.
    // We can inspect it to see if the fault was in kernel-mode (CPL=0) or user-mode (CPL=3).
    // This requires a more complex reading of the interrupt frame.
    // For now, we assume a kernel fault is a panic.
    if ((regs->cs & 3) == 0) {
        panic(regs, "Division by Zero");
    }

    __asm__ volatile("cli; hlt");
}

The core of the logic revolves around two key variables: idt_entries and idt_ptr. The idt_entries array is the table itself, which will hold all 256 vectors. The idt_ptr is the structure we pass to the CPU, containing the base address and limit (size) of the table, so the processor knows exactly where to find it.

In the idt_init() function, we loop through our predefined exception handlers. While you could hardcode each idt_set_gate() call, a loop makes the code cleaner. This loop retrieves the memory address for each handler function and calls idt_set_gate() to correctly populate the entry in the idt_entries table.

The final step is the lidt assembly instruction. This tells the CPU to load our idt_ptr, making our new Interrupt Descriptor Table active. From this point on, the CPU will use our table to find the correct handler for any interrupt or exception that occurs.

When an interrupt happens, the CPU needs to stop its current work and jump to the handler, but it must be able to return and resume its work later. The __attribute__((interrupt)) tells the compiler to automatically add the necessary assembly code to save the machine's state before your C code runs and restore it after. This is why interrupts should be as fast as possible; while a handler is running, the rest of the system is paused. For frequent events like keyboard presses, a common strategy is for the handler to do the bare minimum—like adding a key press to a queue—and letting a separate, lower-priority task scheduler process it later.

Here is a drawing on how the logic of the interrupts are being handled: A flowchart showing the logic of how interrupts are handled, from the interrupt signal to the IDT, to the specific handler, and finally to a task scheduler.

System Calls via Software Interrupts

You ever wondered how system calls worked? Well, a system call is simply a custom software interrupt to do a specific task. It is the most common way to implement system calls. It is probably the most portable way to implement. Linux traditionally uses interrupt 0x80 for this purpose on x86.