The Runtime Theory
Runtime & Execution

How a Function Call Really Works

Stack frames, registers, calling conventions, and tail calls — what the machine does between the opening brace and the first line of your function.

The Runtime Theory Team3 min read#stack#calling-conventions#assembly#performance
On this page

When you write a function call, something genuinely precise happens on the hardware. Not "the runtime handles it" — this happens, in this order, for this reason. This article walks through a call the way a debugger does, so that every performance discussion you've ever been in (or dodged) starts to make sense.

The contract that makes calls possible

A function call is a jump with bookkeeping attached. The callee needs to know:

  • where the arguments are,
  • where to write the result,
  • and how to get back to the caller.

The machine solves this with two contracts, layered on top of each other:

  1. The ABI (Application Binary Interface) — who owns which registers, who cleans up the stack. This is a platform contract, the same for every compiler on that platform.
  2. The stack discipline — the convention that function activations nest like parentheses, and finish in the reverse order they started.

Without the ABI, two compilers couldn't interoperate and every library boundary would be a lie. Without stack discipline, C's "local variables are destroyed when the function returns" would be a lie — and with it, recursion, formal languages, and most of computing just work.

The call, instruction by instruction

Consider this function compiled for x86-64:

c
long add2(long a, long b) {
    return a + b;
}
 
long caller() {
    return add2(3, 4) * 2;
}

Under the System V AMD64 ABI (Linux, macOS on x86), the call compiles to roughly:

asm
mov    edi, 3        ; 1st integer arg -> rdi
mov    esi, 4        ; 2nd integer arg -> rsi
call   add2          ; push return address, jump to add2
add    eax, eax      ; result in rax/eax: 7 + 7 = 14

And inside add2:

asm
add2:
    lea    eax, [rdi+rsi]   ; eax = edi + esi — no stack touch at all
    ret                    ; pop return address, jump back

Look at what didn't happen: no push of arguments, no stack frame allocated. Six integer registers (rdi, rsi, rdx, rcx, r8, r9) are used for the first six arguments; only when there are more arguments (or floating point needs xmm registers) does the stack come in.

js
function add2(a, b) {
    return a + b;
}

That JavaScript, under V8, will eventually be JIT-compiled to a very similar shape: registers for small integers, stack beyond that. The language doesn't get to opt out of the ABI — the VM interprets the same contract on your behalf.

The stack frame in detail

When a function needs more than the registers (locals, spills, calls of its own), the ABI's stack discipline kicks in. The frame looks like this, growing downward:

text
        ┌─────────────────────┐  lower addresses
        │  local variables    │
        ├─────────────────────┤
        │  saved rbp          │  ← rbp (frame pointer, if used)
        ├─────────────────────┤
        │  return address     │  ← pushed by the `call` instruction
        ├─────────────────────┤
        │  caller's locals    │
        └─────────────────────┘  higher addresses

The call instruction does two things atomically: it pushes the address of the next instruction (the return address), then jumps to the callee. The ret instruction pops that address and jumps to it. That pairing — call/ret, each other's inverse — is the entire bookkeeping skeleton of every program you've ever written.

Where the hidden costs live

Three costs of a function call are invisible in source code:

1. The frame itself. Every call that spills to the stack pushes state. In tight loops, this is why compilers inline aggressively — an inlined call has zero frame cost.

2. The return-address prediction. Modern CPUs predict which branch to take next; function returns are predicted with a specialized "return stack buffer". A mispredicted return costs 15–20 cycles — more than the call itself. Deep chains of indirect calls (virtual methods, function pointers) are where this bites.

3. The frame pointer inference. On modern ABIs, the compiler can skip maintaining rbp entirely (frame-pointer omission). That saves two instructions per call, and it's why stripped stack traces are so much harder to read.

Tail calls: the call that doesn't happen

One call form has none of these costs. If the very last action of a function is to return the result of another call, the caller's frame can be discarded before the call — because nothing in it will be needed again:

asm
; return add2(x, y);
call   add2          ; reuse caller's stack entirely
ret                  ; return straight to the original caller

The recursion subclass of this is tail recursion:

elixir
defp count(list, acc) when list == [], do: acc
 
defp count([_ | rest], acc) do
  count(rest, acc + 1)   # tail position — no growth
end

Languages that guarantee this (Scheme, Elixir, the Erlang VM, most functional platforms) let you recurse millions of levels deep. Languages that don't (C, Python, JavaScript, unless the engine optimizes it) recurse until the stack runs out. That is an ABI-shaped decision, not an academic one — and it's why "optimize with recursion" is always followed by "check your language's tail-call guarantees first."

What you actually learn from this

  • Scaling arguments and dimensions of latency rely on the frame cost: function calls are not free, they're just cheap — ~1–5 cycles when predicted, 15–20 when not.
  • Inlining, tail calls, and small functions exist because the hardware made them valuable.
  • "It's just a function call" is never a complete sentence about performance; the ABI prices them, and the pipeline makes them interesting.

The next time a profiler points at a function that "does nothing", zoom in: it might be doing nothing extremely expensively.