
Tail-chaining is one of the most underappreciated latency optimizations in the Cortex-M architecture. When two interrupts fire back-to-back with compatible priorities, the NVIC skips the full register pop/push sequence and vectors directly from the first ISR to the second. On a Cortex-M4 at 168 MHz, this drops interrupt-to-interrupt latency from ~24 cycles to ~6 cycles — a 75% reduction that can determine whether your DMA completion handler meets its deadline or your motor control loop stays in sync.
The mechanism is simple in principle: when an ISR completes, the NVIC checks the pending interrupt register. If a pending interrupt has equal or higher priority than the one just finishing, and no masking (PRIMASK/BASEPRI/FAULTMASK) blocks it, the processor performs a “tail-chained” entry. It does not restore R0-R3, R12, LR, PC, xPSR to the stack. Instead, it loads the new ISR ’s vector address, pushes a minimal interrupt frame (or reuses the current one on some implementations), and begins executing the next handler.
+--------------------------------------------------+| Standard Interrupt Entry/Exit |+--------------------------------------------------+| ISR A Entry | ISR A Exit || Push R0-R3, R12, | Pop R0-R3, R12, || LR, PC, xPSR (8 regs)| LR, PC, xPSR (8 regs) || ~12 cycles | ~12 cycles || | || TOTAL: ~24 cycles | No tail-chaining |+--------------------------------------------------++--------------------------------------------------+| Tail-Chained Transition |+--------------------------------------------------+| ISR A Entry | ISR A Exit || Push R0-R3, R12, | (NVIC detects || LR, PC, xPSR | pending ISR B) || ~12 cycles | || | Direct vector to ISR B || | ~6 cycles || | || ISR B Entry | ISR B Exit || (No push - reuses | Pop R0-R3, R12, || frame or minimal) | LR, PC, xPSR || ~0-2 cycles | ~12 cycles || | || TOTAL: ~20-22 cycles | vs ~48 cycles for two || for two ISRs | separate entries |+--------------------------------------------------+
Tail-chaining only triggers when the next pending interrupt has priority >= current ISR priority (numerically lower or equal value on Cortex-M, where 0 = highest). This means your interrupt priority assignment directly controls whether tail-chaining can occur.
For a typical STM32F4/H7 application with DMA, ADC, and timer interrupts:
/* Priority scheme enabling tail-chaining among high-priority peripherals *//* Lower numerical value = higher priority on Cortex-M */#define IRQ_PRI_DMA1_STREAM0 0 /* Highest: DMA complete, must chain fast */#define IRQ_PRI_ADC1 1 /* ADC conversion complete */#define IRQ_PRI_TIM1_CC 1 /* Timer capture/compare - same as ADC */#define IRQ_PRI_USART1 2 /* UART RX/TX */#define IRQ_PRI_SYSTICK 15 /* Lowest: RTOS tick */NVIC_SetPriority(DMA1_Stream0_IRQn, IRQ_PRI_DMA1_STREAM0);NVIC_SetPriority(ADC1_IRQn, IRQ_PRI_ADC1);NVIC_SetPriority(TIM1_CC_IRQn, IRQ_PRI_TIM1_CC);NVIC_SetPriority(USART1_IRQn, IRQ_PRI_USART1);NVIC_SetPriority(SysTick_IRQn, IRQ_PRI_SYSTICK);
With this scheme, a DMA completion (priority 0) can tail-chain into ADC (priority 1) or TIM1 (priority 1) because 1 >= 0 numerically. But SysTick (priority 15) will never tail-chain from any peripheral ISR — its priority is too low.
On Cortex-M3/M4/M7 with 3-4 priority bits implemented, sub-priority bits (the lower bits of the 8-bit priority field) do not affect tail-chaining decisions. Only the preemption priority bits (upper bits) matter. Two interrupts with the same preemption priority but different sub-priority will tail-chain if the pending interrupt’s sub-priority is numerically lower or equal.
NVIC Priority Register (8 bits, 3 bits implemented = 5 sub-priority bits):[ Preempt(3) | Sub(5) ]Tail-chaining compares ONLY Preempt(3) bits.Sub-priority only breaks ties when preempt is equal AND both are pending simultaneously.
Any critical section using __disable_irq() (sets PRIMASK) or taskENTER_CRITICAL() (sets BASEPRI on FreeRTOS) blocks tail-chaining for all interrupts at or below the mask level. The NVIC sees the mask and treats the current execution as “non-preemptible” for those priorities.
/* This critical section PREVENTS tail-chaining for priorities >= configMAX_SYSCALL_INTERRUPT_PRIORITY */taskENTER_CRITICAL();/* ... protected region ... */taskEXIT_CRITICAL();
Mitigation: Keep critical sections short. Use lock-free structures (atomic operations, seqlocks) where possible. On Cortex-M33/55, use the non-secure callable (NSC) entry for secure/non-secure transitions without full masking.
If a higher-priority interrupt arrives during the stacking sequence of the current ISR (the 12-cycle entry window), the NVIC abandons the current ISR’s stacking, services the higher-priority interrupt first, then returns to complete the original ISR. This is “late-arrival preemption” — it takes precedence over tail-chaining.
Timeline:T=0: ISR A (prio 2) triggered, stacking beginsT=4: ISR B (prio 0) triggered — LATE ARRIVALT=4-12: NVIC detects higher priority, aborts ISR A stackingT=12: ISR B entry begins (full 12-cycle entry)T=24: ISR B executes...T=...: ISR B exits, NVIC resumes ISR A stacking
This is correct architectural behavior — the higher-priority interrupt gets serviced first. But it means tail-chaining from ISR A to ISR B is lost; instead, you get a full preemption.
If the peripheral’s interrupt enable bit is clear, or NVIC_DisableIRQ() was called, the interrupt never reaches the pending state. No pending = no tail-chain candidate.
The most direct measurement uses a GPIO toggle at ISR entry and a logic analyzer:
volatile uint32_t gpio_port = GPIOA_BASE;volatile uint32_t pin_mask = (1 << 5); /* PA5 */void DMA1_Stream0_IRQHandler(void) {GPIOA->BSRR = pin_mask; /* Rising edge = ISR entry *//* ... DMA handling ... */GPIOA->BSRR = (pin_mask << 16); /* Falling edge = ISR exit *//* Check if next ISR is pending and will tail-chain */}void ADC1_IRQHandler(void) {GPIOA->BSRR = (1 << 6); /* PB6 for ADC entry *//* ... ADC handling ... */GPIOA->BSRR = ((1 << 6) << 16);}
With a 1 GS/s logic analyzer, you’ll see:
For cycle-accurate measurement without external gear, use the DWT cycle counter:
/* Enable DWT cycle counter (Cortex-M3/M4/M7/M33) */CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;uint32_t cycles_start, cycles_end;void DMA1_Stream0_IRQHandler(void) {cycles_start = DWT->CYCCNT;GPIOA->BSRR = (1 << 5);/* ... */cycles_end = DWT->CYCCNT;GPIOA->BSRR = ((1 << 5) << 16);/* Store delta for analysis */dma_isr_cycles = cycles_end - cycles_start;}void ADC1_IRQHandler(void) {uint32_t entry_cycles = DWT->CYCCNT;/* The gap from previous ISR exit to this entry: */adc_tail_chain_gap = entry_cycles - cycles_end; /* Should be ~6-8 if chained */GPIOA->BSRR = (1 << 6);/* ... */}
FreeRTOS on Cortex-M uses BASEPRI to mask interrupts up to configMAX_SYSCALL_INTERRUPT_PRIORITY (typically priority 5 on a 3-bit priority system). This means:
taskENTER_CRITICAL() / taskEXIT_CRITICAL() and inside FreeRTOS API calls from ISRs (xQueueSendFromISR, etc.). Tail-chaining blocked while masked.Practical rule: Assign your highest-throughput, latency-critical peripheral interrupts (DMA, ADC, high-speed timers, Ethernet) to priorities above configMAX_SYSCALL_INTERRUPT_PRIORITY. Keep UART, I2C, SPI, and other “slow” peripherals at or below the syscall priority if they use FreeRTOS APIs.
/* FreeRTOSConfig.h typical values */#define configPRIO_BITS 3 /* 3 bits = 8 priority levels (0-7) */#define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 /* Priority 5 = syscall mask */#define configKERNEL_INTERRUPT_PRIORITY 7 /* SysTick, PendSV at lowest *//* Your peripheral interrupts: */#define IRQ_PRI_ETH_DMA 0 /* Above syscall mask - tail-chains freely */#define IRQ_PRI_ADC_DMA 1#define IRQ_PRI_TIMER_CTRL 2#define IRQ_PRI_UART 5 /* At syscall mask - uses xQueueSendFromISR */#define IRQ_PRI_I2C 6
On Armv8-M (Cortex-M33, M55, M85), TrustZone adds Secure/Non-secure state. Tail-chaining works within a security state but not across the Secure/Non-secure boundary. A Non-secure ISR cannot tail-chain into a Secure ISR (or vice versa) because the transition requires the Secure Gateway (SG) veneer and state save/restore.
Secure ISR (prio 1) --> Tail-chain --> Secure ISR (prio 2) ✓ WORKSNon-secure ISR (prio 1) --> Tail-chain --> Non-secure ISR (prio 2) ✓ WORKSNon-secure ISR (prio 1) --> Tail-chain --> Secure ISR (prio 2) ✗ BLOCKEDSecure ISR (prio 1) --> Tail-chain --> Non-secure ISR (prio 2) ✗ BLOCKED
The NSC (Non-secure Callable) entry adds ~20-30 cycles for the domain crossing. If your application mixes Secure and Non-secure interrupts, group them by security domain in the priority scheme to preserve tail-chaining within each domain.
| Factor | Effect on Tail-Chaining |
|---|---|
| Next IRQ priority >= current | Enables tail-chaining |
| Next IRQ priority < current | Blocks (normal preemption) |
| PRIMASK/BASEPRI/FAULTMASK set | Blocks for masked priorities |
| Late-arrival higher-priority IRQ | Preempts, breaks chain |
| IRQ disabled at peripheral/NVIC | No pending = no chain |
| FreeRTOS critical section (BASEPRI) | Blocks at/below syscall priority |
| TrustZone Secure<->Non-secure | Blocks cross-domain |
Actionable takeaways:
Tail-chaining isn’t a feature you enable — it’s a behavior you design for through priority assignment. The hardware does the rest automatically. The 18-cycle savings per chained interrupt adds up fast in a system handling thousands of interrupts per second.
Quick Links
Legal Stuff





