Codegentarget

Reading Assembly Output

How to actually read `clang -S -o -`. The same two-line `add(a, b)` is `lea eax, [rdi+rsi]` on x86-64 System V and `add w0, w0, w1` on AArch64 AAPCS — and neither listing means anything without knowing which ABI produced it.

The question

How do I read the assembly my compiler produces, and what do I need to know before the register names mean anything?

SourceLexingTokensParsingASTSemanticsTypedIROptimizeCodegenMachine codeLinkExecute
What the program is here

Assembly text: mnemonics, operands, labels and assembler directives. It is a *symbolic* representation of machine code — addresses are still names, sections are still declared rather than laid out — which is what it exists for: it is the last form in which a human can read the program and the assembler can still resolve the names.

What this phase may assume or do

The emitter may print any instruction sequence that implements the function's semantics, but it is bound absolutely by the calling convention: arguments arrive in the registers and stack slots the ABI specifies, the return value goes where the ABI says, and callee-saved registers must hold their original values on return. Those are not optimizations that can be traded away — the code on the other side of the call was compiled separately and cannot be renegotiated with. Everything else in the listing is the backend's free choice.

Key points

  • Every register name in an assembly listing is an ABI fact, not an assembly fact. rdi is the first argument on System V and nothing in particular on Windows x64.
  • The structure — arguments in registers, compute, result in the return register, return — transfers between targets. The names do not.
  • x86-64 needs lea to get a three-operand addition; AArch64 has one, which is why the same source is one instruction on both but a different instruction.
  • A register shuffle before a call is a parallel copy and may need a temporary to break a cycle; emitting the moves in order is a real miscompilation.
  • Python and JavaScript do not have a per-function assembly listing in the sense C does. Comparing listings across those languages compares incommensurable things.
  • State the target and the optimization level with every listing, or the listing is a claim with no subject.

Two targets, one function

targetBoth columns assume the System V and AAPCS conventions respectively. On Windows x64 the first two integer arguments are ecx and edx, the return is still eax, the callee-saved set is larger, and there is a 32-byte shadow space the caller must reserve. On Windows on ARM64 the convention is close to AAPCS but not identical. The instruction sets are the same in each case — it is the convention that changes, and the convention is what the listing is actually showing you.

Here is the entire content of this lesson in one comparison. The C function int add(int a, int b) { return a + b; } compiles to two instructions on both x86-64 and AArch64, and the two listings have no register name, no argument convention and no return convention in common. Everything that looks like knowledge in an assembly listing — "rdi is the first argument", "the result goes in eax" — is a fact about an ABI, not about assembly.

Read the matrix below by column rather than by row. The *structure* is identical: arguments arrive in registers, one instruction computes, the result lands in the return register, one instruction returns. The structure is what transfers between targets. The names transfer nowhere, and memorising them as though they did is the single most common way engineers learn something false from a Compiler Explorer screenshot.

int add(int a, int b) { return a + b; } at -O2, two ABIstarget
Aspectx86-64 System V (Linux, macOS)AArch64 AAPCS (Linux, macOS)
First integer argumenttargetedi / rdiw0 / x0
Second integer argumenttargetesi / rsiw1 / x1
Integer returntargeteax / raxw0 / x0
The bodytargetlea eax, [rdi + rsi]add w0, w0, w1
Why that instructiontargetArithmetic is two-operand, so add would need a mov first; lea writes a third register and skips itArithmetic is already three-operand, so no trick is needed and the result overwrites the first argument register
Returntargetretret
Registers preserved for the callertargetrbx, rbp, r12r15x19x28, plus x29 frame pointer and x30 link register
Return addresstargetOn the stack, pushed by callIn x30, the link register — not on the stack unless the callee saves it

A listing that is worth reading

typicalThis is the shape mainstream compilers produce at -O2 for this function; the exact register choices vary between Clang and GCC and between versions, and a compiler may equally use a different callee-saved register or spill to the stack instead. What is not a choice is that a must survive the call somehow, and that rbx must be restored before returning: those are the ABI, and every compiler obeys them.

A two-instruction function teaches the ABI and nothing else. A function with a call in it teaches the rest. The listing below is what a compiler typically produces for a function that takes two arguments, calls something with them swapped, and adds the result — and every line in it is one of the four backend decisions made visible.

