Compiling C to Assembly and Comparing Optimization

Generated assembly is a useful explanation of one compiler decision for one target, not a permanent translation of C. The C abstract machine specifies observable behavior for a well-defined program; a compiler is free to choose any machine code with that behavior. Change the compiler release, target ABI, command line, source context, or optimization level and the listing may change completely. That freedom is the key to reading compiler output carefully rather than treating it as a line-for-line “truth” about the source.

A small, owned lab

Work with a file you wrote and can rebuild. Save this as scale.c; it has no input, no privileges, and no need to execute it to inspect the output.

int scale_add(int x, int y) {
    return x * 5 + y;
}

int sum_positive(const int *a, int n) {
    int total = 0;
    for (int i = 0; i < n; ++i)
        if (a[i] > 0) total += a[i];
    return total;
}

GCC and Clang can stop after compilation with -S. The result is assembly text, not an executable: gcc -S -O0 -g -fverbose-asm scale.c -o scale-gcc-O0.s and clang -S -O2 -g -fverbose-asm scale.c -o scale-clang-O2.s. Add -masm=intel on supported x86 toolchains if Intel syntax is easier to read; GCC and Clang otherwise commonly emit AT&T syntax on Unix-like x86 targets. Compiler Explorer can make quick comparisons convenient, but local, version-pinned commands are better evidence for a reproducible report.

QuestionUseful commandWhat it produces
Compiler’s assemblygcc -S -O2 scale.cAssembly before assembling/linking
Object with debug recordsclang -c -O2 -g scale.c -o scale.oRelocatable object; safe to inspect
GNU disassemblyobjdump -drwC -S scale.oInstructions, relocations, demangled names, available source
LLVM disassemblyllvm-objdump -dr --source scale.oLLVM decoder and source interleaving
ELF metadatareadelf -hSWr scale.oHeader, sections, symbols/relocations as selected
Named symbolsnm -nC scale.oSymbol addresses/types, in address order

-c asks the compiler driver to assemble but not link. This distinction matters: an object-file call to an external function normally carries a relocation, while a linked program may show a PLT stub, import thunk, or final address. Use the tool matching the file type and architecture. For example, inspect an AArch64 object with a binutils build that supports it, or use llvm-objdump --arch-name=aarch64 when appropriate. A host x86 disassembler cannot usefully decode arbitrary ARM64 bytes merely because a file is named .o.

Make the target explicit

“Assembly” includes an instruction set, width, ABI, and object format. On a multilib GCC installation, gcc -m32 -S -O2 scale.c selects x86-32 and gcc -m64 -S -O2 scale.c selects x86-64. The former may require installed 32-bit headers/libraries even if stopping early; diagnose a missing multilib setup rather than copying output from an unrelated target. With Clang, a target triple is often clearer: clang --target=x86_64-pc-linux-gnu -S -O2 scale.c, clang --target=i686-pc-linux-gnu -S -O2 scale.c, or clang --target=aarch64-unknown-linux-gnu -S -O2 scale.c. Cross compilation can likewise need a sysroot; our tiny, header-free example minimizes that dependency.

TargetTypical argument/return contextPatterns likely in scale_addDo not assume
x86-32, common SysVArguments commonly on stack; eax returnLoad operands, then lea/add or shift/addAll 32-bit OSes share one ABI
x86-64 SysVFirst integer arguments commonly in edi, esi; result in eaxlea can express 5×x+yWindows x64 uses the same argument registers
ARM64/AArch64, common AAPCS64Integer arguments in w0, w1; result in w0add with shifted register can express x + (x << 2)Register names alone establish a platform ABI

The table describes common conventions, not a substitute for the ABI. x86-32 has several calling conventions; Windows x64 has different register rules and stack “home” space from System V; Apple platforms use Mach-O and their platform ABI; ARM64 uses w names for 32-bit views and x names for 64-bit registers. Compiler output must be interpreted in its target context.

What optimization levels mean

-O0 usually prioritizes fast compilation and straightforward debugging over efficient code. It often materializes locals on the stack, keeps a frame pointer, and makes each branch visible. It does not guarantee a literal source translation or prevent every simplification. -Og, where supported, seeks a debugging-friendly set of optimizations. -O1, -O2, and -O3 progressively enable collections of transformations; their exact contents are compiler- and release-specific. -Os and -Oz favor smaller code. Never describe -O2 as a standardized set of passes.

At -O2, the compiler may retain total entirely in a register, combine arithmetic, hoist invariant work, use conditional instructions, or transform a loop. At -O3, it may additionally unroll or vectorize a profitable loop. For sum_positive, a scalar listing might load one int, test it, and conditionally accumulate. A vectorized listing may load several elements into SIMD/vector registers, compare lanes, mask nonpositive values, and reduce partial sums. It is still computing the same result only under the source language’s applicable rules and the selected floating-point and overflow settings.

Optimization is a cost model, not a score. A short fixed-size loop can be unrolled at -O2, while a target without a useful vector ISA may retain scalar code at -O3. Vectorization often depends on target features: x86 SSE2, AVX2, or AVX-512 and ARM64 NEON/SVE are different capabilities. State the exact command, compiler version (gcc --version or clang --version), target triple, and relevant -march/-mcpu flag beside any comparison. Do not conclude that one compiler is “better” from a different default CPU, ABI, or library setup.

Why C source and instructions do not map one-to-one

