HomeAbout UsContact Us

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

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

Table Of Contents

01
Priority Ordering for Maximum Tail-Chaining
02
Late-Arriving Exceptions
03
What Actually Breaks Tail-Chaining
04
Measuring Tail-Chaining on Hardware
05
FreeRTOS Interaction
06
Cortex-M33 / M55 / M85: Tail-Chaining with TrustZone
07
Summary
08
Related Reading
09
References
10
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 for pending interrupts. If a pending interrupt has sufficient priority to preempt the execution state the processor is returning to (e.g., returning to Thread mode), the processor performs a “tail-chained” entry. It does not pop R0-R3, R12, LR, PC, xPSR from the stack. Instead, it loads the new ISR’s vector address, modifies the stack frame slightly if necessary (such as updating the stacked PC), 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 | |
| | Tail-chain to ISR B |
| | (vector fetch + enter) |
| | ~6 cycles (no stacking) |
| | |
| ISR B executes | ISR B Exit |
| (uses existing | Pop R0-R3, R12, |
| stack frame) | LR, PC, xPSR |
| | ~12 cycles |
| | |
| TOTAL: ~30 cycles | vs ~48 cycles for two |
| for two ISRs | separate entries |
+--------------------------------------------------+

Priority Ordering for Maximum Tail-Chaining

A common misconception is that interrupts must have different priorities to tail-chain. In reality, assigning different preemption priorities encourages preemption, which breaks tail-chaining.

If a higher-priority interrupt arrives while a lower-priority ISR is executing, the NVIC immediately preempts the current ISR. This costs a full 12-cycle stack push. When the higher-priority ISR finishes, it must perform a full 12-cycle stack pop to resume the preempted ISR. This costs 24 cycles of overhead.

To maximize tail-chaining, assign the same preemption priority to high-speed peripherals that do not strictly need to preempt one another:

/* Priority scheme maximizing tail-chaining among high-speed peripherals */
/* Lower numerical value = higher priority on Cortex-M */
#define IRQ_PRI_HIGH_SPEED 1 /* Grouped together to force tail-chaining */
#define IRQ_PRI_USART1 2 /* UART RX/TX */
#define IRQ_PRI_SYSTICK 7 /* Lowest on a 3-bit system: RTOS tick */
NVIC_SetPriority(DMA1_Stream0_IRQn, IRQ_PRI_HIGH_SPEED);
NVIC_SetPriority(ADC1_IRQn, IRQ_PRI_HIGH_SPEED);
NVIC_SetPriority(TIM1_CC_IRQn, IRQ_PRI_HIGH_SPEED);
NVIC_SetPriority(USART1_IRQn, IRQ_PRI_USART1);
NVIC_SetPriority(SysTick_IRQn, IRQ_PRI_SYSTICK);

With this scheme, if a DMA completion and an ADC conversion fire simultaneously (or one fires while the other is executing), they will not preempt each other. The first will execute, and upon completion, the NVIC will tail-chain directly into the second, saving 18 cycles. If DMA (priority 0) and ADC (priority 1) were used, the DMA would preempt the ADC, forcing a full context save and restore.

Sub-Priority and Tail-Chaining

On Cortex-M3/M4/M7, the 8-bit priority register only has its upper N bits implemented (e.g., 3 or 4 bits). The remaining lower bits are unimplemented and always read as zero. The AIRCR.PRIGROUP field splits the implemented bits between preemption priority (group priority) and sub-priority.

NVIC Priority Register (8 bits, 3 bits implemented):
[ Bit7 | Bit6 | Bit5 | Bit4 | Bit3 | Bit2 | Bit1 | Bit0 ]
[ Implemented (3 bits) | Unimplemented (5 bits, RAZ) ]
Default PRIGROUP=0: all 3 bits are preemption priority, 0 sub-priority.
PRIGROUP=5: 2 bits preemption, 1 bit sub-priority.
PRIGROUP=6: 1 bit preemption, 2 bits sub-priority.

Sub-priority determines the order in which multiple pending interrupts of the same preemption priority are serviced. All such interrupts will sequentially tail-chain into each other, with the lowest sub-priority value (highest sub-priority) executing first.

Late-Arriving Exceptions

Late-arrival is a separate optimization from tail-chaining, but the two work together to minimize total overhead.

If a higher-priority interrupt arrives during the 12-cycle stacking sequence of a lower-priority ISR, the NVIC continues the memory writes (because the saved context is the same — Thread mode state) but redirects the vector fetch to the higher-priority interrupt instead.

Timeline:
T=0: ISR A (prio 2) triggered, stacking begins (saving Thread mode state)
T=4: ISR B (prio 0) triggered — LATE ARRIVAL
T=4-12: NVIC redirects vector fetch to ISR B, stacking completes
T=12: ISR B begins executing
T=...: ISR B exits. ISR A is still pending.
ISR A has higher priority than Thread mode → tail-chains.

The late-arrival optimization avoids wasting the stacking already in progress. When ISR B finishes, the processor would normally return to Thread mode, but ISR A is still pending with sufficient priority, so it tail-chains. The combination of both optimizations means neither a redundant stack push nor a redundant pop occurs.

