Embedded Systems · Microcontrollers

Nested Interrupt Entry, Tail-Chaining and Late Arrival in the NVIC

How the interrupt controller decides which of several pending interrupts runs first, why back-to-back handlers skip the full restore, and what happens when a request arrives during entry.

An interrupt arrives as a signal from a peripheral, and the processor must stop, save enough state to resume later, and branch to a handler whose address came from a table. On a simple design that sequence is several instructions of software and a handful of cycles of overhead. On ARMv7-M the same sequence is hardware, and the interesting part is not that it is fast but that the controller makes a scheduling decision at every step: which pending request to run, whether to restore and immediately re-enter, and whether a request that arrived too late to be included in the current decision should preempt it.

Priority as a comparison, not a number

Each interrupt has an eight-bit priority field in the controller, and the low-order bits of that field may be unimplemented. A part that implements three priority bits reads the field as eight levels spaced thirty-two apart: 0x00, 0x20, 0x40, and so on up to 0xE0. Writing 0x1F into such a field stores 0x00, because the unimplemented bits read as zero, and the interrupt ends up at the highest priority rather than just below it. Priority values are therefore not dense integers to be counted; they are comparisons, and only the implemented bits matter.

The scale is inverted: a numerically lower value is a higher urgency. Zero is the most urgent implemented priority, and 255 — or the highest value the implemented bits can express — is the least. Reset, NMI, and HardFault sit outside the programmable scheme at negative priority numbers, so no programmable interrupt can preempt them and no write to a priority register can lower them.

Preemption is a strict comparison on the priority value, with ties going to the lower exception number. An interrupt of equal priority to the one currently executing does not preempt it, and its request stays pending until the running handler returns. This produces a rule that surprises people who expect FIFO behaviour: two interrupts of the same priority are serviced in order of their exception number, not in the order they arrived, and the second one waits for the first to finish regardless of how long that takes.

The comparison uses the group priority, and the split between group and subpriority is configured in a register that applies to all interrupts at once. With no subpriority bits, the whole implemented field is group priority and preemption happens on any difference. With subpriority bits, interrupts that share a group priority do not preempt each other, and the subpriority only breaks ties among simultaneously pending requests. The split is global rather than per-interrupt, which means a system cannot give one peripheral fine-grained subpriority and another coarse preemption; the choice is made once for the whole controller.

The entry sequence and what gets stacked

When the controller decides to take an exception, the core performs the stacking automatically. It pushes eight words onto the current stack: R0–R3, R12, the link register, the program counter, and the program status register. The stack used is the one the interrupted context was using, which is why the main stack pointer and the process stack pointer are both maintained and why the choice of stack is encoded in the link register value written on entry.

The eight-word frame is 32 bytes, and it is aligned to eight bytes if the stack was not already aligned, with the alignment state recorded in bit 9 of the stacked status register. That bit matters on return: the hardware reads it to decide whether to adjust the stack pointer by four bytes while unstacking. A handler that modifies the stacked frame without preserving that bit corrupts the alignment on return.

The vector is fetched from the table after the stack write is issued, not before, so the stack write and the vector fetch overlap. That is the reason entry costs a small fixed number of cycles rather than the sum of a memory write and a memory read. The table’s alignment requirement and its relocation mechanism are part of the memory map described in the address decoding article.

Handlers are entered in handler mode with the stack pointer set to the main stack, and the link register holds a special value rather than a return address. That value — 0xFFFFFFF9 for a return to handler mode using the main stack, 0xFFFFFFFD for a return to thread mode using the process stack — is what makes a plain BX LR at the end of the handler perform the exception return. There is no separate return instruction; the value in the link register is the instruction.

Tail-chaining: returning without returning

The expensive part of an interrupt is not the entry but the pair of stack operations around it. Unstacking eight words and then immediately stacking eight more for the next handler doubles the cost of servicing two back-to-back interrupts.

Tail-chaining removes the pair. When a handler executes its exception return and the controller finds another request pending at a priority that would have preempted the handler that just finished, it does not unstack. It discards the return, fetches the new vector, and enters the new handler with the original stacked frame still in place. The eight-word frame is written once and read once, no matter how many handlers run in the chain.

The condition for tail-chaining is precise: the pending request’s priority must be higher than the priority of the exception being returned from, and higher than the current execution priority. A request of equal or lower priority is not tail-chained; the core unstacks and resumes the interrupted code, and the request is taken later as a fresh exception. This is why a burst of same-priority interrupts does not chain: each one returns fully before the next begins, and the cost is the full unstack and restack per interrupt.

