Every chapter so far followed a program as it jumps and calls its way through its own code. But control can also leave the program without any jump in it: an instruction divides by zero, touches a page that isn't mapped, or asks the operating system for a service; or a network card finishes receiving a packet. Each time, the CPU stops what it's doing, saves just enough to come back, switches to kernel mode and runs a handler chosen from a table. This mechanism is what makes an operating system possible: it's the only way into the kernel, and the only way the kernel gets the CPU back from a program that never gives it up.
Tanenbaum splits these events in two:
- traps, caused by the program itself: synchronous, and reproducible — run the same program on the same input and they happen at the same instruction every time;
- interrupts, caused by something outside the program, usually I/O: asynchronous, arriving between any two instructions.
Each ISA has its own vocabulary on top. Intel calls the synchronous events exceptions and splits them into faults, traps and aborts; ARM calls everything an exception, interrupts included; RISC-V calls everything a trap, divided into exceptions and interrupts. The ideas are the same.
What the hardware does
When an exception or interrupt is taken, the CPU performs a short fixed sequence in hardware, with no instructions from the program:
- Save the return point: the address of the interrupted or faulting instruction, and the flags.
- Switch privilege: from user mode to kernel mode, and, on x86, to the kernel's own stack.
- Find the handler: look up the event's number in a table the kernel set up in advance.
- Jump to the handler.
The handler saves whatever registers it uses, deals with the event, restores them, and executes a return-from-exception instruction that undoes steps 1 and 2 at once. If it's done right, the interrupted program never notices; Tanenbaum calls that transparency.
| x86-64 | ARM64 | RISC-V | |
|---|---|---|---|
| handler table | IDT: 256 entries of 16 bytes, located by the IDTR register | a table of 16 entry points, 128 bytes each, at VBAR_EL1 | one entry point in stvec (or a table in vectored mode) |
| where the cause is found | the vector number (0–255), plus an error code on the stack | the ESR_EL1 syndrome register | the scause register |
| return address saved in | the kernel stack (with flags, stack pointer, code segment) | ELR_EL1 (flags in SPSR_EL1) | sepc (status in sstatus) |
| return instruction | iretq | eret | sret |
x86 pushes the saved state onto memory; ARM64 and RISC-V put it in special registers and leave it to the handler to save them if it needs to. The book describes the x86 table as 8-byte segment descriptors; in 64-bit mode each entry is 16 bytes, to hold a 64-bit handler address.
Exceptions: faults, traps and aborts
Intel sorts exceptions by where execution resumes:
- a fault is reported before the instruction completes, and the saved address points to the faulting instruction itself, so the handler can fix the problem and run it again. The key example is the page fault: the program touches a page that isn't in memory, the kernel loads it from disk, and the same instruction runs again, successfully. The program never knows. That restart is what makes virtual memory possible.
- a trap is reported after the instruction, and execution resumes at the next one. Breakpoints (
int3) and single-stepping work this way. - an abort is unrecoverable, like a machine check reporting a hardware error.
Restarting a faulting instruction requires precise exceptions: when the handler runs, every earlier instruction must have completed and no later one must have changed anything. That's harder than it sounds in an out-of-order core, which has dozens of instructions in flight; the reorder buffer exists largely to guarantee it, by only raising an exception when the faulting instruction reaches the head.
The common x86 exceptions, and what Linux turns them into for the program:
| Vector | Name | Raised by | Linux signal |
|---|---|---|---|
| 0 | #DE divide error | integer division by zero, or a quotient too large | SIGFPE |
| 1 | #DB debug | single-step, hardware breakpoints | SIGTRAP |
| 3 | #BP breakpoint | int3 (the byte 0xCC) | SIGTRAP |
| 6 | #UD invalid opcode | an undefined instruction, or ud2 (0f 0b) | SIGILL |
| 13 | #GP general protection | a privileged instruction in user mode, a non-canonical address | SIGSEGV |
| 14 | #PF page fault | an access to an unmapped or protected page | handled silently, or SIGSEGV |
| 18 | #MC machine check | a hardware error | usually a kernel panic |
The simulator raises the same divide error:
Try it: Press Step to run one instruction, Run to animate or Continue to finish; the L2–L7 buttons zoom in and out one level at a time.
- mov eax, 7
- xor edx, edx
- xor ecx, ecx
- div ecx ; edx:eax / ecx, with ecx = 0
- mov eax, 1 ; never reached
Stepping onto the div stops the machine with #DE divide error. On a real system, the kernel's handler for vector 0 would send the process SIGFPE, and without a signal handler, the process dies.
Not every ISA traps
The book lists overflow and division by zero among the classic causes of traps. That's where ISAs differ most. Measured on an Apple M2, with the same C program built for ARM64 and for x86-64 (run under Rosetta 2):
volatile int zero = 0; | ARM64 | x86-64 |
|---|---|---|
7 / zero (integer) | returns 0, no trap | SIGFPE |
7.0 / 0.0 (floating point) | inf, no trap | inf, no trap |
undefined instruction (udf / ud2) | SIGILL | SIGILL |
breakpoint (brk / int3) | SIGTRAP | SIGTRAP |
| read through a null pointer | SIGSEGV | SIGSEGV |
ARM64's divide instruction sdiv simply returns 0 for a zero divisor, and RISC-V's returns −1 (all ones); neither traps. Designers of those ISAs chose to keep the divider simple and let software check if it cares, which C compilers don't: division by zero is undefined behavior. Floating-point division by zero doesn't trap anywhere by default. IEEE 754 defines a result (infinity) and just sets a status flag, and the traps it allows are disabled unless a program enables them.
Integer overflow, the book's first example, doesn't trap on today's mainstream ISAs either. x86 had an instruction for it, INTO (the byte 0xCE), which trapped if the overflow flag was set; it was removed from 64-bit mode — nasm refuses it with instruction not supported in 64-bit mode. MIPS has an add that traps on overflow, but compilers emit addu, which doesn't. Languages that check overflow, like Rust in debug builds or Swift, do it in software, with a conditional jump on the overflow flag after each operation.
System calls: traps on purpose
A program can't jump into the kernel: kernel memory isn't mapped for it, and jumping there would fault. The only way in is a deliberate trap, an instruction whose whole purpose is to raise one. The kernel reads the requested service's number and arguments from registers, does the work, and returns.
| x86-64 | ARM64 | RISC-V | |
|---|---|---|---|
| instruction | syscall (0f 05) | svc #0 | ecall |
| call number in | rax | x8 (Linux) | a7 |
| arguments in | rdi, rsi, rdx, r10, r8, r9 | x0–x5 | a0–a5 |
32-bit Linux programs used int 0x80, a software interrupt through the IDT. syscall is a faster, dedicated path that skips the table lookup. The simulator implements Linux's write (number 1) and exit (number 60):
Try it: Press Step to run one instruction, Run to animate or Continue to finish; the L2–L7 buttons zoom in and out one level at a time.
- .data
- msg: .asciz "hello\n"
- .text
- mov eax, 1 ; write(
- mov edi, 1 ; fd 1 = standard output,
- lea rsi, [rip+msg] ; buffer,
- mov edx, 6 ; 6 bytes)
- syscall
- mov eax, 60 ; exit(
- mov edi, 3 ; status 3)
- syscall
It prints hello and exits with status 3. In a real program, the C library's write and exit functions are thin wrappers around exactly these instructions; the operating-system level looks at what the kernel does on the other side.
Entering and leaving the kernel costs far more than a call. On the M2 under macOS, a loop of getppid() — about the cheapest system call there is — took 92 ns per call, against 0.9 ns for an ordinary function call: a factor of about 100. In a Linux virtual machine on the same computer, it was 152 ns. The instruction itself is fast; the cost is in switching stacks, saving registers, and the security mitigations modern kernels apply on every entry. That's why system calls are batched when possible, and why Linux answers some of the most frequent ones, like reading the clock, from a page of kernel data mapped into every process (the vDSO), with no trap at all.
Interrupts
An interrupt comes from outside the instruction stream: a disk finishing a transfer, a network packet arriving, a key being pressed, a timer running out. The CPU checks for pending interrupts between instructions, and when one is pending and interrupts are enabled, it performs the same save-switch-jump sequence as for an exception, with the vector number supplied by the device or its controller.
Tanenbaum describes the classic design: the device raises an interrupt line on the bus, the CPU acknowledges, and the device puts its vector number on the data bus. PCs of that era used an 8259A interrupt controller to arbitrate priorities between devices. Modern machines work differently:
- Each core has a local APIC (on ARM, a GIC redistributor) that receives interrupts, prioritizes them and delivers them to its core.
- PCIe devices don't have interrupt wires at all. They signal an interrupt with MSI (message-signaled interrupts): an ordinary memory write to a special address, carrying the vector number as data. A network card with many queues can target a different vector — and a different core — for each.
- Cores interrupt each other with inter-processor interrupts (IPIs), for example to make another core flush its TLB or reschedule.
- A timer interrupt on each core is what lets the kernel take the CPU back from a program that loops forever, and switch to another: it's the heartbeat of preemptive multitasking.
- Non-maskable interrupts (NMIs) still exist, as the book says, for emergencies — and for profilers and watchdogs that must work even while interrupts are disabled.
The book's priority scheme survives too. A handler for a high-priority interrupt can itself be interrupted by an even higher one, but lower ones wait. The local APIC implements it by ranking interrupts by vector number.
How busy is this? On an idle Linux virtual machine with 24 cores, /proc/stat counted about 1,500 interrupts and 2,200 context switches per second, with no program running. Each one was a trap into the kernel and a transparent return — invisible to every program it interrupted.
Takeaways
- Exceptions (Tanenbaum's traps) are synchronous: caused by an instruction, at the same place every run. Interrupts are asynchronous: caused by devices and timers, between any two instructions.
- The hardware saves the return address and flags, switches to kernel mode, and jumps through a table (x86's IDT, ARM64's
VBAR_EL1, RISC-V'sstvec);iretq,eretandsretreturn. - A fault restarts the faulting instruction (page faults make virtual memory work); a trap resumes after it; an abort can't resume. Restarting needs precise exceptions.
- What traps depends on the ISA: x86 traps on integer division by zero, ARM64 returns 0 and RISC-V −1. Floating-point errors and integer overflow don't trap by default anywhere; x86 removed
INTOin 64-bit mode. - System calls are deliberate traps (
syscall,svc,ecall), about 100 times the cost of a function call — measured at 92 ns on an M2. - Modern interrupts are delivered by per-core APICs, as MSI memory writes from PCIe devices, and as IPIs between cores; a timer interrupt drives the scheduler.