Code Generation

Code generation is where the optimized intermediate representation finally becomes real, concrete assembly instructions for one specific CPU architecture — the point where a compiler's back end truly commits to a target's actual registers, instructions, and calling conventions.

Code generation translates the optimized IR into actual assembly language (or directly into machine code) for a specific target CPU architecture — choosing real instructions, real registers, and a real memory layout for everything the program needs to do.

The IR is deliberately abstract and mostly architecture-independent; a real CPU has none of that abstraction — it has a fixed, specific set of registers and instructions. Code generation is the necessary bridge between the compiler's convenient internal representation and the concrete reality of the hardware that will actually run the program.

Every compiler that produces native executables performs code generation as one of its final internal steps — and the exact instructions generated differ meaningfully between target architectures like x86-64, ARM, and RISC-V, even from the exact same source code and IR.

Think of an architect's abstract floor plan (the IR) being handed to actual construction crews in different countries, each with their own specific building codes, available materials, and construction techniques (different CPU architectures). The floor plan's intent stays the same, but the concrete building process — which specific materials, which specific techniques — differs by location. Code generation is that final, concrete, location-specific construction step.

The abstract IR operation "add a and b, store into x" becomes two concrete x86-64 instructions, using specific real registers (edi, esi) and a specific instruction (add, mov) that this particular CPU architecture actually understands — a different target architecture, like ARM, would generate different instructions and register names for the exact same underlying operation.

Instruction selection chooses which specific machine instructions best implement each IR operation — often not a one-to-one mapping, since a single IR operation might be implementable by several different instruction sequences with different performance trade-offs, or several IR operations might combine into a single, more powerful machine instruction.

Register allocation (introduced in the Compiler Optimization section) decides which of a CPU's limited physical registers hold which values at each point in the generated code — when there aren't enough registers for everything live at once, some values must be "spilled" to memory temporarily, which is slower than keeping a value in a register.

A calling convention (covered fully in the ABI & Calling Conventions section) dictates exactly how function arguments are passed (which registers, or the stack), where the return value goes, and which registers a called function must preserve — code generation must strictly follow the target platform's calling convention so that separately compiled pieces of code (including libraries you didn't write yourself) can correctly call each other.

Every function's generated code typically includes a prologue (setup code at the start — for example, saving the caller's frame pointer and allocating stack space for local variables) and an epilogue (teardown code at the end — restoring saved registers and returning control to the caller), both dictated by the target's calling convention and stack-frame layout.

Assuming there's one single correct assembly translation for a given piece of source code — instruction selection and register allocation both involve genuine choices, and different compilers (or the same compiler at different optimization levels) can legitimately produce different, equally correct assembly for identical source.

Forgetting that generated code must precisely respect the target's calling convention, since violating it (in hand-written assembly, or when interfacing between code compiled by different toolchains) causes subtle, hard-to-diagnose corruption rather than an obvious immediate error.

Assuming register allocation is a minor detail — a poor register allocation, causing frequent unnecessary spilling to memory, can measurably slow down otherwise well-optimized code, which is exactly why it's considered one of the most impactful problems in the entire code-generation stage.

Using Compiler Explorer (godbolt.org) to directly compare the actual generated assembly for the same source snippet across x86-64, ARM, and other architectures, to build concrete intuition for how differently the "same" program can look at the machine-code level.

Reading generated assembly (via a compiler's -S flag, or a disassembler) when trying to understand exactly why a specific piece of code performs the way it does — a technique especially valuable in performance-critical or embedded systems work.

Understanding a target architecture's calling convention when writing hand-optimized assembly, or when interfacing compiled code across language boundaries (calling C from another language, or vice versa), since mismatches here cause serious, hard-to-diagnose bugs.

Instruction selection as tree/DAG pattern matching A common implementation technique represents a basic block's computation as a tree or directed acyclic graph (DAG) of operations, then matches patterns against it corresponding to available machine instructions — some instructions match a single IR operation, others match a whole small subtree at once (for example, a single "multiply-add" instruction covering what would otherwise be two separate IR operations) — and the code generator picks a covering set of instruction patterns that's typically at least locally optimal, since finding the globally optimal covering is prohibitively expensive to compute exactly for realistically sized programs.

Graph-coloring register allocation A widely used formulation treats register allocation as a graph-coloring problem: build an "interference graph" where each node is a value that needs a register and edges connect values that are simultaneously live (and therefore can't share a register), then try to color that graph using only as many colors as there are physical registers — when a value truly can't be given a color (a register), it must be spilled to memory instead. This connects code generation directly to a well-studied area of computational complexity, and different practical allocation algorithms trade off allocation quality against how fast the compiler itself runs.

Instruction scheduling and target-specific tuning Beyond simply choosing correct instructions, code generation on a modern CPU often reorders instructions to account for the target's specific pipeline behavior — hiding memory-access latency behind other useful work, respecting how many instructions can genuinely execute in parallel on that specific microarchitecture, and avoiding known pipeline stalls or hazards. This tuning is often specific not just to an architecture family (x86-64) but to particular microarchitecture generations within that family, which is why compilers offer target-CPU-specific tuning flags in addition to just choosing the base instruction set.

What does the code-generation stage actually produce?

Concrete assembly (or directly machine code) for one specific target CPU architecture — translating the optimized, largely architecture-independent IR into real instructions, real registers, and a real stack-frame layout that hardware can actually execute.

Why can the same source code produce different assembly on x86-64 versus ARM?

Because each architecture has its own distinct instruction set, register set, and conventions — code generation is specifically the stage that commits to one target's concrete reality, so the same abstract IR operation is necessarily translated differently for different targets.

What happens when there aren't enough physical registers for every value that's simultaneously needed?

The register allocator "spills" some values to memory temporarily rather than keeping them in registers — correct, but slower than register access, which is why minimizing unnecessary spills is a major goal of register allocation.

What is a function prologue and epilogue?

The prologue is setup code generated at the start of a function (such as saving the caller's frame pointer and allocating stack space for locals); the epilogue is teardown code at the end (restoring saved registers and returning to the caller) — both dictated by the target platform's calling convention and stack-frame layout.

; assuming a is in register edi, b is in register esi
add  edi, esi     ; edi = edi + esi
mov  [x], edi      ; store the result into x's memory location