HomeAbout UsContact Us

Optimizing Cortex-M Tail-Chaining for Sub-Microsecond Latency

By Jithin Tom
Published in Embedded Concepts
August 15, 2026
4 min read
Optimizing Cortex-M Tail-Chaining for Sub-Microsecond Latency

Table Of Contents

01
Priority Ordering for Maximum Tail-Chaining
02
What Breaks Tail-Chaining
03
Measuring Tail-Chaining on Hardware
04
FreeRTOS Interaction
05
Cortex-M33 / M55 / M85: Tail-Chaining with TrustZone
06
Summary
07
Related Reading
08
References
09
Frequently Asked Questions

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 |
+--------------------------------------------------+

Priority Ordering for Maximum Tail-Chaining

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.

Sub-Priority and Tail-Chaining

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.

What Breaks Tail-Chaining

1. PRIMASK / BASEPRI / FAULTMASK Active

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.

2. Late-Arriving Higher-Priority Interrupt

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 begins
T=4: ISR B (prio 0) triggered — LATE ARRIVAL
T=4-12: NVIC detects higher priority, aborts ISR A stacking
T=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.

3. Interrupt Disabled at Peripheral or NVIC

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.

Measuring Tail-Chaining on Hardware

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:

  • Tail-chained gap: ~35-50 ns (6-8 cycles at 168 MHz) between DMA falling edge and ADC rising edge
  • Non-chained gap: ~140+ ns (24+ cycles) when tail-chaining is blocked

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 Interaction

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:

  • ISRs at priority 0-4 (higher than syscall priority): Never masked by FreeRTOS. Can tail-chain freely among themselves.
  • ISRs at priority 5-7 (at or below syscall priority): Masked during taskENTER_CRITICAL() / taskEXIT_CRITICAL() and inside FreeRTOS API calls from ISRs (xQueueSendFromISR, etc.). Tail-chaining blocked while masked.
  • SysTick (typically priority 15/lowest): Never tail-chains from peripheral ISRs. FreeRTOS tick is the “background” interrupt.

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

Cortex-M33 / M55 / M85: Tail-Chaining with TrustZone

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) ✓ WORKS
Non-secure ISR (prio 1) --> Tail-chain --> Non-secure ISR (prio 2) ✓ WORKS
Non-secure ISR (prio 1) --> Tail-chain --> Secure ISR (prio 2) ✗ BLOCKED
Secure 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.

Summary

FactorEffect on Tail-Chaining
Next IRQ priority >= currentEnables tail-chaining
Next IRQ priority < currentBlocks (normal preemption)
PRIMASK/BASEPRI/FAULTMASK setBlocks for masked priorities
Late-arrival higher-priority IRQPreempts, breaks chain
IRQ disabled at peripheral/NVICNo pending = no chain
FreeRTOS critical section (BASEPRI)Blocks at/below syscall priority
TrustZone Secure<->Non-secureBlocks cross-domain

Actionable takeaways:

  1. Assign peripheral interrupt priorities in descending order of throughput/latency sensitivity — DMA first, then ADC/timers, then comms peripherals. This creates a “tail-chain highway” for your fastest interrupts.
  2. Keep critical sections under 1 µs — Long critical sections disable tail-chaining for a wide priority band.
  3. Measure on target — Use DWT_CYCCNT or a logic analyzer to verify your priority scheme actually produces tail-chained transitions in your specific workload.
  4. On TrustZone devices, partition interrupt priorities by security domain — Don’t interleave Secure and Non-secure priorities if you want tail-chaining.

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.

References

  1. ARM Cortex-M4 Devices Generic User Guide, “Interrupt Control and State Register” and “Tail-Chaining” sections. ARM DUI 0553A.
  2. ARM Cortex-M33 Processor Technical Reference Manual, “Nested Vectored Interrupt Controller” and “Security Extension” chapters. ARM 100690.
  3. Joseph Yiu, The Definitive Guide to ARM Cortex-M3 and Cortex-M4 Processors, 3rd ed., Newnes, 2014. Chapter 11: “Interrupt Handling and Latency.”
  4. FreeRTOS Kernel Developer Guide, “Interrupt Management” and “configMAX_SYSCALL_INTERRUPT_PRIORITY” configuration. https://www.freertos.org/Documentation/02-Kernel/03-Supported-devices/02-Customization
  5. STM32F4xxx Reference Manual RM0090, “Nested Vectored Interrupt Controller (NVIC)” and “Interrupt and exception vectors” tables. STMicroelectronics.
  6. ARM Application Note 321: “Cortex-M Interrupt Latency Optimization.” ARM DAI 0321A.

Frequently Asked Questions

What is tail-chaining on Cortex-M?

Tail-chaining is a Cortex-M NVIC optimization where the processor skips the full context restore and re-save sequence when a pending interrupt has equal or higher priority than the currently executing ISR. Instead of popping all registers and pushing them again, the processor directly vectors to the next ISR, saving 12-18 cycles on Cortex-M3/M4/M7.

When does tail-chaining NOT occur?

Tail-chaining is skipped when: (1) the next pending interrupt has lower priority than the current ISR, (2) the current ISR is executing with BASEPRI or PRIMASK masking the next interrupt, (3) the NVIC detects a late-arriving higher-priority interrupt during stacking (late-arrival preemption takes precedence), or (4) the interrupt is disabled at the peripheral or NVIC level.

How many cycles does tail-chaining save vs. full context switch?

On Cortex-M4/M7, a full interrupt entry/exit pair costs ~24 cycles (12 entry + 12 exit). Tail-chaining reduces this to ~6-8 cycles for the chained transition, yielding a 60-70% latency reduction for back-to-back interrupts of equal or descending priority.

Can tail-chaining be measured on target hardware?

Yes. Toggle a GPIO at the start of each ISR and measure the delta with a logic analyzer or oscilloscope. The tail-chained gap will show ~6-8 cycles (at 168 MHz, ~35-50 ns) versus ~24+ cycles for non-chained transitions. Cycle-accurate measurement requires DWT_CYCCNT or trace.

Does tail-chaining work with FreeRTOS or other RTOS ports?

Yes, but with caveats. FreeRTOS uses BASEPRI to mask interrupts during critical sections, which disables tail-chaining for masked priorities. The RTOS tick interrupt (typically lowest priority) will not tail-chain from application ISRs. However, high-priority peripheral ISRs (DMA complete, ADC, timer) can still tail-chain among themselves if they share or have descending priority.

Tags

cortex-minterrupttail-chaininglatencynvicstm32

Share


Previous Article
Debugging Production Firmware Issues: Field Diagnostics That Work
Jithin Tom

Jithin Tom

A Closer Look at C/C++, RTOS, and Embedded Systems

Related Posts

Cortex-M MPU Configuration for Memory Protection
Cortex-M MPU Configuration for Memory Protection
August 12, 2026
2 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media