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:
- 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.
- 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:
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:
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 = 14And inside add2:
add2:
lea eax, [rdi+rsi] ; eax = edi + esi — no stack touch at all
ret ; pop return address, jump backLook 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.
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:
┌─────────────────────┐ lower addresses
│ local variables │
├─────────────────────┤
│ saved rbp │ ← rbp (frame pointer, if used)
├─────────────────────┤
│ return address │ ← pushed by the `call` instruction
├─────────────────────┤
│ caller's locals │
└─────────────────────┘ higher addressesThe 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:
; return add2(x, y);
call add2 ; reuse caller's stack entirely
ret ; return straight to the original callerThe recursion subclass of this is tail recursion:
defp count(list, acc) when list == [], do: acc
defp count([_ | rest], acc) do
count(rest, acc + 1) # tail position — no growth
endLanguages 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.