One source expression can become several instructions because of address calculation, width extension, checks, spills, calling-convention work, or instrumentation. Several source statements can become one instruction after constant folding or dead-code elimination. A statement can disappear when its result is unobservable. Branches may be inverted, merged, or replaced with a conditional move. A loop’s increment, comparison, and indexing can be folded into one addressing mode on x86 or a shifted add on ARM64. Conversely, a single call in source can become setup, a call sequence, a cleanup, and dynamic-linking indirection.

Inlining is a particularly important reason. If the compiler sees a small called function and can inline it, there may be no call instruction and no separate body in the final binary. Debug information can record an “inlined subroutine” relationship, but a debugger may still show surprising locations. Link-time optimization (LTO) broadens visibility across object files, allowing removal, merging, and inlining after ordinary compilation. To make a baseline comparison, compile without LTO; to study production output, record that LTO was used rather than declaring absent functions “missing.”

Debug information is separate metadata. -g generally emits DWARF information on ELF systems and Mach-O toolchains, while Windows toolchains commonly use PDB information with PE/COFF. It connects instruction address ranges to source locations, types, variables, and inline call sites. objdump -S or llvm-objdump --source can interleave source only when that source and mapping are available. At optimization, a variable may be “optimized out,” occupy different registers over disjoint ranges, or have no single storage location. A line table identifies approximate attribution, not an assertion that every machine instruction executes exactly one C line.

Defined behavior is the foundation

Compiler output is meaningful only after asking whether the source has defined behavior. Signed integer overflow, out-of-bounds accesses, invalid pointer arithmetic, use-after-lifetime, unsequenced conflicting accesses, and a data race in ordinary C are examples of undefined behavior (UB). UB is not a runtime exception the compiler must preserve. Once an execution reaches it, compiler assumptions can permit transformations that make a “defensive” source check vanish or move. Therefore an assembly difference is not automatically a compiler bug, nor proof that a build will behave the same on another machine.

For example, if (x + 1 > x) is not a reliable signed-overflow test: for defined signed values, the compiler may reason it is true. Use a checked arithmetic facility, a wider type after verifying range, or compiler builtins with documented semantics when that is the requirement. Sanitizers are valuable in owned test builds: -fsanitize=address,undefined -fno-omit-frame-pointer can expose many memory and UB errors, but they alter code generation and do not prove the absence of all defects. Compare sanitizer output in a separate build, not as though it were optimized release assembly.

volatile, atomics, and barriers

volatile means accesses to a volatile-qualified object are observable in the C abstract machine. It is appropriate for certain memory-mapped device registers and sometimes signal-related interfaces when the platform documents it. It does not make a counter thread-safe, provide inter-thread ordering, or create a general hardware memory fence. A volatile load or store can prevent a desirable optimization of that access, so it can change an assembly experiment; it is not a magic “keep my code” switch.

For threads, use C atomics and a deliberate memory order. An acquire/release atomic operation, a sequentially consistent operation, and a compiler-only barrier are different tools with different generated instructions. GCC/Clang extended inline assembly often uses an empty asm with a "memory" clobber as a compiler barrier, but that does not necessarily emit a CPU barrier. Conversely, an architecture fence may need a compiler-visible constraint to prevent inappropriate reordering around it. Prefer standard atomics or documented platform primitives; do not copy barrier fragments from a listing into application code without understanding both compiler and hardware contracts.

Reading objects and formats

ELF is common on Linux and many Unix-like systems. Its sections include executable code, data, symbol tables, relocation records, and often DWARF sections. readelf -h shows class, endianness, machine, and type; readelf -S lists sections; readelf -r displays relocations. nm presents symbols compactly, but absence from its default output need not mean a function never existed. Local visibility, dead stripping, inlining, and stripped symbol tables all affect what appears.

Windows usually uses PE/COFF, with imports, exports, sections, and PDB debug information managed by Windows tooling; dumpbin and LLVM tools can inspect it. macOS uses Mach-O, load commands, segments/sections, and often dSYM debug bundles; otool, nm, and LLVM tools are common choices. The concepts of code, metadata, relocations, and symbols overlap, but commands and record layouts are not interchangeable. Ask file program first and then consult the platform tool documentation.

Static linking copies needed library code into a program at link time, subject to licenses and linker selection. Dynamic linking records dependencies resolved by the platform loader, and calls may pass through linkage stubs. These choices can change disassembly size, symbol visibility, startup routines, and addresses without changing the source-level algorithm. Do not mistake loader code for the function you intended to compare; use symbol names and disassemble a named function where available.

A repeatable comparison checklist

Before comparingRecord / check
SourceExact file, language mode, and whether behavior is defined
ToolchainCompiler version, assembler/linker, and flags including -O, -g, LTO, sanitizers
TargetTriple, ABI, word size, endianness, CPU features, sysroot
ArtifactAssembly text, relocatable object, or linked executable; static or dynamic link
EvidenceSymbols, relocations, line tables, and disassembly from a target-aware tool
ConclusionName the transformation and its assumptions; avoid claims from one incidental listing

Begin with the compiler’s -S output, then validate it against a -c object and its relocations. Use objdump -d --disassemble=scale_add scale.o or the analogous LLVM option to reduce noise. The site’s Code disassembly tutorial provides a command-line companion; for live instruction-level investigation on ARM64, see the ARM64 GDB tutorial. Keep experiments tiny, owned, and rebuildable.

References