Real-Time Constraints and Interrupt Handling Questions
Interrupts and hard timing in embedded firmware. Covers the interrupt mechanism (vector tables and their relocation, interrupt controllers such as the Cortex-M NVIC, GIC, APIC and PIC, priorities, nesting and its stack cost, tail-chaining, NMIs, and Cortex-M exception and fault handling, including decoding the stacked registers after a HardFault, capturing crash diagnostics across a reset, and MemManage and BusFault handling with the MPU), writing short and re-entrant interrupt service routines, deferring work out of the ISR (including RTOS ISR-safe APIs and priority-aware deferred queues), sharing data between ISRs and main code (volatile, masking, critical sections, and ISR-safe ring buffers), hardware timers for periodic events, and measuring and bounding interrupt latency and jitter. Includes polling versus interrupt-driven I/O, interrupt storms, DMA with interrupt thresholds for sustained data streams, and multicore interrupt routing. Excludes RTOS scheduling theory and schedulability analysis, general concurrency primitives and lock-free algorithms, serial-bus or peripheral driver protocols, and general-purpose OS interrupt handling such as kernel softirqs and bottom halves.
You are designing motor-control firmware with a 1 ms deterministic control loop and worst-case jitter under 50 microseconds, while quadrature encoder interrupts arrive at up to 100 kHz. How do you structure the interrupts, priorities and buffering, and how do you prove the timing holds?
Sample Answer
Read the requirements precisely
- 1 ms control loop, jitter under 50 microseconds. Jitter is the variation in when each iteration starts or, more importantly for a motor, when its sample is taken and its output applied. 50 microseconds is 5 percent of the period.
- Encoder interrupts up to 100 kHz. A quadrature encoder (two phase-shifted pulse trains A and B that give position and direction) counts steps. First confirm whether 100 kHz is the count rate after decoding or the line rate per channel; the design below uses 100,000 counts per second, one count every 10 microseconds. If 100 kHz is instead the line rate per channel, 4x decoding gives 400,000 counts per second, one every 2.5 microseconds (300 cycles at 120 MHz), which is the harsher reading: a 100-cycle per-edge ISR would then take 33 percent of the CPU, up to 400 counts arrive per control period, and a 16-bit counter wraps in 163.8 ms, still far longer than the 1 ms read interval, so the hardware-counter design below holds for either reading.
The numbers below assume a 120 MHz core as an example and come from this script. In it, PRIMASK is the Cortex-M register that, when set, blocks every interrupt whose priority can be masked (all except NMI and HardFault), so a "PRIMASK section" is a stretch of code with interrupts globally off:
f_hz = 120e6 # assumed core clock for the example
loop_s, jitter_s, edge_hz = 1e-3, 50e-6, 100e3
print(f"cycles per control period : {loop_s * f_hz:,.0f}")
print(f"jitter limit in cycles : {jitter_s * f_hz:,.0f}")
print(f"cycles between encoder edges: {f_hz / edge_hz:,.0f}")
print(f"edges per control period : {edge_hz * loop_s:.0f}")
print(f"16-bit counter wrap time : {65536 / edge_hz * 1e3:.1f} ms")
for cost in (60, 100, 150, 200):
print(f"per-edge ISR of {cost:>3} cycles -> {edge_hz * cost / f_hz * 100:4.1f} % of the CPU")
budget_us = {"longest PRIMASK section (rule)": 5, "emergency-stop ISR, worst case": 10, "exception entry and exit sequences, bus and flash stalls": 1}
print("worst-case start delay of the control ISR:", sum(budget_us.values()), "us of", jitter_s * 1e6, "us allowed")
cycles per control period : 120,000
jitter limit in cycles : 6,000
cycles between encoder edges: 1,200
edges per control period : 100
16-bit counter wrap time : 655.4 ms
per-edge ISR of 60 cycles -> 5.0 % of the CPU
per-edge ISR of 100 cycles -> 8.3 % of the CPU
per-edge ISR of 150 cycles -> 12.5 % of the CPU
per-edge ISR of 200 cycles -> 16.7 % of the CPU
worst-case start delay of the control ISR: 16 us of 50.0 us allowed
The ISR costs (60 to 200 cycles) are assumptions to size the options, not measurements of any chip.
Architecture, with the reasons
1. Do not take one interrupt per encoder edge. At 100,000 edges per second a per-edge ISR (interrupt service routine, the handler the hardware runs when the interrupt fires) of 100 cycles uses 8.3 percent of the CPU and puts 100,000 preemptions per second into a loop that has a 50 microsecond jitter budget. Instead choose a microcontroller whose timer has a hardware quadrature-decoder mode (often called an encoder interface): the timer counts up and down in hardware with no CPU involvement. The control loop reads the count once per iteration and subtracts the previous reading. Keep the previous reading and the difference in a 16-bit unsigned type (or mask the difference with 0xFFFF): the subtraction is then modulo 2^16, so it is correct across a counter wrap as long as fewer than 65,536 counts arrive between two reads, whereas a 32-bit subtraction of two 16-bit readings is wrong across a wrap. A 16-bit counter at 100,000 counts per second wraps every 655 ms, far longer than the 1 ms read interval. At most 100 counts arrive per control period, so velocity resolution is at the 1 percent level at full speed and coarser when slow; add a speed estimator (for example measuring the time between counts at low speed) if low-speed accuracy matters. Interrupts are kept only for rare events: the index pulse (once per revolution) and encoder error or fault lines.
2. Make the sampling and output instants hardware-timed. A timer update event (the moment the timer's counter wraps or reloads, which happens once per period) every 1 ms triggers the ADC (analog-to-digital converter) directly through the chip's internal trigger routing (a hardware wire from the timer to the ADC, so no software runs in between). DMA (direct memory access, hardware that copies data without the CPU) moves the results to a buffer, and the end-of-conversion interrupt (raised when the ADC has finished its samples) starts the control computation. The PWM (pulse-width modulation) duty values written by the control interrupt go into preloaded (shadow) registers: holding registers whose contents the timer copies into the live compare registers only at its next update event, so the output never changes halfway through a period. Then ISR start-time jitter does not move the sample instant or the output instant; it only has to leave the computation finished before the next update event. This turns "start within 50 microseconds" into "finish with margin before the next hardware tick", a much easier thing to guarantee.
3. Emergency shutdown in hardware. An overcurrent or emergency-stop input should force the PWM outputs off through the timer's break input (a dedicated pin or internal signal that makes the timer drive its PWM outputs to a safe level immediately), independent of any code. The fault ISR then only records and informs.
4. Priorities. Lower numbers are more urgent on Cortex-M, so level 0 outranks level 1. The last column says whether the handler may call RTOS functions that are safe to use from an interrupt (the ...FromISR family):
| Level | Source | Maximum rate | Allowed run time | Kernel API calls |
|---|---|---|---|---|
| 0 | Overcurrent / emergency-stop fault ISR (after hardware break) | rare | 10 microseconds | no |
| 1 | Control loop (ADC end-of-conversion) | 1 kHz | 5,000 cycles, 41.7 microseconds at 120 MHz (4.2 percent of the period) | no |
| 2 | Encoder index and error | about once per revolution | 3 microseconds | no |
| 3 and below | UART or CAN DMA completion, supervision timers | as needed | short, defer to tasks | yes |
The 5,000 cycles for the control law is an assumed budget. An RTOS (real-time operating system) kernel masks only up to its configured threshold with BASEPRI (a Cortex-M register: when it holds a nonzero value, every interrupt whose priority number is equal to or greater than that value, that is, equally or less urgent, is blocked, while more urgent ones still run), so levels 0 to 2, the numerically smaller and therefore more urgent ones, sit above that threshold. They are never delayed by kernel critical sections, and they cannot call kernel APIs. FreeRTOS states this rule in its Cortex-M port: ISR-safe API functions must only be called from interrupts at or below configMAX_SYSCALL_INTERRUPT_PRIORITY. The following program, executed on a Cortex-M3 in QEMU 10.0.13, shows the mechanism: an interrupt above the BASEPRI value runs inside the critical section, one below waits for it, and PRIMASK blocks both.
board.h (support code: REG(a) casts an address to a volatile register, UART0_DR and UART0_CR are the serial port's data and control registers used by puts_ to print, the NVIC_* macros are the interrupt controller's enable, disable and set-pending registers, NVIC_IPR(i) is the priority byte of interrupt i, and exit_qemu ends the run through semihosting):
#include <stdint.h>
#define REG(a) (*(volatile uint32_t *)(a))
#define REG8(a) (*(volatile uint8_t *)(a))
#define UART0_DR REG(0x4000C000)
#define UART0_CR REG(0x4000C030)
#define NVIC_ISER(n) REG(0xE000E100 + 4 * (n))
#define NVIC_ICER(n) REG(0xE000E180 + 4 * (n))
#define NVIC_ISPR(n) REG(0xE000E200 + 4 * (n))
#define NVIC_IPR(i) REG8(0xE000E400 + (i))
#define SHPR3_PRI15 REG8(0xE000ED23) /* SysTick priority byte (SHPR3 bits 31:24; 0xE000ED22 is PendSV) */
#define SYST_CSR REG(0xE000E010)
#define SYST_RVR REG(0xE000E014)
#define SYST_CVR REG(0xE000E018)
#define SCB_AIRCR REG(0xE000ED0C)
#define SCB_CFSR REG(0xE000ED28)
#define SCB_HFSR REG(0xE000ED2C)
#define SCB_BFAR REG(0xE000ED38)
#define SCB_SHCSR REG(0xE000ED24)
#define SCB_CCR REG(0xE000ED14)
static __attribute__((unused)) void putc_(char c) { UART0_DR = (uint32_t)c; }
static __attribute__((unused)) void puts_(const char *s) { while (*s) putc_(*s++); }
static __attribute__((unused)) void puthex(uint32_t v) {
puts_("0x");
for (int i = 28; i >= 0; i -= 4) putc_("0123456789abcdef"[(v >> i) & 15]);
}
static __attribute__((unused)) void putdec(uint32_t v) {
char b[11]; int n = 0;
do { b[n++] = '0' + v % 10; v /= 10; } while (v);
while (n) putc_(b[--n]);
}
static __attribute__((unused)) void exit_qemu(void) {
register uint32_t r0 __asm("r0") = 0x18, r1 __asm("r1") = 0x20026;
__asm volatile("bkpt 0xAB" :: "r"(r0), "r"(r1));
for (;;) {}
}
basepri.c:
#include "board.h"
#define IRQ_RTOS_AWARE 10 /* priority 0x80: at or below the kernel threshold, may use kernel APIs */
#define IRQ_ZERO_LAT 11 /* priority 0x20: above the threshold, kernel never masks it */
#define KERNEL_THRESHOLD 0x40 /* BASEPRI value used by the kernel's critical section */
static void irq_a(void) { puts_(" IRQ_RTOS_AWARE (0x80) ran\n"); }
static void irq_z(void) { puts_(" IRQ_ZERO_LAT (0x20) ran\n"); }
void rtos_aware_isr(void) { irq_a(); }
void zero_lat_isr(void) { irq_z(); }
static void pend_both(void) {
NVIC_ISPR(0) = (1u << IRQ_RTOS_AWARE) | (1u << IRQ_ZERO_LAT);
__asm volatile("isb");
}
int main(void) {
UART0_CR = 0x301;
NVIC_IPR(IRQ_RTOS_AWARE) = 0x80; NVIC_IPR(IRQ_ZERO_LAT) = 0x20;
NVIC_ISER(0) = (1u << IRQ_RTOS_AWARE) | (1u << IRQ_ZERO_LAT);
puts_("BASEPRI = 0x40 critical section, both pended:\n");
__asm volatile("msr basepri, %0" :: "r"(KERNEL_THRESHOLD) : "memory");
pend_both();
puts_(" (still inside the critical section)\n");
__asm volatile("msr basepri, %0" :: "r"(0) : "memory");
__asm volatile("isb");
puts_(" (BASEPRI cleared)\n");
puts_("PRIMASK critical section, both pended:\n");
__asm volatile("cpsid i" ::: "memory");
pend_both();
puts_(" (still inside the critical section)\n");
__asm volatile("cpsie i" ::: "memory");
__asm volatile("isb");
puts_(" (PRIMASK cleared)\n");
exit_qemu();
return 0;
}
void reset(void) { main(); }
__attribute__((section(".vectors"), used)) void (*const vectors[16 + 16])(void) = {
[0] = (void *)0x20010000, [1] = reset,
[16 + IRQ_RTOS_AWARE] = rtos_aware_isr, [16 + IRQ_ZERO_LAT] = zero_lat_isr };
Built with arm-none-eabi-gcc -O2 -mcpu=cortex-m3 -mthumb -nostartfiles -ffreestanding -Wl,--section-start=.vectors=0x0 -Wl,-Ttext=0x400 -Wl,-e,reset -Wl,-Tbss=0x20000000 basepri.c -o basepri.elf (GCC 14.2.1) and run with qemu-system-arm -M lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel basepri.elf:
BASEPRI = 0x40 critical section, both pended:
IRQ_ZERO_LAT (0x20) ran
(still inside the critical section)
IRQ_RTOS_AWARE (0x80) ran
(BASEPRI cleared)
PRIMASK critical section, both pended:
(still inside the critical section)
IRQ_ZERO_LAT (0x20) ran
IRQ_RTOS_AWARE (0x80) ran
(PRIMASK cleared)
Read the output against the rules. In the first section BASEPRI is 0x40, so the interrupt at 0x80 (equal or less urgent than 0x40) is blocked, while the one at 0x20 (more urgent) is not. Both were pended inside the critical section, yet IRQ_ZERO_LAT (0x20) runs at once, before the line (still inside the critical section), and IRQ_RTOS_AWARE (0x80) waits until BASEPRI is cleared. In the second section PRIMASK is set, which blocks both, so neither handler runs until cpsie i re-enables interrupts: the (still inside ...) line comes first, then the more urgent handler, then the less urgent one. This is ordering only; QEMU does not model latency.
5. Buffering. The ADC DMA writes a double buffer, so the control ISR reads a complete frame while DMA fills the other. Telemetry and logging go into a ring buffer written by the control ISR and drained by a low-priority task, sized for the worst-case burst. Anything the control ISR shares with lower levels is a single word or a snapshot copy, not a lock. For example, the control ISR writes a 32-bit speed estimate that a display task reads: a 32-bit aligned read or write is one indivisible access on Cortex-M, so the task needs no lock. A multi-word value such as position plus timestamp is instead copied by the task inside a short section that masks the control interrupt (that section delays the control ISR's start, so it must fit inside the masked-section limit in the delay budget below), or published by the ISR through a sequence counter the task rechecks, so the task never sees half of an old pair and half of a new one. A double buffer is two equal buffers used in turn: the hardware or ISR fills one while the code reads the other, and they swap when a frame is complete.
6. Bare metal or RTOS. Run the control loop as an interrupt, not as an RTOS task, because the loop must start at a hardware instant. If the product also has communication stacks, configuration, a file system or a user interface, add an RTOS for those tasks beneath the control interrupt; if it is only the control loop and a simple comms loop, bare metal avoids the kernel's critical sections entirely. Points 1 and 2 (hardware counting and hardware-timed sampling and output) are what make the jitter budget achievable at all; points 3 to 5 bound what software can still do to it, and points 6 and 7 are choices that follow from them.
7. CPU choice. Needs: a timer with encoder-interface mode and a break input, an ADC triggerable by that timer, DMA, and enough cycles. At 120 MHz the 1 ms period is 120,000 cycles, so a 5,000-cycle control law leaves large headroom. A floating-point unit simplifies the control code; if the control ISR uses it, count its extra stacking cost (when the interrupted code has used the FPU, the core stacks a 26-word extended frame instead of the 8-word one; with lazy stacking, which the Cortex-M4F enables out of reset, space is reserved at entry but the FPU registers are only written to it when the handler executes its first floating-point instruction, so the cost lands there, and with lazy stacking off it lands on every such entry).
Where the worst-case start delay comes from
With the control interrupt at the top (apart from the shutdown ISR) and the rest below the kernel threshold, the start of the control ISR can be delayed only by: the shutdown ISR if it is running (10 microseconds), the longest section that masks everything with PRIMASK (a rule: at most 5 microseconds), and the exception entry and exit sequences plus bus or flash stalls (1 microsecond; entry alone is about 12 cycles, 0.1 microsecond at 120 MHz, on a Cortex-M3 with zero-wait-state memory). That sums to 16 microseconds, against 50 allowed. These three figures are design limits, each enforced by a measurement below; they are not properties of any chip.
How to prove the timing holds
- Instrument. At control ISR entry, record the timer's own counter value (the time since the hardware tick) in a small buffer: that is the delay from the hardware tick to the ISR's start, including the fixed ADC conversion time, and the spread between its minimum and maximum is the start jitter. Keep min, max and a histogram.
- Stress while measuring. Run for hours with every other interrupt at its maximum rate, UART and CAN floods, flash writes, an injected overcurrent event, and the slowest code paths exercised. The recorded maximum is a lower bound on the true worst case (the worst case may not have occurred), so require the measured maximum to stay well inside the budget, for example at most half of 16 microseconds, and look at the tail.
- Check the longest masked sections. Measure the longest
PRIMASKsection and the longest run time of every higher-priority ISR with a cycle counter (the Cortex-MDWT CYCCNT, the Data Watchpoint and Trace unit's free-running count of processor cycles, where the chip has one) and a logic analyzer, and compare each to its limit in the table. - Watch the output with a logic analyzer. The PWM edge relative to the trigger should have near-zero jitter regardless of load, because it is hardware-timed.
- Detect overrun at run time. Raise a fault if the control ISR is still running when the next tick arrives (or the timer's update flag is already set on entry), and put the actuators in their safe state.
- Keep it as a regression test on hardware in the loop, so a later change to a masked section or an ISR is caught.
Compare the legacy 8259 PIC, the APIC, the ARM GIC and the Cortex-M NVIC. How do they differ in programmability, priority handling, vectoring and routing to cores, and what does that mean for latency and scalability?
Sample Answer
What they are. An interrupt controller takes the many interrupt wires of a system, decides which one the CPU should serve next, and tells the CPU where its handler is. The four differ in how much of that is hardware and how much is software.
Short answer: the NVIC (Nested Vectored Interrupt Controller) is built into the core and does vectoring and stacking in hardware, which gives the shortest and most predictable latency but no routing between cores. The GIC (Generic Interrupt Controller) is Arm's controller for application processors: many interrupts, per-interrupt priority and a choice of target CPU, at the cost of acknowledge and end-of-interrupt steps in software. The NVIC and the GIC are the two an embedded role meets directly; the 8259 and the APIC are x86 controllers that serve as the comparison points, the 8259 for the simplest design and the APIC for multiprocessor routing.
Terms used in the table. 8259: IR2 is interrupt-request input pin 2; ICW2 (initialisation command word 2) is the byte that sets the base vector number; ELCR is the edge/level control register that says whether a pin is edge- or level-triggered. APIC: the I/O APIC has a redirection table with one entry per input pin; the IDT (interrupt descriptor table) is x86's vector table in memory; TPR and PPR are the task and processor priority registers; delivery mode says how an interrupt is delivered (fixed vector, NMI, INIT to reset a CPU, startup to boot one); an IPI is an inter-processor interrupt, one CPU interrupting another. GIC: the distributor is the shared block that holds per-interrupt configuration and picks the target CPU; the CPU interface is the per-CPU block software talks to; SGI (software-generated interrupt, CPU to CPU), PPI (private peripheral interrupt, per CPU, for example a timer) and SPI (shared peripheral interrupt, from a device, routable to any CPU) are the three interrupt classes; in GICv3 a redistributor is the per-CPU block next to the distributor, and the ITS (interrupt translation service) handles message-signalled interrupts, where a device raises an interrupt by writing to a memory address instead of toggling a wire. EOI is end-of-interrupt, the software write telling the controller that a handler has finished. NVIC_Type is the C struct in Arm's CMSIS headers that lays out the NVIC's registers.
One worked example for each of the other three, with illustrative numbers. 8259: with ICW2 set to 0x20, a signal on pin IR1 makes the chip put vector 0x21 on the data bus. APIC: the I/O APIC redirection entry for a pin can hold vector 0x31 and destination CPU 2, so that CPU's local APIC takes vector 0x31 and the IDT entry for 0x31 selects the handler. GIC: a device wired as SPI number 1 has interrupt ID 33 (SPI IDs start at 32); software sets its target mask to CPU 2, CPU 2 reads the acknowledge register and gets ID 33, runs the handler, then writes 33 to the EOI register. The NVIC example is the demo below. Facts below come from the Linux kernel sources and device-tree bindings (the 8259, APIC and GIC), from Arm's CMSIS headers (the NVIC), and from the demonstrations run in QEMU for the NVIC.
| 8259 PIC (legacy x86) | APIC (x86: Local plus I/O) | GIC (Arm application cores) | NVIC (Cortex-M) | |
|---|---|---|---|---|
| Where it lives | external chip; each chip has 8 inputs | one local APIC in each CPU, one or more I/O APICs on the buses | separate block with a distributor and per-CPU interfaces | inside the core |
| Scale | PC/AT: master plus one slave on the master's IR2, so 15 usable lines; up to 8 slaves per master in the chip's design | 224 usable vectors per CPU; the early I/O APICs had 16 inputs, a later one 24 | up to 1020 interrupt IDs in the GICv2 driver; GICv3 adds an ITS for message-signalled interrupts, and GICv3.1 adds extended SPI and PPI ranges | CMSIS lays out 8 enable words and 240 priority bytes, so up to 240 external interrupts |
| Programmability | mask register, edge/level control, vector base set at initialisation (ICW2) | each I/O APIC redirection entry holds vector, delivery mode, logical or physical destination, polarity, trigger, mask | per-interrupt enable, priority, target CPUs, edge/level configuration | enable, pending, active, priority, software trigger registers (CMSIS NVIC_Type) |
| Priority | fixed or rotating priority by pin position | task and processor priority registers (TPR at offset 0x80, PPR at 0xA0 in Linux's definitions) let the OS hold back low-priority vectors | one priority byte per interrupt | one priority byte per interrupt (only the top bits are implemented), plus grouping and BASEPRI |
| Vectoring | controller puts a vector number on the data bus; software routine for the rest | the vector in the redirection entry indexes the IDT | the CPU interface returns the interrupt ID when software reads the acknowledge register | the core reads the handler address from the vector table in memory and stacks 8 registers itself (a 26-word extended frame on a core with the FPU when the FPU context is active: the space is reserved at entry and the floating-point registers are saved lazily, only if the handler uses the FPU) |
| Routing to cores | none: single CPU | I/O APIC sends to one or several local APICs; local APICs send inter-processor interrupts (fixed, NMI, INIT, startup; self, all, all-but-self) | each shared peripheral interrupt has a target-CPU mask (the Linux GICv2 driver is sized for 8 CPU interfaces); SGIs are sent from one CPU to others; GICv3 has a redistributor per CPU | none: the NVIC register set has no target-CPU field |
| Completion | software end-of-interrupt (EOI) to the chip | EOI register in the local APIC (offset 0xB0) | read acknowledge register, then write EOI register | no EOI: returning from the handler is the completion |
Where the table's facts come from: kernel i8259.c (its comment that the first 8259A, the master, has a slave on IR2, ICW2 vector mapping, ELCR for edge/level, specific and auto EOI); Wikipedia's 8259 and APIC articles (inputs per chip, fixed and rotating priority, 224 vectors, 16 and 24 input I/O APICs, IPIs, single-CPU limitation of the 8259); kernel io_apic.h and apicdef.h (redirection entry fields, TPR, PPR, EOI, ICR modes); kernel irq-gic.c and the GIC and GICv3 device-tree bindings (1020 IDs, SGI/PPI/SPI, target masks, acknowledge and EOI registers, redistributors, ITS); CMSIS core_cm3.h (NVIC_Type).
What it means for latency. On the NVIC the whole dispatch is hardware: the vector table lookup and the register stacking are done by the core, and there is no acknowledge or EOI software step; the demo below never writes an end-of-interrupt register: its handlers just return. (The demo records only whether IRQ1 had already run when IRQ0 finished; it does not count how many times each handler ran.) The cost left to software is clearing the peripheral's own status flag. The GIC, APIC and 8259 paths include software that reads an acknowledge register or writes an EOI register before and after each handler (the Linux GIC driver reads GIC_CPU_INTACK and writes GIC_CPU_EOI), usually through a generic kernel dispatcher; that buys flexibility and costs register accesses on the hot path. Exact cycle counts come from the vendor manual or from measurement: toggle a GPIO at handler entry and watch it with a logic analyser against the input edge. QEMU cannot give cycle counts.
What it means for scalability. The 8259 caps at 15 lines on one CPU, which is why SMP x86 moved to the APIC: many vectors, per-CPU local APICs, and IPIs for cross-core work (Linux defines RESCHEDULE_VECTOR for that). The GIC scales by splitting interrupts into per-CPU ones (SGI, PPI) and shared ones (SPI) that can be routed to a chosen CPU, and GICv3 adds redistributors and message-signalled interrupts through an ITS. The NVIC has no routing registers at all (CMSIS lists enable, pending, active, priority and software-trigger registers only), so it scales in the other direction: low latency on small parts.
Priority handling, shown on the NVIC. Priority bytes are 8 bits wide, but a part implements only the top few: writing 0xff and reading it back in the QEMU lm3s6965evb (a Cortex-M3) gave 0xe0, three bits, so always ask the part how many it has. The priority is split by the PRIGROUP field of AIRCR (the Application Interrupt and Reset Control Register; PRIGROUP is bits 10:8, CMSIS) into a preemption part and a sub-priority part. A PRIGROUP value of n makes bits 7 down to n+1 of the priority byte the preemption part and the remaining low bits the sub-priority; only the preemption part lets one interrupt interrupt another, and the sub-priority only orders interrupts that are pending at the same time. The program has IRQ0 at 0x60, whose handler pends IRQ1 at 0x40, and tries two groupings. Built and run in a gcc:14 container (apt-get install -y qemu-system-arm gcc-arm-none-eabi; arm-none-eabi-gcc 14.2.1, qemu-system-arm 10.0):
#include <stdint.h>
#define REG(a) (*(volatile uint32_t *)(a))
#define UART0_DR REG(0x4000C000u)
#define UART0_FR REG(0x4000C018u)
#define UART0_CTL REG(0x4000C030u)
#define SCB_AIRCR REG(0xE000ED0Cu)
#define NVIC_ISER0 REG(0xE000E100u)
#define NVIC_ISPR0 REG(0xE000E200u)
#define NVIC_IPR(n) (*(volatile uint8_t *)(0xE000E400u + (n)))
static volatile uint32_t a_ran, a_ran_inside_b;
void IRQ1_Handler(void) { a_ran = 1; } /* priority byte 0x40 */
void IRQ0_Handler(void) /* priority byte 0x60 */
{
NVIC_ISPR0 = 1u << 1; /* pend the more urgent interrupt */
__asm volatile("dsb\n isb");
a_ran_inside_b = a_ran; /* did it run before we returned? */
}
static void put(char c) { while (UART0_FR & (1u << 5)) { } UART0_DR = (uint8_t)c; }
static void puts_(const char *s) { while (*s) put(*s++); }
static void dec(uint32_t v) { put((char)('0' + v)); }
static void trial(uint32_t prigroup)
{
a_ran = 0; a_ran_inside_b = 0;
SCB_AIRCR = 0x05FA0000u | (prigroup << 8); /* VECTKEY | PRIGROUP */
NVIC_ISPR0 = 1u << 0; /* pend IRQ0; its handler pends IRQ1 */
__asm volatile("dsb\n isb");
puts_("PRIGROUP="); dec(prigroup);
puts_(": IRQ1 (0x40) preempted IRQ0 (0x60): "); dec(a_ran_inside_b); puts_("\n");
}
int main(void)
{
UART0_CTL = 0x301u;
NVIC_IPR(0) = 0x60u; NVIC_IPR(1) = 0x40u;
NVIC_ISER0 = 3u;
trial(0); /* all three implemented bits are preemption priority */
trial(5); /* top two bits preemption priority, lowest implemented bit sub-priority */
for (;;) { }
}
extern uint32_t _sbss, _ebss, _estack;
void Reset_Handler(void) { for (uint32_t *p = &_sbss; p < &_ebss; p++) *p = 0; main(); for (;;) { } }
void Default_Handler(void) { for (;;) { } }
__attribute__((section(".vectors"), used))
void (*const vectors[18])(void) = {
[0] = (void (*)(void))&_estack, [1] = Reset_Handler, [2 ... 15] = Default_Handler,
[16] = IRQ0_Handler, [17] = IRQ1_Handler,
};
link.ld:
MEMORY { FLASH (rx) : ORIGIN = 0x00000000, LENGTH = 256K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 64K }
ENTRY(Reset_Handler)
SECTIONS {
.text : { KEEP(*(.vectors)) *(.text*) *(.rodata*) } > FLASH
.noinit (NOLOAD) : { *(.noinit*) } > RAM
.bss (NOLOAD) : { _sbss = .; *(.bss*) *(COMMON) _ebss = .; } > RAM
_estack = ORIGIN(RAM) + LENGTH(RAM);
}
arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O1 -ffreestanding -fno-tree-loop-distribute-patterns -Wall -Wextra -nostartfiles -nostdlib -T link.ld nvic_group.c -o g.elf
timeout 5 qemu-system-arm -M lm3s6965evb -nographic -kernel g.elf -serial stdio -monitor none
Output (the build is warning-free):
PRIGROUP=0: IRQ1 (0x40) preempted IRQ0 (0x60): 1
PRIGROUP=5: IRQ1 (0x40) preempted IRQ0 (0x60): 0
The bit picture for the two values used here (the part implements bits 7:5, so 0x40 is 010 and 0x60 is 011 in those bits):
bit: 7 6 5 | 4 3 2 1 0 (not implemented, read as 0)
0x40 (IRQ1): 0 1 0
0x60 (IRQ0): 0 1 1
PRIGROUP=0: preemption = bits 7:1 -> 010 vs 011, different levels, IRQ1 preempts
PRIGROUP=5: preemption = bits 7:6 -> 01 vs 01, same level; bit 5 is sub-priority only
With PRIGROUP=5 on this three-bit part, 0x40 and 0x60 share the same two preemption bits (01) and differ only in the sub-priority bit, so IRQ1 waited until IRQ0 returned. With PRIGROUP=0 all three bits are preemption priority and IRQ1 interrupted IRQ0. A common mistake is to assign "different priorities" and discover the grouping made them the same level.
Recommendation. For a single-core microcontroller doing hard-real-time control, use the NVIC with all implemented bits as preemption priority (PRIGROUP at its default), few distinct levels, and BASEPRI for critical sections: it has the shortest, most predictable path. For a multicore Arm application processor, use the GIC and decide per interrupt which CPU it targets, because routing is the part the NVIC lacks. For x86, the APIC pair is the only option that supports multiple CPUs; the 8259 appears only in legacy modes. The choice flips when a real-time core sits behind a GIC: then the acknowledge and EOI steps are part of the latency budget and have to be measured.
Two Cortex-M interrupts become pending back to back, and the second one starts with almost no gap after the first. What does the hardware do to make that cheap, and what happens if a higher-priority interrupt arrives while the core is still stacking?
Sample Answer
What the hardware does
When an exception is taken, the Cortex-M core (documented here from the Cortex-M3 Technical Reference Manual, ARM DDI 0337G, chapter 5) automatically pushes eight registers onto the current stack: xPSR (the program status register: flags plus the current exception number), PC, LR, r12 and r0-r3. In parallel it reads the handler's address from the vector table (an array of handler addresses in memory; entry number n is at table base + 4n) and starts fetching the handler's first instructions into the core's pipeline (the fetch, decode and execute stages that instructions pass through). It sets LR to a special EXC_RETURN value (a code, not a real address, which tells the core how to return: 0xfffffff9 means return to thread mode on the main stack, 0xfffffffd thread mode on the process stack, 0xfffffff1 handler mode), so a handler can be an ordinary C function that ends with a normal return and the hardware pops the frame. Thread mode is where ordinary code runs and handler mode is where exception handlers run. The manual's example shows a 12-cycle latency from the interrupt being asserted to the handler's first instruction (zero-wait-state memory, meaning memory that answers every access without stalling the core), which is why the push and the vector fetch overlap.
Two optimisations make back-to-back interrupts cheap:
- Tail-chaining. When a handler finishes and another interrupt is already pending with higher priority than everything still stacked, the core skips the pop of eight registers and the push of eight registers, because the stack contents would end up the same, and fetches the next vector directly. The manual states the chained ISR starts six cycles after the previous one exits. If a new interrupt arrives while the pop is in progress and its priority is higher than that of the handler or thread being returned to, the pop is abandoned and it is treated as a tail-chain.
- Late arrival. If a higher-priority interrupt arrives while the core is still stacking for a lower-priority one, the core keeps stacking (the saved state is identical for both), switches the vector fetch to the higher-priority interrupt and runs it first. The manual limits this to the time before the first instruction of the first handler enters the execute stage; the lower-priority interrupt then runs by tail-chain afterwards. Later arrivals preempt normally, with a second frame. As a timeline, with A (low priority) taken first and B (more urgent) arriving late:
1. A is pending; the core starts pushing the 8 registers and fetching A's vector.
2. B becomes pending while that push is still in progress.
3. The core finishes the same single push and redirects the fetch to B's vector.
4. B's handler runs first.
5. B returns; A is still pending, so the core tail-chains into A with no pop and no second push.
6. A returns and the 8 registers are popped once.
Sequence for two pending interrupts, B (priority 0x40, more urgent) and A (0x80), arriving while thread code runs:
thread -> [push 8 regs once] -> B handler -> (no pop, no push, ~6 cycles) -> A handler -> [pop 8 regs once] -> thread
Without tail-chaining the same sequence would pop after B and push again before A.
Observing it in QEMU
QEMU is not cycle-accurate, so it cannot show the 12 or 6 cycle figures; those are documented, not measured here. What it can show is the observable consequences: execution order, the stack pointer, and the EXC_RETURN value in LR. The ENTRY macro copies the stack pointer (mov %0, sp) and LR (mov %1, lr) into C variables as the first thing a handler does and stores them with the handler's number; in a handler LR holds the EXC_RETURN value. This program records SP and LR at handler entry in two cases. It demonstrates ordering and nesting; the late-arrival case is a window of a few cycles during stacking, which cannot be provoked on demand and QEMU does not model, so it is not shown by this output.
#include <stdint.h>
#define REG(a) (*(volatile uint32_t *)(a))
#define NVIC_ISER REG(0xE000E100)
#define NVIC_ISPR REG(0xE000E200)
#define NVIC_IPR ((volatile uint8_t *)0xE000E400)
static void puts_(const char *s) { while (*s) REG(0x4000C000) = *s++; }
static void puth(uint32_t v) { puts_("0x"); for (int i = 28; i >= 0; i -= 4) REG(0x4000C000) = "0123456789abcdef"[(v >> i) & 15]; }
static void done(void) {
register uint32_t r0 __asm("r0") = 0x18, r1 __asm("r1") = 0x20026;
__asm volatile("bkpt 0xAB" :: "r"(r0), "r"(r1));
for (;;) {}
}
static volatile uint32_t rec_sp[4], rec_lr[4];
static volatile int rec_id[4], nrec, nest_from_irq0;
/* Record the stack pointer and EXC_RETURN value (LR) at handler entry. */
#define ENTRY(id) do { uint32_t sp_, lr_; \
__asm volatile("mov %0, sp\n mov %1, lr" : "=r"(sp_), "=r"(lr_)); \
rec_sp[nrec] = sp_; rec_lr[nrec] = lr_; rec_id[nrec++] = id; } while (0)
void irq0(void) { ENTRY(0); if (nest_from_irq0) { NVIC_ISPR = 2; __asm volatile("dsb\n isb" ::: "memory"); } } /* priority 0x80 */
void irq1(void) { ENTRY(1); } /* priority 0x40 */
static void report(const char *title) {
puts_(title); puts_("\n");
for (int i = 0; i < nrec; i++) {
puts_(" IRQ"); REG(0x4000C000) = '0' + rec_id[i];
puts_(" sp="); puth(rec_sp[i]); puts_(" lr="); puth(rec_lr[i]); puts_("\n");
}
nrec = 0;
}
int main(void) {
NVIC_IPR[0] = 0x80; NVIC_IPR[1] = 0x40; NVIC_ISER = 3;
__asm volatile("cpsid i" ::: "memory"); /* both become pending, nothing runs yet */
NVIC_ISPR = 1; NVIC_ISPR = 2;
__asm volatile("cpsie i\n isb" ::: "memory");
report("both pending, then released:");
nest_from_irq0 = 1; /* IRQ0 runs, and IRQ1 arrives inside it */
NVIC_ISPR = 1; __asm volatile("dsb\n isb" ::: "memory");
report("IRQ1 arrives during IRQ0:");
done();
return 0;
}
void reset(void) { main(); }
__attribute__((section(".vectors"), used)) void (*const vectors[18])(void) = {
[0] = (void *)0x20010000, [1] = reset, [16] = irq0, [17] = irq1 };
Built with arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -O2 -nostartfiles -ffreestanding -Wl,--section-start=.vectors=0x0 -Wl,-Ttext=0x400 -Wl,-e,reset -Wl,-Tbss=0x20000000 tail_chain.c -o tail_chain.elf (GCC 14.2.1) and run under QEMU 10.0 (qemu-system-arm -M lm3s6965evb -nographic -semihosting-config enable=on,target=native -kernel tail_chain.elf), it prints:
both pending, then released:
IRQ1 sp=0x2000ffcc lr=0xfffffff9
IRQ0 sp=0x2000ffcc lr=0xfffffff9
IRQ1 arrives during IRQ0:
IRQ0 sp=0x2000ffcc lr=0xfffffff9
IRQ1 sp=0x2000ffa4 lr=0xfffffff1
In the first case both handlers see the same SP, and both carry the EXC_RETURN value 0xfffffff9, meaning "return to thread mode, main stack": neither was entered from inside the other, and the second returns straight to the thread because no handler is stacked beneath it. A pop-then-push sequence would print the same values, so this output does not by itself prove the hardware skipped the pop and push; that saving is in cycles, which QEMU does not model, and the manual's six-cycle figure is the evidence for it. What the run does confirm is the visible contract: no nesting, and the more urgent one ran first even though both were pended in the opposite order. In the second case IRQ1 preempts IRQ0 mid-handler: its SP is lower and its EXC_RETURN is 0xfffffff1, meaning "return to handler mode", because it returns into IRQ0. The SP difference is 0x2000ffcc - 0x2000ffa4 = 0x28 = 40 bytes: 32 bytes for the pushed frame, 4 bytes of padding the core inserts when the stack pointer is not 8-byte aligned at entry (IRQ0's SP ends in 0xc, and the CCR.STKALIGN bit asks for that alignment; the program does not read it, but a one-line addition at the top of main that prints REG(0xE000ED14) shows 0x00000200 on this QEMU machine, so bit 9 is set), and 4 bytes for IRQ1's own push {r4}, which the compiler emits at -O2 before the macro reads SP. These addresses and the 40 bytes belong to the -O2 build named above: built at -O0 the same file prints 0x2000ffc0 and 0x2000ff90 (48 bytes), because the unoptimised handler's prologue is larger.
Consequences and common mistakes
- Latency of the second interrupt in a burst is shorter than the first's, so do not size margins from the single-interrupt figure only, and do not assume a fixed entry cost: it depends on the core, memory wait states and what the core was executing (the manual lists multi-cycle instructions that can be interrupted or continued, and a bit to disable that).
- Priority decides the order, not arrival order. The least urgent pending interrupt can be delayed by a stream of more urgent ones (starvation risk), so bound the rate of the urgent sources.
- The figures above are for the Cortex-M3 with zero wait states. Other cores (M0+, M4, M7, M33) and slow flash change them; read your core's manual and measure on the part, for example with a GPIO toggle on a logic analyzer.
Explain how a multicore SoC complicates interrupt handling for firmware. How do you route and partition interrupts across cores to meet real-time constraints?
Sample Answer
Recommendation. Pin each latency-critical interrupt to one chosen core and run the code that consumes its result on that same core; send noisy or bursty sources (network, storage) to a different core; use "any core" routing only for work with no deadline. Everything below explains why, and what each choice costs.
What a second core changes. (A multicore SoC, system on chip, puts several CPU cores plus the interrupt controller, memory and peripherals on one chip.)
- Someone has to choose the core. On a single core every interrupt goes to the one CPU. With several, the interrupt controller must pick a target, and that choice decides which core's caches, stack and run queue the handler uses.
- Masking is local. Disabling interrupts (on Arm, setting PRIMASK, the register bit that blocks configurable interrupts) affects only the core that executes it. Data shared between an ISR (interrupt service routine) on one core and code on another is no longer protected by it. Cross-core data needs atomics (single indivisible read-modify-write operations the hardware guarantees) or a spinlock (a lock where the waiter loops on the lock word instead of sleeping), and the holder must also mask interrupts on its own core while it holds the lock: if an ISR on the holder's core then tried to take the same lock it would spin forever, waiting for the code it interrupted. A trace: (1) a thread on core 0 takes the lock; (2) an interrupt arrives on core 0 and its ISR starts; (3) the ISR tries to take the lock and spins; (4) the thread cannot run to release it, because the ISR is occupying core 0. Core 0 is deadlocked, and any other core that wants the lock spins forever too, because the holder can never release it. Masking core 0's interrupts at step 1 prevents step 2.
- Caches are per core. (A cache is a small fast memory holding recently used data; each core has its own, so data last touched on another core is not in yours.) Handler code and data that was last touched on another core must be fetched, a miss on the very first instructions of an ISR, so latency varies with where the interrupt lands.
- Cross-core signalling exists. One core can raise an interrupt on another (an inter-processor interrupt), which is how a handler on core 0 wakes a task on core 1, at the price of a second interrupt latency.
How routing is controlled, as documented.
- Arm GICv3 (generic interrupt controller). For a shared peripheral interrupt, the register
GICD_IROUTER<n>selects either a single target processor, identified by its affinity fields a.b.c.d (a four-part hierarchical core identifier, such as cluster and core number; the fields are called Aff3.Aff2.Aff1.Aff0), or, when itsInterrupt_Routing_Modebit is 1, any participating processor chosen by the hardware (Arm GICv3 register description). As an illustration, with the mode bit 0 and the affinity set to 0.0.0.1, that interrupt is delivered only to the processor whose affinity is 0.0.0.1; with the mode bit 1 the hardware picks, which is the "any core" option avoided for deadline sources below. A shared peripheral interrupt (SPI) is one from a device that any core could serve. Software-generated interrupts (SGIs, the inter-processor kind) and private peripheral interrupts (PPIs, per-core sources such as a core's own timer) are handled per processor through the redistributors (one small GICv3 block per core that holds that core's private interrupt state), and GICv3 is not limited to 8 processors the way GICv2 is. - Linux.
/proc/irq/<n>/smp_affinityandsmp_affinity_list"specify which target CPUs are permitted for a given IRQ source". The default mask is 0xffffffff (all CPUs), "It's not allowed to turn off all CPUs, and if an IRQ controller does not support IRQ affinity then the value will not change from the default of all CPUs" (kernel.org,Documentation/core-api/irq/irq-affinity). So affinity is a request the interrupt controller must be able to honour; check that it did. For example,echo 2 > /proc/irq/45/smp_affinitywrites a hexadecimal CPU bitmask: 2 is binary 10, so bit 1 is set and only CPU 1 may take IRQ 45 (the kernel documentation's own example isecho 0f, which permits CPUs 0 to 3). The IRQ number 45 is illustrative. - Bare-metal firmware programs the interrupt controller's distributor (the part of the GIC that decides the target core of each shared interrupt) itself and chooses at start-up: one target per latency-critical line, set once, never "any".
Partitioning for real-time.
| Source | Route to | Why |
|---|---|---|
| Hard-deadline device interrupt (motor control, sensor capture) | One fixed core, together with the task that consumes it | The handler and its data stay in that core's cache, the hand-off to the task needs no inter-processor interrupt, and the latency does not depend on which core the controller picks |
| Per-core timer or tick | That core (a per-core interrupt by design) | Each core keeps its own tick; nothing to route |
| Bursty network or storage interrupts | A different core from the hard-real-time one, with DMA doing the data movement | A burst steals time from whichever core takes it, so it must not be the core holding a deadline |
| Low-rate housekeeping | Any core | No deadline, so variable latency is acceptable |
Forwarding versus a per-core handler: give each core its own handler for per-core resources, and let the device's interrupt land on the core that owns the device. Forward to another core (with an inter-processor interrupt) only when the consumer must run elsewhere, and budget a second interrupt latency for it. If the hard-real-time core does the minimum in its ISR (clear the source, take the data, set a flag) the hand-off stays on one core.
Cache coherency effects on latency.
- Shared state bounces. If two cores write different variables that sit in the same cache line, the line travels between the cores on every write (false sharing), which lengthens ISR time unpredictably. Keep per-core data in separate cache lines. A cache line is the unit in which caches move data, here assumed to be 64 bytes. The program below prints the layout for an array with one statistics entry per core on a 64-byte line (the line size is an assumption; check the core's manual for the real value). It is a layout check on the host (aarch64, GCC 14.4 in a gcc:14 container), not a latency measurement.
#include <stdint.h>
#include <stdio.h>
#define LINE 64 /* assumed cache-line size; check the core's manual */
struct stats_plain { uint64_t isr_count; uint64_t last_timestamp; };
struct stats_padded { uint64_t isr_count; uint64_t last_timestamp; } __attribute__((aligned(LINE)));
static struct stats_plain plain[4] __attribute__((aligned(LINE))); /* one element per core */
static struct stats_padded padded[4];
int main(void) {
printf("sizeof plain=%zu padded=%zu\n", sizeof(struct stats_plain), sizeof(struct stats_padded));
printf("cache line index of each core's element\n");
printf("plain : "); for (int i = 0; i < 4; i++) printf("%lu ", (unsigned long)(((uintptr_t)&plain[i] - (uintptr_t)&plain[0]) / LINE)); printf("\n");
printf("padded: "); for (int i = 0; i < 4; i++) printf("%lu ", (unsigned long)(((uintptr_t)&padded[i] - (uintptr_t)&padded[0]) / LINE)); printf("\n");
return 0;
}
gcc -O2 -Wall -Wextra -fsanitize=address,undefined percore.c -o percore && ./percore
(The sanitizer flags only make the compiler add run-time checks for memory and undefined-behaviour errors; they do not change what the program prints.)
Output, with GCC 14.4.0 in the gcc:14 container:
sizeof plain=16 padded=64
cache line index of each core's element
plain : 0 0 0 0
padded: 0 1 2 3
The line index is the byte distance of each element from the start of the array divided by 64 using integer division, so every element within the first 64 bytes gets index 0. Without padding, all four cores' 16-byte entries fall in one line (index 0 for each); with each entry aligned and padded to 64 bytes, they occupy lines 0, 1, 2 and 3.
- DMA and the cache. DMA (direct memory access) lets a device copy data to or from memory without the CPU. If the DMA engine is not cache-coherent with the CPUs, the ISR (or the code it wakes) must invalidate the cached copy of the buffer before reading it, and clean the cache (write modified lines back to memory) before the device reads a buffer the CPU wrote. Example: the device writes a packet into memory while the CPU still holds an old copy of that buffer in its cache; without invalidating (discarding the cached copy), the ISR reads the stale old bytes. In the other direction, a CPU-written command may still sit only in the cache, so without cleaning, the device reads old memory. If it is coherent, that maintenance is not needed, but the first read of the buffer can still miss in the cache of the core doing the reading.
- DMA affinity. Route the DMA completion interrupt to the core that will process the buffer, and let that core allocate it, so the data and the code that touches it are on one core.
Per-core stacks. Each core takes its interrupts on its own exception stack, and its depth is the nesting of only the interrupts routed to it. Partitioning therefore also shrinks the worst-case stack per core: a core serving two preemption levels needs frames for two levels, not for every level in the system.
Interrupt storms. A faulty or chatty source routed to the real-time core can starve it. Route storm-prone sources away from it, and rate-limit or mask a source that fires faster than its handler can run.
How to verify the plan. Measure the interrupt-to-handler latency distribution, not the average, with each routing option, under load on every core: a GPIO toggle at ISR entry captured on a logic analyser, or a free-running timer read at ISR entry (cycle counters on different cores are not guaranteed to be comparable, so time both ends against one shared timer). Compare the 99.9th percentile and the maximum with the deadline. Confirm the routing actually took effect (on Linux, the per-CPU columns of /proc/interrupts; on bare metal, read back the routing register), and repeat with the other cores saturated and with a core taken offline.
What would flip the recommendation: if one core can run everything within its deadlines, a single-core design avoids every cost above; if the real-time work is too large for one core, split by device so that each interrupt and its consumer still share a core.
What is a nested interrupt? Explain how priorities decide who preempts whom on a Cortex-M, and what you give up by allowing nesting.
Sample Answer
A nested interrupt is an interrupt that preempts (interrupts) another interrupt's handler while that handler is still running. The first handler's state stays on the stack, the higher-priority handler runs to completion, and the first resumes.
How Cortex-M priorities decide who preempts whom.
- Every configurable exception and interrupt has a priority number, and a lower number means a higher priority. Reset, NMI (non-maskable interrupt) and HardFault have fixed priorities above all configurable ones.
- The NVIC (nested vectored interrupt controller, the Cortex-M interrupt controller) tracks which exceptions are active (handler running or interrupted). A new request preempts the running handler only if its priority is higher (a smaller number) than the active one's. Equal or lower priority waits in the pending state.
- Implemented priority bits sit in the upper bits of the 8-bit priority field. The test part (LM3S6965, 3 bits) accepts 0x20, 0x40, 0x80 and so on, which are levels 1, 2 and 4 of 0 to 7: the level is the field value shifted right by the 5 unimplemented low bits, so 0x20 >> 5 = 1, 0x40 >> 5 = 2 and 0x80 >> 5 = 4 (a 3-bit field has 8 levels, 0 to 7; writing 0xFF and reading back gives 0xE0, which is how a program finds out how many bits exist).
- The priority grouping setting (PRIGROUP) splits the field into a group priority and a subpriority. Only the group priority controls preemption; the subpriority only orders pending exceptions of the same group. A tiny example with this part's 3 bits and PRIGROUP = 5, which gives CMSIS's split of 2 group bits and 1 subpriority bit: an interrupt at 0x40 (binary 010 in the implemented bits: group 01, subpriority 0) and one at 0x60 (011: group 01, subpriority 1) are in the same group, so neither can preempt the other; if both are pending, 0x40 runs first because its subpriority is lower. An interrupt at 0x20 (001: group 00) is in a lower-numbered group and preempts both. The split rule is CMSIS's
NVIC_EncodePriority(incore_cm3.h): group bits = min(7 - PRIGROUP, implemented bits), subpriority bits = max(0, PRIGROUP - 7 + implemented bits). The demo below leaves PRIGROUP at its reset value, 0, so every implemented bit is group priority (the reset value was read back as 0 from AIRCR bits 10:8 in the QEMU run).
Executed demonstration. The stack on a Cortex-M grows downward: pushing moves the stack pointer to a smaller address, so a nested handler's frame has a lower address than the frame it interrupted. frame below is the address of the block of eight registers the hardware saved on entry (the exception frame), obtained from the stack pointer by a small assembly wrapper (a naked function, one with no compiler-generated prologue) that passes it to the C handler. note(letter, value) appends a letter, and for entry also the frame address, to a log in RAM that dump() prints later. On a Cortex-M3 in QEMU (lm3s6965evb; it checks ordering and stack addresses, and says nothing about cycle timing), IRQ0 has priority 0x80, IRQ1 0x20, IRQ2 0x40, IRQ3 0x80. Handlers append a letter on entry (A, B, C, D = IRQ0..3) and the lower-case letter on exit; PEND(n) sets the NVIC pending bit and issues dsb/isb:
#define PEND(irq) do { NVIC_ISPR0 = 1u << (irq); __asm volatile("dsb\n isb" ::: "memory"); } while (0)
void irq0_c(uint32_t *frame) { /* frame = SP at handler entry (naked wrapper passes it) */
note('A', (uint32_t)frame);
if (scenario == 1) PEND(1); /* higher priority: preempts here */
if (scenario == 2) PEND(3); /* same priority: must wait */
note('a', 0);
}
void irq1_c(uint32_t *frame) {
note('B', (uint32_t)frame);
if (scenario == 3) { PEND(0); PEND(2); } /* pend low, then mid, while running at high */
note('b', 0);
}
(irq2_c and irq3_c only log entry and exit; dump() prints the log and the frame addresses over the UART.) Not shown: the naked wrappers for IRQ0 to IRQ3 (each does mov r0, sp and then branches to the matching irqN_c), the vector table, the startup code, the linker script, note and dump. The frame addresses depend on how much stack that scaffolding has used by the time main runs, so only the difference between two frames is meaningful, and the 40-byte difference below belongs to a build at -O1 (at -O0 the handler's prologue is larger and the difference is larger too). Output (frame lines for runs 2 and 3 left out):
1 low IRQ0 pends high IRQ1 inside its handler: ABba
A frame at 0x2000ffd0
B frame at 0x2000ffa8
2 IRQ0 pends same-priority IRQ3 inside its handler: AaDd
3 high IRQ1 pends low IRQ0 then mid IRQ2: BbCcAa
Reading it: in run 1, B starts between A and a, so IRQ1 nested inside IRQ0. In run 2 the same-priority IRQ3 waited for IRQ0 to finish (Aa then Dd). In run 3 two interrupts were pended while the highest-priority handler ran; afterwards the mid-priority one (C) ran before the low one (A) even though A was pended first: pending exceptions run in priority order, not arrival order.
What nesting costs.
- Stack. Each preemption stacks eight registers, 32 bytes, onto the stack in use. In the run above A's frame is at 0x2000ffd0 and B's at 0x2000ffa8, a difference of 0x28 = 40 bytes: the 32-byte hardware frame plus 8 bytes that A's own code had pushed (
push {r3, lr}: two 4-byte registers saved by the handler's compiler-generated prologue at -O1, where r3 is typically padding that keeps the stack 8-byte aligned; the exact registers depend on the build). Because the stack grows downward, B's frame is lower: A's frame address 0x2000ffd0, minus A's 8 pushed bytes, minus B's 32-byte hardware frame, is 0x2000ffd0 - 8 - 0x20 = 0x2000ffa8. Worst-case stack is the sum over the deepest chain: with three levels whose handlers use 56, 40 and 24 bytes beyond the hardware frame, the nested total is (32 + 56) + (32 + 40) + (32 + 24) = 216 bytes, on top of the interrupted code's own usage. Size the stack for the deepest chain that priorities allow, not for the average. - Latency for the lower handler. Every higher-priority ISR extends the lower one's run time. Its worst-case duration is its own time plus every higher-priority ISR that can interrupt it.
- Analysis effort. Shared data between nested handlers needs protection, and the number of possible orderings grows with the number of levels.
- Interference with an RTOS (real-time operating system). An RTOS kernel typically masks interrupts only up to a chosen priority limit; interrupts more urgent than that limit are never delayed by the kernel, and for that reason they must not call kernel functions.
Limiting it for determinism.
- Use few preemption levels: give each ISR a deliberate priority, put short urgent ones on top, and give everything else the same level so none can nest.
- Equal priority already prevents nesting (run 2): this is the cheapest way to bound the depth.
- BASEPRI blocks every interrupt whose priority number is at or above the value written to it, and leaves higher priorities running; zero turns it off. PRIMASK blocks all configurable priorities. The same program continues with:
__asm volatile("msr basepri, %0" :: "r"(0x40) : "memory");
PEND(0); PEND(1); PEND(2); /* dump(): only IRQ1 ran -> Bb */
__asm volatile("msr basepri, %0\n isb" :: "r"(0) : "memory"); /* dump() -> CcAa */
__asm volatile("cpsid i" ::: "memory");
PEND(1); /* log is still empty here */
__asm volatile("cpsie i\n isb" ::: "memory"); /* dump() -> Bb */
With BASEPRI = 0x40, IRQ1 (0x20) ran and IRQ2 (0x40) and IRQ0 (0x80) stayed pending; after BASEPRI was cleared they ran in priority order (CcAa). With PRIMASK set by cpsid i the pended IRQ1 did not run until cpsie i (Bb). BASEPRI is not available on Cortex-M0 and M0+ (ARMv6-M), so those cores offer PRIMASK only (ARMv6-M is the architecture of the smaller Cortex-M0 and M0+ cores; the Cortex-M3 used here is ARMv7-M).
- Size the stack for the deepest permitted chain and check it with a stack watermark (fill the unused stack with a known pattern at start-up, run a stress test, then scan from the far end for the first word that no longer holds the pattern: that is the high-water mark, the deepest the stack ever reached).
Unlock Full Question Bank
Get access to all 49 Real-Time Constraints and Interrupt Handling interview questions and detailed answers.
Sign in to ContinueJoin thousands of developers preparing for their dream job.