Loading learning content...
When an interrupt occurs, the CPU must instantly determine where to jump—which handler should process this specific interrupt? With potentially hundreds of different interrupt sources (hardware devices, exceptions, software traps), the CPU needs a fast, reliable lookup mechanism.
The Interrupt Vector Table (IVT) (or its more sophisticated cousin, the Interrupt Descriptor Table (IDT)) provides exactly this capability. It's a data structure in memory that the CPU consults during every interrupt, using the interrupt number as an index to find the handler's address. Understanding this mechanism is essential for anyone working at the systems level—from operating system developers to security researchers analyzing exploits.
By the end of this page, you will understand the fundamental design of interrupt vector tables, master the differences between real-mode IVT and protected-mode IDT on x86, learn how the CPU uses these tables during interrupt dispatch, and explore Linux's approach to populating and managing the IDT.
An interrupt vector is simply a number that uniquely identifies a specific interrupt source or type. When hardware or software generates an interrupt, it provides a vector number that tells the CPU what kind of interrupt occurred.
The Vector Space:
Most CPUs define a fixed vector space—a range of possible interrupt numbers. On x86, this space contains 256 vectors (0-255). Each vector can be assigned to a specific purpose:
| Vector Range | Assignment | Description |
|---|---|---|
| 0-31 | CPU Exceptions | Divide error, page fault, double fault, etc. |
| 32-127 | External Interrupts | Timer, keyboard, disk, network, etc. |
| 128 (0x80) | System Call | Linux traditionally uses INT 0x80 |
| 129-238 | More IRQs / MSI | Additional device interrupts |
| 239 | APIC Spurious | Spurious interrupt handling |
| 240-249 | Reserved | Reserved for system use |
| 250-255 | IPI (Multi-CPU) | Inter-processor interrupts |
The Vector Number as an Index:
The brilliance of the vector table design is its simplicity: the vector number is used directly as an index into an array. No searching, no hashing—just a constant-time array lookup.
If the table starts at address BASE and each entry is SIZE bytes:
Handler Address = Table[Vector] = Memory[BASE + (Vector × SIZE)]
This constant-time lookup is critical because interrupt dispatch happens millions of times per second. Any complexity here would devastate system performance.
Different architectures use different terms: x86 real mode has the Interrupt Vector Table (IVT), x86 protected mode uses the Interrupt Descriptor Table (IDT), ARM has the Vector Table (reset/exception vectors) plus the Generic Interrupt Controller (GIC), and RISC-V uses the machine trap-vector register (mtvec). The core concept—mapping interrupt numbers to handlers—remains consistent.
The x86 processor's real mode (used during boot and in legacy systems) implements the simplest form of interrupt vectoring. Understanding this design provides historical context and insight into why the protected-mode IDT evolved.
IVT Structure:
The first 1 KB of memory in any x86 system booting in real mode is reserved for the IVT. The BIOS populates it with default handlers during POST (Power-On Self-Test).
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
; x86 Real Mode Interrupt Vector Table Structure;; Memory Map:; 0x00000 - 0x003FF : Interrupt Vector Table (256 × 4 bytes); Each entry: [Offset (2 bytes)] [Segment (2 bytes)];; Vector 0: 0x0000-0x0003 Handler for Divide Error; Vector 1: 0x0004-0x0007 Handler for Debug Exception; Vector 2: 0x0008-0x000B Handler for NMI; Vector 3: 0x000C-0x000F Handler for Breakpoint; ...; Vector 255: 0x03FC-0x03FF Last vector ; Example: Reading the keyboard interrupt handler address (Vector 9); ; Address = 0x0000 + (9 × 4) = 0x0024; Value at 0x0024: Offset (word); Value at 0x0026: Segment (word); Handler Address = Segment:Offset = (Segment << 4) + Offset ; Installing a custom interrupt handler in real modeinstall_keyboard_handler: ; Disable interrupts during modification cli ; Save old handler (for chaining) xor ax, ax mov es, ax ; ES = 0 (segment for IVT) mov ax, [es:0x24] ; Get old offset (vector 9) mov [old_offset], ax mov ax, [es:0x26] ; Get old segment mov [old_segment], ax ; Install new handler mov word [es:0x24], new_keyboard_handler mov word [es:0x26], cs ; Current code segment ; Re-enable interrupts sti ret new_keyboard_handler: ; Our custom keyboard handling code push ax in al, 0x60 ; Read scancode from keyboard ; ... process keystroke ... pop ax ; Chain to original handler jmp far [old_handler] ; Jump to old_segment:old_offset section .dataold_handler: old_offset dw 0 old_segment dw 0CPU Interrupt Dispatch in Real Mode:
When an interrupt with vector N occurs:
N × 4N × 4 + 2Limitations of Real Mode IVT:
The real-mode IVT design dates to the original 8086 processor (1978). Its simplicity was intentional—transistor counts were precious, and complex memory protection wasn't yet a priority. The design remained unchanged through the 80286 for backward compatibility and still exists in modern CPUs during initial boot.
Protected mode (introduced with the 80286 and fully realized in the 80386) replaced the simple IVT with the Interrupt Descriptor Table (IDT)—a more sophisticated structure that integrates with the CPU's memory protection and privilege mechanisms.
Key Differences from IVT:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// x86-64 Interrupt Descriptor Table Structures // The IDTR register points to the IDTstruct idtr { uint16_t limit; // Size of IDT in bytes - 1 uint64_t base; // Linear address of IDT} __attribute__((packed)); // 64-bit IDT Gate Descriptor (16 bytes per entry)struct idt_gate_64 { uint16_t offset_low; // Handler address bits 0-15 uint16_t segment_selector; // Code segment selector (usually kernel CS) uint8_t ist; // Interrupt Stack Table index (bits 0-2) // Reserved bits 3-7 uint8_t type_attr; // Gate type and attributes // Bits 0-3: Gate Type (0xE = 64-bit interrupt gate) // Bit 4: 0 (reserved) // Bits 5-6: DPL (Descriptor Privilege Level) // Bit 7: Present bit uint16_t offset_mid; // Handler address bits 16-31 uint32_t offset_high; // Handler address bits 32-63 uint32_t reserved; // Must be zero} __attribute__((packed)); // Gate types#define GATE_INTERRUPT_32 0x8E // 32-bit interrupt gate, DPL=0#define GATE_INTERRUPT_64 0x8E // 64-bit interrupt gate, DPL=0#define GATE_TRAP_32 0x8F // 32-bit trap gate, DPL=0#define GATE_TRAP_64 0x8F // 64-bit trap gate, DPL=0#define GATE_SYSCALL 0xEE // Interrupt gate, DPL=3 (user can call) // Complete IDT - 256 entries, each 16 bytesstruct idt_gate_64 idt[256];struct idtr idt_ptr; // Load the IDT into the CPUstatic inline void load_idt(void) { idt_ptr.limit = sizeof(idt) - 1; idt_ptr.base = (uint64_t)&idt; asm volatile("lidt %0" : : "m"(idt_ptr));} // Set up an IDT entryvoid set_idt_gate(uint8_t vector, void (*handler)(void), uint16_t selector, uint8_t type_attr, uint8_t ist) { uint64_t addr = (uint64_t)handler; idt[vector].offset_low = addr & 0xFFFF; idt[vector].offset_mid = (addr >> 16) & 0xFFFF; idt[vector].offset_high = (addr >> 32) & 0xFFFFFFFF; idt[vector].segment_selector = selector; idt[vector].ist = ist & 0x7; idt[vector].type_attr = type_attr; idt[vector].reserved = 0;}IDT Descriptor Fields Explained:
Offset (Handler Address): Split across three fields for historical reasons (16-bit, then 32-bit, then 64-bit extensions). Combined, they form the full 64-bit handler address.
Segment Selector: Points to a code segment descriptor in the GDT. For kernel interrupt handlers, this is typically the kernel code segment with DPL=0.
DPL (Descriptor Privilege Level): Determines who can trigger this interrupt via software (INT instruction). For hardware interrupts, DPL doesn't matter. For software traps like system calls, DPL=3 allows user code to execute INT n.
IST (Interrupt Stack Table): x86-64 feature allowing specific interrupts to use predetermined stacks. Critical for handling double faults and NMIs where the current stack may be corrupted.
| Gate Type | IF After Entry | Use Case |
|---|---|---|
| Interrupt Gate (0xE) | Cleared (interrupts disabled) | Hardware IRQs, exceptions needing atomicity |
| Trap Gate (0xF) | Unchanged | Debugger breakpoints, some exceptions |
The DPL field is a security barrier. If a software interrupt's gate has DPL=0, user-mode code cannot trigger it via INT n—the CPU will raise a General Protection Fault. This is why INT 0x80 (Linux syscall) has DPL=3 but INT 0 (divide error) has DPL=0. An attacker cannot directly invoke exception handlers.
When an interrupt occurs in protected mode, the CPU performs a complex sequence of validation, privilege checking, and state saving before transferring control. This sequence is hardwired into the processor.
Complete Interrupt Dispatch Sequence (x86-64):
Stack Frame After Interrupt (x86-64):
After the CPU completes its automatic push sequence, the stack contains:
1234567891011121314151617181920212223242526272829303132333435363738394041
/* * x86-64 Interrupt Stack Frame * (addresses increase downward, stack grows up) * * If privilege level changes (ring 3 → ring 0): * * +40: SS (old stack segment) * +32: RSP (old stack pointer) * +24: RFLAGS (processor flags) * +16: CS (old code segment) * +8: RIP (return address) * +0: Error Code (if applicable, CPU pushes) * ----> RSP points here when handler starts * * If same privilege level: * * +24: RFLAGS * +16: CS * +8: RIP * +0: Error Code (if applicable) * ----> RSP points here when handler starts */ // C structure matching the interrupt stack framestruct interrupt_frame { uint64_t rip; // Instruction to return to uint64_t cs; // Code segment uint64_t rflags; // Processor flags uint64_t rsp; // Stack pointer (only if ring change) uint64_t ss; // Stack segment (only if ring change)}; // For exceptions with error codes (page fault, GP fault, etc.)struct interrupt_frame_with_error { uint64_t error_code; // Pushed by CPU uint64_t rip; uint64_t cs; uint64_t rflags; uint64_t rsp; uint64_t ss;};The IRET (Interrupt Return) instruction reverses this entire process atomically. It pops RIP, CS, RFLAGS (and RSP, SS if returning to a different privilege level), restoring the interrupted context. The atomicity is crucial—no window exists where state is partially restored.
x86-64 introduced the Interrupt Stack Table (IST)—a mechanism to unconditionally switch to a known-good stack during interrupt handling. This solves a critical chicken-and-egg problem: what if the current stack is corrupted or exhausted, and we receive an interrupt?
The Problem IST Solves:
Consider a stack overflow in kernel code. The stack pointer now points to invalid memory. If an interrupt arrives:
With IST, specific interrupts (like NMI, double fault, machine check) can switch to a pre-configured, known-good stack unconditionally.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
// Interrupt Stack Table Setup in x86-64 // The TSS (Task State Segment) contains IST entriesstruct tss64 { uint32_t reserved0; uint64_t rsp0; // Stack for ring 0 (used for privilege changes) uint64_t rsp1; // Stack for ring 1 (rarely used) uint64_t rsp2; // Stack for ring 2 (rarely used) uint64_t reserved1; uint64_t ist[7]; // Interrupt Stack Table entries 1-7 // (IST index 0 in gate descriptor means "use normal rules") uint64_t reserved2; uint16_t reserved3; uint16_t iopb_offset; // I/O Permission Bitmap offset} __attribute__((packed)); // Linux IST stack assignments#define IST_DOUBLEFAULT 1 // Double fault handler#define IST_NMI 2 // Non-maskable interrupt#define IST_DEBUG 3 // Debug exceptions#define IST_MCE 4 // Machine check exceptions // Allocate IST stacks (per-CPU)DEFINE_PER_CPU(unsigned long, ist_doublefault_stack[8192/sizeof(long)]);DEFINE_PER_CPU(unsigned long, ist_nmi_stack[8192/sizeof(long)]);DEFINE_PER_CPU(unsigned long, ist_debug_stack[8192/sizeof(long)]);DEFINE_PER_CPU(unsigned long, ist_mce_stack[8192/sizeof(long)]); // Initialize TSS IST entries for this CPUvoid init_tss_ist(int cpu) { struct tss64 *tss = &per_cpu(cpu_tss, cpu); // IST stacks grow downward, so point to top of allocated region tss->ist[IST_DOUBLEFAULT - 1] = (unsigned long)&per_cpu(ist_doublefault_stack, cpu) + sizeof(ist_doublefault_stack); tss->ist[IST_NMI - 1] = (unsigned long)&per_cpu(ist_nmi_stack, cpu) + sizeof(ist_nmi_stack); tss->ist[IST_DEBUG - 1] = (unsigned long)&per_cpu(ist_debug_stack, cpu) + sizeof(ist_debug_stack); tss->ist[IST_MCE - 1] = (unsigned long)&per_cpu(ist_mce_stack, cpu) + sizeof(ist_mce_stack);} // Configuring an IDT gate to use ISTvoid set_ist_gate(uint8_t vector, void *handler, uint8_t ist_index) { // Set up normal gate fields... idt[vector].offset_low = (uint64_t)handler & 0xFFFF; idt[vector].segment_selector = KERNEL_CS; idt[vector].type_attr = GATE_INTERRUPT_64; idt[vector].offset_mid = ((uint64_t)handler >> 16) & 0xFFFF; idt[vector].offset_high = (uint64_t)handler >> 32; // Set IST index (1-7, or 0 for normal stack behavior) idt[vector].ist = ist_index; } // During boot:set_ist_gate(8, double_fault_handler, IST_DOUBLEFAULT);set_ist_gate(2, nmi_handler, IST_NMI);set_ist_gate(1, debug_handler, IST_DEBUG);set_ist_gate(18, machine_check_handler, IST_MCE);IST has a critical limitation: if the same IST interrupt occurs while its handler is running, the second interrupt will overwrite the first's stack frame (same stack pointer loaded unconditionally). Linux addresses this for NMI by carefully structuring the NMI handler and using a technique called 'NMI nesting' that copies the frame before enabling further NMIs.
Linux sets up the IDT during early boot, populating it with handlers for CPU exceptions, hardware interrupts, and system calls. Understanding this initialization illuminates how the kernel takes control of the interrupt mechanism.
Boot-Time IDT Setup:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475
// Simplified view of Linux IDT initialization (arch/x86/kernel/idt.c) // Exception entries (vectors 0-31)static const struct idt_entry exception_table[] = { // Vector Handler IST DPL { 0, divide_error, 0, 0 }, // #DE Divide Error { 1, debug, IST_DB, 0 }, // #DB Debug { 2, nmi, IST_NMI, 0 }, // NMI { 3, int3, 0, 3 }, // #BP Breakpoint (DPL=3 for user) { 4, overflow, 0, 0 }, // #OF Overflow { 5, bounds, 0, 0 }, // #BR Bound Range { 6, invalid_op, 0, 0 }, // #UD Invalid Opcode { 7, device_not_available, 0, 0 }, // #NM Device Not Available { 8, double_fault, IST_DF, 0 }, // #DF Double Fault { 10, invalid_TSS, 0, 0 }, // #TS Invalid TSS { 11, segment_not_present, 0, 0 }, // #NP Segment Not Present { 12, stack_segment, 0, 0 }, // #SS Stack Fault { 13, general_protection, 0, 0 }, // #GP General Protection { 14, page_fault, 0, 0 }, // #PF Page Fault { 16, coprocessor_error, 0, 0 }, // #MF x87 FP Error { 17, alignment_check, 0, 0 }, // #AC Alignment Check { 18, machine_check, IST_MCE, 0 },// #MC Machine Check { 19, simd_coprocessor_error, 0, 0 }, // #XM SIMD Exception { 20, virtualization_exception, 0, 0 }, // #VE Virtualization // ... more exceptions ...}; // Hardware IRQ entries (vectors 32-255)// Most are initially set to a common stub, then configured per-device // System call entry (traditional INT 0x80)static const struct idt_entry syscall_entry = { .vector = 0x80, .handler = entry_INT80_compat, .ist = 0, .dpl = 3, // User space can trigger INT 0x80}; // Main initialization functionvoid __init idt_setup_early_traps(void) { // Load exception handlers (critical path) for (int i = 0; i < ARRAY_SIZE(exception_table); i++) { set_idt_gate(exception_table[i].vector, exception_table[i].handler, KERNEL_CS, gate_type(exception_table[i].dpl), exception_table[i].ist); } // Load the IDT register load_idt();} void __init idt_setup_traps(void) { // Set up remaining handlers after early boot set_idt_gate(0x80, entry_INT80_compat, KERNEL_CS, GATE_SYSCALL, 0); // DPL=3 for syscall} void __init idt_setup_apic(void) { // Set up APIC interrupt vectors for_each_possible_cpu(cpu) { set_handler_irq(LOCAL_TIMER_VECTOR, local_apic_timer_interrupt); set_handler_irq(SPURIOUS_APIC_VECTOR, spurious_interrupt); // ... more APIC vectors ... }} void __init idt_setup_ist_traps(void) { // Configure IST stack assignments set_ist_gate(2, nmi, IST_NMI); set_ist_gate(1, debug, IST_DB); set_ist_gate(8, double_fault, IST_DF); set_ist_gate(18, machine_check, IST_MCE);}The IDT Lifecycle:
Viewing the Current IDT:
12345678910111213141516171819202122232425262728
# View interrupt handlers and stats in Linux # Current interrupt counts per CPU and IRQ$ cat /proc/interrupts CPU0 CPU1 CPU2 CPU3 0: 22 0 0 0 IO-APIC 2-edge timer 1: 0 0 0 9 IO-APIC 1-edge i8042 8: 0 0 0 0 IO-APIC 8-edge rtc0 9: 0 4 0 0 IO-APIC 9-fasteoi acpi 16: 0 0 0 0 IO-APIC 16-fasteoi ehci_hcd 23: 156 0 0 0 IO-APIC 23-fasteoi ehci_hcd 24: 0 0 0 0 PCI-MSI 327680-edge xhci_hcd 25: 0 1892 0 0 PCI-MSI 512000-edge ahci 26: 0 0 12487 0 PCI-MSI 1048576-edge nvme0q0 27: 0 0 0 89432 PCI-MSI 1048577-edge nvme0q1NMI: 0 0 0 0 Non-maskable interruptsLOC: 123456 118234 125678 119087 Local timer interrupts... # More detailed IRQ information$ ls /proc/irq/0 1 10 11 12 13 14 15 16 17 18 19 2 20 ... $ cat /proc/irq/25/smp_affinityf # All 4 CPUs can handle this IRQ (bitmask) $ cat /proc/irq/25/affinity_hint8 # Hint: CPU 3 preferred (bitmask)The kernel protects the IDT from modification by user-space code (it's in kernel memory) and by kernel modules using memory protection. Some security features like SMAP/SMEP add additional protection. Modern exploits targeting the IDT face multiple defensive layers.
While we've focused on x86, every processor architecture has some form of interrupt vector mechanism. Understanding the variations helps appreciate design trade-offs and prepares you for cross-platform work.
ARM Exception Vector Table:
ARM uses a fundamentally different approach. Instead of a table of addresses, ARM has a table of instructions at the vector location. Each exception type has exactly 4 bytes (one instruction), which is typically a branch to the actual handler.
ARMv7 and Earlier:
ARMv8 (AArch64):
// ARMv8 exception vector table entry points
// Each entry point gets 128 bytes (32 instructions)
// Organized by target EL and source EL/SP
.align 11 // 2^11 = 2048 byte alignment
exception_vectors:
// From same EL, using SP_EL0 (entries 0-3)
b sync_current_el_sp0 // +0x000
b irq_current_el_sp0 // +0x080
b fiq_current_el_sp0 // +0x100
b serror_current_el_sp0 // +0x180
// From same EL, using SP_ELx (entries 4-7)
b sync_current_el_spx // +0x200
b irq_current_el_spx // +0x280
// ... etc
| Feature | x86 (IDT) | ARM64 | RISC-V |
|---|---|---|---|
| Table Location | Configurable (IDTR) | Configurable (VBAR_ELx) | Configurable (mtvec) |
| Entry Size | 16 bytes | 128 bytes | 4 bytes (vectored) |
| Max Entries | 256 | 16 (4 types × 4 source modes) | Varies |
| Entry Content | Descriptor (address + metadata) | Instructions (branch/code) | Instruction (jump) |
| Privilege Check | DPL in descriptor | Exception level inherent | CSR access controls |
We've explored how processors map interrupt numbers to handler addresses. Let's consolidate the key takeaways:
What's Next:
With a solid understanding of how handlers are located, the next page explores interrupt priority—how systems determine which interrupt to service when multiple arrive simultaneously, and how priority affects handler preemption and overall system responsiveness.
You now understand the data structures that connect hardware interrupt signals to software handlers. The interrupt vector table is one of the most fundamental concepts in systems programming—it's how every device driver, every exception handler, and every system call enters the kernel.