Introduction: When Things Go Wrong (and They Will)
Let me tell you something that might make you anxious: everything we’ve discussed so far assumes that things go right.
You write MOV EAX, EBX — and it works. You write ADD EAX, ECX — and it works. You write CALL some_function — and it works.
But what happens when things go wrong? What happens when:
- You divide by zero (
DIVwith divisor = 0)? - You access memory that doesn’t exist (null pointer dereference)?
- You try to execute privileged instructions in user mode?
- A hardware device needs attention (keyboard keypress, network packet arrival)?
- The user presses Ctrl+C to interrupt the program?
These are exceptions and interrupts, and they’re the mechanisms by which the CPU says: “Hey, something happened that the currently running code can’t handle. Let’s switch to a different piece of code that can handle it.”
If data transfer instructions (MOV, PUSH, POP) are the nouns of assembly language, and arithmetic instructions (ADD, SUB, MUL) are the verbs, then interrupts and exceptions are the punctuation — they’re the mechanisms that let you break the normal flow of execution to handle special events.
In this article, we’re going to dive deep into the interrupt and exception mechanism of the x86 architecture. We’ll talk about what interrupts and exceptions are, how they’re implemented in hardware, how to write interrupt handlers in assembly, and the famous Interrupt Descriptor Table (IDT) that controls it all.
This is critical material. If you don’t understand exceptions and interrupts, you can’t write an operating system kernel. You can’t write device drivers. You can’t even debug your assembly code effectively (because you won’t understand what happens when a segfault occurs).
What Are Interrupts and Exceptions? (And Why You Should Care)
Let’s start with the fundamental question: what is an interrupt?
An interrupt is a mechanism by which the CPU can temporarily suspend the currently executing code, switch to a different piece of code (the interrupt handler), execute that handler, and then resume the originally executing code.
Think of an interrupt like a phone call while you’re working. You’re deep in concentration, writing assembly code. Suddenly, the phone rings. You suspend your work, answer the phone, handle the conversation, and then resume your work where you left off.
Interrupts are essential because they allow the CPU to respond to events in real time. Without interrupts, the CPU would have to poll (continuously check) every device to see if it needs attention. This is:
- Inefficient: The CPU wastes cycles checking devices that don’t need anything.
- Slow: If a device needs attention right now (like a network packet arrival), polling might not detect it until milliseconds later.
- Complicated: You’d have to write polling loops for every device in your main program.
With interrupts, devices can signal the CPU when they need attention. The CPU stops what it’s doing, handles the device, and then resumes.
Exceptions vs. Interrupts: What’s the Difference?
The x86 architecture (and most modern architectures) distinguish between interrupts and exceptions, though they’re handled similarly:
Interrupts (External)
Interrupts are external events — they come from outside the CPU. Specifically:
- Hardware interrupts come from devices (keyboard, mouse, network card, timer, etc.) via the Interrupt Controller (PIC or APIC).
- Software interrupts are triggered by the
INTinstruction (e.g.,INT 0x80for Linux system calls,INT 0x21for DOS API calls).
Interrupts are asynchronous — they can happen at any time, regardless of what the CPU is currently executing (except when interrupts are disabled, which we’ll discuss).
Exceptions (Internal)
Exceptions are internal events — they’re triggered by something the CPU itself detected while executing instructions.
Exceptions are synchronous — they happen as a direct result of executing a specific instruction.
x86 classifies exceptions into three categories:
- Faults: These are recoverable. The CPU can restart the faulting instruction after the handler fixes the problem.
- Example: Page fault (trying to access a page that’s swapped out to disk). The handler loads the page from disk, then restarts the instruction.
- Traps: These are reporting events. The CPU executes the instruction completely, then takes the trap. The handler runs, and then execution continues with the next instruction.
- Example: Breakpoint (debugger
INT 3instruction). The debugger runs, then continues with the next instruction.
- Example: Breakpoint (debugger
- Aborts: These are unrecoverable. The CPU can’t continue executing the faulting code. The handler typically terminates the program.
- Example: Double fault (an exception occurred while handling another exception). The OS typically kills the program (or crashes itself).
The Difference Matters (A Lot!)
Understanding the difference between faults, traps, and aborts is essential for debugging:
- If you hit a fault (like a page fault), and the handler fixes it, the same instruction runs again. If the handler doesn’t fix it, it’s a bug.
- If you hit a trap (like a breakpoint), the next instruction runs after the handler. This is what debuggers use.
- If you hit an abort (like a double fault), the program can’t continue. This is a “your program is dead” situation.
The Interrupt Descriptor Table (IDT): The CPU’s “Phone Directory”
If interrupts and exceptions are like phone calls, then the Interrupt Descriptor Table (IDT) is like the CPU’s phone directory — it tells the CPU which number to call (which handler to run) for each type of interrupt or exception.
What Is the IDT?
The IDT is a table in memory that contains 8-byte descriptors (called Gate Descriptors), one for each possible interrupt or exception.
x86 supports 256 possible interrupts/exceptions (numbered 0-255), so the IDT has 256 entries.
Each entry in the IDT specifies:
- The address of the handler (the code to run when that interrupt/exception occurs)
- The segment selector (the code segment where the handler lives)
- The privilege level (ring 0 = kernel mode, ring 3 = user mode)
- Whether the entry is present (valid)
The IDTR Register: Finding the IDT
The CPU needs to know where the IDT is in memory. This is stored in the IDTR (Interrupt Descriptor Table Register).
The IDTR is a 48-bit register that contains:
- Base address (32 bits in 32-bit mode, 64 bits in 64-bit mode): The physical address of the start of the IDT in memory.
- Limit (16 bits): The size of the IDT minus 1. (Since there are 256 entries × 8 bytes = 2048 bytes, the limit is typically
0x0FFF= 2047.)
The LIDT instruction loads the IDTR from memory:
; Load IDTR from memory
LIDT [idt_descriptor] ; Load IDTR with base and limit from memory
The SIDT instruction stores the IDTR to memory:
; Store IDTR to memory
SIDT [idt_descriptor] ; Store IDTR to memory
IDT Gate Descriptor Format (32-bit Protected Mode)
Each entry in the IDT is an 8-byte Gate Descriptor. Here’s the format for 32-bit protected mode:
Byte: 0 1 2 3 4 5 6 7
┌────────┬────────┬────────┬────────┬────────┬────────┬────────┬────────┐
│ Offset │ Offset │ Segment │ Flags │ Zero │ Offset │ Offset │
│ (low) │ (medium) │ Selector│ (Type) │ (must) │ (high) │ (highest)│
└────────┴────────┴────────┴────────┴────────┴────────┴────────┴────────┘
More specifically:
- Bytes 0-1: Offset low (bits 0-15 of handler address)
- Bytes 2-3: Segment Selector (the code segment where the handler lives)
- Byte 4: Zero (must be 0)
- Byte 5: Flags/Type
- Bit 7-5: Reserved
- Bit 4: Storage Segment (0 for interrupt gates)
- Bit 3-0: Gate type:
0x6= 16-bit interrupt gate0x7= 16-bit trap gate0xE= 32-bit interrupt gate0xF= 32-bit trap gate
- Bits 5-7: DPL (Descriptor Privilege Level) — which ring can trigger this interrupt?
- Bit 7: P (Present) — is this gate valid?
- Bytes 6-7: Offset high (bits 16-31 of handler address)
For a 32-bit interrupt gate (type 0xE):
- The CPU disables further interrupts while the handler runs (by clearing
IFflag). - The CPU saves the EFLAGS register on the stack.
For a 32-bit trap gate (type 0xF):
- The CPU does NOT disable further interrupts.
- The CPU saves the EFLAGS register on the stack.
The difference matters for reentrant interrupt handlers (handlers that can be interrupted by another interrupt). Most OS kernels use interrupt gates for most exceptions, and trap gates for system calls (like INT 0x80 in Linux).
Setting Up the IDT (Simplified Example)
Let me show you how to set up a simple IDT in 32-bit protected mode. (This is OS kernel code, so it runs in ring 0.)
; Data section
section .data
; IDT descriptor (for LIDT instruction)
idt_descriptor:
dw 0x0FFF ; Limit = 2047 (256 entries × 8 bytes - 1)
dd 0x00000000 ; Base address (will fill in later)
; The actual IDT (256 entries × 8 bytes = 2048 bytes)
align 8 ; Align to 8-byte boundary
idt:
times 256 dq 0 ; 256 entries, initially zero (invalid)
; Code section
section .text
; Function: set_up_idt
; Sets up the IDT with a few basic handlers
set_up_idt:
; First, zero out the entire IDT
PUSH EDI
MOV EDI, idt
MOV ECX, 256 ; 256 entries
XOR EAX, EAX ; Zero
REP STOSD ; Fill with zeros (dblword at a time)
POP EDI
; Set up handler for interrupt 0 (divide by zero error)
; Handler address = divide_error_handler
MOV EAX, divide_error_handler
MOV BX, 0x08 ; Segment selector for kernel code segment
MOV CX, 0x8E00 ; Flags: Present=1, DPL=0, 32-bit interrupt gate (0xE)
CALL set_idt_gate ; Set IDT gate for interrupt 0
; Set up handler for interrupt 0x80 (Linux system call)
MOV EAX, syscall_handler
MOV BX, 0x08 ; Segment selector for kernel code segment
MOV CX, 0xEF00 ; Flags: Present=1, DPL=3, 32-bit trap gate (0xF)
CALL set_idt_gate ; Set IDT gate for interrupt 0x80
; Load the IDTR
LIDT [idt_descriptor]
RET
; Function: set_idt_gate
; Inputs: EAX = handler address, BL = interrupt number,
; BX = segment selector, CX = flags
set_idt_gate:
PUSH EDI
PUSH EAX
; Calculate address of IDT entry
XOR EDI, EDI
MOV DL, BL ; DL = interrupt number
SHL EDX, 3 ; EDX = interrupt number × 8 (byte offset)
ADD EDX, idt ; EDX = address of IDT entry
; Fill in the IDT entry
POP EAX ; EAX = handler address
MOV [EDX], AX ; Offset low (bits 0-15)
SHR EAX, 16 ; EAX = offset high (bits 16-31)
MOV [EDX + 6], AX ; Offset high (bits 16-31)
POP EDI
RET
This is heavily simplified — a real OS kernel would set up handlers for all 256 possible interrupts/exceptions, not just 2. But it shows you the structure.
The Interrupt/Exception Handling Process: Step by Step
Now that we know what the IDT is, let’s talk about what happens when an interrupt or exception occurs. This is a multi-step process that involves the CPU, the MMU (Memory Management Unit), and the interrupt controller.
Step 1: The Event Occurs
Something happens that triggers an interrupt or exception:
- Hardware interrupt: A device asserts its interrupt line. The Interrupt Controller (PIC or APIC) signals the CPU.
- Software interrupt: The
INT ninstruction executes. - Exception: The CPU detects a problem while executing an instruction (divide by zero, page fault, etc.).
Step 2: The CPU Checks If It Should Handle the Interrupt
The CPU doesn’t always handle an interrupt immediately. It checks:
- Is the interrupt maskable? Some interrupts are maskable (can be disabled). If the
IF(Interrupt Flag) in EFLAGS is 0, the CPU ignores maskable interrupts.- Non-maskable interrupts (NMI): These can’t be disabled. They’re used for critical events (like hardware errors).
- Is the interrupt allowed at the current privilege level? The IDT gate has a DPL (Descriptor Privilege Level). If the currently running code’s privilege level (CPL) is lower (more privileged) than the DPL, the interrupt is allowed. Otherwise, the CPU raises a #GP (General Protection Fault).
- This prevents user-mode code from triggering kernel-mode-only interrupts.
Step 3: The CPU Saves State on the Stack
If the CPU decides to handle the interrupt, it needs to save the current state so it can resume later.
What gets saved depends on:
- Whether we’re switching privilege levels (e.g., from ring 3 to ring 0)
- Whether we’re using an interrupt gate or trap gate
For a 32-bit interrupt gate (the most common case):
graph TD
A[Interrupt/Exception Occurs] --> B{Interrupt<br/>Enabled?}
B -->|No| C[Ignore Maskable<br/>Interrupt]
B -->|Yes| D[Save EFLAGS<br/>to Stack]
D --> E[Save CS<br/>to Stack]
E --> F[Save EIP<br/>to Stack]
F --> G[Switch to<br/>Handler]
G --> H[Execute Handler]
H --> I[IRET<br/>Restores State]
I --> J[Resume<br/>Interrupted Code]
style A fill:#ffcdd2
style D fill:#c8e6c9
style E fill:#c8e6c9
style F fill:#c8e6c9
style I fill:#e3f2fd
style J fill:#e3f2fdThe CPU pushes these values onto the current stack (or a different stack if we’re switching privilege levels — more on that later):
- EFLAGS: The current flags register.
- CS (Code Segment): The segment of the interrupted code.
- EIP (Instruction Pointer): The address of the next instruction to execute (for traps) or the faulting instruction (for faults).
If we’re switching from user mode (ring 3) to kernel mode (ring 0), the CPU also:
- Switches to the kernel stack: It loads
SSandESPfrom the TSS (Task State Segment). - Pushes the user-mode
SSandESPonto the kernel stack (so the handler can resume the user stack later).
Step 4: The CPU Looks Up the IDT Entry
The CPU takes the interrupt/exception number (e.g., 0 for divide error, 0x80 for Linux system call, 14 for page fault) and looks up the corresponding entry in the IDT.
If the IDT entry is not present (P bit = 0), the CPU raises a #GP (General Protection Fault).
If the entry is present, the CPU:
- Loads
CSandEIPfrom the IDT entry (the handler address). - Sets the
IFflag to 0 (if it’s an interrupt gate — this disables further interrupts).
Step 5: The Handler Executes
The CPU jumps to the handler code. The handler does whatever it needs to do:
- For a divide-by-zero exception: Maybe print an error message and terminate the program.
- For a page fault exception: Maybe load the missing page from disk.
- For a timer interrupt: Maybe switch to a different process (context switch).
- For a system call (INT 0x80): Maybe read a file, write to a file, etc.
Step 6: The Handler Returns with IRET
After the handler finishes, it executes the IRET (Interrupt Return) instruction.
IRET does the opposite of the interrupt entry:
- Pops
EIPfrom the stack. - Pops
CSfrom the stack. - Pops
EFLAGSfrom the stack. - If we switched stacks: Pops the user-mode
ESPandSSfrom the kernel stack.
After IRET, the CPU resumes the interrupted code exactly where it left off.
Writing an Interrupt Handler (in Assembly)
Let’s write a simple interrupt handler in assembly. We’ll handle interrupt 0 (divide by zero error).
The Handler Structure
A typical interrupt handler in assembly:
- Saves all registers it will modify (because the interrupted code might be using those registers).
- Does its work (handles the interrupt).
- Restores all registers.
- Executes
IRETto return.
; Handler for divide-by-zero error (interrupt 0)
divide_error_handler:
; Step 1: Save registers we'll modify
PUSH EAX
PUSH EBX
PUSH ECX
PUSH EDX
PUSH ESI
PUSH EDI
; Step 2: Do our work
; For this example, let's just print an error message
; (In a real OS, we'd probably terminate the program.)
; Call a C function to print the message
PUSH dword divide_error_msg
CALL print_string
ADD ESP, 4 ; Clean up stack (cdecl convention)
; Step 3: Restore registers
POP EDI
POP ESI
POP EDX
POP ECX
POP EBX
POP EAX
; Step 4: Return from interrupt
IRET
section .data
divide_error_msg db "Divide by zero error!", 0
A More Complex Example: Timer Interrupt Handler
The timer interrupt (typically IRQ 0, which maps to interrupt 0x20 on x86 systems using the PIC) is responsible for preemptive multitasking — it’s what allows the OS to switch between processes even if they don’t cooperate.
; Handler for timer interrupt (IRQ 0 → INT 0x20)
timer_handler:
; Save registers
PUSHAD ; Save all general-purpose registers (EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI)
PUSHFD ; Save EFLAGS
; Acknowledge the interrupt to the PIC
MOV AL, 0x20 ; OCW2: End of Interrupt (EOI) command
OUT 0x20, AL ; Send to PIC command port
; Now, do the actual work: context switch
; (This is *extremely* simplified — a real context switch is way more complex.)
; 1. Save the current process's state (stack pointer, etc.)
MOV [current_process_esp], ESP
; 2. Load the next process's state
MOV ESP, [next_process_esp]
; 3. Update the "current process" pointer
MOV EAX, [next_process]
MOV [current_process], EAX
; Restore registers
POPFD ; Restore EFLAGS
POPAD ; Restore all general-purpose registers
; Return from interrupt
IRET
This is extremely simplified. A real context switch would also:
- Save/restore segment registers (
DS,ES,FS,GS) - Save/restore floating-point state (x87, SSE, AVX)
- Update the page tables (switch to the next process’s address space)
- Possibly handle signals (Unix-style asynchronous notifications)
But it gives you the idea: the timer interrupt is what makes preemptive multitasking possible.
Common Interrupts and Exceptions (x86)
Let’s go through the most common interrupts and exceptions you’ll encounter in x86 assembly programming.
Exceptions (0-31)
x86 reserves interrupt numbers 0-31 for exceptions. Here are the important ones:
| Number | Name | Type | Description |
|---|---|---|---|
| 0 | #DE (Divide Error) | Fault | Division by zero, or result too large for destination |
| 1 | #DB (Debug) | Fault/Trap | Debug exception (breakpoints, single-step) |
| 3 | #BP (Breakpoint) | Trap | INT 3 instruction (used by debuggers) |
| 4 | #OF (Overflow) | Trap | INTO instruction (if OF flag set) |
| 6 | #UD (Invalid Opcode) | Fault | Invalid/undecoded instruction |
| 8 | #DF (Double Fault) | Abort | Exception while handling exception |
| 10 | #TS (Invalid TSS) | Fault | Invalid Task State Segment |
| 11 | #NP (Segment Not Present) | Fault | Segment not present |
| 12 | #SS (Stack Fault) | Fault | Stack segment fault |
| 13 | #GP (General Protection) | Fault | General protection violation |
| 14 | #PF (Page Fault) | Fault | Page fault (missing page, protection violation) |
| 16 | #MF (x87 FPU Error) | Fault | x87 FPU floating-point error |
| 17 | #AC (Alignment Check) | Fault | Unaligned memory access |
The Most Important Ones for Assembly Programmers
#DE(Divide Error, interrupt 0): Triggered byDIVorIDIVwith divisor = 0, or result too large.MOV AX, 10 MOV BL, 0 DIV BL ; Triggers #DE (divide by zero)#BP(Breakpoint, interrupt 3): Triggered byINT 3(opcode0xCC). Used by debuggers to set breakpoints.INT 3 ; Trigger breakpoint (debugger will catch this)#GP(General Protection Fault, interrupt 13): The “catch-all” exception. Triggered by:- Accessing memory with wrong privileges
- Loading an invalid segment selector
- Executing a privileged instruction in user mode
; This will trigger #GP in user mode (ring 3) MOV CR0, EAX ; Attempt to modify control register (privileged!)#PF(Page Fault, interrupt 14): Triggered by accessing a page that’s:- Not present in memory (swapped out to disk)
- Protected (e.g., writing to a read-only page)
SIGSEGVto the process (for protection violations).
Software Interrupts (0x20-0xFF)
Interrupt numbers 0x20-0xFF are available for software interrupts (triggered by INT n instruction) and hardware interrupts (from devices).
The Most Famous One: INT 0x80 (Linux System Call)
In 32-bit Linux, the system call interface uses INT 0x80.
To make a system call:
- Set
EAXto the system call number (e.g.,1=exit,4=write). - Set the other registers to the arguments.
- Execute
INT 0x80.
; Linux system call: write(1, "Hello\n", 6)
; System call number for `write` = 4
; Arguments: EBX = fd, ECX = buffer, EDX = count
section .data
hello_msg db "Hello", 10
section .text
global _start
_start:
MOV EAX, 4 ; System call number: 4 = write
MOV EBX, 1 ; Arg 1: fd = 1 (stdout)
MOV ECX, hello_msg ; Arg 2: buffer
MOV EDX, 6 ; Arg 3: count = 6
INT 0x80 ; Trigger system call!
; Now exit
MOV EAX, 1 ; System call number: 1 = exit
MOV EBX, 0 ; Arg 1: exit code = 0
INT 0x80 ; Trigger system call!
This is extremely inefficient (taking an interrupt is slow — hundreds of CPU cycles). Modern Linux uses the SYSENTER/SYSEXIT instructions or SYSCALL/SYSRET instructions (on AMD) for faster system calls.
But INT 0x80 is historically important and easy to understand.
DOS Interrupts (For 16-bit Real Mode)
If you’re writing 16-bit DOS programs (using MASM/TASM in DOSBox), you’ll use DOS interrupts extensively:
INT 0x21: DOS API (open file, read file, write file, etc.)INT 0x10: BIOS video services (change video mode, write character to screen, etc.)INT 0x16: BIOS keyboard services (read keypress)
; 16-bit DOS program: print "Hello" using INT 0x21
.MODEL SMALL
.STACK 100h
.DATA
hello_msg db "Hello$", 0 ; '$' terminates the string for INT 0x21
.CODE
main PROC
MOV AX, @DATA
MOV DS, AX
; Print string using DOS INT 0x21, function 09h
MOV AH, 09h ; Function 09h = print string
LEA DX, hello_msg ; DS:DX = pointer to string
INT 0x21 ; Call DOS
; Exit using INT 0x21, function 4Ch
MOV AH, 4Ch
MOV AL, 0
INT 0x21 ; Call DOS (exit)
main ENDP
END main
The PIC and APIC: Managing Hardware Interrupts
We’ve talked about what interrupts are and how they’re handled. Now let’s talk about where they come from: hardware devices.
The PIC (Programmable Interrupt Controller)
In the early days of x86 (the 8086/8088 era), there was a single PIC (Programmable Interrupt Controller) chip called the 8259A.
The 8259A manages 8 hardware interrupt lines (IRQ 0-IRQ 7). When a device needs attention, it asserts one of these lines. The 8259A signals the CPU (via the INTR pin), and the CPU handles the interrupt.
The PIC also supports interrupt prioritization: lower-numbered IRQs have higher priority.
The Two PIC Configuration (For 16 IRQs)
The original PC had only 8 IRQs (not enough!). So, IBM added a second 8259A PIC, cascaded to the first one. This gives 16 IRQs (IRQ 0-IRQ 15).
The standard mapping:
- IRQ 0: Timer
- IRQ 1: Keyboard
- IRQ 2: Cascade (connects to second PIC)
- IRQ 3: COM2 (serial port 2)
- IRQ 4: COM1 (serial port 1)
- IRQ 5: LPT2 (parallel port 2) / Sound card
- IRQ 6: Floppy disk
- IRQ 7: LPT1 (parallel port 1)
- IRQ 8-15: Available for devices (network card, sound card, etc.)
Remapping the PIC (Important!)
The PIC’s interrupt numbers conflict with the CPU’s exceptions (0-31). By default, the PIC sends interrupts numbered 0-15, but exceptions 0-31 are already used!
So, the OS remaps the PIC to use interrupt numbers 0x20-0x2F (32-47).
; Remap the PIC (8259A) to use interrupts 0x20-0x2F
; This is OS kernel code (ring 0)
remap_pic:
; Save current interrupt masks
IN AL, 0x21 ; Read master PIC IMR (Interrupt Mask Register)
PUSH EAX ; Save it
IN AL, 0xA1 ; Read slave PIC IMR
PUSH EAX ; Save it
; Start initialization sequence (ICW1)
MOV AL, 0x11 ; ICW1: Initialization + ICW4 needed
OUT 0x20, AL ; Send to master PIC
OUT 0xA0, AL ; Send to slave PIC
; Set master PIC vector offset (ICW2)
MOV AL, 0x20 ; Master PIC → interrupts 0x20-0x27
OUT 0x21, AL ; Send to master PIC
; Set slave PIC vector offset (ICW2)
MOV AL, 0x28 ; Slave PIC → interrupts 0x28-0x2F
OUT 0xA1, AL ; Send to slave PIC
; Set master PIC cascade (ICW3)
MOV AL, 0x04 ; Master PIC has slave at IRQ 2
OUT 0x21, AL
; Set slave PIC cascade (ICW3)
MOV AL, 0x02 ; Slave PIC cascade identity
OUT 0xA1, AL
; Set both PICs to 8086 mode (ICW4)
MOV AL, 0x01 ; ICW4: 8086 mode
OUT 0x21, AL
OUT 0xA1, AL
; Restore saved interrupt masks
POP EAX
OUT 0xA1, AL ; Restore slave PIC IMR
POP EAX
OUT 0x21, AL ; Restore master PIC IMR
RET
This is complex and ugly, which is why modern systems use the APIC.
The APIC (Advanced Programmable Interrupt Controller)
The APIC is the modern replacement for the PIC. It’s used in multiprocessor (and even single-processor) systems.
The APIC has several advantages:
- Supports more IRQs (24+ instead of 16).
- Supports processor affinity (you can direct an interrupt to a specific CPU core).
- Supports MSI (Message-Signaled Interrupts): Devices can signal interrupts by writing to a memory address (faster than the old PIC protocol).
If you’re writing a modern OS kernel (for x86-64), you’ll use the APIC, not the old PIC.
Common Bugs: Forgetting to Restore Registers, Stack Imbalance
Writing interrupt handlers is tricky. Here are the most common bugs:
Bug #1: Forgetting to Save/Restore Registers
Symptom: The interrupted program crashes or produces incorrect results after the interrupt handler runs.
Cause: The handler modified a register that the interrupted code was using.
Fix: Always save all registers at the start of the handler, and restore them before IRET.
; WRONG: doesn't save/restore registers
timer_handler:
MOV EAX, [tick_count] ; Modifies EAX!
INC EAX
MOV [tick_count], EAX
IRET ; BOOM! The interrupted code's EAX is lost!
; CORRECT: saves/restores registers
timer_handler:
PUSH EAX ; Save EAX
MOV EAX, [tick_count]
INC EAX
MOV [tick_count], EAX
POP EAX ; Restore EAX
IRET
Actually, the best way is to use PUSHAD (push all doublewords) and POPAD:
; BEST: uses PUSHAD/POPAD
timer_handler:
PUSHAD ; Save EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI
; ... do work ...
POPAD ; Restore all general-purpose registers
IRET
Bug #2: Stack Imbalance
Symptom: The system crashes with a #GP or #SS (stack fault) after handling a few interrupts.
Cause: The handler pushes values onto the stack but doesn’t pop them (or pops the wrong number).
Fix: Make sure the stack pointer (ESP) is exactly the same before and after the handler.
; WRONG: stack imbalance
timer_handler:
PUSH EAX
PUSH EBX
; ... do work ...
POP EAX ; Oops! Should be POP EBX, then POP EAX
IRET ; BOOM! Stack is unbalanced!
; CORRECT:
timer_handler:
PUSH EAX
PUSH EBX
; ... do work ...
POP EBX ; Pop in REVERSE order
POP EAX
IRET
Bug #3: Not Sending EOI (End of Interrupt)
Symptom: The system stops responding to a device after the first interrupt.
Cause: The PIC/APIC won’t send another interrupt from that device until it receives an EOI (End of Interrupt) signal.
Fix: Always send EOI to the PIC/APIC before returning from the handler.
; WRONG: doesn't send EOI
timer_handler:
PUSHAD
; ... do work ...
POPAD
IRET ; BOOM! Timer will never interrupt again!
; CORRECT:
timer_handler:
PUSHAD
; ... do work ...
; Send EOI to master PIC
MOV AL, 0x20
OUT 0x20, AL
POPAD
IRET