Read it top to bottom asking "which phase put this here". The push/mov prologue is [[stack-frame-layout]]. The register shuffle before the call is the calling convention constraining [[instruction-selection]]. The mov into a callee-saved register is the register allocator deciding a value must survive a call. The pop and ret are the epilogue, and their existence is what makes the register-saving contract hold.

x86-64 System V, -O2, for `int f(int a, int b) { return g(b, a) + a; }`
1f:
2 push rbx ; rbx is callee-saved: save it because we are about to use it
3 mov ebx, edi ; a must survive the call, so park it in a callee-saved register
4 mov eax, edi ; parallel copy: rescue a before rdi is overwritten
5 mov edi, esi ; first argument of g is b
6 mov esi, eax ; second argument of g is a
7 call g
8 add eax, ebx ; g's result is in eax; add the saved a
9 pop rbx ; restore what we promised to preserve
10 ret

The three moves before the call are the interesting part. Swapping two argument registers is a *parallel* copy — mov edi, esi; mov esi, edi would destroy a — so a third register breaks the cycle. That is the same cycle-breaking our backend routes every call through, and [[calling-conventions]] is where it is argued properly.

The comparison that does not exist

There is a common exercise that looks like this lesson and is not: writing the "same" function in C, Python and JavaScript and comparing their assembly. It does not work, and the reason is worth being precise about rather than hand-waving.

For C, clang -S prints the machine instructions that will execute. For Python, there is no assembly for your function at all — python -m dis prints CPython bytecode, which is an instruction set for a virtual machine, and the machine instructions that run are the interpreter's dispatch loop, which is the same code regardless of what you wrote. For JavaScript, a modern engine may produce machine code for your function, but only after it has run enough times to be tiered up, and that code is specialised to the types actually observed and is discarded when a guard fails — see [[deoptimization]].

So the three listings are not three answers to one question. They are answers to three different questions: what will execute, what the VM will interpret, and what one engine chose to emit for one run under one set of observed types. Putting them side by side and comparing instruction counts produces a conclusion about nothing. [[four-languages-one-program]] is the lesson that does this comparison honestly, by comparing the *pipelines* rather than pretending the outputs are commensurable.

  • C and Rust: clang -S -o - / rustc --emit asm print the actual instructions, and they are what runs.
  • Python: python -m dis file.py prints bytecode for a virtual machine. There is no per-function machine code — see [[bytecode]].
  • JavaScript: node --print-opt-code (with --allow-natives-syntax for forcing tiers) shows what the optimizing tier emitted *this run*, for the types it saw this run.
  • Java: javap -c prints JVM bytecode; the JIT's machine code needs -XX:+PrintAssembly and a disassembler plugin, and again varies per run.
  • The comparable artefact across all four is the pipeline, not the listing.

Reading it without lying to yourself

A few habits separate useful assembly reading from cargo cult. Always name the target explicitly — --target=x86_64-unknown-linux-gnu, -mcpu= — because a listing without a stated target is a claim without a subject. Always state the optimization level, since -O0 output is a different program from -O2 output and neither is "what the compiler does". Always compile the smallest function that exhibits the thing, because the interesting three instructions are otherwise buried in prologue and inlined noise.

And use -fverbose-asm on GCC, or the source interleaving of objdump -S on a build with debug information, to get the compiler to tell you which source construct produced which instruction rather than guessing. Guessing is where most wrong conclusions come from: an instruction attributed to the wrong source line supports any theory you like.

Finally, treat the count as weak evidence. Fewer instructions is not faster if the shorter sequence has a longer dependence chain or a cache miss in it, and the out-of-order engine underneath is executing several per cycle in an order you cannot see. If the question is "which is faster", the answer comes from measurement — see [[compiler-performance]] for the compile-time half and the performance domain for the runtime half.

How it works