What Actually Breaks Tail-Chaining

1. Normal Preemption

If a higher-priority interrupt arrives after the current ISR has already begun executing, it immediately preempts it. The NVIC pushes a new stack frame containing the lower-priority ISR’s state. When returning, the higher-priority ISR must pop this frame to resume the lower-priority ISR. This cannot be tail-chained.

2. Interrupt Disabled or Masked

If the peripheral’s interrupt enable bit is clear, or NVIC_DisableIRQ() was called, the interrupt never reaches the pending state. Masking via PRIMASK or BASEPRI will also delay interrupts from becoming active until unmasked.

Measuring Tail-Chaining on Hardware

The most direct measurement uses a GPIO toggle at ISR entry and a logic analyzer:

#define PIN_DMA (1U << 5) /* PA5 — DMA ISR indicator */
#define PIN_ADC (1U << 6) /* PA6 — ADC ISR indicator */
void DMA1_Stream0_IRQHandler(void) {
GPIOA->BSRR = PIN_DMA; /* PA5 rising edge = ISR entry */
/* ... DMA handling ... */
GPIOA->BSRR = (PIN_DMA << 16); /* PA5 falling edge = ISR exit */
}
void ADC1_IRQHandler(void) {
GPIOA->BSRR = PIN_ADC; /* PA6 rising edge = ADC ISR entry */
/* ... ADC handling ... */
GPIOA->BSRR = (PIN_ADC << 16); /* PA6 falling edge = ADC ISR exit */
}

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. On a device with 3 implemented priority bits, configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY = 5 means library-level priority 5 (NVIC value 5 << (8-3) = 0xA0). This means:

  • ISRs at library priority 0-4 (higher than syscall priority): Never masked by FreeRTOS. Can tail-chain freely among themselves. These ISRs must not call FreeRTOS API functions.
  • ISRs at library priority 5-7 (at or below syscall priority): Masked during taskENTER_CRITICAL() / taskEXIT_CRITICAL() and inside FreeRTOS API calls. They pend in the background. When the critical section exits and BASEPRI is cleared, all pending interrupts will fire and tail-chain into one another based on priority.
  • SysTick / PendSV (library priority 7, lowest): The FreeRTOS tick and context switch interrupts. Because they have higher priority than Thread mode, SysTick will successfully tail-chain from any application ISR if it is the only remaining pending interrupt when the ISR completes.

Practical rule: Group your highest-throughput, latency-critical peripheral interrupts (DMA, ADC, high-speed timers) at the same preemption priority (if they don’t require FreeRTOS API access) or at least manage their grouping logically. This allows them to tail-chain rather than preempting one another.

/* 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 */
/* Group high-speed peripherals at the same preemption priority to force tail-chaining */
#define IRQ_PRI_HIGH_SPEED 0
#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
Pending IRQ has sufficient priority vs. return stateEnables tail-chaining
Higher-priority IRQ preempts running ISRCauses preemption (breaks tail-chaining)
Same preemption priority groupForces tail-chaining instead of preemption
Late-arrival during stackingSeparate optimization; tail-chaining follows
FreeRTOS critical section (BASEPRI)Delays interrupts; tail-chains upon exit
TrustZone Secure↔Non-secureBlocks cross-domain tail-chaining

Actionable takeaways:

  1. Assign high-throughput interrupts the same preemption priority — DMA, ADC, and timers grouped at the same preemption level will tail-chain instead of preempting each other, creating a low-latency “tail-chain highway”.
  2. Understand that preemption breaks tail-chaining — Don’t arbitrarily assign different priority levels unless you truly need one ISR to interrupt the active execution of another.
  3. Measure on target — Use DWT_CYCCNT or a logic analyzer to verify your priority scheme actually produces tail-chained transitions (6-8 cycles) 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 100230.
  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 DAI 0321A: “ARM Cortex-M Programming Guide to Memory Barrier Instructions.” (Relevant for understanding ISB/DSB interactions with interrupt entry sequences.)

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 transitioning between exceptions. If an interrupt is pending when the current ISR finishes, instead of popping all registers to return to Thread mode 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 does not occur during normal preemption. If a higher-priority interrupt arrives while an ISR is executing, it immediately preempts it, forcing a full context save (12 cycles). Upon completion, it must fully restore the preempted ISR's state. Tail-chaining only bridges the gap between consecutive exceptions returning to the same execution 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.

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. However, FreeRTOS uses BASEPRI to mask interrupts during critical sections. While masked, interrupts pend but cannot execute. Once the critical section exits, pending interrupts will fire. The RTOS tick interrupt (typically lowest priority) will absolutely tail-chain from application ISRs if it is the only remaining pending interrupt when the application ISR completes.

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

Fixing UART DMA Overrun Errors on STM32
Fixing UART DMA Overrun Errors on STM32
August 21, 2026
5 min
© 2026, All Rights Reserved.
Powered By Netlyft

Quick Links

Advertise with usAbout UsContact Us

Social Media