Lesson 21-Exception Handling and Interrupt Mechanism Internals

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:

  1. You divide by zero (DIV with divisor = 0)?
  2. You access memory that doesn’t exist (null pointer dereference)?
  3. You try to execute privileged instructions in user mode?
  4. A hardware device needs attention (keyboard keypress, network packet arrival)?
  5. 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:

  1. Inefficient: The CPU wastes cycles checking devices that don’t need anything.
  2. Slow: If a device needs attention right now (like a network packet arrival), polling might not detect it until milliseconds later.
  3. 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 INT instruction (e.g., INT 0x80 for Linux system calls, INT 0x21 for 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:

  1. 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.
  2. 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 3 instruction). The debugger runs, then continues with the next instruction.
  3. 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:

  1. The address of the handler (the code to run when that interrupt/exception occurs)
  2. The segment selector (the code segment where the handler lives)
  3. The privilege level (ring 0 = kernel mode, ring 3 = user mode)
  4. 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:

  1. 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.
  2. 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 gate
      • 0x7 = 16-bit trap gate
      • 0xE = 32-bit interrupt gate
      • 0xF = 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 IF flag).
  • 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 n instruction 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:

  1. 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).
  2. 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:

  1. Whether we’re switching privilege levels (e.g., from ring 3 to ring 0)
  2. 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:#e3f2fd

The CPU pushes these values onto the current stack (or a different stack if we’re switching privilege levels — more on that later):

  1. EFLAGS: The current flags register.
  2. CS (Code Segment): The segment of the interrupted code.
  3. 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:

  1. Switches to the kernel stack: It loads SS and ESP from the TSS (Task State Segment).
  2. Pushes the user-mode SS and ESP onto 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:

  1. Loads CS and EIP from the IDT entry (the handler address).
  2. Sets the IF flag 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:

  1. Pops EIP from the stack.
  2. Pops CS from the stack.
  3. Pops EFLAGS from the stack.
  4. If we switched stacks: Pops the user-mode ESP and SS from 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:

  1. Saves all registers it will modify (because the interrupted code might be using those registers).
  2. Does its work (handles the interrupt).
  3. Restores all registers.
  4. Executes IRET to 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:

NumberNameTypeDescription
0#DE (Divide Error)FaultDivision by zero, or result too large for destination
1#DB (Debug)Fault/TrapDebug exception (breakpoints, single-step)
3#BP (Breakpoint)TrapINT 3 instruction (used by debuggers)
4#OF (Overflow)TrapINTO instruction (if OF flag set)
6#UD (Invalid Opcode)FaultInvalid/undecoded instruction
8#DF (Double Fault)AbortException while handling exception
10#TS (Invalid TSS)FaultInvalid Task State Segment
11#NP (Segment Not Present)FaultSegment not present
12#SS (Stack Fault)FaultStack segment fault
13#GP (General Protection)FaultGeneral protection violation
14#PF (Page Fault)FaultPage fault (missing page, protection violation)
16#MF (x87 FPU Error)Faultx87 FPU floating-point error
17#AC (Alignment Check)FaultUnaligned memory access

The Most Important Ones for Assembly Programmers

  1. #DE (Divide Error, interrupt 0): Triggered by DIV or IDIV with divisor = 0, or result too large. MOV AX, 10 MOV BL, 0 DIV BL ; Triggers #DE (divide by zero)
  2. #BP (Breakpoint, interrupt 3): Triggered by INT 3 (opcode 0xCC). Used by debuggers to set breakpoints. INT 3 ; Trigger breakpoint (debugger will catch this)
  3. #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!)
  4. #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)
    The page fault handler can fix this by loading the page from disk (for demand paging) or by sending a SIGSEGV to 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:

  1. Set EAX to the system call number (e.g., 1 = exit, 4 = write).
  2. Set the other registers to the arguments.
  3. 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:

  1. Supports more IRQs (24+ instead of 16).
  2. Supports processor affinity (you can direct an interrupt to a specific CPU core).
  3. 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
Share your love