JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Interrupt Handlers and Bottom Halves

Hardware does not wait politely for your code to ask. When a packet arrives or a disk finishes, the device yanks the CPU away mid-instruction to demand attention. This guide is about that yank: how a driver answers an interrupt fast, why it must not do the slow work right there, and how it hands that slow work off to a calmer place — the bottom half.

The interrupt: hardware that cannot be polite

Picture the CPU running steadily through some thread's code, fetching and executing one instruction after another. A network card on the PCI bus has just received a packet. It cannot call a function — it has no idea what code you are running. Instead it raises a voltage on a physical wire, an interrupt request (IRQ), that runs into the processor's interrupt controller. The CPU finishes its current instruction, then stops the thread it was running, saves just enough state to come back, and jumps to a fixed kernel routine. This is the third member of the family you met earlier — alongside the trap and the exception, an interrupt is the asynchronous one: it comes from outside, at a moment no instruction asked for it.

How does the CPU know where to jump? Each interrupt source has a number — its vector — and the processor uses that number as an index into a table the kernel set up at boot, the interrupt descriptor table (on x86; ARM has the equivalent). Entry number N holds the address of the routine to run when interrupt N fires. So the hardware path is mechanical and fast: wire goes high, controller sends a vector, CPU indexes the table, control lands in kernel code. None of this involves a system call — nobody asked. The thread that got interrupted did nothing wrong; it was simply in the wrong place at the wrong nanosecond, and it will be resumed exactly where it left off once the interrupt is handled.

Registering a handler, and the strange place it runs

A driver does not write into the descriptor table by hand. Instead, during its probe — the moment from the last guide when the driver model binds code to a device — it asks the kernel to wire one of its own functions to the device's IRQ line. That is IRQ registration, done with a call like request_irq(irq, my_handler, flags, name, dev). From that point on, every time the device pulls its line high, the kernel will call my_handler(). The function you register is the interrupt handler, often called the top half: it is the code that runs immediately, in direct response to the hardware, before anything else gets a turn. As always in the kernel, the call returns an error code, and you check it — if the IRQ could not be claimed, the device is useless and probe must fail and unwind.

Now the part that makes interrupt code feel alien. The top half does not run as a normal thread. It runs in interrupt context — and the central, load-bearing fact about interrupt context is that there is no process behind it. The handler borrowed whatever CPU it landed on; it has no thread of its own, no PID, no user-space behind it to copy data to or from. Two hard rules fall straight out of this. First, the handler must not sleep: it cannot block, cannot wait on a mutex, cannot call anything that might put a thread to sleep, because there is no thread to put to sleep — sleeping in interrupt context is a kernel bug that the system catches and screams about. Second, it must not call into user space, because there is no current user process whose memory it could touch.

Why fast matters, and the top half / bottom half split

There is a deeper reason the handler must be quick, beyond "don't sleep." While an interrupt handler runs, that interrupt line — and often others — is masked: the CPU will not take another interrupt of the same kind until this one returns. Every microsecond you spend in the top half is a microsecond the next packet, the next keystroke, the next disk completion sits waiting, raising the system's interrupt latency. Worse, the work an interrupt implies is often genuinely slow — wake up the threads waiting for this data, walk a protocol stack, allocate buffers, copy a kilobyte around. You cannot do that with interrupts masked and no thread to sleep on. So the kernel splits the response in two.

This is the heart of the rung's name: the top half and bottom half. The top half is the interrupt handler proper — it runs in interrupt context, does the bare minimum that truly cannot wait (acknowledge the device so it stops yelling, grab the few bytes of status the hardware will overwrite, note that work is pending), and then returns fast. The bottom half is the deferred remainder — the slow, sleepable work — scheduled to run a little later, with interrupts re-enabled, off the critical path. The art of writing an interrupt handler is mostly the discipline of deciding what truly belongs in the top half (almost nothing) and pushing everything else down.

device raises IRQ line  ->  CPU masks line, jumps to top half
                                      |
  TOP HALF (interrupt context, no sleeping, line masked):
    - ack the device (stop it asserting the IRQ)
    - read volatile status the hardware will clobber
    - schedule the bottom half  ->  return FAST
                                      |
            ... interrupts re-enabled, system runs on ...
                                      |
  BOTTOM HALF (later, sleepable context, line unmasked):
    - wake waiting threads, run protocol code, copy buffers,
      kmalloc, take a mutex -- all the slow work that may sleep
The two-stage shape. The top half steals as little time as possible with interrupts masked; the bottom half does the real work afterward where sleeping and locking are allowed.

