Skip to main content Why an Interrupt Is Not a Normal Function Call | IoT Worker

Why an Interrupt Is Not a Normal Function Call

Device software often creates a misleading mental model: code runs in source order, and the next thing only happens after the current function returns. That model is useful inside a single function, but it breaks down as soon as timers, UART input, network packets, GPIO events, and DMA completion enter the system.

A peripheral does not wait until the main loop reaches the right line. A UART byte arrives when it arrives. A network packet arrives when it arrives. A timer expires when it expires. If the CPU had to discover every event by polling, response would either be slow or the system would waste a large amount of time checking status bits.

Interrupts solve this problem: hardware events need a way to get CPU attention outside the current code path.

The safest first model is this: an interrupt is not a place in the program calling a function. It is a hardware or CPU event that makes the processor pause the current execution, save enough state, enter a pre-registered handler path, then return to the interrupted execution or let the scheduler choose a different one.

Current code is running
-> A hardware event or CPU exception occurs
-> CPU enters an interrupt or exception entry
-> Required state is saved
-> Interrupt handling code acknowledges and handles the event
-> CPU returns to the old execution flow, or scheduling chooses another task

This can look like “jumping to a function,” but its boundaries are very different from a normal function call.

Who Starts a Normal Function Call

A normal function call is started by the current execution flow. The caller knows where the call happens and which function it calls. The compiler and ABI define where arguments go, which registers are preserved, and how the return value comes back.

caller
-> call function
-> function returns
-> caller continues

That model assumes several things:

  • The call happens inside the current thread’s own execution path
  • The call site is determined by source code or compiled instructions
  • The called function still runs in the same thread context
  • The function can use stack, local variables, locks, and blocking APIs under ordinary code rules

An interrupt does not fit those assumptions.

When an interrupt occurs, the current code usually did not call the interrupt handler. The CPU may have been running user code, kernel code, an idle loop, or even a lower-priority interrupt handler. The interrupt entry is selected by the hardware event, the interrupt controller, and the interrupt vector table, not by the current function’s source code.

So thinking of an interrupt handler as “a callback automatically called by the system” is only a rough first impression. It becomes misleading when writing drivers, reasoning about latency, or debugging hangs.

What an Interrupt Actually Interrupts

An interrupt interrupts the CPU context that is currently running. That context may belong to:

  • a bare-metal main loop
  • an RTOS task
  • a Linux user-space process
  • a Linux kernel path
  • another nestable interrupt handler
  • an idle CPU state

The interrupted object is not “a business function.” It is the current execution state occupying the CPU. The CPU must save at least the state required to continue later, such as the program counter and status register. Some architectures also save part of the general registers. Then it uses the interrupt number to enter the corresponding handling path.

That explains why the same interrupt handler can interrupt the system at many different places. It does not care where the business code was. It cares that the hardware event happened.

Because of that, interrupt handling code cannot assume it is inside a stable business workflow. It is handling an asynchronous event, not continuing a sequential call chain.

Why Interrupt Context Must Not Block Freely

When a normal thread blocks, the scheduler can run another thread. Later the blocked thread can be woken up and continue from the blocking point.

Interrupt context is different. Interrupt handling code is usually not an ordinary schedulable thread. It is a special execution path entered when the CPU responds to hardware. In many systems, it does not have the same sleep, wakeup, and scheduling identity as a normal thread.

That is why interrupt handlers usually must not do things like:

  • wait for a mutex
  • sleep for a period of time
  • wait for I/O completion
  • allocate memory through a path that may block
  • call APIs that are only valid in thread context

The problem is not only that this would be slow. The semantics may be invalid. If an interrupt handler waits for a lock that can only be released by a normal task, and that task cannot run until the interrupt returns, the system can deadlock.

A common engineering rule is: the top half of interrupt handling should do only what must be done immediately, and defer slow or blocking work to a schedulable context.

Top half:
    acknowledge the hardware event
    read or save minimal required state
    clear or acknowledge the interrupt source
    wake a later handling path

Deferred path:
    parse data
    copy larger buffers
    call APIs that may block
    notify application logic

In Linux, the deferred path may be a softirq, tasklet, workqueue, or driver thread. In an RTOS, the common pattern is to release a semaphore, send a queue message, or set an event flag from the ISR so that a task can wake up and process the work.

Why the Interrupt Source Must Be Cleared or Acknowledged

Many peripheral interrupts are not one-shot messages. They are triggered by a status bit, a level, or a queue condition. If the handler does not clear the source, or does not move the hardware state out of the triggering condition, the interrupt may immediately fire again.

Common examples include:

  • a timer expiration flag was not cleared
  • a UART receive register was not drained
  • a GPIO level trigger is still active
  • a network receive queue still has unacknowledged packets
  • a DMA completion flag was not acknowledged