The saving is largest exactly where it matters least in a latency sense and most in a throughput sense: a peripheral that generates a request for every byte, at a rate the core can just keep up with. In that pattern the handler is short, the stack traffic dominates, and tail-chaining turns the cost per byte into entry plus handler plus vector fetch rather than entry plus handler plus exit plus entry plus handler.

Late arrival and the recomputed decision

Between the moment the controller decides to take interrupt A and the moment the first instruction of A’s handler executes, several cycles pass. If interrupt B, of higher priority, becomes pending during that window, the core must not execute A first. The mechanism is late arrival: the vector fetch is not committed until the point of no return, and a higher-priority request that arrives before that point replaces the pending vector.

The effect is that the priority decision is effectively re-evaluated at the last possible instant. A handler for a low-priority source that is about to start is displaced by a high-priority source that arrived microseconds later, and the low-priority handler runs afterwards. The stacked frame is unchanged, so the substitution costs a second vector fetch rather than a second stack operation.

The window is small, on the order of a few cycles, and its existence is the reason a designer cannot rely on “the first interrupt to assert wins”. Two peripherals that assert within the same cycle are ordered by priority and exception number, and two that assert a few cycles apart are ordered by whichever the controller sees first only if the second is not more urgent.

Priority groups, masking and the registers that implement them

Three registers do most of the work. The set-enable register turns an interrupt on, the clear-enable turns it off, and the set-pending and clear-pending registers allow software to request or withdraw an interrupt without involving the peripheral. Writing a one to a bit in the set-pending register makes that interrupt pending even if the peripheral never asserted; writing a one to clear-pending withdraws a request that the peripheral has already raised. This is how a handler acknowledges a source whose peripheral has no clear mechanism of its own.

The priority registers are byte-addressable, one byte per interrupt, in a contiguous array. A common mistake is to write a word to that array and thereby set four interrupts’ priorities at once, which is fine if all four are intended and a defect if one of them is the interrupt the write was meant to leave alone.

Masking is done with the base priority register, which sets a numeric floor: any interrupt whose priority value is greater than or equal to the value written stays pending instead of being taken, without any individual enable being changed. This is the mechanism a critical section uses when it must exclude a defined set of interrupts rather than all of them: the floor is raised above the priority of the interrupts to be excluded, the critical work runs, and the previous value is restored, at which point anything that arrived during the section is taken.

The PRIMASK register is coarser: it masks everything at configurable priority, leaving only NMI and HardFault. FAULTMASK is coarser still, masking HardFault as well, and it is cleared automatically on exception return from any handler except NMI. The base priority register and the two mask registers are special-purpose registers accessed by MRS and MSR, not memory-mapped, and they are saved and restored by software during a context switch rather than by the hardware stacking.

Where the scheme produces surprising behaviour

Several consequences are worth stating explicitly because they are not obvious from the register descriptions.

A handler that clears its peripheral’s request late runs the risk of re-entering immediately. If the request is still asserted when the handler returns, the interrupt is pending again, and the core tail-chains straight back into the same handler. The symptom is a handler that appears to run twice for one event, or a system that never leaves the handler. The fix is to clear the source before the return, and on peripherals where the clear requires a read of one register followed by a write to another, to perform both before re-enabling.

A handler that lowers its own priority, or that raises another interrupt’s priority above its own while running, can be preempted by that interrupt. Priorities are not frozen on entry. This is occasionally useful — a handler can promote a deferred source — and frequently a source of reentrancy bugs when it happens by accident.

The stack requirement is not visible from the code. Each nesting level needs its own 32-byte frame plus whatever the handler’s own locals and calls require, so the worst-case stack depth is the sum over the deepest chain of nested handlers, not the maximum over any single handler. A system that measures stack use with a single interrupt enabled and then enables a second, higher-priority one has doubled the requirement without changing any handler.

Finally, the vector table’s contents are data, and a table entry that is zero or points into unmapped memory produces a fault on the first occurrence of that interrupt rather than at link time. The table is checked by the linker only for size, not for content, so a missing handler in the startup file becomes a HardFault whose stacked PC points at the vector fetch, not at any instruction the programmer wrote. Reading the stacked frame in the fault handler — the eight words at the address the fault handler can recover from the stack pointer — is the way to find which interrupt was being entered when it went wrong.

References

  1. ARMv7-M Architecture Reference Manual (ARM DDI 0403) — Arm Limited
  2. Cortex-M3 Devices Generic User Guide (ARM DUI 0552) — Arm Limited
  3. The Definitive Guide to Arm Cortex-M3 and Cortex-M4 Processors — Joseph Yiu, Newnes