Embedded Systems · Bare-Metal Programming
What the Hardware Does Between an Interrupt and Its Handler
The stacked frame, the link register value that redirects the return, and the chaining shortcuts that make back-to-back interrupts cheap.
An interrupt handler in C looks like a function that is called by nobody. The call is made by the processor, and the argument set is implicit: the mechanism decides which registers survive into the handler, what the return address means, and whether that return resumes the interrupted code or falls into the next pending interrupt. Understanding the sequence explains most of the surprising properties of interrupt-driven firmware, including why a handler that forgets to acknowledge its peripheral runs forever and why two consecutive interrupts cost less than the arithmetic of two entry sequences.
Eight words stacked before the handler’s first instruction
The core recognises a pending exception with sufficient priority, then saves a fixed set of registers on the stack the current context is using: the program status register, the return address, the link register, the intra-procedure register, and the four argument registers. Eight words, 32 bytes, in a fixed order and at descending addresses. Only then does it fetch the handler address from the vector table, load it into the program counter, set the link register to a special value, and update the status register to name the exception that is now active.
The set is exactly the caller-saved set of the procedure call standard: the registers a normal function call is not obliged to preserve. Everything the handler’s prologue would have to save is saved by hardware, and the callee-saved registers stay the compiler’s responsibility. A handler written in plain C is therefore correct with no special attributes: the compiler emits the same prologue and epilogue it would for any function, saving the callee-saved registers it actually uses and leaving the rest alone.
On cores with floating-point hardware the frame changes shape. If floating-point state is live, the architecture can stack it as well, which is 17 further words, and the mechanism provides for deferring that stacking until the handler actually touches floating-point, so a handler that never does pays nothing. Software that disables the deferral, or that toggles floating-point context without informing the core, changes the size of every frame on the stack; a fixed-size task stack that was sized for the integer frame only can then overflow, which is one of the ways an interrupt-driven system corrupts memory it never wrote.
The return address is not a return address
The link register does not hold a code address while a handler is running. The core writes a reserved value whose low bits encode which mode to return to and which stack to return to: bits that distinguish return-to-handler from return-to-thread mode, and main stack from process stack. Executing a branch to that value is the instruction that ends the exception; the core detects the pattern, pops the eight-word frame, and resumes the interrupted instruction stream.
The consequence is that a handler must return only by an ordinary function return, or by loading the value into the program counter deliberately. Pushing the link register and popping it later works, because the value is data that the core interprets on the way out. What does not work is returning to a computed address, since that produces a normal branch out of handler mode with the frame left on the stack, and the stack steadily fills with frames.
The second consequence concerns nested handlers. On entry the hardware stacks the current link register with the rest of the frame and replaces it with the special return value, so a handler that is itself interrupted keeps its own return value in its frame rather than in the register. The same stacking is what lets a nested handler use the argument and intra-procedure registers freely: those registers are not banked per exception depth, they are written to the stack on entry and restored on return, which is why the interrupted handler sees its own values again once the nested exception ends.
Chaining, late arrival, and the cost of two interrupts
The documented entry latency for the classic implementations of this architecture is around twelve cycles, assuming no wait states on the memory holding the table or the stack. Returning from an exception costs a comparable amount, since the frame has to be popped and the pipeline refilled.
Two shortcuts reduce the cost of sequences rather than of single events. If an exception becomes pending while another is being returned from, the core does not unstack the frame and immediately restack it for the next handler; it continues from the existing frame and enters the second handler, a path that costs roughly the fetch and pipeline refill only — on the order of six cycles rather than the cost of a full entry plus a full return. If a higher-priority exception arrives while a lower-priority one is still stacking, the entry sequence is restarted with the new exception without re-stacking the frame, which is the late-arrival case and costs about the same six cycles. A burst of interrupts therefore dispatches far more efficiently than the same interrupts spread out in time, and the benefit vanishes the moment a handler’s return path is interrupted by anything expensive.
Priority arbitration is what makes these shortcuts available, and priority on this architecture is inverted relative to intuition: a numerically lower value in the priority field means a more urgent exception. Implementations place the implemented priority bits in the most significant bits of the byte-sized field, so the number a program writes must be shifted left by the number of unimplemented bits. Writing an unshifted value produces a priority that is not the one intended, which is invisible until two interrupts race and the wrong one wins.
Priorities decide what can interrupt what
The controller compares the priority of a pending exception against the priority of whatever is currently executing, and a handler can be pre-empted only by an exception of strictly greater priority. Handlers at the same priority therefore run to completion in turn rather than interrupting one another, which is a property firmware relies on when two handlers share state without protection.
Implementations implement a subset of the priority field, and the subset is commonly the most significant bits of a byte; a part with three implemented bits offers eight distinct priorities, and a part with eight offers 256. The unimplemented bits read as zero, so the shift required to place a priority value in the field depends on the part, and a value written without that shift selects an unrelated priority. The field can also be split by configuration into a pre-emption part and a subpriority part, which changes whether two interrupts with different subpriorities can pre-empt each other; the split is a global configuration, and changing it while exceptions are pending is not safe.
Because the controller’s configuration registers are not memory in the ordinary sense, the architecture requires a barrier after changing priority or enable configuration and before relying on the change having taken effect, typically a data synchronisation barrier followed by an instruction synchronisation barrier. Omitting the barriers produces code that works at low optimisation levels and fails when the surrounding instructions are reordered, which is the same class of defect as the one described in volatile and memory-mapped register access.
What a handler must do and must not do
The obligations are short. Acknowledge the source at the peripheral, because the pending flag that caused the entry is set by hardware and cleared by software; a handler that does not leaves the interrupt pending and re-enters immediately, which produces a machine that spends all its time re-entering the same handler and looks like a hang. Keep the handler short enough that the interrupts it has masked by its own priority are not delayed beyond their deadline. And treat every variable shared with the main flow as needing the protection described in critical sections without an operating system, because on a single core the corruption is not a race between cores but an aliasing problem inside one thread of control.
/* The hardware has already saved the caller-saved registers. Only the
callee-saved registers the compiler actually uses are saved here. */
typedef struct { volatile uint32_t CTRL, STATUS, PENDING; } timer_regs_t;
#define TIMER0 ((timer_regs_t *)0x40002000u)
volatile uint32_t ticks;
void timer0_handler(void)
{
TIMER0->PENDING = 1u; /* acknowledge, or this handler re-enters forever */
ticks++; /* shared with main: needs protection to be atomic */
}
Unused vector entries matter as well. An interrupt that is enabled at the controller but whose table entry is empty — a zero or a leftover default — takes an exception whose handler is not a handler, and the resulting fault arrives nested inside the exception it was meant to serve, which obscures the original cause. Filling every entry from a single table generated from the device’s interrupt list is the usual defence. Interrupts that remain enabled but unhandled should point at a handler that records the exception number and stops, not at the reset handler.
The entry sequence also explains the diagnosis techniques for handlers that fault. When a handler raises an exception of its own, the frame of the interrupted context is still on the stack below the current one, and decoding it recovers the address at which the machine was executing and the values of the argument registers at that moment. That decoding is the basis of the crash-diagnosis approach in fault handling and crash diagnosis on bare metal, and it depends on knowing that the frame is eight words of a specific layout rather than whatever the compiler’s own prologue would have produced.
References
- ARMv7-M Architecture Reference Manual (ARM DDI 0403) — Arm
- Cortex-M3 Processor Technical Reference Manual (ARM DDI 0337) — Arm
- Procedure Call Standard for the Arm Architecture (AAPCS32) — Arm
- The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — Joseph Yiu, Newnes