5.3.17 · D2Build Systems & Toolchain

Visual walkthrough — Disassembly — objdump, reading assembly

2,379 words11 min readBack to topic

We will use exactly one function the whole way:

int add(int a, int b) {
    return a + b;
}

Everything below is built from zero — we define every box, arrow, and word before using it.


Step 1 — The two languages, and the bridge between them

WHAT. A .c file is text a human types. The CPU cannot read text; it reads machine code — a stream of numbers. In between sits the assembler: a fixed rulebook that turns short word-like names (mnemonics) into those numbers. Disassembly walks that rulebook backwards.

WHY. Before we can read a single assembly line we must agree on what a line even is: it is one row in a three-way translation. If we skip this, every later symbol (rbp, edi, 0x8) floats with no home.

PICTURE. Look at Step-1 figure. The orange arrow (left→right) is what the compiler + assembler does. The teal arrow (right→left) is what objdump does. The middle column — the numbers 55 48 89 e5 ... — is the only thing the CPU ever sees. Assembly (the right column) is a human costume worn over those exact bytes.

Figure — Disassembly — objdump, reading assembly

Step 2 — The CPU's tiny boxes: registers

WHAT. A register is a small named box inside the CPU that holds one number. It is not in RAM; it is right where the arithmetic happens, so it is the fastest storage there is. On x86-64 the boxes we care about here are rax, rbp, rsp, rdi, rsi, rdx.

WHY. Our function must add a + b. Addition happens between registers. So before values can be added, they must first arrive in registers. Every line of our assembly is really about moving numbers into and between these boxes. You cannot read the derivation without a mental picture of the boxes — so here they are, drawn.

PICTURE. Step-2 figure shows six boxes. Note the nesting: eax is the lower 32 bits of the 64-bit rax. The names r.. (64-bit) and e.. (32-bit) are the same box viewed at two widths. Because our int is 32 bits, the code will use the e-named views.

Figure — Disassembly — objdump, reading assembly

Step 3 — The contract: where do a and b arrive?

WHAT. When something calls add(3, 4), where does the 3 go, and where does the 4 go? This is not a choice the compiler makes freely — it obeys a calling convention, a fixed agreement so caller and callee understand each other.

WHY. Without an agreed rule, the caller might leave arguments in rdi while the function looks for them in rsi — chaos. The rule (System V x86-64, used on Linux/macOS) says: integer arg 1 → rdi, arg 2 → rsi, and the return value comes back in rax. We must know this before line 4 of the disassembly makes sense.

PICTURE. Step-3 figure: two incoming values slide into edi and esi (the arg slots), and a dashed teal arrow shows the result later leaving through eax. Learn this shape — it is the same for every function you will ever disassemble.

Figure — Disassembly — objdump, reading assembly

Step 4 — The prologue: carving out a frame

WHAT. The first two instructions are always the same at -O0:

0:  55           push   rbp
1:  48 89 e5     mov    rbp, rsp

WHY. A function needs a stable "home base" address to find its own local variables, even as the stack grows and shrinks. rbp (base pointer) is that home base. But rbp currently holds the caller's home base, so we (1) save it by pushing it onto the stack, then (2) copy the current stack top (rsp) into rbp to make a fresh base. This pair is the function prologue.

PICTURE. Step-4 figure: the stack drawn as a downward stack of boxes (addresses shrink going up — that is the x86 convention). push rbp writes the old base and drops rsp by 8 bytes; then rbp is planted at the new top. Term-by-term:

Figure — Disassembly — objdump, reading assembly

Step 5 — Spilling arguments to the stack

WHAT.

4:  89 7d fc     mov    [rbp-0x4], edi   ; store a
7:  89 75 f8     mov    [rbp-0x8], esi   ; store b

WHY. At -O0 ("no optimization") the compiler is deliberately literal: it gives every variable a real home in memory so a debugger can always find it. So it copies a (in edi) and b (in esi) out of registers into stack slots. This copying-to-stack is called a spill.

PICTURE. Step-5 figure: rbp is the anchor at offset 0; [rbp-0x4] is 4 bytes below it (one int), [rbp-0x8] is 8 bytes below (the next int). The square brackets mean "the memory at this address", not the address itself. Reading the operand:

