Sep 28, 2026

Where Did My Stack Frame Go? Blocking Tail Calls in Clang and GCC

A crash in g shows a backtrace that jumps straight from main to g, even though main called f and f called g. The debugger is fine. The compiler removed f on purpose.

Tail-call optimization

int g(void);
int f(void) { return g(); }
Calling g is the last thing f does, so f no longer needs its stack frame. At -O2, Clang and GCC compile f to a single jump:
f:
    jmp g
This saves a stack frame and a return. It also removes f from the stack, which breaks: 
  • Backtraces: crash reports and debuggers leave out f. 
  • Profilers: g's time is charged to f's caller. Stack walkers: a logger that skips N frames reports the wrong caller.

Three ways to keep the frame

1. Empty asm volatile after the call (one call site, Clang and GCC)
int f(void) {
  int r = g();
  __asm__ __volatile__("");
  return r;
}
The empty asm produces no instructions. It is volatile, so the compiler can neither delete it nor move it before the call. Because it runs after g() returns, g() is no longer in tail position:
f:
    subq $8, %rsp
    call g
    addq $8, %rsp
    ret
2. __attribute__((disable_tail_calls)) (one function, Clang only)
__attribute__((disable_tail_calls))
int f(void) { return g(); }
This states the intent clearly and covers every call inside f.

3. -fno-optimize-sibling-calls (whole file, Clang and GCC) This flag fits profiling builds. For a single function, it turns off too much. Inlining still removes frames
If the compiler inlines f into its caller, f has no frame to keep. When the frame must exist, also add __attribute__((noinline)).
 

Cost

Each blocked tail call costs a call/ret pair and one stack frame instead of a jmp. This matters only on very hot paths, or in deep recursion that relied on tail calls to avoid overflowing the stack.

Demo

Paste this into Compiler Explorer (godbolt.org) and compile with -O2:
int g(void);
int with_tail_call(void)    { return g(); }
int without_tail_call(void) { int r = g(); __asm__ __volatile__(""); return r; }
with_tail_call compiles to jmp g. without_tail_call compiles to call g followed by ret. 
Verified with Clang 21 and GCC 15 on x86-64.

Takeaway

  • One call site: add __asm__ __volatile__("") after the call.
  • One function (Clang): use __attribute__((disable_tail_calls)).
  • A whole file: compile with -fno-optimize-sibling-calls.
  • To guarantee the frame: also add noinline.

No comments:

Post a Comment

Note: Only a member of this blog may post a comment.