The steps, in the order the compiler takes them.

  • The backend finishes selection, allocation and scheduling, and an emitter prints one line of text per machine instruction plus the directives that define sections, symbols and alignment.
  • Argument registers, the return register and the callee-saved set come from the target's calling convention, which the emitter consults rather than chooses.
  • The prologue saves whatever callee-saved registers this function decided to use and establishes a frame pointer if one is in use; the epilogue undoes it exactly.
  • Labels stand in for addresses that are not yet known; the assembler resolves local ones and leaves the rest as relocations for the linker.
  • -S stops the pipeline here and prints the text; without it, an integrated assembler encodes the same instructions directly into an object file.

How it breaks

What the engineer observes when it goes wrong — not what goes wrong internally.

  • An engineer memorises rdi as "the first argument", writes inline assembly on that basis, and it corrupts arguments when built for Windows x64 — where the first argument is rcx.
  • A conclusion is drawn from a listing compiled at -O0 and applied to a production build, where the middle-end has already deleted the code being reasoned about.
  • Two source spellings are compared, found to produce different instruction counts, and one is adopted — with no measurement, and the shorter sequence turns out to have the longer dependence chain.
  • Hand-written inline assembly clobbers a callee-saved register without declaring it, and the caller's local variable changes value across the call. The corruption appears far from the assembly.
  • A listing is read with objdump -d against an optimized binary and attributed to the wrong source lines, because scheduling interleaved several statements and the line table is approximate.

When it helps

  • Confirming an optimization actually happened. "Did the bounds check get eliminated" is answered in seconds by looking, and answered wrongly by reasoning.
  • Understanding a performance cliff: seeing stack traffic in a loop tells you the allocator spilled, which is a different problem from the one you thought you had.
  • Writing or reviewing inline assembly and intrinsics, where the ABI contract is yours to keep and the compiler will not check it.
  • Learning an ISA — reading compiler output is a far better introduction to an instruction set than reading the manual front to back.

When it hurts

  • Estimating speed from instruction counts on an out-of-order machine, where the count correlates weakly with time.
  • Reasoning about a language whose implementation is a JIT, where the code you are looking at was produced for one run under one set of observed types and may not exist on the next.
  • Optimizing the source to produce prettier assembly. The middle-end has usually already normalised the spellings, so most such changes move nothing.

What it costs

Every one of these is paid by something.

  • Reading assembly buys certainty about what was generated and costs time plus a real risk of overfitting to one target, one compiler version and one optimization level.
  • Compiling with -S for readability buys a clean listing and costs fidelity: the integrated assembler path is what actually runs, and macro expansion and relaxation can differ slightly from the text.
  • Building with debug information so objdump -S can interleave source buys attribution and costs binary size and a line table that, in optimized builds, is approximate and sometimes misleading.

What else you could do

What a different compiler or language does instead, and when that is better.

  • Compiler Explorer, for the comparison rather than the listing: the same source across compilers, versions, targets and flag sets, with colour-coded source-to-assembly mapping.
  • llvm-mca if the question is throughput rather than content — it simulates a pipeline model and reports port pressure and the critical path instead of leaving you to count.
  • Reading LLVM IR (clang -S -emit-llvm) instead when the question is about an optimization rather than about the machine. Most "did the compiler do X" questions are IR questions and the IR is far easier to read.
  • Measuring. If the question is which is faster, a benchmark answers it and a listing does not — see the performance domain's treatment of benchmarking.

See it for yourself

The flag, dump or tool that shows you this directly.

  • x86-64: clang -O2 -S -masm=intel -o - file.c (Intel syntax; drop the flag for AT&T, which is the default on Unix targets).
  • Cross-target from the same machine: clang -O2 -S --target=aarch64-unknown-linux-gnu -o - file.c needs no cross-toolchain for -S, since nothing is being assembled or linked.
  • GCC with commentary: gcc -O2 -S -fverbose-asm -o - file.c annotates operands with the variable names they came from.
  • What actually shipped: objdump -d --no-show-raw-insn binary, or objdump -S on a build with -g to interleave source.
  • Rust: rustc -O --emit asm -C llvm-args=-x86-asm-syntax=intel file.rs, or cargo asm for a specific monomorphized instance.
  • Compiler Explorer for everything at once, and our own assembly explorer at /compilers/codegen for the AtlasLang backend with the IR beside it.

Plausible wrong readings