Figure — Disassembly — objdump, reading assembly

Step 6 — Load back and add

WHAT.

a:  8b 55 fc     mov    edx, [rbp-0x4]   ; edx = a
d:  8b 45 f8     mov    eax, [rbp-0x8]   ; eax = b
10: 01 d0        add    eax, edx         ; eax = eax + edx = b + a

WHY. Arithmetic works register-to-register, so the just-spilled values must be loaded back into registers. The compiler pulls a into edx and b into eax, then adds them. It chose eax for b on purpose: the sum lands in eax, which — from Step 3 — is exactly where the return value belongs. So no extra move is needed afterward.

PICTURE. Step-6 figure: two loads (memory → register, teal arrows) then one add that folds edx into eax (orange). Term-by-term on the add:

Figure — Disassembly — objdump, reading assembly

Step 7 — Epilogue: tear down and return

WHAT.

12: 5d           pop    rbp   ; restore caller's base
13: c3           ret          ; jump back to caller

WHY. We borrowed rbp; we must give the caller theirs back. pop rbp reads the value we saved in Step 4 and bumps rsp back up — undoing the prologue exactly. Then ret pops the return address (put on the stack by the original call) and jumps to it. The answer is already sitting in eax, honoring the contract from Step 3.

PICTURE. Step-7 figure: the stack un-winds — rsp climbs back to where it started, rbp returns to the caller's base, and control flies back along the teal arrow with eax carrying the result.

Figure — Disassembly — objdump, reading assembly

Step 8 — The degenerate/optimized case: it all collapses

WHAT. Recompile with -O2 and the entire eight-instruction story shrinks to two:

0:  8d 04 37     lea    eax, [rdi+rsi]
3:  c3           ret

WHY. With optimization on, the compiler proves the local variables and stack frame are never needed: nothing is debugged, nothing outlives the call. So it deletes the prologue, the spills, the reloads, and the epilogue. It then uses lea — an instruction built to compute an address like rdi + rsi — as a sneaky one-shot adder that writes straight into eax. This is the edge case you must recognize: the same C, radically different bytes.

PICTURE. Step-8 figure: the tall -O0 tower on the left, an arrow labelled "optimizer", and the tiny two-line -O2 result on the right — with dotted crossings-out over everything that got removed.

Figure — Disassembly — objdump, reading assembly

The one-picture summary

Everything above, on one canvas: C on the far left, the two register arg-slots feeding in, the -O0 shuffle through the stack, the value landing in eax, and the -O2 shortcut drawn as a dotted express lane that skips the whole detour.

Figure — Disassembly — objdump, reading assembly
Recall Feynman retelling — the whole walkthrough in plain words

Two numbers show up at the front door in two labelled cubbies called edi and esi. The function first nails a little signpost (rbp) into the ground so it can find its own stuff, then — because we told the compiler don't be clever (-O0) — it carefully copies both numbers into two floor tiles just below the signpost. A moment later it picks them right back up, one into edx and one into eax, and adds them. It deliberately used eax for the answer because eax is the "outbox" everyone reads when the function is done. Finally it pulls up the signpost, puts the ground back the way it found it, and jumps home carrying the answer in eax. When we flip the switch to be clever (-O2), the compiler realizes all that copying was pointless, throws it away, and adds the two cubbies straight into the outbox with a single address-math trick (lea). Same answer, one-quarter the instructions.


Connections

  • Disassembly — objdump, reading assembly (index 5.3.17) — the parent; this page derives its central example from zero.
  • Calling Conventions (System V x86-64) — Step 3's edi/esi/eax contract.
  • Stack Frames & The Stack Pointer — Steps 4–7 (prologue, spills, epilogue).
  • Compiler Optimization Levels — Step 8's -O0 vs -O2 collapse.
  • Compilation Pipeline — the forward arrow disassembly reverses.
  • Debugging with GDB — why -O0 keeps everything on the stack.
  • ELF Object Files & Sections — the .text section these bytes live in.
  • CISC vs RISC — why x86 bytes are variable length and lea can do math.