Interrupt-Driven I/O and Exceptions
Polling
left us with a machine that spends its life asking "ready yet? ready yet?" — a Ferrari idling in a car park.
The fix is one of the most beautiful ideas in computer architecture: stop asking, and let the
device tap the CPU on the shoulder when it actually has something to say. That tap is an
interrupt, and it turns the processor from a nagging pollster into a busy worker who only
looks up when the doorbell rings.
The gorgeous part is where the doorbell is wired. Remember the
instruction-cycle
FSM that loops forever? At the very end of each instruction — just before looping back to FETCH
— the control unit adds one extra check: is an interrupt pending and enabled? If not, carry on. If
so, the machine detours. And once you have that mechanism, the same hardware handles device interrupts,
program errors (exceptions), and deliberate system calls (traps) — three
things that turn out to be one thing.
The mechanism, step by step
When the end-of-instruction check finds a pending, enabled interrupt, the hardware performs a tightly
choreographed detour. Follow the diagram as it builds:
- Save state. The current \text{PC} (where to come back to) and
the processor status register \text{PSR} (condition flags, privilege,
priority) are pushed onto the supervisor stack. This is the snapshot the machine will restore.
- Vector through the table. The interrupting device supplies a small
vector number. The hardware uses it to index the interrupt vector table (IVT) —
a table of handler addresses — and loads the matching address into the
\text{PC}. The machine now begins fetching the handler.
- Run the handler (ISR). The interrupt service routine does the real work — reads the
keystroke from \text{KBDR}, buffers it, acknowledges the device.
- Return. A special RTI (return-from-interrupt) instruction pops the
saved \text{PSR} and \text{PC}, and the main program
resumes exactly where it was — usually never even knowing it was paused.
- the control-unit FSM tests for a pending, enabled interrupt at the end of each instruction
cycle;
- on acceptance it saves PC + PSR, then vectors through the interrupt
vector table to the handler's address;
- the ISR runs; RTI restores PC + PSR and resumes the interrupted
program transparently;
- the CPU does useful work between events instead of busy-waiting — no wasted polling cycles.
Priority and masking: not all interrupts are equal
A disk finishing a transfer and a "the power supply is failing" alert should not be treated the same. So
interrupts carry a priority level, and the PSR records the priority the CPU is currently
running at. Two rules follow:
- Masking. An incoming interrupt is accepted only if its priority is higher than
the CPU's current level; otherwise it waits (is "masked"). Software can also explicitly disable interrupts
for a critical section.
- Nesting. Because the handler saved the old state on a stack, a higher-priority interrupt
can interrupt a running handler, and its RTI returns to the first handler. Stacks make interrupts
naturally nest, many deep.
This is why the state is saved on a stack rather than a single fixed register: the stack is exactly
the data structure that lets interrupts nest and unwind in the right order.
Interrupts, exceptions, and traps: one mechanism, three triggers
Here is the unification. All three bend the instruction stream through the same save-vector-handle-return
machinery; they differ only in what sets them off:
| Kind | Trigger | Timing | Example |
| Interrupt | an external device | asynchronous — any time | keyboard key ready, timer tick |
| Exception / fault | an error in the running instruction | synchronous — caused by an instruction | divide by zero, illegal opcode, page fault |
| Trap / syscall | the program asks on purpose | synchronous — deliberate | LC-3 \text{TRAP}, x86 \text{syscall} |
A trap is best understood as a software interrupt: the LC-3
\text{TRAP x23} instruction deliberately vectors through a table to an OS routine,
exactly like a device interrupt but requested by the program itself. This is how user code safely asks the
operating system for a privileged favour (read a key, print a string, halt) without being trusted to do it
directly — the trap is the guarded doorway between user and kernel.
Simulate it: the detour, then the payoff
First, watch a single interrupt play out — save, vector through the table, run the handler, RTI. Then compare
the cycles polling wastes against the cycles interrupts save on a slow device.
// Part 1: one interrupt, start to finish.
const IVT: Record<number, string> = { 0x01: "illegalOpcodeHandler", 0x80: "keyboardISR" };
let PC = 0x3005, PSR = 0x0302; // main program was here, at priority level 3
function deliver(vector: number): void {
console.log(`END OF INSTRUCTION: interrupt vector 0x${vector.toString(16)} pending`);
console.log(` 1. SAVE push PC=0x${PC.toString(16)}, PSR=0x${PSR.toString(16)} onto supervisor stack`);
const handler = IVT[vector];
console.log(` 2. VECTOR IVT[0x${vector.toString(16)}] -> ${handler}; load it into PC`);
console.log(` 3. ISR ${handler} runs, services the device`);
console.log(` 4. RTI pop PSR, pop PC=0x${PC.toString(16)}; main resumes transparently`);
}
deliver(0x80);
// Part 2: polling vs interrupts on a slow device (bigger gap = slower device).
const items = 5; // characters to receive
const gap = 1000; // CPU cycles between characters
const handler = 25; // cycles to save/vector/run-ISR/RTI per character
const pollingIO = items * gap; // CPU spins through every gap: all wasted
const interruptIO = items * handler; // CPU only pays the handler per character
const total = items * gap;
console.log("");
console.log(`Polling: ${pollingIO} cycles on I/O = ${Math.round(100 * pollingIO / total)}% of the time (busy-wait).`);
console.log(`Interrupts: ${interruptIO} cycles on I/O = ${(100 * interruptIO / total).toFixed(1)}% of the time.`);
console.log(`Interrupts free ${pollingIO - interruptIO} cycles for real work -- a ${Math.round(pollingIO / interruptIO)}x reduction in I/O overhead.`);
Through this exact dance, millions of times a day. You press a key; the keyboard controller raises an
interrupt line; at the end of the current instruction the CPU finishes the frame it was rendering,
vectors to the OS keyboard handler, which stashes the character in a buffer and returns — all in well under a
microsecond. The game never notices the pause. Multiply by every device — network packets, disk completions,
the timer that drives multitasking itself — and you see that a modern computer is not really "running your
program". It is running a storm of tiny interrupt handlers, with your program stitched into the gaps between
them. Interrupts are what make one CPU feel like it is doing a thousand things at once.
On our simple machine the FSM checks for interrupts only at the end of the instruction cycle, so an
instruction is never torn in half — the handler always sees a clean, consistent machine state, and RTI can
resume perfectly. That "clean boundary" is doing enormous work. On a
pipelined
machine, though, several instructions are in flight at once, so "where exactly do we stop?" becomes genuinely
hard — the machine must retire the right instructions and squash the rest so the handler still sees a tidy
boundary. That is the whole problem of
precise
exceptions, and it is the direct descendant of this humble end-of-cycle check.