Stated the way a confident engineer states them.

  • "rdi is the first argument." On System V, for integer and pointer arguments, for this platform. On Windows x64 it is rcx; on AArch64 it is x0; for floating point it is xmm0 on both x86 ABIs.
  • "Fewer instructions means faster." Not on a machine that issues several per cycle and stalls for hundreds on a cache miss. Instruction count is a proxy for code size, and only loosely for time.
  • "This is what my Python function compiles to." Python functions do not compile to machine code in CPython. What you disassembled was bytecode for a virtual machine, and the machine code that runs is the interpreter.
  • "The compiler generated bad code here." Before concluding that, check the optimization level, check that the function was not inlined into its caller and optimized there, and check that the ABI did not require what looks redundant.

Misconceptions

The claim, and what is actually true.

Assembly is the same thing as machine code.
Assembly is text with symbolic addresses. The assembler turns it into bytes and a relocation table; the linker resolves the rest. Some things visible in assembly — labels, directives, macros — have no encoding at all.
Every language has an assembly listing for a given function.
Only languages compiled ahead of time to native code do, in the sense that the listing is what will run. For bytecode VMs the analogous artefact is bytecode, and for JITs it is per-run and per-type-feedback.
If two compilers produce different assembly, one is wrong.
They agree on observable behavior and the ABI, and disagree about everything else, which is exactly what they are allowed to do. That freedom is the [[as-if-rule]].

Go deeper

The same idea at increasing depth. Stop wherever it stops being useful.

overview

Compilers will print the assembly they generate: clang -S -o - or gcc -S -o -. Reading it tells you exactly what the machine was asked to do. The one thing to know before starting is that the register names are dictated by a calling convention, so the same function on Linux x86-64, Windows x86-64 and ARM64 macOS uses three different sets of registers to do the identical thing.

practical

Compile the smallest function that shows the effect, at the optimization level you actually ship, for the target you actually ship, and say all three out loud when quoting the result. Use -fverbose-asm or objdump -S so the compiler attributes instructions to source rather than you guessing. Look for shapes rather than counting: stack traffic inside a loop means spilling, a call you expected to be inlined means an inlining decision to investigate, and a branch you expected to be eliminated means the compiler could not prove the condition.

advanced

The subtle danger in assembly reading is that it is *specific enough to feel like proof*. A listing is one compiler version's output for one target at one optimization level, and it will change. Conclusions worth keeping are structural — this value has to survive the call, so it costs a callee-saved register or a spill; this bounds check could not be eliminated because the index came from an opaque source. Conclusions about which instruction was chosen are perishable, and building source-level habits on them produces code that was tuned for a compiler version nobody runs any more. The durable use of a listing is to confirm or refute a specific hypothesis, not to browse.

How much this depends on

Nothing in this domain is true of every compiler. These say how much.

targetEvery register name and calling-convention claim here is x86-64 System V or AArch64 AAPCS as labelled. Windows x64 passes the first four integer arguments in rcx, rdx, r8, r9, reserves 32 bytes of shadow space, and has a different callee-saved set. Nothing about the instruction set changes between System V and Windows on x86-64 — only the contract does.
implementationThat add(a, b) compiles to lea on x86-64 is what Clang and GCC typically emit at -O2 at the time of writing. At -O0 both emit a stack-based sequence of half a dozen instructions, and the choice can change between compiler versions as cost models are retuned. Any specific listing is a fact about one compiler, one version, one flag set and one target.
simplifiedListings in this lesson omit the assembler directives real output carries — .text, .globl, .cfi_* unwind annotations, .size, alignment. The CFI directives in particular are not decoration: they are what makes stack unwinding and profiling work, and they are a large fraction of the lines in real output.

If you were asked this in an interview

  • I show you an assembly listing with no other information. What do you need to know before any register name in it means anything?
  • Why can you not meaningfully compare the "assembly output" of a C function and a Python function?
  • You see a mov into rbx at the top of a function and a pop rbx at the bottom. What does that tell you about the function?

Connections

Domains that do not exist yet
  • Programming Languages & Runtime Internals — Why a JIT-compiled function has no single stable machine-code form
    The reason a Python or JavaScript function has no comparable listing is that its machine code, where it exists at all, is produced per run from observed types and discarded on deoptimization. The runtime's account of that lives there; the compiler-side half is [[deoptimization]] here.