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:
-gembeds debug information (source file names, line numbers, variable info) into the binary — this is what letsobjdump -Sinterleave your original C source alongside the assembly.-O0disables 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.-O0gives 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
-ddisassembles executable sections.-Sinterleaves original source lines (requires-gat 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.%rbpconventionally 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 oneint 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 variablex— 4 bytes below the frame base. This isx’s actual address in memory for the lifetime of this function call — the same kind of address you’ve been taking with&xat the source level.$0x2dis45in hex (0x2d = 45).movl(“move long word”) writes this 4-byte immediate value directly into that memory location — this one instruction isx = 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— loadsx’s value (45) from memory back into the%eaxregister. (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-O2optimization would eliminate; at-O0the compiler doesn’t bother.)mov %eax,%esi— movesx’s value into%esi, which is the register for the second argument toprintf. (printf’s arguments are the format string, thenx— soxis argument #2.)lea 0xe27(%rip),%rax—lea= “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>isobjdumphelpfully 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%eaxto0. This looks unrelated to your code, but it’s part of the x86-64 calling convention specifically for variadic functions likeprintf:%eaxmust hold the number of vector/floating-point registers used for the call (0here, 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 becauseprintflives in the dynamically-linked C library (libc), not in this binary itself; the PLT resolves the real address ofprintfat load time (or on first call).
print_binary((unsigned int) x);
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
xfrom 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-byteintas a 4-byteunsigned intinvolves 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@plt—print_binaryis 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 howmain’sreturn 0becomes the process’s exit status.leave— a shorthand instruction that undoes the prologue in one step: restores%rspfrom%rbp(deallocating the stack frame) and pops the saved%rbpback (undoing the very firstpush %rbp). This is the function epilogue.ret— pops the return address (pushed automatically by thecallinstruction 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)—xarrived in%ediper the calling convention (it’s the argument register). This instruction immediately copies it out intox’s own stack slot, at-0x14(%rbp). This is standard-O0behavior: 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 isi = sizeof(x) * CHAR_BIT - 1, fully computed at compile time.0x1f=31decimal. The compiler already knowssizeof(unsigned int)is 4 andCHAR_BITis 8, so4 * 8 - 1 = 31— no runtime multiplication ever happens; the compiler folded the whole expression into a single constant.ilives at-0x4(%rbp), a separate stack slot fromx’s-0x14(%rbp).jmp 11ab— jumps straight down to the loop’s condition check (address0x11ab, shown further below), skipping the loop body on this first pass through. This is a very characteristic pattern for compiledforloops: 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 yourfor(init; cond; incr) bodyinto 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— loadiinto%eax.mov -0x14(%rbp),%edx— loadxinto%edx.mov %eax,%ecx— copyiinto%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%clregister (the low byte of%ecx) — there’s no shift-by-any-register form. The compiler is forced to shuffleiinto%ecxpurely to satisfy this instruction encoding rule.shr %cl,%edx— the actual shift!shr= shift right, logical (zero-fill) — matchesunsigned int x, confirming exactly the logical-vs-arithmetic distinction from your earlier question: becausexisunsigned, the compiler emitsshr, notsar. This computesx >> 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%eaxwith 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 lasttestfound zero) — jump down to the'0'branch. This is the compiled form of your ternary’sfalsepath.mov $0x31,%eax/jmp 11a0—0x31is ASCII'1'(decimal 49). This is the ternary’struebranch: load the character'1', then jump past the other branch.mov $0x30,%eax(at label119b) —0x30is ASCII'0'(decimal 48) — thefalsebranch’s value.mov %eax,%edi/call 1060 <putchar@plt>— same calling convention pattern as before: put the argument (whichever character was selected) into%edi, callputcharthrough the PLT (dynamically linked, likeprintf).
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 fromi’s stack slot).cmpl $0x0,-0x4(%rbp)— comparesiagainst0(sets flags based oni - 0, without storing the subtraction result anywhere — same “compute flags only” idea astestabove).jns 1181— “jump if not sign” — i.e., jump back up to the loop body (address0x1181) if the comparison result was non-negative, meaningi >= 0still holds. This single instruction is the entire compiled form of youri >= 0loop condition. Onceibecomes-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,%edi—0x0ais 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>— sameputcharcall 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 asmain.
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 - 1never executes as a multiplication and subtraction at runtime — it’s baked into a singlemovl $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 formain(in this binary) to callprintf(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(notsar) appears becausexis declaredunsigned— direct, physical confirmation of the logical-vs-arithmetic shift discussion: changeunsigned int xtoint xin the function signature and recompile, and you’d seesaremitted instead for the exact same source-level>>operator.-O0is verbose and redundant on purpose — notice how often a value gets stored to memory and immediately reloaded (e.g.xis 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-O0is the right choice for a tutorial like this one, and-O2is 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.