Three flavours of bottom half, and which to pick

"Schedule the bottom half" is not one mechanism but a small family, and the choice turns on a single question: does the deferred work need to sleep? The lightest option is the softirq and its friendlier wrapper the tasklet: these still run in interrupt-like context, cannot sleep, but execute after the top half returns with interrupts re-enabled, so they no longer block new interrupts. They are for work that is too slow to do with the line masked but still never needs to wait. The heavier option is the work queue: it runs your deferred function in a real kernel thread, in process context, where it can sleep — take a mutex, allocate memory that might block, do anything a normal kernel thread may do. The price is a context switch to that thread, so it is a touch slower to start.

Notice how this maps onto ideas from earlier rungs without any new magic. The bottom half is just deferred work, the same idea as a callback you queue to run later; a tasklet is a tiny task scheduled on the same CPU; a work queue is, underneath, a scheduler-managed kernel thread pulling jobs off a list — a producer/consumer pattern where the top half produces and a worker consumes. You already understand all the pieces. What is new is only the constraint: the top half is a place where the usual freedom to sleep, lock, and touch user memory is taken away, and the bottom half is where it is handed back.

Sharing data with an interrupt, the right way

Here is the subtle trap, and it is a genuine one. Your driver's normal code — its read()/write() functions, running in some process's context — often needs to share a variable or a buffer with the interrupt handler: a counter, a ring of received bytes, a flag. But an interrupt can fire at any instant, even in the middle of your normal code updating that shared structure. That is a race condition of the nastiest kind, because the interrupt is not another thread you scheduled — it preempts you with no warning, mid-update, leaving the structure half-written. You cannot reason it away; you must lock.

But you cannot use an ordinary sleeping mutex here, and the reason is exact. Suppose your normal code holds a mutex, and the interrupt fires and its handler tries to take the same mutex. The handler cannot sleep to wait for it — and even if it could, the code holding the lock has been interrupted and frozen on this very CPU, so it can never release it. That is an instant, unbreakable deadlock against yourself. The kernel's answer is the spinlock, paired with disabling that interrupt on the local CPU while the lock is held (the spin_lock_irqsave() idiom). A spinlock never sleeps — it busy-waits — so it is legal in interrupt context, and disabling the interrupt locally guarantees the handler cannot fire and try to re-take the lock you are holding.

On a multi-core machine there is one more wrinkle, and it is why interrupt-side data is often per-CPU. Disabling the interrupt locally only stops the handler on this CPU; the same interrupt could fire on another core at the same time. A pure spinlock still covers that — the second core spins until the first releases — but a tidier pattern, where the design allows, is to give each CPU its own private copy of the data so the cores never contend at all. We are only touching the surface here; the synchronization rung went deep into why these locks behave as they do, and that whole apparatus — spinlocks, lock ordering, per-CPU state — is exactly what keeps interrupt-shared data from corrupting under you. The honest summary: any byte the top half and your normal code both touch needs a deliberate plan, every time.

Stepping back, and the road ahead

  1. Device pulls its IRQ line high; the CPU finishes the current instruction, masks the line, saves minimal state, and indexes the descriptor table to your registered handler.
  2. The top half runs in interrupt context: acknowledge the device, grab the volatile status it will overwrite, schedule the bottom half — and return fast, never sleeping, never touching user space.
  3. Interrupts re-enable; later the bottom half runs — a tasklet if the work cannot sleep, a work queue (thread context) if it can — doing the real protocol, buffer, and wake-up work.
  4. Any data shared between the handler and normal driver code is guarded with a spinlock plus local interrupt-disable, or kept per-CPU, never with a sleeping mutex.

Stand back and the shape is simple and humane: respond instantly to the smallest possible degree, then do the bulk of the work in a calmer place where the normal rules apply again. That single instinct — minimize time in interrupt context — drives nearly every design choice a driver author makes around interrupts. It is the same value the whole course keeps returning to: be honest about constraints, and arrange the code so the dangerous, locked-down moment is as short as it can possibly be.

There is also a sting in the tail that the next guides confront. Interrupts are wonderful until there are too many of them. A network card under heavy load can raise an interrupt for every single packet — millions per second — and at that rate the cost of taking the interrupt itself swamps the machine, a failure mode called an interrupt storm. The cure is to switch, under load, from interrupts back to polling: ask the device "got anything?" on a schedule instead of being poked each time. Linux's hybrid of the two is NAPI, and weighing polling against interrupts is the closing question of this rung. First, though, comes the matter we have kept deferring — how a driver actually moves the bytes, through memory-mapped I/O and DMA — which is the next guide.