Part 2 · The calling contract
Reading a call chain
Reconstructing what the source said from a listing with no symbols — and noticing when the compiler deleted the calls entirely.
What this lesson is trying to break (2)
- Every function in the source appears in the output.
- A call in the listing corresponds to a call in the source.
Part 2 gave you the contract. This lesson uses it to read something you have no source for — which is the actual situation in a profiler or a crash dump.
Reconstructing a call
The convention runs both ways. If you know arguments go in rdi, rsi, rdx, …,
then a cluster of writes to those registers before a call is the argument
list.
mov esi, 10
mov rdi, rbx
call process
mov r12, rax
Read it as r12 = process(rbx, 10). That is a complete recovery of a source
line, from four instructions and one symbol name.
Do this repeatedly and you rebuild the call graph, which is usually what you actually want — far more than understanding every instruction.
static int helper(int x) { return x * 2 + 1; }
int outer(int a, int b) {
return helper(a) + helper(b);
}Two calls in the source. How many call instructions at -O2?
Zero.
helper is small and static, so the compiler inlines both calls, then folds the
arithmetic. You get something like a couple of leas and an add — and
helper may not exist in the binary at all.
This is the most important thing to internalise about reading optimised output:
the source call graph and the binary call graph are different graphs. A
function you are looking for may have been dissolved into its callers. A profiler
attributing all the time to outer is not lying to you; helper genuinely is not
there.
The practical consequence for profiling: if a hot function seems to be missing,
suspect inlining before suspecting the tool. Compiling with
-fno-inline temporarily is a legitimate diagnostic technique — it makes the
graph readable at the cost of the performance you were measuring, which is a
trade you make knowingly.
Walking one chain
Step 1 — establish the frame and what survives
Read the prologue first, not the body.
Callee-saved pushes tell you how much state crosses calls
(2.4). sub rsp, N tells you the frame size. On
ARM64, whether x30 was saved tells you immediately whether this function calls
anything.
From the prologue alone you can usually predict: this function makes calls, holds about three values across them, and has a small frame.
Step 2 — find every call and its arguments
Search for call. For each one, read the ~4 instructions above it as the argument
list, and the instruction below it as what happened to the result.
Where does each argument come from? An argument register means it was passed through from this function’s own parameters. A callee-saved register means it was computed earlier and preserved. A stack slot means it was spilled or is a local whose address matters.
That sourcing information is often the interesting part — it tells you the data flow, not just the control flow.
Step 3 — check for tail calls, which hide in plain sight
A jmp to a function symbol, rather than a call, is a tail call: the call
was the last thing the function did, so the compiler reused the frame and jumped.
Two consequences worth knowing:
- No stack growth. Tail-recursive code compiles to a loop and cannot overflow.
- The frame disappears from stack traces. A tail-called function appears to have been called by your caller, because your frame is gone. This makes perfectly correct traces look wrong, and it is a common source of confusion when reading profiles of functional-style or heavily-optimised code.
Interleaved practice
mov edx, 3
mov rsi, r12
mov rdi, rbx
call lookup
test rax, rax
je .LnotfoundRoughly:
p = lookup(rbx_value, r12_value, 3);
if (p == NULL) goto notfound;Three arguments in rdi, rsi, edx. The result in rax is immediately
null-checked with the test rax, rax idiom
(1.4), so it is a pointer.
The fact that the first two arguments came from callee-saved registers tells you they were computed before some earlier call and deliberately preserved.
add rsp, 24
pop rbx
jmp format_errorA tail call, and an unusually clear one: the function fully tears down its
frame — freeing locals and restoring rbx — and then jumps.
So format_error runs with this function’s frame already gone, and will return
directly to this function’s caller. In a stack trace, format_error will appear
to have been called by whoever called us.
This is the error-path idiom: do the cheap work inline, delegate the expensive formatting to a cold function, and do not pay for a frame on the way.
Part 2 checkpoint
- In what sense is the stack a convention? (2.1)
- What does
callactually do? (2.1) - Which registers carry the first three integer arguments on SysV? (2.2)
- What happens if code violates the ABI? (2.2)
- Is the frame pointer required, and what does omitting it cost? (2.3)
- Why are argument registers caller-saved a problem, and how is it solved? (2.4)
- Why might a source function have no call in the binary? (2.5)
You can now read a function and a call chain. Part 3 is about the fact that what you are reading often bears no structural resemblance to the source — and that is the part of this course with the most direct daily use.