Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Tutorial: Reading objdump Output, Line by Line

We’ll write a tiny C program, compile it, and disassemble it — then go through the actual x86-64 assembly the compiler produced, instruction by instruction.

The program

#include <stdio.h>
#include <limits.h>

void print_binary(unsigned int x)
{
    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
        putchar((x >> i) & 1 ? '1' : '0');
    putchar('\n');
}

int main(void)
{
    int x = 45;
    printf("%d\n", x);
    print_binary((unsigned int) x);
    return 0;
}

Save this as example.c.

Compiling with debug info, no optimization

gcc -g -O0 -o example example.c

Two flags matter a lot here:

  • -g embeds debug information (source file names, line numbers, variable info) into the binary — this is what lets objdump -S interleave your original C source alongside the assembly.
  • -O0 disables optimization. This is deliberate for learning: an optimized build (-O2) would reorder, merge, and eliminate instructions aggressively, making the assembly much harder to map back to your source line-by-line. -O0 gives you a close-to-literal, unoptimized translation — useful for understanding, not for shipping.

Disassembling

objdump -dS --disassemble=main example
objdump -dS --disassemble=print_binary example
  • -d disassembles executable sections.
  • -S interleaves original source lines (requires -g at compile time).
  • --disassemble=<function> restricts output to just that one function, instead of dumping the entire binary.

Part 1: main, line by line

00000000000011be <main>:

The label. main starts at address 0x11be within this binary.

Function prologue

