Why read assembly — Compiler Explorer as a daily tool
The compiler is not a black box; it is a machine-code generator with inspectable output. Reading that output answers two daily questions: "what does this code really do?" and "is this likely to be fast?" without folklore. You do not need to write much assembly to gain a lot from reading it. A small amount of fluency turns compiler output into another debugging surface, right next to tests, logs, and a profiler.
The reset: C is not what the CPU runs. C is what the compiler consumes. Assembly is the first readable form of what the CPU will execute.
How it really works
Compiler Explorer is the fastest feedback loop for this skill: paste a small C function, choose a compiler, choose the target and flags, and read the assembly pane. The useful habit is to make the experiment tiny. Change one line, one type, or one flag, then watch the emitted instructions change.
Read a Compiler Explorer pane in this order:
- Compiler and flags first.
gcc -O2 -masm=intelandclang -O0are different experiments. Keep the flags visible when you share a result. - Source-to-assembly coloring. The highlighting maps source lines to instruction ranges. It is not a proof of one-to-one correspondence, but it tells you which C expression caused which region of output.
- Function boundary next. Find the label for the function you care about and ignore unrelated startup, constants, exception tables, and library noise.
- Registers before mnemonics. Identify the incoming argument registers and the outgoing return register first. That anchors the rest of the reading.
- Hot path last. For performance questions, focus on the loop body, branches, loads/stores, calls, and spills. Pretty setup code outside the hot path rarely matters.
This ties directly back to registers & the ISA: the compiler has to express your high-level code using a fixed register set, an instruction set, memory operands, and a calling convention. Reading assembly is reading that translation.
A tiny function, compiled
The executable artifact for this note lives in
examples/assembly-and-compiler-output/why-read-assembly-compiler-explorer/. The core
function is intentionally boring:
long weighted_score(long packets, long cost) {
return packets * 3 + cost * 5 + 7;
}
On this machine, gcc is Apple clang 15 targeting x86-64 Mach-O. This is the relevant
function body copied from the real output of:
gcc -S -O2 -masm=intel demo.c -o demo.s
_weighted_score: ## @weighted_score
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
lea rax, [rdi + 2*rdi]
lea rcx, [rsi + 4*rsi]
add rax, rcx
add rax, 7
pop rbp
ret
.cfi_endproc
Start with the registers, not the cleverness. Under the usual x86-64 C calling
convention on this platform, the first two integer arguments arrive in rdi and
rsi, and the return value leaves in rax. That is the same mental map from
registers & the ISA.
The interesting instructions:
push rbp,mov rbp, rsp,pop rbpare the frame-pointer prologue/epilogue this compiler keeps by default for this target. With different flags, they can disappear.lea rax, [rdi + 2*rdi]computespackets * 3. Hereleais arithmetic; it does not load from memory.lea rcx, [rsi + 4*rsi]computescost * 5into a scratch register.add rax, rcxandadd rax, 7finish the expression.retreturns to the caller, with the answer already inrax.
That is the payoff: the source said "multiply by constants"; the compiler emitted
address-generation arithmetic. You did not need to guess whether this became actual
imul instructions. You looked.
Reproduce it locally
Compiler Explorer is for fast exploration. Your local compiler is the final authority for the binary you are actually shipping.
cd examples/assembly-and-compiler-output/why-read-assembly-compiler-explorer
gcc -O0 -Wall -Wextra demo.c -o demo
./demo
gcc -S -O2 -masm=intel demo.c -o demo.s
clang -S -O2 -arch arm64 demo.c -o demo.arm64.s
Use -S when you want the compiler's assembly file. Use objdump when you already
have an object file or executable and want to disassemble the bytes:
gcc -g -O2 demo.c -o demo
objdump -d demo
objdump -d -S demo
-g asks the compiler to include debug information, and objdump -S uses that
information to intermix source and disassembly when possible. On macOS you may reach
for llvm-objdump or otool -tvV instead; the workflow is the same: compile with
debug info, disassemble the binary, and line up source with instructions.
Two syntax families show up constantly:
| Intel syntax | AT&T syntax | |
|---|---|---|
| Operand order | destination first: add rax, rcx |
source first: addq %rcx, %rax |
| Registers | rax, rdi |
%rax, %rdi |
| Immediates | 7 |
$7 |
| Memory | [rdi + 2*rdi] |
(%rdi,%rdi,2) |
| Size | often inferred from operands | often in mnemonic suffix: q, l, w, b |
This atlas uses Intel syntax for x86-64 because it matches common Compiler Explorer settings and keeps memory operands visually close to the addressing-mode formula.
ARM64 appendix
The same demo.c was also cross-compiled on this machine with:
clang -S -O2 -arch arm64 demo.c -o demo.arm64.s
Relevant function body:
_weighted_score: ; @weighted_score
.cfi_startproc
; %bb.0:
add x8, x0, x0, lsl #1
add x9, x1, x1, lsl #2
add x8, x8, x9
add x0, x8, #7
ret
.cfi_endproc
AArch64 makes the dataflow feel more regular. The first two integer arguments arrive
in x0 and x1; the return value also leaves in x0. add x8, x0, x0, lsl #1
means "add x0 to x0 << 1", so it computes packets * 3. add x9, x1, x1, lsl #2
computes cost * 5. Then the function adds the partial sums, adds 7, and returns.
Notice the differences from x86-64: no rbp/rsp frame setup in this tiny leaf
function, a larger general-purpose register file, shifted-register operands built into
the arithmetic instruction, and x0 serving as both first argument and return value.
The same C expression became different instructions because the target
ISA changed.
Failure modes & trade-offs
- Assembly is not unique. Compiler, compiler version, target triple, ABI, CPU
tuning, and flags all matter.
gcc -O2, Apple clang-O2, and GCC on Linux can all produce different correct answers. - Do not over-read
-O0. Unoptimized output is built for debuggability, not for representing the code the optimizer would choose. It often spills variables to the stack and preserves source-shaped control flow. -O2changes the shape, not the contract. The optimizer can fold constants, reorder work, inline calls, and delete dead code as long as the observable behavior obeys the C abstract machine. If your C has undefined behavior, that contract is already broken.- C++ names are mangled. A C function named
weighted_scorestays recognizable. A C++ function may become a long encoded symbol unless you useextern "C"for the experiment boundary. - Symbols are platform-shaped. Mach-O commonly prefixes C symbols with
_(_weighted_score); ELF/Linux usually showsweighted_score. Learn the convention before assuming you are looking at a different function. - Source mapping needs debug info. Use
-gwithobjdump -Swhen you want source lines next to disassembly. Without debug info, addresses and symbols are often all you get.
In practice
- Keep Compiler Explorer open while designing small C changes. It is especially good for checking integer conversions, structs passed by value, tiny loops, function calls, and whether a helper got inlined.
- Move one variable at a time. If the output changes too much, shrink the example. Assembly is readable when the experiment is small.
- Read registers first. Seeing
rdi/rsi/raxorx0/x1/x0turns noise into a dataflow sketch. - Use local assembly for final claims. Compiler Explorer is a lab bench. The compiler, flags, platform, and binary you ship are the ground truth.
- Connect assembly back to the pipeline.
-Sis just one stop in the path from source to execution; object files, linking, loading, and runtime behavior are the next layers from how source becomes execution.
Connects to: Assembly & Compiler Output · Registers & the ISA · The CPU: fetch–decode–execute · How source becomes execution · C from the Metal
Sources
- Compiler Explorer — the daily tool for comparing source with compiler output across compilers, flags, and targets. https://godbolt.org/
- GCC Manual + GNU Binutils
objdumpmanual —-Sfor compiler-emitted assembly, thenobjdump -d/-Sfor disassembling existing objects and binaries. https://gcc.gnu.org/onlinedocs/gcc/Overall-Options.html · https://sourceware.org/binutils/docs/binutils/objdump.html - System V AMD64 ABI — the register calling convention behind
rdi,rsi, andraxon Unix-like x86-64 targets. https://gitlab.com/x86-psABIs/x86-64-ABI - Jonathan Bartlett — Programming from the Ground Up — assembly from first principles, useful for building the "CPU executes instructions" mental model. https://savannah.nongnu.org/projects/pgubook/
- Ed Jorgensen — x86-64 Assembly Language Programming with Ubuntu — approachable x86-64 register, instruction, and calling-convention reference with examples. https://open.umn.edu/opentextbooks/textbooks/x86-64-assembly-language-programming-with-ubuntu
- Intel SDM + Felix Cloutier x86 reference — Intel is the authority; Felix Cloutier is the fast lookup for
lea,add,ret, and friends. https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html · https://www.felixcloutier.com/x86/ - Arm Architecture Reference Manual + Apple ARM64 docs — authoritative AArch64 semantics plus Apple-platform ABI details for
clang -arch arm64output. https://developer.arm.com/documentation/ddi0487/latest · https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms