What belongs inside an interrupt service routine, and what must be kept out of it?
Interrupt service routine rules are simple: acknowledge the hardware, capture the minimum safe state and return fast. Blocking calls, allocation, logging and long loops belong in deferred work because ISR runtime becomes worst-case latency for interrupts at the same or lower priority.
What the interviewer is scoring
- Does the candidate justify a short handler by its effect on other interrupts' latency
- Whether you distinguish what `volatile` guarantees from what atomicity guarantees
- That they identify a multi-word shared variable as a torn-read hazard
- Whether the deferred-work split is described concretely rather than named
- Whether reentrancy of any function called from the handler is considered at all
Answer
Short answer
Keep an ISR tiny: acknowledge the device, copy the minimum data, schedule deferred work, and avoid blocking, allocation, logging or loops that stretch interrupt latency.
Keep interrupts explicit in the answer because that is the concept the interviewer is actually trying to test.
The handler's cost is charged to everyone else
An interrupt handler runs with other interrupts masked, or at least with everything at or below its own priority masked. So the time it spends executing is not merely its own cost. It is added directly to the worst-case interrupt latency of every source that cannot preempt it. A 200-microsecond handler on a timer is not a slow timer handler; it is a 200-microsecond hole in the responsiveness of the UART, the ADC and the motor commutation interrupt, and the symptom appears there rather than where the fault is.
That reframing is the answer to "why keep it short". The rule is not stylistic hygiene, it is that you cannot reason about any deadline in the system without knowing the sum of the handler times that can run ahead of it.
So the handler does three things: it acknowledges the hardware so the source stops asserting, it captures whatever is perishable such as a received byte or a capture-register timestamp, and it signals that work is pending. Then it returns.
What must not be in there
Anything that can block, because there is nothing to block on. An ISR is not a task; it has no context to suspend. Taking a mutex that a task holds cannot work, since the task cannot run to release it while the handler occupies the core, and the result is a hard hang rather than a wait. RTOSes address this by providing a separate set of ISR-safe primitives — typically the same queue and semaphore operations with a distinct entry point that never blocks and only signals — and calling the task-context version from a handler is one of the most common bugs in a first RTOS port.
Dynamic allocation is out, because the allocator's data structures are shared with whatever the handler interrupted and the allocator is generally not reentrant. Formatted printing is out for the same reason plus its cost: a printf to a polled UART can take milliseconds. Busy-waiting on a peripheral is out, because you have converted a bounded handler into one whose duration depends on an external device.
The general test is reentrancy. Any function called from the handler must be safe to enter while another invocation of it is part-way through, which rules out anything holding internal static state — string tokenisers, error-code globals, non-reentrant library maths routines that keep scratch state.
Passing state out without tearing it
The handler and the main loop share memory, and nothing serialises them. Two separate mistakes hide here, and a strong answer separates them.
The first is the compiler. If a variable is written by the handler and read in a loop by main, the compiler is entitled to hoist that read out of the loop, because within the visible control flow nothing changes it. volatile fixes exactly this: it forces the access to happen at the point the source says, every time. That is all it does.
The second is atomicity, and volatile does nothing for it. On a 32-bit core a naturally aligned 32-bit load is a single instruction and is safe. A 64-bit counter is not, and neither is a struct, nor an aligned 32-bit value on an 8- or 16-bit core. If the interrupt lands between the two halves of a read, the main loop gets a value composed of the old high word and the new low word — a number that was never written.
volatile uint32_t tick_lo; /* handler writes both halves */
volatile uint32_t tick_hi;
/* Wrong: the interrupt can fire between the two loads and the
composed value is neither the before nor the after value. */
uint64_t now_broken(void) {
return ((uint64_t)tick_hi << 32) | tick_lo;
}
/* Right: re-read the high word and retry if the handler moved it.
No interrupts are disabled, so latency is untouched. */
uint64_t now(void) {
uint32_t hi, lo;
do {
hi = tick_hi;
lo = tick_lo;
} while (hi != tick_hi);
return ((uint64_t)hi << 32) | lo;
}
The alternative is to mask interrupts around the read, which is correct and costs you latency on every source for the duration. Either answer is acceptable; not knowing that a choice exists is not. Where the language offers it, C11's _Atomic types express the intent directly and let the compiler pick the mechanism, though on many small targets the implementation reduces to masking anyway.
The split, drawn
The deferred half is what makes the short handler useful, and its shape decides whether data is ever lost.
sequenceDiagram
participant HW as Peripheral
participant ISR as Handler
participant RB as Ring buffer
participant Task as Task or main loop
HW->>ISR: Byte received
ISR->>ISR: Read data register, clear flag
ISR->>RB: Write one byte, advance head
ISR-->>HW: Return
Task->>RB: Drain from tail
Task->>Task: Parse frame, run protocolLook at the gap between the two halves rather than at either half. The ring buffer must be sized for the longest the task can go without draining it, which means the handler's correctness depends on the scheduling of code that is not in the handler. Overflow here is silent unless you count it, so a production ring buffer increments a dropped-byte counter that something eventually reports.
The flag-clear race that makes a handler run twice
Worth knowing because it looks like a hardware fault. Clearing a peripheral's interrupt flag as the very last statement of the handler is a natural way to write it, and on cores with a write buffer between the CPU and the peripheral bus the write can still be in flight when the handler returns. The interrupt controller sees the source still asserted and re-enters the handler immediately, so the byte is processed twice or a spurious edge is counted.
The fix is to clear the flag early and then force the write to complete before returning — commonly by reading the register back, or by issuing a data synchronisation barrier. Clearing early has a second benefit: an event arriving during the handler is then correctly latched as a new pending interrupt rather than being lost.
The interview signal is not the list of forbidden calls. It is whether you can say, for one specific handler, how long it runs, whose deadlines that time is charged against, and what happens to the data if the deferred half is late.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- Your handler updates a 64-bit microsecond counter that the main loop reads. How do you make that read safe without disabling interrupts?
- Why can clearing a peripheral's interrupt flag on the last line of the handler cause the handler to run twice?
- How do you measure the real worst-case execution time of a handler on hardware rather than reasoning about it?
- When is it correct to do the whole job inside the ISR and defer nothing?
Related questions
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on concurrency7 min
- Two threads write to adjacent counters and throughput collapses. What is happening and how do you fix it?hardAlso on concurrency5 min
- Every call you make is on a ConcurrentHashMap, and you still lost an update. How does a thread-safe collection get raced?hardAlso on concurrency5 min
- Two users edit the same record and both save. What does EF Core do about it?mediumAlso on concurrency5 min