Skipping a Stuck delay() with Interrupts on Arduino
If you've ever written Arduino firmware with a long delay() or a busy-wait loop, you've probably run into this problem: the microcontroller is stuck waiting, and there's no clean way to say "never mind, move on" without rebooting or redesigning your whole control flow.
The instinct a lot of people reach for — myself included, the first time I hit this — is to interrupt the wait and literally redirect execution somewhere else. It turns out there are two very different ways to attempt that, one fragile and one solid. Here's the story of both.
The tempting (but broken) approach: rewriting the return address
The idea sounds elegant on paper. When an interrupt fires on AVR, the current return address gets pushed onto the stack. So why not reach into the stack from inside the ISR, overwrite that address, and force execution to resume somewhere else entirely when the ISR exits?
Here's roughly what that looks like:
volatile bool skipFunction = false;
void nextFunction() {
Serial.println("Jumped to next function!");
}
void customDelay(unsigned long duration) {
unsigned long start = millis();
while (millis() - start < duration) {
if (skipFunction) break;
}
Serial.println("Exited custom delay!");
}
ISR(INT0_vect) {
void (*nextFuncPtr)() = nextFunction;
asm volatile (
"ldi r30, lo8(%0) \n"
"ldi r31, hi8(%0) \n"
"sts 0x5D, r30 \n"
"sts 0x5E, r31 \n"
:
: "r" (nextFuncPtr)
: "r30", "r31"
);
skipFunction = true;
}
It reads convincingly: attach an interrupt to a pin, and when the pin fires, hand-craft your way into a different function via inline assembly.
The problem is that 0x5D and 0x5E aren't the return address at all — they're SPL and SPH, the AVR stack pointer registers. Writing nextFuncPtr into them doesn't touch the saved return address; it just relocates the stack pointer itself somewhere else in SRAM. The actual return address lives in the two bytes the stack pointer is pointing at (with a one-byte offset, and stored big-endian, word-addressed), not in the pointer registers themselves. On real hardware, this code would corrupt your stack rather than redirect execution.
There's a second issue too: attachInterrupt(digitalPinToInterrupt(2), ISR, RISING) passes ISR as if it were an ordinary callback, but ISR(INT0_vect){...} is the raw AVR vector macro — the two mechanisms don't mix. You'd need to pick one: a normal attachInterrupt callback, or a hand-registered vector with PCICR/PCMSK, not both at once.
None of this is meant as a knock — return-address manipulation is a real, well-documented technique in lower-level embedded and systems work. It's just genuinely easy to get subtly wrong, architecture-specific, and risky to debug when it misbehaves, which is exactly why it's worth pausing on before reaching for it.
The approach that actually works: a flag
Here's the thing — you don't need to touch the stack at all to skip a running delay. delay()-style code is just a loop checking elapsed time. An ISR doesn't need to "jump into" that loop; it just needs to set a flag the loop is already checking.
volatile bool skipDelay = false;
void skippableDelay(unsigned long duration) {
unsigned long start = millis();
while (millis() - start < duration) {
if (skipDelay) break; // ISR set this — bail out early
}
skipDelay = false; // reset for next time
}
void onButtonPress() {
skipDelay = true; // ISR: just flip the flag, nothing else
}
void setup() {
Serial.begin(9600);
pinMode(2, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(2), onButtonPress, FALLING);
}
void loop() {
Serial.println("Starting delay...");
skippableDelay(5000);
Serial.println("Delay finished (or skipped)!");
}
This is simpler, safer, and — importantly — portable. It works identically on AVR, ESP32, and ARM Cortex boards, because it doesn't depend on any chip-specific stack layout. Flipping a volatile bool inside an ISR is about as close to "instant and side-effect free" as embedded code gets, which is exactly what you want inside an interrupt handler in the first place.
If you need to skip through multiple stacked delays or steps rather than just one, the same pattern extends naturally into a counter:
volatile uint8_t skipCount = 0;
void onButtonPress() {
skipCount++;
}
Each skippable block decrements skipCount and breaks early if it's still nonzero, letting repeated button presses skip through a queue of steps one at a time.
The one real limitation
This trick works because you're polling inside your own loop. It does not work if you call the built-in Arduino delay() function directly — that one blocks at a lower level and never checks any flag, interrupt-driven or otherwise. The moment you want skippable waits, you have to write your own polling loop (as above) instead of calling delay().
But what if you actually want to jump to an address?
The flag approach solves "skip a delay" completely. But it's worth asking the more general question it sidesteps: is there a way to actually jump to a known function address from an ISR — properly this time, not the broken SPL/SPH version — and have it work?
The answer is yes, but it takes more than just fixing which registers you write to.
Why patching the right stack bytes still isn't enough. If you correctly locate the return address on the stack (rather than the SPL/SPH registers) and patch it in place, you fix the immediate crash — but the current function's stack frame is still sitting there, abandoned, never properly unwound. The moment the function you jumped into tries to ret, it pops leftover garbage from that orphaned frame and jumps into nonsense.
The correct fix is closer to a manual longjmp: snapshot the stack pointer before entering the section you might skip, and in the ISR, reset SP wholesale back to that snapshot — discarding the entire abandoned frame at once — then jump directly, rather than patching two bytes in place.
volatile uint16_t savedSP; // stack pointer snapshot, taken before entering the skippable section
void nextFunction() __attribute__((used, noinline));
void nextFunction() {
Serial.println("Jumped to next function!");
while (1) { /* dead end on purpose - see below */ }
}
// Naked ISR: no compiler-generated prologue/epilogue, we control everything
ISR(INT0_vect, ISR_NAKED) {
asm volatile (
"lds r16, savedSP \n"
"lds r17, savedSP+1 \n"
"cli \n"
"out __SP_L__, r16 \n"
"out __SP_H__, r17 \n" // stack pointer reset to pre-call snapshot
"sei \n"
"ldi r30, lo8(pm(nextFunction)) \n"
"ldi r31, hi8(pm(nextFunction)) \n"
"ijmp \n" // jump directly, no return expected
::: "r16", "r17", "r30", "r31"
);
}
void loop() {
savedSP = SP; // snapshot taken right before the skippable call
customDelay(5000);
}
This version genuinely works on real AVR hardware — the stack pointer really does get reset to a known-good point rather than corrupted. But notice the cost: nextFunction() can never return. There's no valid return address left anywhere for it to pop; we discarded that whole frame on purpose. It has to be a dead end (as shown), or explicitly jump somewhere else itself. This is a one-way context switch, not a function call — the same underlying idea a bootloader or an RTOS task-switcher uses, not "call and come back."
Getting back: setjmp / longjmp
If you actually need to return to where you jumped from — run nextFunction(), then continue loop() normally afterward — the one-way stack reset above can't do that. What you want is the standard C mechanism built for exactly this: setjmp/longjmp, which avr-libc supports.
#include <setjmp.h>
jmp_buf loopContext; // saved CPU context: SP, PC, callee-saved registers
volatile bool jumpRequested = false;
void onButtonPress() {
jumpRequested = true; // ISR stays trivial - flag only
}
void customDelay(unsigned long duration) {
unsigned long start = millis();
while (millis() - start < duration) {
if (jumpRequested) {
jumpRequested = false;
nextFunction(); // called normally - nextFunction ends with longjmp back
}
delay(200);
}
}
void nextFunction() {
Serial.println("Running...");
delay(1000);
longjmp(loopContext, 1); // unwinds straight back to the setjmp() in loop()
}
void loop() {
if (setjmp(loopContext) == 0) {
customDelay(5000); // normal path
} else {
Serial.println("Back in loop via longjmp!"); // landed here after longjmp
}
}
setjmp(loopContext) records the stack pointer and registers at that exact point in loop(). Calling longjmp(loopContext, 1) from anywhere later — deep inside customDelay(), from nextFunction() — unwinds the stack back to that saved point and makes setjmp() return again, this time with the value 1. It's a real two-way jump, and it has real advantages over the hand-rolled asm version: it saves the full register set the AVR calling convention actually requires (not just SP), so it's specified behavior rather than "probably works"; there's no naked ISR or inline asm; and <setjmp.h> exists on ESP32 and ARM too, so unlike the raw-asm version, it isn't AVR-only.
Two caveats matter here, and they're easy to get wrong:
- Never call
longjmpdirectly from an ISR. It isn't signal-safe — you'd skip theretithat re-enables the AVR's global interrupt flag, leaving interrupts silently dead with no crash to tip you off. The pattern above avoids this correctly: the ISR only setsjumpRequested; the actuallongjmphappens later, fromnextFunction(), called as ordinary code from insidecustomDelay()'s loop, well outside ISR context. longjmpdoesn't run C++ destructors for objects that go out of scope during the unwind, and it doesn't restore hardware/peripheral state either — ifcustomDelay()had half-toggled a GPIO sequence or left an SPI transaction open, that's abandoned exactly like the local variables are. For plain-data sketches like this one, there's nothing to bypass; the moment you add a C++ object with real cleanup logic on this call path (aFile, a buffer you own), that's a signlongjmpis the wrong tool for that spot.
Hardening it: three rounds of fixes
The version above works — but building on it a bit further surfaces real problems, each one worth knowing about even if you never hit them in your own sketch.
Round 1 — re-entrancy. What happens if the button is pressed a second time while nextFunction() is already mid-flight, during its own 1-second "work" delay? customDelay()'s loop would see jumpRequested set again and call nextFunction() a second time, nested inside the first call. The fix is a second flag guarding against exactly this:
volatile bool jumpInProgress = false;
void nextFunction() {
jumpInProgress = true;
Serial.println("Running...");
delay(1000);
longjmp(loopContext, 1);
}
and in customDelay(), only entering nextFunction() if a jump isn't already in progress. It's also worth reading and clearing jumpRequested under noInterrupts()/interrupts(), so the check-and-clear happens as one atomic step rather than two — on a single bool this is effectively atomic on AVR anyway, but wrapping it makes the intent explicit and keeps things correct if the flag type ever changes.
Round 2 — wrapping it up cleanly. Once there are two coordination flags plus the jump target itself, it's worth grouping them into one small class rather than three loose globals:
class JumpContext {
public:
jmp_buf context;
void requestJump() { requested_ = true; }
bool consumeRequest() {
noInterrupts();
bool r = requested_;
requested_ = false;
interrupts();
return r;
}
void begin() { inProgress_ = true; }
void end() { inProgress_ = false; }
bool inProgress() const { return inProgress_; }
private:
volatile bool requested_ = false;
volatile bool inProgress_ = false;
};
One subtlety: setjmp() itself still has to be called directly inside loop(), not wrapped inside a method on this class. setjmp() only saves a valid return point for the stack frame of whichever function calls it directly — if a method wrapped the call, that method's frame would already be gone by the time it returned, making any later jump into it undefined behavior.
This is also a natural place to enforce the destructor caveat from earlier at compile time instead of just documenting it in a comment:
#define JUMP_SAFE_LOCAL(Type, name, initExpr) \
static_assert(std::is_trivially_destructible<Type>::value, \
#Type " has a non-trivial destructor - unsafe on the " \
"setjmp/longjmp path"); \
Type name = (initExpr)
Any local declared this way on the jump path fails to compile if it has real cleanup logic — catching the mistake before it ever runs, rather than relying on remembering the rule.
Round 3 — failing loud, and a cooperative timeout. Two more things are worth hardening once the pattern is otherwise solid. First, calling longjmp on a jmp_buf that setjmp was never actually run on is undefined behavior — it can jump to a garbage stack pointer and program counter. It's better to check and halt with a clear message than corrupt memory silently:
void jumpTo(int value) {
if (!isSet_) {
Serial.println("FATAL: jumpTo() before setjmp() - halting.");
while (true) { delay(1000); }
}
longjmp(buf_, value);
}
Second, nextFunction() can be given a cooperative timeout, so if its work runs long it jumps back on its own rather than blocking indefinitely:
const unsigned long JUMP_TIMEOUT_MS = 3000;
void nextFunction() {
unsigned long workStart = millis();
while (millis() - workStart < WORK_DURATION_MS) {
if (millis() - workStart > JUMP_TIMEOUT_MS) {
jumpTo(2); // distinct code: timeout, not a normal finish
return;
}
delay(50);
}
jumpTo(1);
}
This only works because nextFunction() polls the clock itself between chunks of work — it's a cooperative timeout, not a preemptive one. It still can't rescue you from a call that blocks without ever returning control, like a hung I2C read; firing longjmp from a timer ISR to force that would run straight into the signal-safety problem from earlier.
Takeaways
- Debounce mechanical buttons feeding your interrupt, or you'll get spurious triggers.
- Keep ISRs short. Setting a flag is ideal; heavy logic, register surgery, or
longjmpinside an ISR invites hard-to-debug timing bugs or silently dead interrupts. - Wrap multi-step critical sections in
noInterrupts()/interrupts()if a second interrupt firing mid-update could leave shared state inconsistent. - Stack/return-address hacking is architecture-specific and fragile — AVR, ESP32, and ARM all lay out their stacks differently, and it's very easy to target the wrong registers or leave a frame unbalanced.
setjmp/longjmpis the right tool if you actually need a two-way jump — but keep the jump path free of C++ objects with real destructors, guard against re-entrancy, and never calllongjmpfrom inside an ISR.
For nearly every "I need to bail out of a wait" situation on Arduino, a flag — or a small state machine if you need to skip through several stages — gets you there with far less risk than reaching for the stack. And if you do need a genuine jump-and-return, setjmp/longjmp, built out carefully as above, is the properly-scoped way to get it.

No comments:
Post a Comment