Reading Compiler Output (Godbolt)
Learn to read the machine language your compiler writes — and see exactly what it does to your C, your optimizations, and your bugs.
Overview
Every C program you write is a conversation with the compiler. You say "sort this array" or "add these two integers," and the compiler translates your intent into a sequence of raw machine instructions — a ballet of registers, memory loads, branches, and arithmetic that the CPU executes at billions of steps per second. For most programmers, that translation is a black box. For systems programmers and kernel contributors, it cannot be.
This module teaches you to read the compiler's output directly. We use Compiler Explorer (godbolt.org), a free, browser-based tool that compiles your C in real time and shows the generated assembly side-by-side with color-coded source mapping. You will learn to recognize idioms: how a loop becomes a branch-and-decrement, how a struct becomes offsets from a base pointer, how an if statement becomes a conditional jump. You will understand what -O0 (debug) and -O2 (optimized) mean and why the difference is dramatic. And you will learn to spot the frightening phenomenon of undefined behavior exploitation — where the compiler legally and silently deletes code you thought was there, because the C standard says your program's behavior is undefined.
By the end of this module you will have a superpower: you can look at any small C function and predict — or verify — what the machine actually does. This skill is essential for performance work, for debugging subtle bugs, for writing correct inline assembly, and for understanding how the Linux kernel squeezes every last cycle out of hardware. It also builds the intuition you need to read kernel patches, because patch authors often say things like "this change avoids an extra branch" or "GCC was hoisting this load out of the loop" — and now you will know exactly what they mean.
Compiler Explorer was created by Matt Godbolt and has grown into an indispensable community tool. It supports GCC, Clang, MSVC, and dozens of other compilers across x86-64, ARM, RISC-V, MIPS, and more. Everything in this module can be followed along in your browser with zero setup.
When you eventually read Linux kernel source code, you will encounter comments like /* force gcc to reload the value */, barrier(), READ_ONCE(), WRITE_ONCE(), __attribute__((noinline)), and carefully placed volatile keywords. These are not superstitions — they are hard-won lessons about what the compiler can and cannot be trusted to do. Without the ability to read assembly, these idioms are cargo cult magic. With it, they become obvious engineering choices.
Understanding compiler output is also the foundation for performance analysis. Profilers tell you where time is spent; assembly tells you why. When a hot kernel path allocates an extra stack frame, misses a vectorization opportunity, or emits a costly division instead of a shift-and-add, you need to see the assembly to fix it. The maintainers who review your patches can see it too — if your change introduces a regression in the generated code, a reviewer will catch it and ask you to explain. This module gives you the vocabulary to participate in those conversations confidently.
From C Source to Assembly: The Compiler Pipeline
GCC/Clang transform C through multiple intermediate representations before emitting assembly. The optimization passes (yellow) run on an architecture-neutral IR — this is where -O2 makes its dramatic changes. Godbolt shows you the assembly output (green) after all passes have run.
The concepts
01What Is Assembly and Why Does It Look Like That?
Assembly language is a human-readable representation of machine code — a one-to-one mapping (with a few exceptions) between text mnemonics and the binary instructions a CPU executes. Each line roughly corresponds to one CPU instruction. On x86-64 (the architecture of most desktop/server CPUs), an instruction looks like:
mov rax, 42
This means: load the 64-bit integer value 42 into the register named rax. The CPU has about 16 general-purpose 64-bit registers (rax, rbx, rcx, rdx, rsi, rdi, rsp, rbp, r8–r15). Think of them as extremely fast scratch space — faster than L1 cache by a factor of 10 or more — but there are only 16 of them.
Assembly comes in two syntactic flavors on x86: Intel syntax (mov rax, 42 — destination first) and AT&T syntax (movq $42, %rax — source first, sigils everywhere). GCC defaults to AT&T; Clang and most educational materials prefer Intel. In Godbolt, you can switch between them. This module uses Intel syntax because it is easier to read.
There are two major instruction categories you will see constantly:
- Data movement:
mov,push,pop,lea— shuffle values between registers and memory. - Arithmetic/logic:
add,sub,imul,and,or,xor,shl,shr— compute things. - Control flow:
jmp,je,jne,jl,jg,call,ret— decide where to execute next. - Comparison:
cmp,test— set CPU flags without storing a result.
Each instruction operates on operands, which can be registers, immediate values (constants baked into the instruction), or memory references (addresses). A memory reference like [rbp - 8] means "the 8 bytes of memory at address rbp minus 8" — a stack slot.
// Minimal C function
int add(int a, int b) {
return a + b;
}
// GCC -O0 x86-64 Intel syntax output:
add:
push rbp ; save caller's frame pointer
mov rbp, rsp ; set up our own frame
mov DWORD PTR [rbp-4], edi ; spill arg 'a' to stack
mov DWORD PTR [rbp-8], esi ; spill arg 'b' to stack
mov eax, DWORD PTR [rbp-4] ; reload 'a'
add eax, DWORD PTR [rbp-8] ; add 'b'
pop rbp ; restore frame pointer
ret ; return (result already in eax)
02Compiler Explorer (Godbolt): Your Assembly Microscope
Compiler Explorer at godbolt.org is the single most important tool for this module. It is a web application with two panes: on the left you type C (or C++, Rust, Go, Fortran, or dozens of other languages); on the right you see the assembly output, updated in real time as you type. Colored highlights link source lines to assembly instructions.
Key controls to know:
- Compiler selector: the dropdown at the top of the right pane. Pick
x86-64 gcc 13.2to start. Later, compare withx86-64 clang 17.0orARM64 gcc 13.2. - Compiler flags box: next to the compiler name. This is where you type
-O0,-O1,-O2,-O3,-Os,-march=native, etc. Start with just-O0and then change to-O2to see the dramatic difference. - Intel syntax toggle: add
-masm=intelto the flags box, or find it under the output options menu (the gear icon). Much easier to read. - Diff mode: click the
+to add a second compiler pane and compare the output of-O0vs-O2side-by-side. - Share button: generates a permalink to your exact code + flags. The exercises in this module link to godbolt URLs.
A crucial workflow tip: keep your C snippets small (one function at a time). When you paste a 200-line file, the assembly is overwhelming. Isolate the function you want to study, mark it __attribute__((noinline)) if needed, and look at just that function's label in the output.
Reading the output pane: each function in C becomes a labeled block in assembly. The label is the function name (possibly mangled for C++). The block ends at ret (or jmp to a tail call). Everything between the label and ret is the function body. Memory references like QWORD PTR [rsp+8] are stack variables. Calls to other functions appear as call printf or call .L42 (an internal label).
// Paste this into godbolt.org left pane
// Flags: -O2 -masm=intel
// Watch the right pane update as you type
#include <stdint.h>
int64_t square(int64_t x) {
return x * x;
}
// Expected -O2 output (x86-64):
// square:
// imul rdi, rdi
// mov rax, rdi
// ret
03The Calling Convention: How Functions Pass Arguments and Return Values
When one function calls another, they need to agree on where to put arguments and where to find the return value. This agreement is the calling convention, and it is architecture-specific.
On x86-64 Linux (the System V AMD64 ABI), the rules are:
- Integer/pointer arguments 1–6 go in registers:
rdi,rsi,rdx,rcx,r8,r9(in that order). - Further arguments go on the stack.
- The return value goes in
rax(orrdx:raxfor 128-bit values). - Floating-point arguments use
xmm0–xmm7. - The caller is responsible for saving
rdi,rsi,rdx,rcx,r8,r9,r10,r11if it needs them after the call (caller-saved). The callee must save and restorerbx,rbp,r12–r15if it uses them (callee-saved). rspmust be 16-byte aligned before acallinstruction.
This is why in the add(int a, int b) example, the compiler loads a from edi (the lower 32 bits of rdi) and b from esi — those are where the caller put them.
Knowing this lets you read any function in isolation: the first argument is always in rdi, the second in rsi, etc. When you see mov eax, edi at the top of a function, you know the function's first argument is being moved into the return value register — a very short function that just returns its first argument, or the start of something slightly more complex.
On ARM64 (AArch64), the convention is similar but uses x0–x7 for the first eight integer arguments and x0 for the return value. On RISC-V, it is a0–a7 for arguments and a0/a1 for return values.
// Three-argument function — watch which registers carry each arg
int fma_int(int a, int b, int c) {
return a * b + c;
}
// GCC -O2 -masm=intel x86-64 output:
// fma_int:
// imul edi, esi ; edi (a) *= esi (b)
// lea eax, [rdi+rdx] ; eax = edi + rdx (c)
// ret
//
// Note: 'a' is in edi, 'b' in esi, 'c' in edx.
// Result ends up in eax. No stack frame needed at -O2.
04-O0 vs -O2: What Optimization Actually Means
-O0 (the default when you compile without flags) tells GCC: "Do not optimize. Compile as literally as possible. Every variable should live at a real stack address so I can inspect it with a debugger." This produces slow but debuggable code. You will see:
- Every local variable spilled to the stack immediately after assignment.
- Values reloaded from the stack before every use.
- Every sub-expression computed in a separate step.
- Function calls never inlined.
- Loops structured exactly as written.
-O2 enables a large set of classical compiler optimizations:
- Register allocation: variables live in registers, never touching the stack unless there are too many.
- Constant folding and propagation:
x = 2 * 3becomesx = 6at compile time; ifxis then used asy = x + 1, that becomesy = 7. - Dead code elimination: code whose result is never used is deleted.
- Inlining: small functions are expanded inline — no
call/retoverhead. - Loop optimizations: loop-invariant code motion (moving computations out of loops), strength reduction (replacing
i*8with an accumulator that adds 8 each iteration), loop unrolling. - Branch prediction hints: unlikely branches are moved to cold paths.
The difference in code size and speed can be 5–10× for compute-heavy code. The difference in readability is also dramatic: -O2 output is often 3–4× shorter because the compiler has eliminated redundant work.
A key insight: at -O0, the assembly is a faithful but slow translation of your C. At -O2, the assembly is an equivalent but not isomorphic translation — the compiler may have completely restructured the computation as long as the observable behavior (outputs, side effects, I/O) is the same. The compiler is allowed to reorder, combine, split, delete, and invent any operations it likes, as long as the program behaves the same from the outside.
// Compile this at -O0 then -O2 and compare
int sum_to_n(int n) {
int total = 0;
for (int i = 1; i <= n; i++) {
total += i;
}
return total;
}
// -O0: You will see the loop in full: increment i,
// compare with n, branch back, add to total — all
// using stack slots.
// -O2 (GCC 13): The compiler may apply the Gauss
// formula or at least restructure into a tight
// counted loop with no memory traffic.
// Actual GCC -O2 output (simplified):
// sum_to_n:
// test edi, edi
// jle .zero ; n <= 0 → return 0
// lea eax, [rdi-1]
// lea ecx, [rdi-2]
// imul ecx, eax
// shr ecx, 1
// lea eax, [rcx+rdi]
// ret
// .zero:
// xor eax, eax
// ret
// It computed n*(n-1)/2 + n = n*(n+1)/2 at compile time!
05Reading Control Flow: Loops and Branches
High-level control flow (if, for, while, switch) maps to assembly as comparisons followed by conditional jumps. The CPU has a set of flags (the EFLAGS / RFLAGS register on x86): Zero Flag (ZF), Sign Flag (SF), Carry Flag (CF), Overflow Flag (OF). Instructions like cmp and test set these flags based on the result of a subtraction or AND, without storing the result. Then conditional jump instructions (je, jne, jl, jg, jle, jge, jb, ja...) branch based on those flags.
A simple if (a > b) compiles to:
cmp edi, esi ; compute edi - esi, set flags
jle .else ; jump if edi <= esi (i.e., NOT a > b)
A for loop becomes a branch-at-bottom pattern: the initial comparison is at the top (or sometimes omitted and jumped to), the loop body runs, an increment happens, then a backward jump re-enters the loop. Look for backward jumps (jumps to labels that appear before the jump in the listing) — those are loops.
Switch statements can compile in two ways:
- For sparse cases: a chain of
cmp/jepairs — essentially an if-else ladder. - For dense consecutive cases: a jump table — an array of function pointers indexed by the switch value. You will see
jmp QWORD PTR [rax*8 + table]. This is O(1) dispatch regardless of the number of cases — a beautiful optimization.
Understanding this mapping is essential for reading kernel code. The kernel uses likely() and unlikely() macros, which expand to __builtin_expect, to hint to the compiler which branch is common. The compiler puts the likely path inline (no jump) and the unlikely path in a cold section (requires a jump). This is not superstition — it matters for the CPU's branch predictor and for instruction cache layout.
// Study control flow with a simple example
int clamp(int x, int lo, int hi) {
if (x < lo) return lo;
if (x > hi) return hi;
return x;
}
// GCC -O2 -masm=intel x86-64:
// clamp:
// cmp edi, esi ; x vs lo
// jge .L2 ; if x >= lo, skip
// mov eax, esi ; return lo
// ret
// .L2:
// cmp edi, edx ; x vs hi
// jle .L3 ; if x <= hi, skip
// mov eax, edx ; return hi
// ret
// .L3:
// mov eax, edi ; return x
// ret
//
// Notice: three separate ret instructions. The compiler
// duplicated the return rather than using a single exit
// point, because that avoids extra jumps on the hot path.
06Stack Frames and Local Variables
When a function is called, it gets a stack frame — a reserved region of the stack where it stores its local variables, saved registers, and bookkeeping. The stack grows downward on x86 (toward lower addresses). The rsp register always points to the top of the stack (the current lowest used address).
At -O0, every local variable gets a stack slot. The compiler allocates space by subtracting from rsp at the function prologue: sub rsp, 32 reserves 32 bytes. Individual variables are accessed as [rbp - 4], [rbp - 8], etc., where rbp is the frame pointer — a saved copy of rsp at function entry. The frame pointer makes stack walking easy for debuggers.
At -O2, rbp as a frame pointer is often eliminated (-fomit-frame-pointer). Variables live in registers whenever possible. Stack space is only allocated when:
- You take the address of a local variable (
&xforcesxto memory). - There are more live variables than registers (register pressure spills).
- You call other functions (arguments beyond 6, alignment, etc.).
The prologue (function start) typically saves rbp and sets it: push rbp; mov rbp, rsp. The epilogue (function end) restores it: pop rbp; ret (or the combined leave; ret). At -O2 without frame pointer, prologues and epilogues often vanish entirely for leaf functions — a function that calls no other functions.
For the kernel, this matters because stack space is precious. The kernel stack is only 8–16 KB per thread (vs. megabytes in userspace). Stack overflows are silent and catastrophic. Reading assembly helps you verify that a function is not accidentally allocating a large array or deeply nesting calls.
// A function that forces stack allocation
void swap(int *a, int *b) {
int tmp = *a; // tmp must live somewhere
*a = *b;
*b = tmp;
}
// GCC -O2 -masm=intel: no stack frame needed!
// swap:
// mov eax, DWORD PTR [rdi] ; eax = *a
// mov ecx, DWORD PTR [rsi] ; ecx = *b
// mov DWORD PTR [rsi], eax ; *b = tmp (eax)
// mov DWORD PTR [rdi], ecx ; *a = ecx
// ret
//
// tmp lives in eax the whole time — never touches the stack.
// The compiler proved it didn't need to spill.
07Structs, Padding, and Memory Layout
Structs in C are laid out in memory according to the alignment rules of the target ABI. Each field must be aligned to its own size: a 4-byte int must start at an address divisible by 4; an 8-byte double must start at an address divisible by 8. The compiler inserts padding bytes between fields to satisfy alignment, and may add trailing padding to make the struct size a multiple of its largest member's alignment (for arrays of structs).
In assembly, struct field accesses become fixed byte offsets from a base pointer. If p is a pointer to a struct in register rdi, then p->field at offset 8 becomes QWORD PTR [rdi+8].
This matters for several reasons:
- Cache lines: a struct that spans a 64-byte cache line boundary is slower to access than one that fits in a single line. Kernel developers carefully order struct fields by access frequency and size.
- False sharing: two fields in the same cache line modified by different CPU cores causes cache ping-pong. The Linux kernel uses
____cacheline_aligned_in_smpto prevent this. - Size bugs: if you assume a struct is a certain size and it is actually larger due to padding, you get subtle bugs — especially when serializing structs or using
sizeofincorrectly.
You can inspect struct layout by looking at the offsets in the generated assembly, or by using __builtin_offsetof / the offsetof macro. Godbolt also lets you compile printf("%zu\n", sizeof(struct foo)) to see sizes directly.
struct padded {
char a; // offset 0, size 1
// 3 bytes padding
int b; // offset 4, size 4
char c; // offset 8, size 1
// 7 bytes padding
double d; // offset 16, size 8
}; // total: 24 bytes
struct packed_better {
double d; // offset 0, size 8
int b; // offset 8, size 4
char a; // offset 12, size 1
char c; // offset 13, size 1
// 2 bytes padding
}; // total: 16 bytes — 33% smaller!
int get_b(struct padded *p) {
return p->b;
}
// Assembly: mov eax, DWORD PTR [rdi+4]
// The '4' is the compile-time-known offset of 'b'.
08Inlining and the Dead Code Elimination Pass
Function inlining is one of the most impactful optimizations. When the compiler inlines a function call, it replaces the call instruction with a copy of the callee's body, adapted for the specific arguments. This eliminates:
- The
call/retoverhead (saving/restoring the return address). - The function prologue/epilogue (saving/restoring registers, adjusting
rsp). - Argument passing overhead (moving values into the correct registers).
More importantly, inlining enables interprocedural optimizations: once the callee is inlined, the compiler can see both functions at once and apply constant propagation, dead code elimination, etc. across the boundary.
Dead code elimination (DCE): if the compiler can prove that a computation's result is never used (or that a branch is never taken), it deletes it entirely. This is powerful but also the source of the scariest optimization behavior: DCE can delete your safety checks if those checks are made redundant by undefined behavior.
You can force or prevent inlining with:
__attribute__((always_inline)): always inline this function.__attribute__((noinline)): never inline this function.static inline: hint to inline (honored at-O1and above).
For Godbolt work, noinline is your friend: it forces the compiler to emit a visible assembly block for the function you are studying, rather than inlining it away into its callers.
// Demonstrating DCE: the check disappears!
#include <stdint.h>
void process(int *p) {
// At -O2, if the compiler can prove p != NULL
// because we dereferenced p before this check,
// it may delete the null check entirely:
*p = 42; // dereference — implies p != NULL
if (p == NULL) // <- DCE may delete this!
__builtin_unreachable();
}
// Safe pattern: check BEFORE use
void process_safe(int *p) {
if (p == NULL) return;
*p = 42;
}
// __attribute__((noinline)) forces a visible asm block:
__attribute__((noinline))
int helper(int x) { return x * 2; }
int caller(int x) {
return helper(x) + 1; // -O2: inlined, no call visible
}
09Undefined Behavior Exploitation by the Compiler
This is the most important and most dangerous concept in this module. The C standard defines certain operations as undefined behavior (UB): signed integer overflow, null pointer dereference, reading uninitialized memory, use-after-free, out-of-bounds array access, data races, and others. The standard says that if your program executes UB, anything at all can happen — and crucially, the compiler is allowed to assume your program never executes UB.
That assumption is a license for some breathtaking optimizations — and some breathtaking bugs.
Example: signed overflow removal. Consider a loop:
void f(int i) {
for (; i < i + 1; i++) { ... }
}
Signed overflow is UB. So the compiler assumes i + 1 > i is always true (because if it were false, i would have overflowed, which is UB, which can't happen). It compiles this to an infinite loop with no comparison.
Example: null pointer check deletion. The kernel had a famous real-world bug (CVE-2009-1897): code that first dereferenced a pointer (*ptr), then checked if (ptr == NULL). The compiler proved: "if we reached the dereference, the pointer was non-null (otherwise UB); therefore the null check is always false and can be deleted." The null check disappeared in the -O2 binary, creating a security vulnerability.
Example: strict aliasing. The compiler assumes that pointers of different types never point to the same memory. If you cast an int * to a float * and modify it, the compiler may not update the int in registers because it believes no float * can alias an int *. Use memcpy or -fno-strict-aliasing (which the Linux kernel does with -fno-strict-aliasing in its Makefile) to work around this.
The correct response to UB is never to introduce it and to compile with sanitizers (-fsanitize=undefined,address) during development to catch it dynamically. In kernel code, READ_ONCE and WRITE_ONCE are used to prevent the compiler from tearing or eliding accesses to shared variables.
// UB exploitation: the null check disappears
// Compile at -O2 and see for yourself on Godbolt
#include <stddef.h>
void copy_value(int *src, int *dst) {
int val = *src; // dereference: compiler now
// KNOWS src != NULL
if (src == NULL) { // <- this check is UB-dead
return; // <- compiler may delete this
}
*dst = val;
}
// Safer version:
void copy_value_safe(int *src, int *dst) {
if (src == NULL || dst == NULL) return; // check first!
*dst = *src;
}
// Signed overflow UB: the loop condition may be
// optimized away entirely:
void infinite_ub(int start) {
// i < i+1 is always true (no signed overflow in C)
// so the compiler can make this an infinite loop!
for (int i = start; i < i + 1; i++) {
// body
}
}
10Common Compiler Idioms and Tricks to Recognize
Once you read enough assembly, you start recognizing recurring patterns — the compiler's "dialect." Knowing these patterns lets you read unfamiliar assembly much faster.
XOR-zeroing: xor eax, eax is the fastest way to set a register to zero on x86. It is 2 bytes (vs. 5 for mov eax, 0) and breaks the false dependency on the previous value of eax.
Strength reduction: The compiler replaces expensive operations with cheaper equivalents. x * 4 becomes shl eax, 2 (left shift by 2). x / 8 (for non-negative x) becomes shr eax, 3. x * 5 becomes lea eax, [rax*4 + rax] (load effective address — a creative use of the address calculation hardware to do 4x + x = 5x in one instruction).
LEA for arithmetic: lea rax, [rdi + rsi*2 + 8] computes rdi + 2*rsi + 8 and stores the result in rax — all in one instruction. The CPU's address generation unit can do base + index*scale + offset for scale in {1,2,4,8}. Compilers exploit this for integer arithmetic even when no address is being computed.
Test-itself: test eax, eax is cheaper than cmp eax, 0. It performs eax AND eax, sets ZF if zero and SF if negative — exactly what you need for if (x == 0) or if (x < 0) — without using a second register or an immediate.
Conditional moves (cmov): cmovne eax, ecx means "if the Zero Flag is not set, set eax = ecx." The compiler uses these instead of branches to avoid branch misprediction penalties. A branchless maximum: cmp edi, esi; cmovl edi, esi; mov eax, edi.
Return value in eax: almost every integer-returning function on x86-64 ends with the result in eax or rax. If you see an xor eax, eax; ret, the function is returning 0. If you see mov eax, 1; ret, it is returning 1.
// Observe these idioms on Godbolt with -O2
int max(int a, int b) {
return a > b ? a : b;
}
// Output (x86-64 -O2):
// max:
// cmp edi, esi
// cmovle edi, esi // branchless: if a <= b, a = b
// mov eax, edi
// ret
int mul5(int x) { return x * 5; }
// Output:
// mul5:
// lea eax, [rdi + rdi*4] // eax = x + 4x = 5x
// ret
int is_zero(int x) { return x == 0; }
// Output:
// is_zero:
// test edi, edi
// sete al // al = 1 if ZF, else 0
// movzx eax, al
// ret
11Comparing Architectures: ARM64 and RISC-V
x86-64 is the dominant server and desktop architecture, but the Linux kernel runs on dozens of architectures. ARM64 (AArch64) powers virtually all mobile devices and an increasing number of servers (Apple M-series, AWS Graviton, Ampere). RISC-V is an open-source ISA gaining traction in embedded systems and research.
ARM64 key differences from x86-64:
- 31 general-purpose 64-bit registers:
x0–x30(withx0sometimes used as a zero register,x30as the link register / return address,spas the stack pointer). - Load/store architecture: arithmetic instructions only operate on registers, never memory. To add a memory value, you must first
ldr(load register) it, compute, thenstr(store register) the result back. x86-64 can compute directly from memory with instructions likeadd eax, [rbx]. - Fixed 4-byte instruction size (vs. x86-64's variable 1–15 bytes). This simplifies instruction fetch and decode.
- Condition codes work differently: ARM uses
NZCVflags, and many instructions have conditional variants (e.g.,CINC,CSEL).
RISC-V key differences:
- 32 registers:
x0(always zero),x1(return address),x2(stack pointer),x3–x31. - Extremely clean, minimal instruction set. No condition codes — comparisons store a boolean result into a register (
slt rd, rs1, rs2setsrd = (rs1 < rs2)). - Branches compare two registers directly:
blt a0, a1, label(branch ifa0 < a1).
Godbolt supports both. Try compiling your C functions for ARM64 gcc 13.2 and RISC-V rv64gc gcc 13.2 to see how different ISAs express the same computation. This is invaluable preparation for kernel porting work.
// Same function — three architectures
int abs_val(int x) {
return x < 0 ? -x : x;
}
// x86-64 GCC -O2:
// abs_val:
// mov eax, edi
// neg eax ; eax = -x
// cmovns eax, edi ; if x >= 0, use original
// ret
// ARM64 GCC -O2:
// abs_val:
// cmp w0, 0
// cneg w0, w0, mi ; conditionally negate if minus
// ret
// RISC-V rv64gc GCC -O2:
// abs_val:
// srai a5, a0, 31 ; a5 = sign mask (0 or -1)
// xor a0, a0, a5 ; flip bits if negative
// sub a0, a0, a5 ; add 1 if negative (two's complement trick)
// ret
Key insights
- ✦The compiler does not translate C to assembly — it translates C's *observable behavior* to assembly. Any internal structure (variables, loops, intermediate values) is fair game to be eliminated, reordered, or replaced as long as the outside world cannot tell.
- ✦Undefined behavior is not just 'crashes may happen.' It is a compile-time license the compiler uses to delete code, invert logic, and create security vulnerabilities. The kernel team treats UB as a bug category as serious as a memory corruption.
- ✦The calling convention is the ABI's DNA: knowing that the first argument is in rdi and the return value is in rax lets you read any x86-64 function in isolation, without context.
- ✦Godbolt is not a toy — it is the primary tool that GCC/LLVM developers, kernel hackers, and performance engineers use daily to verify that a code change produces the assembly they expect. Get comfortable with it.
- ✦The difference between -O0 and -O2 is not just speed — it is a completely different program structure. A bug that only appears at -O2 is almost always either a UB exploitation or a race condition (where -O0's slower pace masks the timing).
- ✦LEA (Load Effective Address) is a Swiss Army knife: the compiler uses the CPU's address calculation hardware to do arbitrary multiply-and-add arithmetic in one clock cycle, often replacing several arithmetic instructions.
- ✦ARM64 and RISC-V are load-store architectures: this fundamental difference from x86-64 means the assembly looks more verbose but is more regular and easier to reason about — important if you plan to work on kernel ports.
Common pitfalls
- ⚠Confusing AT&T and Intel syntax: AT&T writes 'movq $42, %rax' (source, then destination) while Intel writes 'mov rax, 42' (destination, then source). Always set -masm=intel on Godbolt when learning — AT&T's sigils obscure the structure.
- ⚠Thinking -O0 output is 'correct' and -O2 is 'optimized but equivalent': both are correct. The -O0 output just happens to be a more literal translation. Never assume the -O0 variable layout is what the CPU sees at runtime when you compile with -O2.
- ⚠Ignoring padding when reasoning about struct sizes: a struct { char a; int b; } is NOT 5 bytes — it is 8 bytes due to alignment padding. This causes buffer overruns when code assumes the wrong size.
- ⚠Assuming volatile prevents all compiler reordering: volatile prevents the compiler from eliminating or reordering volatile accesses relative to each other, but it does not provide memory ordering guarantees against the CPU's out-of-order execution. For concurrency in the kernel, you need memory barriers (smp_rmb, smp_wmb, READ_ONCE, WRITE_ONCE).
- ⚠Trying to read a full function's assembly without isolating it: paste 300 lines of C into Godbolt and you will be overwhelmed. Always isolate the one function you are studying. Mark helpers as __attribute__((noinline)) to see their assembly separately.
- ⚠Mistaking a jump table for a data section: when you see .rodata labels with a series of addresses, followed by a 'jmp QWORD PTR [rax*8+table]', that is a switch statement's jump table — not arbitrary data. Recognizing this pattern saves significant confusion.
Practice
- 01easy
Open godbolt.org. Write a function `int factorial(int n)` with a recursive implementation. Compile it with GCC x86-64 at -O0 and then -O2. At -O2, does the recursion disappear? Is there a `call factorial` in the output? If not, what happened? Now add __attribute__((noinline)) to the helper and recheck. Write 3-5 sentences explaining what you observed.
- 02easy
Write a struct with fields ordered poorly (small char, large double, int, char) and then reorder them for minimum size. Verify using Godbolt by writing a function that returns sizeof(struct bad) and sizeof(struct good) — the compiler will fold these to constants you can read directly in the assembly as immediate values. How many bytes did you save? What is the offset of each field in each version?
- 03medium
Recreate the UB null-pointer-check-deletion bug in Godbolt. Write a function that dereferences a pointer and THEN checks if it is null. Compile at -O0 and -O2. In the -O2 output, find where the null check should be and confirm it is missing. Then fix the function by moving the check before the dereference, and confirm the check is present in the fixed -O2 output.
- 04medium
Write a function that computes the dot product of two int arrays of length 4 (unrolled, no loop). Compile for x86-64 at -O2 and for ARM64 at -O2. Count the number of memory load instructions in each output (mov with a memory operand on x86-64, ldr on ARM64). Are they the same? Explain why ARM64 requires explicit load instructions while x86-64 can fold them into arithmetic instructions.
- 05hard
Implement a simple integer stack (push/pop with a capacity of 16 ints using a global array and a global top index). Compile the push and pop functions at -O2. Then change the global array and top to be local to a struct that you pass by pointer. Compare the assembly. Which version generates better code, and why? (Hint: think about aliasing — can the compiler assume the global top index is not modified by writing to the array?)
- 06hard
Use Godbolt's diff mode to compare GCC -O2 vs Clang -O2 output for a non-trivial function (try a binary search or a CRC32 computation). Document at least 3 places where the two compilers made different choices. Neither is wrong — they are exercising different heuristics. Which do you prefer, and why? This is the kind of analysis you would do when choosing a compiler for a performance-critical kernel subsystem.
Resources
The essential tool for this entire module — compile any C/C++/Rust/Go in your browser and see assembly in real time.
The creator of Compiler Explorer walks through exactly the kind of analysis this module teaches — essential watching.
The authoritative document on calling conventions, struct layout rules, and register usage on Linux x86-64.
The gold standard textbook for this material — chapter 3 covers x86-64 assembly with C mapping in exhaustive depth.
Exhaustive reference for every -O flag and what it enables — understand exactly what -O2 turns on vs -O1.
Three-part series by the creator of LLVM/Clang — the most important reading on UB exploitation, directly relevant to kernel C.
The authoritative reference for every x86 instruction — use Volume 2 for instruction encodings and semantics.
The canonical ARM64 ISA reference — free download with registration; essential for kernel work on ARM hardware.
Not assembly-specific, but the kernel's rationale for avoiding clever tricks maps directly to what you learn by reading compiler output.
World-class reference on CPU microarchitecture and how to write C/asm that maps to fast machine code — required reading for performance work.
Checkpoints — you can now…
- ✓You can open godbolt.org, paste a C function, select GCC x86-64, and switch between Intel and AT&T syntax without confusion.
- ✓You can look at any x86-64 function's assembly and immediately identify: which register holds the first argument, where the return value lives, and where the function returns.
- ✓You can compile the same function at -O0 and -O2 and explain at least three concrete differences in the generated code (e.g., stack spilling eliminated, loop structure changed, constant folded).
- ✓You can write a struct, predict its size including padding, and verify your prediction by reading the sizeof constant from the assembly output.
- ✓You can identify a backward jump in assembly and explain that it is the bottom of a loop, and identify a jump table and explain that it came from a dense switch statement.
- ✓You can explain, with a concrete code example, why dereferencing a pointer before checking if it is null is undefined behavior that the compiler may exploit to delete the null check.
- ✓You can compare assembly output for the same function compiled for x86-64 vs ARM64 and explain why ARM64 requires explicit load instructions where x86-64 can fold them into arithmetic.