This can show up as high CPU usage and a system that looks stuck in interrupt handling. Application threads get little chance to run, and logs may stop at a place that is not the real cause.

So the first job of an interrupt handler is often not to finish all business logic. It is to tell the hardware and interrupt controller: this event has been observed, and it should not keep interrupting the CPU pointlessly.

How Interrupts Relate to Scheduling

An interrupt can stop the current execution, but returning from an interrupt does not always mean returning to the same task.

For example, a network interrupt arrives, the driver receives data, and a thread waiting on socket data is woken up. If that thread has higher priority, or the scheduler decides it should run, the interrupt return path may switch directly to that thread.

Task A is running
-> Network interrupt occurs
-> Interrupt handler wakes Task B
-> Scheduler decides B should run
-> Interrupt return enters Task B

From Task A’s perspective, it was interrupted. From the system’s perspective, the interrupt changed the ready queue and therefore changed who gets the CPU next.

That is why interrupt latency and scheduling latency must be separated in real-time analysis:

  • interrupt latency: time from event occurrence to handler start
  • interrupt handling time: CPU time spent inside the interrupt path
  • scheduling latency: time from waking the target task to that task actually running

“The interrupt is fast” does not mean the application task will run immediately. Long interrupt handlers, long interrupt-disabled sections, lock contention, and higher-priority CPU usage can all delay the final response.

Why Disabling Interrupts for Too Long Is Dangerous

Disabling interrupts is a heavy protection mechanism. It can prevent certain interrupts from preempting a critical section, but all affected hardware events must wait.

Short interrupt-disabled sections can protect very small critical regions, such as updating a status variable shared with an ISR. Long sections create larger problems:

  • timer ticks are delayed, affecting scheduling and timekeeping
  • UART, network, or sensor data may not be handled in time
  • peripheral FIFOs can overflow
  • high-priority events cannot be serviced quickly
  • real-time behavior degrades in ways that may be hard to reproduce

The closer a critical section is to interrupt-level code, the shorter it should be. If atomic operations, finer locks, ring buffers, or threaded handling can solve the problem, broad interrupt masking is usually the wrong tool.

Was the Interrupt Lost, or Was the Data Lost

Debugging often starts with “the interrupt was lost.” That phrase hides several different cases.

The first case is that the hardware event did not generate an interrupt. The interrupt may not be enabled, the trigger mode may be wrong, pin muxing may be wrong, or the peripheral condition may not have occurred.

The second case is that the interrupt was generated but masked or delayed too long. Global interrupts may have been disabled too long, the interrupt priority may be too low, or the CPU may have been handling a higher-priority path.

The third case is that the interrupt handler ran, but the data was already gone. A UART FIFO may have overflowed, a DMA buffer may have been overwritten, or a NIC ring may not have been reclaimed in time.

The fourth case is that the data arrived and the ISR handled it, but the later task did not consume it fast enough. The problem may be queue length, task priority, lock waiting, or a blocked application thread.

These cases have different observation points:

  • interrupt counters show whether the handler ran
  • peripheral status registers show whether the event condition exists
  • FIFO, DMA ring, and error counters show whether data overflowed
  • task state and queue backlog show whether deferred processing kept up

Without separating these layers, “the application did not receive data” too easily becomes “interrupts do not work.”

What Interrupt Code Should Be Careful About

The main goal of an interrupt handler is not to put business logic there. It is to respect the context boundary.

Useful checks include:

  • Can this code sleep or wait
  • Can this code take a lock that a thread may hold for a long time
  • Does runtime grow with the amount of data
  • Does it access data shared with ordinary tasks
  • Must it happen inside the interrupt, or can it wake a task
  • Has the interrupt source been cleared or acknowledged correctly

If a path needs to parse a protocol, write a file, allocate a large buffer, print a lot of logs, or wait for another device, it usually does not belong in the interrupt top half.

A more robust structure is: the interrupt reliably exports “an event happened” and the minimal required data; the complex work runs later in a schedulable context.

What to Remember in Practice

The core idea is not “the system automatically called a function.” An interrupt is a controlled path where a hardware event, CPU state, and operating system handling can stop the current execution.

That explains several important boundaries:

  • an interrupt handler is not part of the ordinary business call chain
  • interrupt context usually cannot block freely
  • long interrupt handling delays other execution flows
  • an interrupt can wake a task and make interrupt return enter a different task
  • missing application data does not necessarily mean the interrupt never happened

When debugging, do not look only at the ISR code. A better order is: confirm that the hardware event occurs, confirm that the interrupt handler runs, confirm that the interrupt source is handled correctly, then confirm that the deferred task consumes the data in time.

Once those layers are separated, interrupt bugs become a runtime path that can be checked step by step instead of a vague system hang.