Loading learning content...
In the early days of computing, processors had to constantly check whether I/O devices were ready—a wasteful practice called polling that consumed precious CPU cycles asking "Are you ready yet?" over and over. This approach was fundamentally flawed: imagine if you had to constantly walk to your mailbox every second to check for mail, instead of simply waiting for the mail carrier to ring your doorbell.
Interrupts transformed this paradigm entirely. They provide a mechanism for hardware devices to proactively signal the CPU when they require attention, freeing the processor to perform useful work until that signal arrives. This single innovation fundamentally changed how computers interact with the physical world, enabling everything from responsive keyboards to high-speed network communication.
By the end of this page, you will understand the fundamental principles of interrupt-driven I/O, why it replaced polling as the dominant I/O paradigm, the complete anatomy of an interrupt signal, and how interrupts enable efficient asynchronous communication between hardware devices and the CPU. You'll gain the foundational knowledge necessary to understand modern operating system I/O architectures.
Before diving into interrupts, we must understand the problem they solve. Polling (also called programmed I/O or busy waiting) is the simplest approach to I/O: the CPU repeatedly checks a device's status register to determine if the device is ready for data transfer.
Consider a CPU reading data from a disk controller. In a polling-based system, the processor executes a tight loop:
12345678910111213141516
// Polling-based I/O - The CPU wastes cycles waitingvoid read_disk_polling(uint32_t sector, void* buffer) { // Step 1: Issue the read command disk_controller->sector_register = sector; disk_controller->command_register = READ_COMMAND; // Step 2: Poll the status register continuously while ((disk_controller->status_register & READY_BIT) == 0) { // CPU is trapped in this loop for MILLIONS of cycles // A typical disk operation takes 3-10 milliseconds // At 3 GHz, that's 9-30 MILLION wasted CPU cycles! } // Step 3: Only now can we transfer the data memcpy(buffer, disk_controller->data_buffer, SECTOR_SIZE);}The fundamental inefficiency is temporal mismatch: CPU operations execute in nanoseconds, while I/O devices operate in milliseconds or even seconds. This creates a staggering disparity:
| Operation Type | Typical Latency | CPU Cycles Wasted (at 3 GHz) |
|---|---|---|
| CPU Register Access | ~0.3 ns | 1 cycle |
| L1 Cache Access | ~1 ns | 3 cycles |
| Main Memory Access | ~100 ns | 300 cycles |
| SSD Read | ~100 μs | 300,000 cycles |
| Hard Disk Read | ~10 ms | 30,000,000 cycles |
| Network Packet (cross-country) | ~50 ms | 150,000,000 cycles |
| Human Keyboard Input | ~100 ms | 300,000,000 cycles |
During a single hard disk read, a polling CPU wastes enough cycles to execute 30 million instructions. In a system with multiple devices (disk, network, USB, etc.), polling becomes catastrophically inefficient—the CPU spends most of its time asking devices if they're ready instead of doing actual computation.
Three Critical Problems with Polling:
CPU Utilization Collapse: The processor cannot perform any useful work while waiting. In multi-device systems, this leads to near-zero effective utilization.
Latency Unpredictability: If multiple devices need attention, the polling order determines response time. A device at the end of the polling loop may wait unacceptably long.
Power Consumption: Continuous polling keeps the CPU in its highest power state, consuming electricity even when no real work is being done—disastrous for battery-powered devices.
These problems demanded a fundamentally different approach: instead of the CPU asking devices for updates, let devices proactively notify the CPU when they need attention.
An interrupt is a hardware signal that causes the CPU to suspend its current execution and transfer control to a specific routine called an Interrupt Service Routine (ISR) or interrupt handler. The term "interrupt" perfectly captures the mechanism: the device interrupts whatever the CPU was doing.
The conceptual model is elegant:
Device operates independently: The I/O device performs its task (reading data, receiving a network packet, etc.) without CPU involvement.
Device signals completion: When the device requires CPU attention, it asserts an interrupt request (IRQ) signal on a dedicated hardware line connected to the CPU.
CPU responds asynchronously: At the end of its current instruction, the CPU detects the interrupt signal, saves its current state, and jumps to the appropriate handler.
Handler processes the event: The ISR performs the necessary operations (e.g., transferring data from the device) and then returns control to the interrupted program.
The Key Innovation: Asynchrony
The power of interrupts lies in their asynchronous nature. The CPU doesn't need to know or care when a device will complete its operation. It simply continues executing useful work, confident that the hardware will notify it at the appropriate moment. This decoupling of CPU execution from I/O timing is what makes modern multitasking possible.
Consider the contrast with polling:
Interrupts were first implemented in the UNIVAC 1103A in 1953, just seven years after the first electronic computers. Engineers immediately recognized that polling was unsustainable for systems with multiple I/O devices. The interrupt mechanism has remained fundamentally unchanged for 70+ years—a testament to its elegant design.
At the electrical level, an interrupt is a carefully coordinated signal between a device and the CPU. Understanding this hardware mechanism is crucial for grasping how operating systems manage interrupts.
The Interrupt Request (IRQ) Line
In traditional architectures, each interrupt source connects to the CPU through a dedicated IRQ line—a physical wire that carries the interrupt signal. When a device needs attention, it changes the electrical state of its IRQ line (typically from low voltage to high voltage, or vice versa).
Signal Types: Edge-Triggered vs. Level-Triggered
Interrupt signals come in two fundamental types, each with distinct characteristics:
Edge-Triggered Interrupts: The interrupt is recognized when the signal transitions from one state to another (e.g., from low to high). The CPU responds to the change in signal level.
Level-Triggered Interrupts: The interrupt is recognized as long as the signal remains at a particular level (e.g., high). The CPU responds to the state of the signal.
| Characteristic | Edge-Triggered | Level-Triggered |
|---|---|---|
| Trigger Condition | Signal transition (edge) | Signal state (level) |
| Detection Window | Instantaneous | Continuous while asserted |
| Miss Risk | High if CPU is busy during edge | Low—signal remains until acknowledged |
| Handling Requirement | Must handle immediately | Can handle when convenient |
| Spurious Interrupt Risk | Low | Higher if line is noisy |
| Sharing Capability | Difficult to share lines | Multiple devices can share a line |
| Common Usage | Legacy ISA devices | PCI devices, modern systems |
1234567891011121314151617181920212223242526
// Conceptual difference in interrupt detection // Edge-triggered: Interrupt fires exactly once per eventvoid edge_triggered_handler(void) { // The interrupt fired because a LOW→HIGH transition occurred // If we don't handle it now, we miss it—no second chance // But also: no spurious re-triggering uint32_t data = device->data_register; process_data(data); // No need to explicitly clear anything—the edge is gone} // Level-triggered: Interrupt fires continuously while condition exists void level_triggered_handler(void) { // The interrupt fires because IRQ line is HIGH // It will KEEP firing until we make the line go LOW // We MUST clear the device's interrupt-pending flag uint32_t data = device->data_register; device->status_register = CLEAR_INTERRUPT; // CRITICAL! process_data(data); // If we forget to clear, we immediately re-enter this handler // This causes an "interrupt storm" that locks the system}One of the most common driver bugs is forgetting to acknowledge a level-triggered interrupt. Since the interrupt line remains asserted, the CPU immediately re-enters the handler upon returning, creating an infinite loop that freezes the entire system. This "interrupt storm" is a frequent cause of system hangs in driver development.
Message Signaled Interrupts (MSI)
Modern systems increasingly use Message Signaled Interrupts (MSI) and its enhanced version MSI-X, which replace dedicated IRQ lines with in-band messages written to a specific memory address. Instead of asserting a physical wire, the device writes a data value to a pre-configured memory location, which the CPU interprets as an interrupt.
Advantages of MSI/MSI-X:
MSI has essentially replaced traditional IRQ-based interrupts in modern high-performance systems, particularly for NVMe SSDs, network adapters, and GPUs.
When an interrupt signal reaches the CPU, a precisely defined sequence of hardware actions occurs. This sequence is atomic—it cannot be interrupted itself—ensuring that the system transitions cleanly from the interrupted context to the handler.
The Interrupt Recognition and Response Sequence:
1234567891011121314151617181920212223242526272829303132333435
; What the x86 CPU does automatically when an interrupt occurs:; (This is hardwired behavior, not software!) ; === IF CROSSING PRIVILEGE LEVELS (user → kernel) ===; 1. Load new SS:ESP from TSS (Task State Segment); 2. Push old SS and ESP onto NEW stack; 3. Push EFLAGS; 4. Push old CS and EIP; 5. Clear IF flag (disable further interrupts); 6. Clear TF flag (disable single-step); 7. Load new CS:EIP from IDT entry ; === IF SAME PRIVILEGE LEVEL ===; 1. Push EFLAGS onto current stack; 2. Push CS and EIP onto current stack; 3. Clear IF and TF flags; 4. Load new CS:EIP from IDT entry ; === What the ISR must do to return ===; The IRET (Interrupt Return) instruction reverses all of this:interrupt_handler: pusha ; Save all general-purpose registers push ds ; Save segment registers push es push fs push gs ; ... handle the interrupt ... pop gs ; Restore segment registers pop fs pop es pop ds popa ; Restore general-purpose registers iret ; Atomic restore of EIP, CS, EFLAGS (and SS, ESP if ring transition)Critical Timing Considerations
The time from interrupt signal assertion to the first instruction of the handler is called interrupt latency. This latency has several components:
| Component | Typical Duration | Cause |
|---|---|---|
| Instruction Completion | 1-10 cycles | Current instruction must finish |
| Interrupt Recognition | 1-3 cycles | CPU detects and prioritizes |
| Context Save (Hardware) | 5-20 cycles | Automatic register saves |
| Vector Table Lookup | 3-10 cycles | Memory fetch of handler address |
| Mode Switch | 10-50 cycles | If crossing privilege levels |
| Cache Effects | Variable | Handler code may not be in cache |
| Total Typical | 20-100 cycles | ~7-33 nanoseconds at 3 GHz |
For real-time systems, interrupt latency must be bounded and predictable. Real-time operating systems (RTOS) go to extreme lengths to minimize and guarantee worst-case interrupt latency, including disabling CPU features like caching and branch prediction that can cause variable timing. Some hard real-time systems achieve guaranteed interrupt response in under 1 microsecond.
Let's trace through a complete interrupt-driven I/O operation to see how all the pieces fit together. We'll use disk I/O as our example—one of the most common interrupt-driven operations in any system.
Scenario: Reading a Disk Sector with Interrupts
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556
// Interrupt-driven disk read - The modern approach // Global state for tracking pending I/Ostruct io_request { uint32_t sector; void* buffer; volatile bool complete; struct process* waiting_process;}; static struct io_request current_request; // Step 1: Application initiates read (system call)ssize_t sys_read_disk(uint32_t sector, void* buffer) { // Set up the request current_request.sector = sector; current_request.buffer = buffer; current_request.complete = false; current_request.waiting_process = current_process(); // Issue the command to the disk controller disk_controller->sector_register = sector; disk_controller->command_register = READ_COMMAND; // Controller begins reading—this takes ~5-10ms // Block this process and schedule another one // CPU does USEFUL WORK while disk operates! block_process(current_request.waiting_process); schedule(); // Run another process // When we reach here, the interrupt has woken us return SECTOR_SIZE;} // Step 2: Disk interrupt handler (runs when disk is ready)void disk_interrupt_handler(void) { // Acknowledge the interrupt to the controller uint32_t status = disk_controller->status_register; disk_controller->status_register = CLEAR_INTERRUPT; // Check for errors if (status & ERROR_BIT) { current_request.error = true; } else { // Transfer data from controller to memory memcpy(current_request.buffer, disk_controller->data_buffer, SECTOR_SIZE); } // Mark request complete and wake the waiting process current_request.complete = true; wake_process(current_request.waiting_process); // Handler returns; scheduler will eventually run woken process}The Critical Efficiency Gain
During the 5-10 milliseconds the disk takes to read the sector, the CPU executed potentially millions of instructions from other processes. Compare this to polling, where those millions of cycles would be wasted checking the status register repeatedly.
Timeline Comparison:
| Time | Polling Approach | Interrupt Approach |
|---|---|---|
| 0 μs | Issue read command | Issue read command |
| 1-10,000 μs | Loop: check status, check status, check status... | Execute Process B, Process C, Process D... |
| 10,000 μs | Status shows ready | Interrupt fires |
| 10,001 μs | Copy data | Handler copies data, wakes Process A |
| 10,002 μs | Continue Process A | Eventually schedule Process A |
| CPU Utilization | ~0% (100% wasted polling) | ~100% (useful work on other processes) |
Interrupt-driven I/O doesn't just improve efficiency—it fundamentally enables multiprocessing. Without interrupts, a single slow I/O operation would freeze the entire system. With interrupts, the operating system can maintain the illusion that many processes run simultaneously, each making progress between I/O waits.
While we've focused on hardware I/O interrupts, the interrupt mechanism serves multiple purposes in modern systems. Understanding the different types of interrupts is essential for grasping the complete picture of CPU exception handling.
INT n, ARM SVC). Used primarily for system calls.| Type | Trigger Source | Timing | Maskable | Examples |
|---|---|---|---|---|
| Hardware IRQ | External device | Asynchronous | Usually yes | Disk ready, keyboard press, timer tick |
| Software Trap | INT instruction | Synchronous | N/A | System calls, breakpoints |
| Fault | Instruction error | Synchronous, restartable | No | Page fault, divide by zero |
| Abort | Unrecoverable error | Synchronous | No | Machine check, double fault |
| NMI | Critical hardware | Asynchronous | No | Memory parity error, watchdog |
Synchronous vs. Asynchronous Interrupts
A critical distinction exists between interrupts that are caused by the currently executing instruction versus those that arrive independently:
Synchronous interrupts (exceptions, software traps) occur at a predictable point in execution. If you run the same code with the same data, the interrupt occurs at the same instruction every time. The CPU can precisely identify which instruction caused the exception.
Asynchronous interrupts (hardware IRQs) arrive at unpredictable times based on external events. The same code might be interrupted at different points on each execution. The CPU notes that an interrupt occurred between instructions, not because of any specific instruction.
This distinction matters for how the operating system handles returns. After a synchronous exception like a page fault, the OS may need to restart the faulting instruction. After an asynchronous interrupt, the OS resumes at the next instruction.
Different architectures use different terminology. Intel x86 distinguishes "interrupts" (asynchronous) from "exceptions" (synchronous). ARM uses "exceptions" for everything and categorizes them as IRQs, FIQs, aborts, etc. Despite the terminology differences, the underlying concepts are consistent across all modern processors.
The operating system must sometimes temporarily disable interrupts to protect critical sections of code. If an interrupt occurred in the middle of certain sensitive operations, it could corrupt kernel data structures or cause race conditions.
The Interrupt Flag
Most CPUs provide a global interrupt enable/disable flag. On x86, this is the IF (Interrupt Flag) in the FLAGS register. When IF=0, maskable interrupts are ignored (held pending); when IF=1, they are delivered.
1234567891011121314151617181920212223242526272829303132333435363738
// x86 interrupt control primitives // Disable interrupts and return previous statestatic inline unsigned long irq_save(void) { unsigned long flags; asm volatile( "pushf\n\t" // Push FLAGS onto stack "pop %0\n\t" // Pop into 'flags' variable "cli" // Clear Interrupt Flag (disable interrupts) : "=r" (flags) // Output: flags variable : // No inputs : "memory" // Clobbers memory ordering ); return flags;} // Restore previous interrupt statestatic inline void irq_restore(unsigned long flags) { asm volatile( "push %0\n\t" // Push saved flags "popf" // Restore FLAGS register : // No outputs : "r" (flags) // Input: flags variable : "memory", "cc" // Clobbers memory and condition codes );} // Critical section patternvoid update_critical_data(void) { unsigned long flags = irq_save(); // Disable interrupts // This code cannot be interrupted critical_data.field1 = new_value1; critical_data.field2 = new_value2; // Consistency is guaranteed irq_restore(flags); // Restore previous state}Why Not Just cli/sti?
A naive approach would just use cli (clear interrupt flag) and sti (set interrupt flag) directly. But this fails when functions nest:
12345678910111213141516171819202122232425262728293031
// WRONG: Using cli/sti directlyvoid outer_function(void) { cli(); // Disable interrupts inner_function(); // This calls sti()! // BUG: Interrupts are now ENABLED even though // outer_function expected them disabled critical_operation(); // UNSAFE - can be interrupted! sti();} void inner_function(void) { cli(); // ... do work ... sti(); // This re-enables interrupts prematurely!} // CORRECT: Save and restore statevoid outer_function_correct(void) { unsigned long outer_flags = irq_save(); inner_function_correct(); // This restores its own entry state // Interrupts are still in the state outer_function set critical_operation(); // SAFE irq_restore(outer_flags);}While interrupts are disabled, incoming interrupts accumulate as pending. If interrupts remain disabled too long, events can be delayed significantly, causing dropped data (in high-speed networking) or audible glitches (in audio processing). The kernel should minimize time spent with interrupts disabled—typically no more than a few microseconds.
We've covered the foundational concepts of interrupt-driven I/O. Let's consolidate the key takeaways:
What's Next:
Now that we understand why and how interrupts work at the hardware level, the next page explores interrupt handling—how the operating system software manages the transition from the interrupted context, executes the appropriate handler, and ensures all devices are serviced correctly. We'll dive into the two-part handler architecture (top half/bottom half) that balances responsiveness with system stability.
You now understand the fundamental principles of interrupt-driven I/O. Interrupts are arguably the most important hardware mechanism enabling modern operating systems—without them, multitasking as we know it would be impossible. Next, we'll see how the operating system manages and responds to these hardware signals.