Home Architecture

What Happens During a Function Call: Stack Frames, Calling Conventions, and Return Addresses

Architecture

September 20, 2026

What Happens During a Function Call: Stack Frames, Calling Conventions, and Return Addresses

Type foo(a, b) and something more elaborate than it looks takes place. The compiler has to decide where a and b live, arrange for foo to find its way back, reserve space for its locals, and make sure no register the callee clobbers destroys something the caller still needs. Almost all of that happens in a handful of instructions you never wrote. Here is the machinery, on x86-64 and ARM64.

The Stack, Briefly

The stack is a contiguous run of memory that both architectures treat as growing downward, toward lower addresses. A register tracks its top: rsp on x86-64, sp on ARM64. On x86 you get dedicated push and pop instructions that move the pointer and touch memory in one step. ARM has no push; instead you use an ordinary store with pre-decrement writeback, like str x0, [sp, #-16]!. Same result, different spelling.

Everything else — frames, argument passing, saved registers — is a convention layered on top of that pointer. The hardware only knows how to move it.

Anatomy of a Stack Frame

A frame is the slice of stack one function owns: the arguments that didn't fit in registers, the return address, saved registers, spilled temporaries, and any local arrays. Its exact shape depends on the architecture.

x86-64

When call foo executes, the processor pushes the address of the next instruction and jumps. So on entry to foo, the return address sits at the top of the stack. A typical unoptimised prologue then does this:

push rbp
mov rbp, rsp
sub rsp, 32

Now rbp is a stable anchor that stays put while rsp moves around, which is convenient for debuggers and for addressing locals. From high addresses to low, the frame reads: the caller's frame, any stack-passed arguments, the return address, the saved rbp, then the locals. The epilogue reverses it — leave expands to mov rsp, rbp followed by pop rbp — and then ret pops the return address and jumps.

ARM64

ARM has no call instruction. bl foo writes the return address into the link register x30 and branches. A leaf function that calls nothing else can return with ret and never touch the stack at all. A function that does make calls saves the pair first:

stp x29, x30, [sp, #-16]!
mov x29, sp

That single store-pair instruction decrements sp by 16, stores the frame pointer and link register, and writes the new pointer back. The epilogue is ldp x29, x30, [sp], #16 followed by ret.

This distinction is worth internalising: on x86-64 the return address is memory from the first instant, while on ARM64 it is a register that happens to get spilled.

Calling Conventions

A calling convention — formally, part of an ABI — answers three questions: which registers carry arguments, which carry the result, and which the callee must restore before returning.

System V AMD64 (Linux, macOS, BSD):

  • Integer and pointer arguments go in rdi, rsi, rdx, rcx, r8, r9, then on the stack.
  • Floating-point arguments go in xmm0 through xmm7.
  • The result comes back in rax, or rax and rdx for a 128-bit value, or xmm0 for a float.
  • Caller-saved: rax, rcx, rdx, rsi, rdi, r8 through r11.
  • Callee-saved: rbx, rbp, r12 through r15, and rsp.

AAPCS64 (ARM64 Linux, macOS, Windows on ARM):

  • Arguments go in x0 through x7, then on the stack.
  • Floating-point arguments go in v0 through v7.
  • The result comes back in x0, or v0 for floating point.
  • Caller-saved: x0 through x17, plus v16 through v31.
  • Callee-saved: x19 through x28, plus x29 and x30. The low 64 bits of v8 through v15 must also survive a call.

Two details trip people up. First, x86-64 on Windows is different: the first four arguments go in rcx, rdx, r8, r9, and the caller must reserve 32 bytes of shadow space above the return address. Second, structures larger than a couple of registers are not returned in registers at all — the caller passes a hidden pointer to space it owns, and the callee fills it in. On ARM64 that hidden pointer arrives in x8.

Return Addresses and Their Consequences

On x86-64 the return address lives on the stack, a few bytes above your local buffer. A buffer overflow that writes past a local array can overwrite exactly that slot, and the function's own ret will jump wherever the overwritten value points. That single fact explains a whole family of exploits and the defences built against them: stack canaries that the compiler stores between locals and the return address and checks before returning, non-executable stack pages, and address-space randomisation.

On ARM64 the return address sits in x30 until it is spilled, which means a pure local-buffer overflow is less immediately fatal. But non-leaf functions nearly always spill it, and it is usually the very next thing after the frame pointer on the stack.

Reading a Real Prologue

The fastest way to make this concrete is to look at generated code. A short routine:

  1. Compile with debug info and frame pointers intact: gcc -O0 -g -fno-omit-frame-pointer -o demo demo.c.
  2. Disassemble with objdump -d -Mintel demo, or ask the compiler directly with gcc -S -masm=intel demo.c.
  3. In gdb, set a breakpoint with break foo, run, then inspect info registers rsp rbp and info frame.
  4. Dump the raw stack with x/8gx $rsp and compare what you see against the layout above.
  5. Step instruction by instruction with si and watch rsp move through the prologue.
  6. On an ARM64 board or under emulation, the same commands use sp, x29, and x30.

When Frames Disappear

At -O2, the frame pointer is usually gone; rbp becomes just another general-purpose register and locals are addressed relative to rsp. Backtraces then depend on DWARF call-frame information in .eh_frame rather than a linked list of saved frame pointers. Inlining and tail calls flatten frames further, so a backtrace can show fewer levels than the source suggests. Tools that need fast, dependable unwinding — sampling profilers in particular — often ask for -fno-omit-frame-pointer for exactly this reason. The System V ABI also grants a 128-byte red zone below rsp that leaf functions may use without adjusting the pointer; signal handlers and interrupts must respect it.

Putting It to Work

Three habits pay off. Keep the 16-byte alignment rule in mind: rsp must be 16-byte aligned immediately before a call, so it is 8 mod 16 at function entry. If hand-written assembly crashes inside printf on an SSE instruction, misalignment is the first thing to check. Respect callee-saved registers in your own assembly, or a caller will read back garbage it never expected to change. And when a backtrace looks nonsensical, inspect

Photo: Marta Branco / Pexels

Related Posts

Static vs Dynamic Binary Analysis: Which Should You Use First?
Reverse Engineering

October 12, 2026

Static vs Dynamic Binary Analysis: Which Should You Use First?

Static analysis maps what a binary could do; dynamic analysis shows what it actually does. Here is how to choose your starting workflow...

read more
Embedded Debugging Checklist: Logic Analyzer, SWD, and UART Before You Rewrite Code
Embedded Systems

October 08, 2026

Embedded Debugging Checklist: Logic Analyzer, SWD, and UART Before You Rewrite Code

A hardware-first checklist for embedded targets: verify power, clock, UART, and SWD before assuming your firmware is at fault.

read more
From Source to Silicon: A Beginner's Guide to the Compiler, Assembler, and Linker Pipeline
Architecture

October 03, 2026

From Source to Silicon: A Beginner's Guide to the Compiler, Assembler, and Linker Pipeline

Follow a C file through the preprocessor, compiler, assembler, linker, and loader, and learn the commands that leave every intermediate...

read more