Interrupt Handling: Top/Bottom Halves, softirqs, workqueues
Master the kernel's interrupt handling split — from hardware signal to deferred bottom half — and write fast, correct, context-aware driver code.
Overview
When a piece of hardware needs the CPU's attention — a key was pressed, a network packet arrived, a DMA transfer finished — it raises an interrupt. The CPU drops whatever it was doing, saves its state, and jumps to a small piece of kernel code called an interrupt handler. Done right, this is one of the most elegant mechanisms in all of systems programming: hardware and software talking in real time, with the OS orchestrating the chaos.
But interrupt handling hides a deep tension. The hardware is impatient: while your handler is running, that interrupt line is masked. Every microsecond you spend in the handler is a microsecond where new events pile up, latency grows, and the system feels sluggish or drops data. The kernel's answer to this tension is the top/bottom half split — a beautiful architectural idea that says: do the bare minimum right now (top half), and defer everything else (bottom half). The "bottom half" is not a single mechanism but a family: softirqs, tasklets, workqueues, and threaded IRQs, each with different properties and tradeoffs.
Understanding this split is not just trivia. It explains why the kernel is structured the way it is, why device drivers are written the way they are, and why so many subtle concurrency bugs in kernel code trace back to "ran in the wrong context." It is also your first real taste of the kernel's multi-layer scheduling: hardware interrupts preempt everything, softirqs preempt normal kernel code, and workqueues run in kernel threads that can sleep. Each layer exists because the one above it is too expensive or too restricted for certain work.
By the end of this module you will be able to read a real Linux device driver's interrupt handler, understand every design choice the author made, and write your own. You will see how a single keypress travels from electrical signal to character in your terminal — and exactly which piece of kernel code handles each leg of that journey.
Almost every device driver you will ever write or read involves interrupts. The moment you touch networking, storage, input devices, timers, or serial ports, you are in interrupt territory. The Linux kernel's interrupt subsystem is one of the highest-throughput paths in the entire OS: a 10 Gbps NIC can deliver millions of interrupts per second. Understanding how the kernel handles this load — and why the design choices were made — is prerequisite knowledge for contributing to drivers, the networking stack, the block layer, or any real-time subsystem.
More broadly, interrupt handling is where kernel concurrency gets visceral. The rules are strict and non-obvious: you cannot sleep in a hardirq handler, you cannot take certain locks, you cannot call most kernel APIs. Breaking these rules produces bugs that are intermittent, architecture-specific, and terrifying to debug. Internalizing the "which context am I in?" discipline will make you a safer kernel developer across every subsystem you touch — and it will make code reviews faster, because you will immediately spot the violation that the author missed.
Interrupt Lifecycle: From Hardware Signal to Deferred Work
A hardware interrupt travels through the interrupt controller to the CPU, which runs the top half handler in hardirq context (no sleeping, very fast). The top half then kicks one of three deferred mechanisms: a tasklet or raw softirq (both still in softirq context, no sleep), a workqueue work item (runs in a kworker kernel thread, can sleep), or wakes an IRQ thread (for threaded IRQs, can sleep). All paths eventually produce results available to userspace.
The concepts
01What Is an Interrupt, Really?
An interrupt is an electrical signal on one of the CPU's dedicated interrupt pins (or a message-signaled interrupt for PCIe devices). When the signal is asserted, the CPU finishes its current instruction, atomically saves the program counter and CPU flags onto the kernel stack, masks the current interrupt line (on x86, clears IF in EFLAGS), and jumps to a fixed address in the Interrupt Descriptor Table (IDT). On ARM the vector table plays the same role; on RISC-V it is the mtvec/stvec register.
The key insight is that this all happens in hardware, before any software runs. The CPU has already switched to kernel mode, saved minimal state, and is about to execute your handler. This is fundamentally different from a system call (which is a deliberate software trap) or a fault (which is caused by a bad instruction). Interrupts are asynchronous — they can arrive at literally any point in the instruction stream.
Interrupt controllers (x86: APIC/IO-APIC; ARM: GIC; RISC-V: PLIC) sit between devices and the CPU. They arbitrate priority, route interrupts to specific CPUs in an SMP system, and provide the mechanism for masking individual lines. The kernel programs these controllers during boot via irq_chip operations (irq_mask, irq_unmask, irq_ack, irq_eoi).
Each hardware interrupt line maps to a Linux IRQ number (a virtual number, not the hardware pin number). The mapping lives in the IRQ domain subsystem. When you call request_irq(irq, handler, ...), you are registering a handler for a Linux IRQ number; the kernel handles the hardware-to-Linux mapping transparently.
/* On x86, the IDT entry for IRQ0 (timer) lives at vector 0x20.
* After the CPU jumps to the stub, assembly saves registers and calls:
*/
/* arch/x86/kernel/irq.c (simplified) */
void common_interrupt(struct pt_regs *regs, u32 vector)
{
/* 'regs' points to the full register save area pushed by asm stub */
struct irq_desc *desc = __this_cpu_read(vector_irq[vector]);
handle_irq(desc, regs);
}
02request_irq: Registering Your Handler
The primary API for a driver to register an interrupt handler is request_irq(). It takes: the IRQ number (obtained from the platform or PCI subsystem), a handler function pointer, flags, a name for /proc/interrupts, and a dev_id cookie used to identify this specific handler when multiple drivers share the same IRQ line.
The flags are important:
IRQF_SHARED: allows multiple drivers to share one IRQ line. The kernel calls each registered handler in turn until one returnsIRQ_HANDLED.IRQF_TRIGGER_RISING/IRQF_TRIGGER_LEVEL: tell the interrupt controller whether this is edge-triggered or level-triggered.IRQF_TIMER: marks the handler as a timer interrupt (affects CPU affinity and/proc/interruptsdisplay).
The handler's return value matters: IRQ_HANDLED means 'yes, this was my interrupt'; IRQ_NONE means 'not mine, keep searching'; IRQ_WAKE_THREAD is used with threaded IRQs (covered later).
To unregister, call free_irq(irq, dev_id). The dev_id must match exactly — this is how the kernel finds the right handler to remove when multiple drivers share the line. A classic driver bug is passing NULL as dev_id with IRQF_SHARED; the kernel rejects this for good reason.
The IRQ number for a PCI device comes from pci_irq_vector(pdev, 0) (for MSI/MSI-X) or from the device tree / ACPI table for platform devices.
#include <linux/interrupt.h>
static irqreturn_t my_handler(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
/* Read the device's status register to confirm it raised the IRQ */
u32 status = readl(dev->base + STATUS_REG);
if (!(status & IRQ_PENDING))
return IRQ_NONE; /* Not our interrupt */
/* Acknowledge / clear the interrupt at the device level */
writel(IRQ_CLEAR, dev->base + STATUS_REG);
/* Top half: wake up bottom half, do nothing else */
tasklet_schedule(&dev->bh_tasklet);
return IRQ_HANDLED;
}
static int my_probe(struct platform_device *pdev)
{
struct my_device *dev = /* ... alloc ... */;
int irq = platform_get_irq(pdev, 0);
int ret;
ret = request_irq(irq, my_handler,
IRQF_SHARED, "my_device", dev);
if (ret)
return ret;
dev->irq = irq;
return 0;
}
static int my_remove(struct platform_device *pdev)
{
struct my_device *dev = platform_get_drvdata(pdev);
free_irq(dev->irq, dev);
return 0;
}
03The Top Half: Do As Little As Possible
The top half is the function you register with request_irq. It runs in hardirq context — with interrupts disabled on the current CPU (for non-nested IRQs), no access to sleeping primitives, and under extreme time pressure. Think of it as a surgeon during a code blue: you have thirty seconds to stabilize the patient, not perform a bypass.
The rules in hardirq context are strict and enforced (sometimes only at runtime by WARN_ON or lockdep):
- Cannot sleep.
msleep(),mutex_lock(),kmalloc(GFP_KERNEL)— all forbidden. Any sleep would deadlock the CPU. - Cannot hold a spinlock that is also held in process context without disabling IRQs there (use
spin_lock_irqsavein process context). - Cannot call most blocking kernel APIs — no
schedule(), nowait_event(), nocopy_to_user(). - Must be fast. The longer you run, the more interrupt latency you add to every other device on the system.
What should you do in the top half?
- Acknowledge the interrupt at the device (write to a status register to clear the interrupt line; if you skip this, the interrupt fires again immediately in an infinite loop — a classic hardware bug to watch for).
- Read the minimum state you need from hardware registers (they may be reset on the next interrupt).
- Store that state in a per-device buffer or flag.
- Kick a bottom half to do the real work.
- Return
IRQ_HANDLED.
The in_hardirq() macro returns true when you are in this context. The kernel uses this internally, and you can use it in assertions.
/* The absolute minimum correct top half for a hypothetical UART */
static irqreturn_t uart_irq(int irq, void *dev_id)
{
struct uart_port *port = dev_id;
unsigned int iir = serial_in(port, UART_IIR);
/* IIR bit 0 = 0 means interrupt pending from THIS device */
if (iir & UART_IIR_NO_INT)
return IRQ_NONE;
/* Read the byte from hardware FIFO before it's overwritten */
unsigned char ch = serial_in(port, UART_RX);
/* Store in a circular buffer — this is O(1) and non-blocking */
kfifo_put(&port->rx_fifo, ch);
/* Wake the read() waiter — but via a tasklet, not directly */
tasklet_schedule(&port->rx_tasklet);
return IRQ_HANDLED;
/* Total time: ~50ns. Perfect. */
}
04The Bottom Half Philosophy
The bottom half is the deferred work that the top half set up. The phrase comes from the original split in early Unix drivers: the first half of the driver code ran in interrupt context, the second half ran later with interrupts re-enabled. Linux has evolved three distinct bottom-half mechanisms, each with a different tradeoff between latency, flexibility, and complexity.
Here is the key gradient to understand:
| Mechanism | Runs in | Can sleep? | Per-CPU? | Latency | Use when |
|---|---|---|---|---|---|
| softirq | softirq context (not process) | No | Yes (by design) | Very low | Network RX/TX, timers, block I/O |
| tasklet | softirq context (TASKLET_SOFTIRQ) | No | Serialized per-tasklet | Low | Simple device BH |
| workqueue | kernel thread (kworker) | Yes | Configurable | Medium | Anything needing sleep |
| threaded IRQ | dedicated kernel thread | Yes | No | Low-medium | Most new drivers |
The evolution tells a story: softirqs were first, fast but limited. Tasklets were added as a safe, serialized wrapper. Workqueues came later to handle the 'I need to sleep' case. Threaded IRQs are the modern answer: move the entire handler into a kernel thread, making the API simpler and RT-preemptible.
A mnemonic: softirq = fire-and-forget rocket (fastest, most dangerous), tasklet = tame missile (safe, still no sleep), workqueue = courier with a schedule (slower, can sleep), threaded IRQ = dedicated employee (clean, modern, RT-friendly).
05softirqs: The Kernel's Fast Path
softirqs are the lowest-level deferred work mechanism. There are exactly 10 softirq types compiled into the kernel, each with a fixed priority (index 0 is highest). They are not for driver use directly — they are kernel infrastructure.
HI_SOFTIRQ (0) — high-priority tasklets
TIMER_SOFTIRQ (1) — kernel timers
NET_TX_SOFTIRQ (2) — network transmit
NET_RX_SOFTIRQ (3) — network receive ← most traffic goes here
BLOCK_SOFTIRQ (4) — block layer completion
IRQ_POLL_SOFTIRQ (5) — IO polling
TASKLET_SOFTIRQ (6) — tasklets
SCHED_SOFTIRQ (7) — scheduler load balancing
HRTIMER_SOFTIRQ (8) — high-res timers
RCU_SOFTIRQ (9) — RCU callbacks
The key properties of softirqs:
- SMP-parallel: the same softirq can run simultaneously on multiple CPUs. This is why NET_RX is so fast — every CPU can process packets concurrently.
- Not serialized: if you use softirqs, you are responsible for your own per-CPU data or spinlocks.
- Cannot sleep: still in softirq context, no blocking.
- Reentrancy: a softirq can be preempted by a hardware interrupt, but not by another softirq of the same type on the same CPU.
When does softirq processing happen? After a hardirq handler returns, the kernel checks if any softirqs are pending and runs them in __do_softirq(). If too many softirqs are pending (the kernel limits burst size to 10 iterations or 2ms), the remainder are handed to ksoftirqd, a per-CPU kernel thread.
You can raise a softirq from a hardirq handler with raise_softirq(NET_RX_SOFTIRQ). The networking stack does exactly this: the NIC driver's top half raises NET_RX_SOFTIRQ, and the NAPI poll loop runs in NET_RX softirq context.
/* How the network RX path uses softirqs (simplified from net/core/dev.c) */
/* Called from hardirq context by the NIC driver */
void netif_rx(struct sk_buff *skb)
{
/* Enqueue to per-CPU input queue */
struct softnet_data *sd = this_cpu_ptr(&softnet_data);
__skb_queue_tail(&sd->input_pkt_queue, skb);
/* Raise the softirq — actual processing happens AFTER hardirq returns */
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}
/* This runs in softirq context, after hardirq returns */
static void net_rx_action(struct softirq_action *h)
{
/* Process up to budget packets from all NAPI-registered devices */
struct list_head *list = this_cpu_ptr(&softnet_data.poll_list);
int budget = netdev_budget; /* default: 300 packets */
while (!list_empty(list)) {
struct napi_struct *n = list_entry(list->next, ...);
int work = n->poll(n, budget); /* driver's NAPI poll fn */
budget -= work;
if (budget <= 0) break; /* hand rest to ksoftirqd */
}
}
06Tasklets: The Safe Bottom Half for Drivers
Tasklets are built on top of softirqs (they use TASKLET_SOFTIRQ and HI_SOFTIRQ) but add one crucial guarantee: a given tasklet instance will not run on two CPUs simultaneously. This makes them much easier to use than raw softirqs.
The API is simple:
- Declare:
DECLARE_TASKLET(name, function)ortasklet_init(&t, fn, data)at runtime. - Schedule from hardirq:
tasklet_schedule(&t)— this sets a bit and raises TASKLET_SOFTIRQ. - The function runs in softirq context — still no sleeping.
- To kill (e.g., in module unload):
tasklet_kill(&t)waits for any running instance to finish.
Tasklets are serialized per instance: if you call tasklet_schedule() ten times before the tasklet runs, it runs exactly once. This is the 'pending' bit behavior — a second schedule while the tasklet is already scheduled is a no-op.
When to use tasklets vs workqueues? Tasklets are right when: your bottom half is short (microseconds), does not sleep, and per-CPU parallelism is not needed. They are wrong when: you need to sleep, allocate memory with GFP_KERNEL, call any blocking API, or do significant amounts of work (which would hurt scheduling latency).
Note on deprecation: as of Linux 5.x, tasklets are considered legacy. New drivers are encouraged to use threaded IRQs (or workqueues if they need explicit defer scheduling). But tasklets are everywhere in existing drivers and you will read them constantly.
#include <linux/interrupt.h>
struct my_device {
struct tasklet_struct rx_tasklet;
/* ... */
};
/* Bottom half: runs in softirq context, no sleeping */
static void my_rx_tasklet(struct tasklet_struct *t)
{
struct my_device *dev = from_tasklet(dev, t, rx_tasklet);
/* Process data that the top half stored in dev->rx_buf */
while (!kfifo_is_empty(&dev->rx_fifo)) {
u8 byte;
kfifo_get(&dev->rx_fifo, &byte);
/* push byte up to the line discipline / tty layer */
tty_insert_flip_char(dev->port.state->port.tty, byte, TTY_NORMAL);
}
tty_flip_buffer_push(dev->port.state->port.tty);
/* tty_flip_buffer_push may internally use a workqueue — that's fine,
* we are not sleeping ourselves */
}
static int my_probe(struct platform_device *pdev)
{
struct my_device *dev = devm_kzalloc(&pdev->dev, sizeof(*dev), GFP_KERNEL);
tasklet_setup(&dev->rx_tasklet, my_rx_tasklet);
/* ... register IRQ ... */
return 0;
}
07Workqueues: Deferred Work That Can Sleep
Workqueues are the most flexible bottom-half mechanism. A work item runs in a kernel thread (named kworker/N:M in ps output), so it has a process context: it can sleep, allocate memory with GFP_KERNEL, take mutexes, call blocking I/O APIs — essentially anything normal kernel code can do.
The workqueue API:
- Define a work item:
DECLARE_WORK(work, fn)orINIT_WORK(&w, fn)at runtime. - Schedule it:
schedule_work(&w)(uses the system workqueue) orqueue_work(wq, &w)for a private workqueue. - Delayed variant:
schedule_delayed_work(&dw, delay_jiffies). - Flush (wait for completion):
flush_work(&w)orflush_workqueue(wq). - Cancel:
cancel_work_sync(&w)— cancels and waits for any running instance.
The system workqueue (system_wq) is a shared, concurrency-managed workqueue. The kernel maintains enough threads to keep CPUs busy without excessive parallelism. For most drivers, schedule_work() is the right choice.
For latency-sensitive work, use system_highpri_wq. For long-running work that should not block other work items, create a private workqueue with alloc_workqueue("name", WQ_UNBOUND, max_active).
The classic pattern: top half reads hardware, stores data, calls schedule_work(&dev->rx_work). The work function wakes up in a kworker thread, takes a mutex, processes the data, possibly sleeping while waiting for memory allocation or I/O, then signals userspace via wake_up(). This is how most USB drivers, I2C drivers, and many network drivers work.
#include <linux/workqueue.h>
struct my_device {
struct work_struct rx_work;
struct mutex lock;
/* ... */
};
/* Runs in kworker kernel thread — CAN sleep */
static void my_rx_work(struct work_struct *work)
{
struct my_device *dev =
container_of(work, struct my_device, rx_work);
/* Taking a mutex is fine here — we can sleep */
mutex_lock(&dev->lock);
/* GFP_KERNEL allocation is fine — can sleep if memory is tight */
char *buf = kmalloc(PAGE_SIZE, GFP_KERNEL);
if (!buf) {
mutex_unlock(&dev->lock);
return;
}
/* Copy data from hardware ring buffer to buf... */
size_t len = consume_hw_ring(dev, buf, PAGE_SIZE);
/* Push to userspace via a character device read queue */
kfifo_in(&dev->user_fifo, buf, len);
wake_up_interruptible(&dev->read_queue);
kfree(buf);
mutex_unlock(&dev->lock);
}
/* Top half: hardirq context, must be fast */
static irqreturn_t my_irq(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
/* Acknowledge hardware interrupt */
writel(IRQ_ACK, dev->base + STATUS_REG);
/* Defer real work to kworker */
schedule_work(&dev->rx_work);
return IRQ_HANDLED;
}
08Threaded IRQs: The Modern Approach
Threaded IRQs are the cleanest modern solution to interrupt handling. Instead of splitting the handler manually, you give request_threaded_irq() two functions: a short primary handler that runs in hardirq context and a thread handler that runs in a dedicated kernel thread created for this IRQ.
The primary handler does only what must be done in hardirq context: check if the interrupt is ours, acknowledge it at hardware level, then return IRQ_WAKE_THREAD. The kernel then wakes the IRQ thread, which runs the thread handler in process context — with full ability to sleep.
Advantages over tasklets/workqueues:
- RT-preemptible: with
PREEMPT_RT, the IRQ thread is a normal real-time thread with configurable priority (irq/<n>-nameinps). - Simpler code: one natural function for each 'phase', no manual work item management.
- Automatic synchronization:
free_irq()waits for the thread handler to complete — nocancel_work_syncneeded. - Forced threaded mode: boot with
threadirqskernel parameter to make all interrupt handlers threaded (useful for RT debugging).
The IRQF_ONESHOT flag is critical: it tells the kernel to keep the interrupt line masked until the thread handler finishes. Without it, the hardware can re-raise the interrupt before the thread handler reads the status register, causing a spurious second invocation. For level-triggered interrupts, IRQF_ONESHOT is almost always required.
For the vast majority of new driver code written today, threaded IRQs are the recommended approach.
#include <linux/interrupt.h>
/* Primary handler: hardirq context, must be minimal */
static irqreturn_t my_primary_handler(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
/* Confirm interrupt is ours */
if (!(readl(dev->base + STATUS) & DATA_READY))
return IRQ_NONE;
/* Do NOT read data here — let the thread do it.
* Just tell the kernel: wake the thread. */
return IRQ_WAKE_THREAD;
}
/* Thread handler: process context, CAN sleep */
static irqreturn_t my_thread_handler(int irq, void *dev_id)
{
struct my_device *dev = dev_id;
/* Read all pending data from hardware — may take many microseconds */
while (readl(dev->base + STATUS) & DATA_READY) {
u32 data = readl(dev->base + DATA_REG);
kfifo_put(&dev->rx_fifo, data);
}
wake_up_interruptible(&dev->read_queue);
return IRQ_HANDLED;
}
static int my_probe(struct platform_device *pdev)
{
struct my_device *dev = /* ... */;
int irq = platform_get_irq(pdev, 0);
/* IRQF_ONESHOT: keep IRQ masked until thread_handler finishes */
return request_threaded_irq(irq,
my_primary_handler,
my_thread_handler,
IRQF_ONESHOT | IRQF_TRIGGER_LOW,
"my_device", dev);
}
09Context Rules and Kernel Concurrency Discipline
The single most important mental model for kernel interrupt code is knowing what context you are in. The kernel provides predicates for this:
in_irq()orin_hardirq(): true if executing a hardware interrupt handler.in_softirq(): true in a softirq or tasklet.in_interrupt(): true in either hardirq or softirq context.in_task(): true in process context (normal kernel code or kernel thread).
These are checked by might_sleep() — the kernel's assertion that the current call site is allowed to sleep. Every path through the kernel that can sleep calls might_sleep() or might_resched(), and these will fire a warning (with a stack trace) if you are in interrupt context. This is how lockdep and the kernel's runtime checking catch your bugs at development time.
The locking corollary: if data is shared between a hardirq handler and process context code, protect it with a spinlock, but you must use spin_lock_irqsave() in process context (not plain spin_lock()). Why? Because if you hold the spinlock in process context and an interrupt fires on the same CPU, the interrupt handler tries to take the same spinlock — deadlock. spin_lock_irqsave() disables interrupts on the local CPU while the lock is held, preventing this.
If data is shared between a tasklet/softirq and process context, spin_lock_bh() / spin_unlock_bh() disables softirqs (bottom halves) on the local CPU — sufficient because tasklets do not preempt process context on SMP; they only run on the same CPU if that CPU is not in process context.
The rule of thumb: the higher the preemption level, the heavier the locking needed in the lower levels.
/* Correct locking for data shared between hardirq and process context */
struct my_dev {
spinlock_t lock;
u32 counter; /* written in IRQ handler, read in read() */
};
/* In hardirq handler: plain spin_lock is fine — IRQs are already off */
static irqreturn_t my_irq(int irq, void *dev_id)
{
struct my_dev *d = dev_id;
spin_lock(&d->lock); /* IRQs already disabled by CPU */
d->counter++;
spin_unlock(&d->lock);
return IRQ_HANDLED;
}
/* In process context (e.g., read syscall): MUST use irqsave variant */
ssize_t my_read(struct file *f, char __user *buf, size_t n, loff_t *off)
{
struct my_dev *d = f->private_data;
unsigned long flags;
u32 val;
/* Disable IRQs on this CPU while holding lock, save old IF state */
spin_lock_irqsave(&d->lock, flags);
val = d->counter;
spin_unlock_irqrestore(&d->lock, flags);
/* IRQs are re-enabled here */
return copy_to_user(buf, &val, sizeof(val)) ? -EFAULT : sizeof(val);
}
10Interrupt Affinity, NAPI, and Performance
On a modern multi-core system, which CPU handles an interrupt matters enormously for performance. The IO-APIC (x86) or GIC (ARM) can route IRQs to specific CPUs. /proc/irq/N/smp_affinity is a CPU bitmask controlling this. For a 10Gbps NIC with 16 queues and 16 CPUs, binding each queue's IRQ to a dedicated CPU means sixteen CPUs can process packets in parallel without contention.
The irqbalance daemon dynamically adjusts IRQ affinity based on load. For latency-sensitive workloads (HPC, real-time), you often pin IRQs manually and isolate CPUs with isolcpus= and nohz_full= kernel parameters.
NAPI (New API) is the Linux network driver interrupt mitigation technique, and it is a beautiful example of interrupt engineering:
- First packet arrives → NIC raises IRQ → hardirq handler disables the interrupt on that NIC queue and schedules a NAPI poll.
- softirq (NET_RX) calls the driver's
poll()function, which processes up tobudgetpackets from the hardware ring buffer in a tight loop. - If the ring is empty, re-enable the NIC interrupt and return.
- If the budget was exhausted (more packets waiting), yield and reschedule — without re-enabling the interrupt.
This means under high load, the NIC interrupt fires once, and then the driver polls until the ring drains. Under low load, every packet gets a dedicated interrupt. NAPI automatically adapts between interrupt-driven and polling mode, giving you the latency of interrupts and the throughput of polling. The Linux e1000, igb, and mlx5 drivers all use NAPI.
/* Minimal NAPI driver skeleton */
struct my_nic {
struct napi_struct napi;
/* ... ring buffer, register base ... */
};
/* NAPI poll: runs in NET_RX softirq context */
static int my_poll(struct napi_struct *napi, int budget)
{
struct my_nic *nic = container_of(napi, struct my_nic, napi);
int work = 0;
while (work < budget && hw_ring_has_packet(nic)) {
struct sk_buff *skb = build_skb_from_ring(nic);
netif_receive_skb(skb); /* push up the network stack */
work++;
}
if (work < budget) {
/* Ring drained — re-arm interrupt and exit polling mode */
napi_complete_done(napi, work);
enable_hw_irq(nic); /* writel to NIC register */
}
return work;
}
/* Top half: disable IRQ, schedule poll */
static irqreturn_t my_nic_irq(int irq, void *dev_id)
{
struct my_nic *nic = dev_id;
disable_hw_irq(nic); /* prevent re-entry while polling */
napi_schedule(&nic->napi); /* raise NET_RX_SOFTIRQ */
return IRQ_HANDLED;
}
static int my_nic_probe(struct pci_dev *pdev, ...)
{
struct my_nic *nic = /* ... */;
netif_napi_add(nic->netdev, &nic->napi, my_poll);
/* ... request_irq(pdev->irq, my_nic_irq, ...) ... */
napi_enable(&nic->napi);
return 0;
}
Key insights
- ✦The top/bottom half split exists because hardirq context has brutal constraints (no sleep, minimal time) while most useful work requires flexibility. The split is not a hack — it is a principled layering.
- ✦softirqs, tasklets, and workqueues form a strict hierarchy of capability vs. overhead: faster means more restricted. Match your mechanism to your workload.
- ✦The moment you share data between any interrupt context and any process context, you need interrupt-safe locking. Forgetting `spin_lock_irqsave` in process context is the single most common interrupt-related bug in driver code.
- ✦Threaded IRQs are the present and future of interrupt handling in Linux because they make the PREEMPT_RT goal achievable: every interrupt handler becomes a schedulable entity.
- ✦NAPI is the canonical example of a hybrid interrupt/polling design. It shows that 'interrupts vs. polling' is a false dichotomy — you can dynamically switch based on load.
- ✦The `might_sleep()` annotation system, combined with lockdep, means the kernel will *tell you* when you violate context rules during development. Trust it, configure it (CONFIG_DEBUG_ATOMIC_SLEEP, CONFIG_LOCKDEP), and read the splats carefully.
- ✦Acknowledging the interrupt at the device level (writing to a status register) before returning from the hardirq handler is mandatory for level-triggered interrupts. Skip it and you get an infinite IRQ storm.
- ✦The 10 softirq types are not arbitrary — they encode a priority ordering. A NET_TX softirq (index 2) will starve an RCU callback (index 9) if it keeps re-raising itself. Understanding this helps diagnose real-time latency issues.
Common pitfalls
- ⚠Sleeping in hardirq or softirq context: calling kmalloc(GFP_KERNEL), mutex_lock(), msleep(), or any blocking API in a hardirq handler. The system will deadlock or trigger a BUG_ON. Always use GFP_ATOMIC for allocations in interrupt context.
- ⚠Using plain spin_lock() in process context when the same lock is taken in an IRQ handler. This causes a deadlock on a uniprocessor and a data race on SMP. Always use spin_lock_irqsave() in process context.
- ⚠Forgetting IRQF_ONESHOT with threaded IRQs for level-triggered interrupts. The IRQ line is re-asserted before the thread handler clears it, causing an interrupt storm or double-processing.
- ⚠Passing NULL as dev_id with IRQF_SHARED. The kernel refuses this. Even if you 'don't need' the cookie, pass your device struct — it is mandatory for safe unregistration.
- ⚠Not calling free_irq() on module unload (or in the error path of probe()). The IRQ handler references freed memory after the module is gone, causing a kernel crash or silent corruption.
- ⚠Calling tasklet_schedule() from process context without disabling IRQs when the tasklet function reads/writes data also accessed in the hardirq handler — this is a classic race on SMP.
- ⚠Forgetting to acknowledge the interrupt at the hardware level inside the top half. For level-triggered lines, the CPU re-enters the IRQ handler immediately after returning, pegging one CPU at 100% and stalling the system.
- ⚠Doing too much in the top half — for example, copying large buffers, running algorithms, or printing with pr_debug(). Even printk() is relatively expensive. Keep the top half to register reads, a flag set, and a schedule call.
Practice
- 01medium
Write a minimal kernel module that registers a character device and a timer. Every 500ms, the timer fires and increments a counter. When userspace reads from the device, return the counter value. Use a workqueue to transfer the counter value to a kfifo that the read() handler drains. Compile with a Buildroot or QEMU kernel, load it, and verify with `cat /dev/mycounter`.
- 02hard
Using a QEMU-emulated virtio device (or a real USB-serial adapter), write a driver that registers a threaded IRQ handler. The primary handler returns IRQ_WAKE_THREAD; the thread handler reads bytes from a hardware register (simulate with a memory-mapped region in QEMU) and stores them in a kfifo. Add a /proc file that dumps the kfifo contents. Verify that IRQF_ONESHOT prevents double-invocation by deliberately removing it and observing the behavior.
- 03hard
Instrument the Linux kernel's softirq path to measure latency from when raise_softirq() is called (end of a hardirq handler) to when the softirq action function begins. Use a kernel module that installs a kprobe on raise_softirq and a second kprobe on __do_softirq, records timestamps with ktime_get(), and exposes the distribution via a debugfs histogram. Run under light and heavy load and compare the latency distributions.
- 04easy
Read the source of the 8250 UART driver (drivers/tty/serial/8250/8250_core.c) and the interrupt handler (serial8250_interrupt). Identify: (1) how it acknowledges the interrupt, (2) what work it defers and how, (3) whether it uses a softirq, tasklet, workqueue, or threaded IRQ, and (4) what locking protects the ring buffer. Write a one-page technical note explaining each choice and whether you would make the same choices today.
- 05medium
Write a benchmark module that measures the overhead of schedule_work() vs tasklet_schedule() vs direct softirq raise. For each mechanism, schedule 1,000,000 bottom-half activations that each increment a per-CPU atomic counter, then measure wall time with ktime_get_ns(). Print results to the kernel log. What is the per-activation overhead of each mechanism?
Resources
Robert Love's book. Chapter 7 (Interrupts and Interrupt Handlers) and Chapter 8 (Bottom Halves and Deferring Work) are the canonical reference for this exact topic.
Free online. Chapter 10 (Interrupt Handling) covers request_irq, the top/bottom split, and tasklets with real, buildable examples.
The official kernel documentation for the generic IRQ subsystem — authoritative and up to date.
Comprehensive documentation of the concurrency-managed workqueue system, including design rationale. Required reading before writing any non-trivial deferred work.
The LWN article from when threaded IRQs were merged, by Thomas Gleixner. Explains the motivation (PREEMPT_RT) and the API choices.
Tejun Heo's explanation of why the concurrency-managed workqueue replaced the old create_workqueue API. Explains the design brilliantly.
Free PDF slides from Bootlin's Linux kernel training. The interrupt handling section has excellent diagrams of the softirq/tasklet/workqueue hierarchy.
Bovet & Cesati. More detailed than Love's book on the internals of the interrupt descriptor tables, APIC programming, and softirq implementation details.
Discusses the latency impact of softirqs running too long and the ksoftirqd fallback — important for understanding real-world performance tuning.
Checkpoints — you can now…
- ✓You can explain, without hesitation, why sleeping inside a hardirq handler causes a deadlock, and which kernel macro detects this at runtime.
- ✓You can write a correct request_irq() call for a shared, level-triggered interrupt, including the right flags, a proper dev_id, and a matching free_irq() in the cleanup path.
- ✓You can identify which bottom-half mechanism to use given a description of the work: a tasklet for short non-sleeping work, a workqueue when sleeping is needed, a threaded IRQ for modern driver design.
- ✓You can explain the three places where softirq processing can happen: after a hardirq returns, in ksoftirqd, and in local_bh_enable().
- ✓You can correctly use spin_lock_irqsave() in process context and explain precisely why plain spin_lock() is unsafe there.
- ✓You can describe the NAPI design: how it transitions from interrupt-driven to polling mode under load, and what napi_complete_done() does.
- ✓You can read a kernel oops or WARN splat that says 'BUG: sleeping function called from invalid context' and identify whether it was caused by a hardirq or softirq context violation.
- ✓You can sketch the data flow of a single received network packet from NIC hardware interrupt through the NET_RX softirq to netif_receive_skb().