int main(void)
{
    11be:	f3 0f 1e fa          	endbr64
    11c2:	55                   	push   %rbp
    11c3:	48 89 e5             	mov    %rsp,%rbp
    11c6:	48 83 ec 10          	sub    $0x10,%rsp

This block is boilerplate that appears at the start of nearly every function compiled with -O0 — the function prologue. It sets up this function’s own private workspace (its stack frame).

  • endbr64 — a CPU security feature (Intel CET / “Control-flow Enforcement Technology”), marking this as a valid landing point for indirect jumps/calls. Not something you write yourself; the compiler inserts it automatically as a defense against certain exploit techniques. Safe to mentally skip past for understanding program logic.
  • push %rbp — saves the caller’s base pointer (%rbp) onto the stack, so it can be restored when this function returns. %rbp conventionally points at the base of the current function’s stack frame — this instruction preserves whoever was using it before us.
  • mov %rsp,%rbp — copies the current stack pointer (%rsp) into %rbp, establishing this function’s own frame base. From here on, local variables are addressed as offsets from %rbp.
  • sub $0x10,%rsp — moves the stack pointer down by 16 bytes, reserving 16 bytes of scratch space on the stack for this function’s local variables (here, just the one int x, though the compiler reserved more than the strict minimum — normal at -O0, and often related to stack alignment requirements).

This is the concrete, physical version of the abstract “stack frame” / “activation record” concept from earlier in this series of questions — you’re looking at the actual instructions that create one.

int x = 45;

    int x = 45;
    11ca:	c7 45 fc 2d 00 00 00 	movl   $0x2d,-0x4(%rbp)
  • -0x4(%rbp) is the memory location the compiler chose for the local variable x — 4 bytes below the frame base. This is x’s actual address in memory for the lifetime of this function call — the same kind of address you’ve been taking with &x at the source level.
  • $0x2d is 45 in hex (0x2d = 45). movl (“move long word”) writes this 4-byte immediate value directly into that memory location — this one instruction is x = 45.

printf("%d\n", x);

    printf("%d\n", x);
    11d1:	8b 45 fc             	mov    -0x4(%rbp),%eax
    11d4:	89 c6                	mov    %eax,%esi
    11d6:	48 8d 05 27 0e 00 00 	lea    0xe27(%rip),%rax        # 2004 <_IO_stdin_used+0x4>
    11dd:	48 89 c7             	mov    %rax,%rdi
    11e0:	b8 00 00 00 00       	mov    $0x0,%eax
    11e5:	e8 86 fe ff ff       	call   1070 <printf@plt>

This block is entirely about the x86-64 calling convention — the rule for how arguments get passed to a function. On Linux x86-64, the first several integer/pointer arguments go in specific registers, in order: %rdi, %rsi, %rdx, %rcx, %r8, %r9.

  • mov -0x4(%rbp),%eax — loads x’s value (45) from memory back into the %eax register. (The compiler stored it to memory a moment ago and now has to reload it — this kind of redundant memory round-trip is exactly the sort of thing -O2 optimization would eliminate; at -O0 the compiler doesn’t bother.)
  • mov %eax,%esi — moves x’s value into %esi, which is the register for the second argument to printf. (printf’s arguments are the format string, then x — so x is argument #2.)
  • lea 0xe27(%rip),%raxlea = “load effective address.” This computes the address of the string literal "%d\n" (stored elsewhere in the binary, referenced here relative to the current instruction pointer, %rip) and puts that address into %rax. The comment # 2004 <_IO_stdin_used+0x4> is objdump helpfully telling you the resolved absolute address and what section it falls in.
  • mov %rax,%rdi — moves that string address into %rdi, the register for the first argument (the format string).
  • mov $0x0,%eax — sets %eax to 0. This looks unrelated to your code, but it’s part of the x86-64 calling convention specifically for variadic functions like printf: %eax must hold the number of vector/floating-point registers used for the call (0 here, since we’re passing plain integers, no floats).
  • call 1070 <printf@plt> — the actual call. <printf@plt> means this jumps through the PLT (Procedure Linkage Table) — a layer of indirection used because printf lives in the dynamically-linked C library (libc), not in this binary itself; the PLT resolves the real address of printf at load time (or on first call).
    print_binary((unsigned int) x);
    11ea:	8b 45 fc             	mov    -0x4(%rbp),%eax
    11ed:	89 c7                	mov    %eax,%edi
    11ef:	e8 75 ff ff ff       	call   1169 <print_binary>
  • Reload x from memory into %eax.
  • Move it into %edi — the register for the first argument. Note: the (unsigned int) cast in your source doesn’t generate any actual instruction here — reinterpreting a 4-byte int as a 4-byte unsigned int involves no bit-pattern change at all, only a change in how later instructions interpret the same bits (recall the earlier discussion on reinterpreting bytes via pointer casts — this is the same idea: the cast is purely a compile-time typing decision, with zero runtime cost here).
  • call 1169 <print_binary> — this time, no @pltprint_binary is defined right here in this same binary, so the call target is a direct, known address (0x1169), no dynamic-linking indirection needed.

return 0; and epilogue

    return 0;
    11f4:	b8 00 00 00 00       	mov    $0x0,%eax
}
    11f9:	c9                   	leave
    11fa:	c3                   	ret
  • mov $0x0,%eax — the x86-64 convention for returning an integer value is: put it in %eax. This is exactly how main’s return 0 becomes the process’s exit status.
  • leave — a shorthand instruction that undoes the prologue in one step: restores %rsp from %rbp (deallocating the stack frame) and pops the saved %rbp back (undoing the very first push %rbp). This is the function epilogue.
  • ret — pops the return address (pushed automatically by the call instruction that got us here) and jumps back to the caller.

Part 2: print_binary, line by line

This is the more interesting one — it’s a real loop with a real branch, so you get to see how C control flow actually becomes machine code.

0000000000001169 <print_binary>:
void print_binary(unsigned int x)
{
    1169:	f3 0f 1e fa          	endbr64
    116d:	55                   	push   %rbp
    116e:	48 89 e5             	mov    %rsp,%rbp
    1171:	48 83 ec 20          	sub    $0x20,%rsp
    1175:	89 7d ec             	mov    %edi,-0x14(%rbp)

Same prologue pattern as before, plus one new instruction:

  • mov %edi,-0x14(%rbp)x arrived in %edi per the calling convention (it’s the argument register). This instruction immediately copies it out into x’s own stack slot, at -0x14(%rbp). This is standard -O0 behavior: even a parameter that arrived in a register gets spilled to memory right away, purely so the rest of the function can treat it uniformly like any other local variable.

The for loop setup

    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
    1178:	c7 45 fc 1f 00 00 00 	movl   $0x1f,-0x4(%rbp)
    117f:	eb 2a                	jmp    11ab <print_binary+0x42>
  • movl $0x1f,-0x4(%rbp) — this is i = sizeof(x) * CHAR_BIT - 1, fully computed at compile time. 0x1f = 31 decimal. The compiler already knows sizeof(unsigned int) is 4 and CHAR_BIT is 8, so 4 * 8 - 1 = 31 — no runtime multiplication ever happens; the compiler folded the whole expression into a single constant. i lives at -0x4(%rbp), a separate stack slot from x’s -0x14(%rbp).
  • jmp 11ab — jumps straight down to the loop’s condition check (address 0x11ab, shown further below), skipping the loop body on this first pass through. This is a very characteristic pattern for compiled for loops: check the condition before the first iteration by jumping to the check first, then looping back up to the body only if the check passes. It restructures your for(init; cond; incr) body into something closer to “init; goto check; body: …; incr; check: if (cond) goto body;” — logically identical to your source, just rearranged so the condition test is a single piece of code reused both for the “should I run at all” question and the “should I loop again” question.

The loop body: putchar((x >> i) & 1 ? '1' : '0');

        putchar((x >> i) & 1 ? '1' : '0');
    1181:	8b 45 fc             	mov    -0x4(%rbp),%eax
    1184:	8b 55 ec             	mov    -0x14(%rbp),%edx
    1187:	89 c1                	mov    %eax,%ecx
    1189:	d3 ea                	shr    %cl,%edx
    118b:	89 d0                	mov    %edx,%eax
    118d:	83 e0 01             	and    $0x1,%eax
    1190:	85 c0                	test   %eax,%eax
    1192:	74 07                	je     119b <print_binary+0x32>
    1194:	b8 31 00 00 00       	mov    $0x31,%eax
    1199:	eb 05                	jmp    11a0 <print_binary+0x37>
    119b:	b8 30 00 00 00       	mov    $0x30,%eax
    11a0:	89 c7                	mov    %eax,%edi
    11a2:	e8 b9 fe ff ff       	call   1060 <putchar@plt>

This is the whole expression (x >> i) & 1 ? '1' : '0', broken into its real, individual pieces — a great example of how much machinery one line of C can expand into:

  • mov -0x4(%rbp),%eax — load i into %eax.
  • mov -0x14(%rbp),%edx — load x into %edx.
  • mov %eax,%ecx — copy i into %ecx. This looks redundant, but it isn’t: x86’s shift instructions have a hardware quirk where the shift amount, when it’s a variable (not a compile-time constant), must be supplied specifically in the %cl register (the low byte of %ecx) — there’s no shift-by-any-register form. The compiler is forced to shuffle i into %ecx purely to satisfy this instruction encoding rule.
  • shr %cl,%edx — the actual shift! shr = shift right, logical (zero-fill) — matches unsigned int x, confirming exactly the logical-vs-arithmetic distinction from your earlier question: because x is unsigned, the compiler emits shr, not sar. This computes x >> i, in place, in %edx.
  • mov %edx,%eax — copy the shift result into %eax.
  • and $0x1,%eax — the & 1 — masks off every bit except the lowest one. This is the literal machine-level version of the bitmask extraction technique from earlier in this conversation.
  • test %eax,%eax — a common idiom for “is this value zero?” — it ANDs %eax with itself (without storing the result anywhere) purely to set the CPU’s internal flags based on the value.
  • je 119b — “jump if equal” (i.e., jump if the last test found zero) — jump down to the '0' branch. This is the compiled form of your ternary’s false path.
  • mov $0x31,%eax / jmp 11a00x31 is ASCII '1' (decimal 49). This is the ternary’s true branch: load the character '1', then jump past the other branch.
  • mov $0x30,%eax (at label 119b) — 0x30 is ASCII '0' (decimal 48) — the false branch’s value.
  • mov %eax,%edi / call 1060 <putchar@plt> — same calling convention pattern as before: put the argument (whichever character was selected) into %edi, call putchar through the PLT (dynamically linked, like printf).

Loop increment and condition check

    for (int i = sizeof(x) * CHAR_BIT - 1; i >= 0; i--)
    11a7:	83 6d fc 01          	subl   $0x1,-0x4(%rbp)
    11ab:	83 7d fc 00          	cmpl   $0x0,-0x4(%rbp)
    11af:	79 d0                	jns    1181 <print_binary+0x18>
  • subl $0x1,-0x4(%rbp)i--, done directly in memory (subtract 1 from i’s stack slot).
  • cmpl $0x0,-0x4(%rbp) — compares i against 0 (sets flags based on i - 0, without storing the subtraction result anywhere — same “compute flags only” idea as test above).
  • jns 1181 — “jump if not sign” — i.e., jump back up to the loop body (address 0x1181) if the comparison result was non-negative, meaning i >= 0 still holds. This single instruction is the entire compiled form of your i >= 0 loop condition. Once i becomes -1, this jump is no longer taken, and execution falls through to what’s next.

putchar('\n'); and epilogue

    putchar('\n');
    11b1:	bf 0a 00 00 00       	mov    $0xa,%edi
    11b6:	e8 a5 fe ff ff       	call   1060 <putchar@plt>
}
    11bb:	90                   	nop
    11bc:	c9                   	leave
    11bd:	c3                   	ret
  • mov $0xa,%edi0x0a is ASCII newline (\n). Loaded directly as a constant — no memory lookup needed, since '\n' is a literal known at compile time.
  • call 1060 <putchar@plt> — same putchar call pattern as inside the loop.
  • nop — “no operation” — does literally nothing. Compilers sometimes insert these for instruction alignment purposes (certain addresses are faster for the CPU to fetch/decode from); not meaningful to your program’s logic.
  • leave / ret — same epilogue pattern as main.

Things worth noticing across both functions

  • Every local variable got its own stack slot, addressed as a negative offset from %rbp — this is the concrete reality behind the abstract idea of “a variable’s address,” which you’ve been reasoning about symbolically (&x) all through this conversation. Here you can see the actual offset the compiler picked.
  • Constant expressions get folded at compile time. sizeof(x) * CHAR_BIT - 1 never executes as a multiplication and subtraction at runtime — it’s baked into a single movl $0x1f, ... instruction. The compiler evaluates anything it can determine ahead of time, once, rather than making the CPU redo it on every run.
  • Function calls follow a strict, mechanical calling convention — arguments in %rdi, %rsi, %rdx, … in order, return value in %eax/%rax. This is exactly what makes it possible for main (in this binary) to call printf (compiled separately, in glibc, decades ago) and have it just work — both sides agree on this convention without ever having seen each other’s source code.
  • shr (not sar) appears because x is declared unsigned — direct, physical confirmation of the logical-vs-arithmetic shift discussion: change unsigned int x to int x in the function signature and recompile, and you’d see sar emitted instead for the exact same source-level >> operator.
  • -O0 is verbose and redundant on purpose — notice how often a value gets stored to memory and immediately reloaded (e.g. x is loaded into %eax, stored, then reloaded again just a few instructions later). A real optimizing build (-O2) would keep such values in registers throughout and eliminate most of this — but that would also make the assembly much harder to map back to individual source lines, which is exactly why -O0 is the right choice for a tutorial like this one, and -O2 is what you’d actually ship.

Try it yourself

# See the difference optimization makes:
gcc -g -O2 -o example_opt example.c
objdump -dS --disassemble=print_binary example_opt

Compare this against the -O0 version above — at -O2, the compiler will likely eliminate most of the memory round-trips, possibly unroll part of the loop, and generally produce something much less line-by-line traceable back to source — a good next step once this version makes sense.