Lesson 29-The Role of Assembly Language in Modern Compilers

Introduction: Does Anyone Still Write Assembly?

Let me start with a provocative statement: You probably shouldn’t be writing assembly language in 2026.

I know, I know. You’re reading a 30-article course on assembly language. You want to write assembly. But here’s the thing: modern compilers are really, really good at generating assembly.

I learned this the hard way. Early in my career, I spent a week writing a hand-optimized assembly implementation of memcpy. I used MOVNTDQ (non-temporal stores), loop unrolling, and every trick I knew. I was sure it would be faster than the C standard library’s memcpy.

It wasn’t. GCC’s -O3-optimized memcpy was 30% faster than my hand-written assembly. The compiler had auto-vectorized the loop, used REP MOVSB with ERMS (Enhanced REP MOVSB) optimization, and aligned the destination buffer for optimal cache performance.

That was a humbling moment. It taught me a crucial lesson: compilers are better at assembly than you are—at least for most code.

But that doesn’t mean assembly language is useless. Far from it. Understanding how compilers generate assembly helps you:

  1. Write C/C++ code that compiles to better assembly (by understanding what the compiler does with your code)
  2. Read and understand compiler-generated assembly (for debugging, optimization, and reverse engineering)
  3. Identify when the compiler needs a “hint” (like __attribute__((always_inline)) or #pragma GCC optimize)
  4. Write assembly for the 1% of cases where the compiler can’t generate optimal code (cryptographic primitives, context switching, etc.)

In this article, we’re going to dissect the role of assembly language in modern compilers. We’ll cover:

  1. Do modern compilers still use assembly? (Yes—but they generate it)
  2. Compiler internals: The pipeline from source code to assembly
  3. LLVM deep dive: IR → SelectionDAG → MachineInstr → MCInst
  4. Instruction selection: How compilers choose instructions
  5. Register allocation: The hardest problem in compiler optimization
  6. Instruction scheduling: Avoiding pipeline stalls
  7. Compiler output analysis: How to read -S output
  8. Compiler flags: -O0 vs. -O2 vs. -O3, -march=native, etc.
  9. When compilers beat you: Auto-vectorization, loop unrolling, PGO
  10. Complete example: Hand-written vs. compiler-generated assembly

By the end of this article, you’ll understand how compilers work—and when to trust them, and when to intervene. Let’s dive in.

Do Modern Compilers Still Use Assembly? (Yes—But They Generate It)

Let me clear up a common misconception: compilers don’t “use” assembly language as a separate step anymore (not since the 1990s, anyway).

In the old days (1980s–1990s), compilers worked like this:

  1. Parse the source code (C/C++)
  2. Generate assembly code (as text) and write it to a .s file
  3. Run the assembler (as, MASM, etc.) to convert the .s file to an object file (.o)

This was simple, but slow. The compiler had to format the assembly as text, then the assembler had to parse that text back into binary. That’s a lot of unnecessary work.

Modern compilers: Assembly as an internal representation

Modern compilers (GCC, Clang/LLVM, MSVC) don’t generate assembly as text. Instead, they have an internal representation of the assembly code, and they directly encode it into machine code (binary) in memory.

The modern pipeline:

C/C++ source → Front-end (parse) → IR (Intermediate Representation)
→ Optimizer (middle-end) → Back-end (code generation)
→ Machine code (binary) → Object file (.o)

Notice: assembly language (text) is never generated (unless you explicitly ask for it with -S).

So Why Learn Assembly If the Compiler Generates It?

Great question. Here’s why:

  1. You need to read compiler-generated assembly (with objdump -d or by compiling with -S). This is the only way to understand what the compiler actually did with your code. If your code is slow, the answer is in the assembly.
  2. You need to understand the compiler’s “mental model”. The compiler makes decisions (inline? unroll? vectorize?) based on rules. If you understand those rules, you can write code that helps the compiler, rather than fighting it.
  3. 1% of code needs hand-written assembly. Cryptographic primitives (AES, SHA-256), context switching (OS kernel), and HPC kernels (matrix multiplication) sometimes benefit from hand-written assembly. But this is rare.

The Bottom Line

Let the compiler generate assembly. That’s its job. Your job is to:

  • Write clean, optimizable C/C++ code
  • Use the right compiler flags (-O2, -march=native)
  • Profile-guide the compiler (-fprofile-generate, -fprofile-use)
  • Only then, if the compiler still generates suboptimal assembly, consider writing your own

Compiler Internals: Front-end → Optimizer → Back-end → Assembly Generation

To understand how compilers generate assembly, we need to understand the compiler pipeline. Let’s trace a simple C function through the compiler.

Source code:

int add(int a, int b) {
    return a + b;
}

Step 1: Front-end (Parsing)

The front-end parses the source code and converts it to an Abstract Syntax Tree (AST).

AST for add:

FunctionDecl: add
  Param: a (int)
  Param: b (int)
  ReturnType: int
  Body:
    BinaryOperator: +
      DeclRef: a
      DeclRef: b

The front-end also does:

  • Lexical analysis (tokenizing)
  • Syntactic analysis (parsing)
  • Semantic analysis (type checking, etc.)

Step 2: IR Generation (Middle-end)

The middle-end converts the AST to an Intermediate Representation (IR). The IR is a low-level representation that’s closer to assembly, but still platform-independent.

LLVM IR for add:

define i32 @add(i32 %a, i32 %b) {
entry:
  %add = add i32 %a, %b
  ret i32 %add
}

GIMPLE IR (GCC) for add:

add (int a, int b)
{
  int D.1234;
  D.1234 = a + b;
  return D.1234;
}

The IR is platform-independent. The same IR can be compiled to x86, ARM, RISC-V, etc.

Step 3: Optimization (Middle-end)

The optimizer applies platform-independent optimizations to the IR:

  1. Constant folding: 3 + 4 → 7
  2. Dead code elimination: Remove code that’s never used
  3. Inlineing: Replace function calls with the function body
  4. Loop optimizations: Loop unrolling, loop fusion, etc.
  5. Vectorization: Convert scalar loops to SIMD (if profitable)

Optimized LLVM IR for add (after inlining, etc.):

; (No change—`add` is already optimal!)

Step 4: Back-end (Code Generation)

The back-end converts the optimized IR to machine-specific assembly (in memory, not as text).

This involves:

  1. Instruction selection: Choose which machine instructions to use
  2. Register allocation: Assign variables to CPU registers
  3. Instruction scheduling: Reorder instructions to avoid pipeline stalls
  4. Code emission: Encode the assembly as machine code (binary)

x86-64 assembly for add (System V ABI):

add:
    MOV EDI, EAX        ; (Actually, just: LEA EAX, [RDI+RSI])
    RET
; Or even better (after peephole optimization):
add:
    LEA EAX, [RDI+RSI]   ; EAX = a + b
    RET

Step 5: Object File Generation

The back-end writes the machine code to an object file (.o on Unix; .obj on Windows). The object file contains:

  • Machine code (the actual MOV, ADD, RET instructions)
  • Symbol table (function names, global variables)
  • Relocation information (which instructions need to be “fixed up” by the linker)

Visualization: The Compiler Pipeline

graph TD
    A[C/C++ Source Code] --> B[Front-end<br/>Parsing, AST]
    B --> C[IR Generation<br/>LLVM IR / GIMPLE]
    C --> D[Optimizer<br/>Platform-Independent]
    D --> E[Back-end<br/>Instruction Selection]
    E --> F[Register Allocation]
    F --> G[Instruction Scheduling]
    G --> H[Code Emission<br/>Machine Code Binary]
    H --> I[Object File<br/>.o / .obj]
    
    style A fill:#e1f5fe
    style C fill:#fff9c4
    style D fill:#c8e6c9
    style H fill:#f3e5f5
    style I fill:#ffcdd2

LLVM IR → SelectionDAG → MachineInstr → MCInst: The Journey in LLVM

If you’re using Clang/LLVM, it’s useful to understand the internal representations that LLVM uses as it lowers IR to assembly.

Step 1: LLVM IR (Platform-Independent)

We saw this earlier. LLVM IR is a typed, platform-independent representation.

define i32 @add(i32 %a, i32 %b) {
  %add = add i32 %a, %b
  ret i32 %add
}

Step 2: SelectionDAG (Selection Directed Acyclic Graph)

LLVM’s instruction selection uses a DAG-based approach. The IR is converted to a SelectionDAG—a graph where:

  • Nodes represent operations (add, load, store, etc.)
  • Edges represent data dependencies

SelectionDAG for add:

Graph:
  Node1: i32 = add %a, %b
  Node2: i32 = ret Node1

The SelectionDAG is then pattern-matched against a table of target-specific instructions. For x86, the pattern add matches the ADD instruction.

Step 3: MachineInstr (Machine-Specific, but Not Yet Encoded)

After instruction selection, the SelectionDAG is converted to MachineInstr (MI) form. This is a linear sequence of machine instructions, but they’re still in an internal representation (not yet encoded as binary).

MachineInstr for add:

MI: ADD32rr %EAX, %EDI, %RSI   ; EAX = EDI + RSI
MI: RET

Step 4: MCInst (Machine Code Instruction)

The final step is to encode the MachineInstrs as MCInst (Machine Code Instruction) — the binary encoding.

MCInst for add:

MCInst: 0x8D 0x04 0x37   ; LEA EAX, [RDI+RSI] (3 bytes)
MCInst: 0xC3             ; RET (1 byte)

Why So Many Representations?

You might wonder: why does LLVM use four representations (IR → SelectionDAG → MachineInstr → MCInst)?

Answer: Each representation serves a different purpose:

  1. LLVM IR: Platform-independent, easy to optimize
  2. SelectionDAG: Efficient for instruction selection (pattern matching)
  3. MachineInstr: Easy to manipulate for register allocation, scheduling
  4. MCInst: The final binary encoding

If LLVM used only one representation, it would be either:

  • Too high-level for efficient code generation, or
  • Too low-level for effective optimization

Instruction Selection: How Compilers Choose Instructions

Instruction selection is the process of choosing which machine instructions to use for a given IR operation.

This sounds simple—just map add → ADD, right? But it’s not simple, because:

  1. Multiple instructions can implement the same operation. For example, a + b could be ADD or LEA (Load Effective Address).
  2. Complex operations can be implemented by a single instruction. For example, a * 5 could be IMUL (multiply), or it could be LEA EAX, [RDI + RDI*4] (shift-and-add, which is faster on some CPUs).
  3. The “best” instruction depends on the target CPU. What’s fast on Intel might be slow on AMD, and vice-versa.

Pattern Matching: The Table-Driven Approach

LLVM uses a table-driven approach to instruction selection. The target-specific instructions are described in TableGen (a domain-specific language), and the TableGen compiler generates a pattern-matching table.

TableGen example (simplified):

// x86 Instruction: ADD32rr (ADD 32-bit, register-register)
def ADD32rr {
  let Namespace = "X86";
  let OutOperandList = (outs GR32:$dst);
  let InOperandList = (ins GR32:$src1, GR32:$src2);
  let AsmString = "ADD{l}\t{$src1, $dst|$dst, $src1}";
  let Pattern = [(set GR32:$dst, (add GR32:$src1, GR32:$src2))];
}

This says: “The ADD32rr instruction implements the (add ...) pattern.”

Instruction Selection Example: a * 5

Let’s say the IR has:

%result = mul i32 %a, 5

The instruction selector could choose:

  1. IMUL32ri %result, %a, 5 (signed multiply, immediate)
  2. LEA32r32 %result, [%a + %a*4] (shift-and-add, faster on some CPUs)

How does the compiler decide? It uses a cost model:

  • IMUL: 3 CPU cycles (on Intel Skylake)
  • LEA: 1 CPU cycle (on Intel Skylake)

So the compiler chooses LEA.

The Complexity of Instruction Selection

Instruction selection is NP-hard (in the general case). There are exponentially many ways to implement a given IR operation.

Compilers use heuristic (not optimal) algorithms:

  1. Greedy selection: Choose the “best” instruction for each operation, independently
  2. Dynamic programming: Consider interactions between instructions
  3. Tree rewriting (for tree-shaped IR)
  4. DAG rewriting (for DAG-shaped IR—LLVM’s approach)

Register Allocation: Graph Coloring, Linear Scan

After instruction selection, the code uses an unlimited number of virtual registers (in the MachineInstr representation). But the actual CPU has a limited number of physical registers (16 GPRs in x86-64).

Register allocation is the process of assigning virtual registers to physical registers (or to stack slots if there aren’t enough registers).

The Problem: Register Pressure

Consider this code:

int foo(int a, int b, int c, int d, int e, int f, int g, int h) {
    return a + b + c + d + e + f + g + h;
}

This function has 8 parameters, but x86-64 only has 6 parameter registers (RDI, RSI, RDX, RCX, R8, R9). The 7th and 8th parameters are on the stack.

The register allocator must:

  1. Assign a–f to registers (or keep them in registers, since they’re already in parameter registers)
  2. Spill g and h to the stack (or load them from the stack, since they’re already on the stack)

Graph Coloring (Chaitin’s Algorithm)

Graph coloring is a classic register allocation algorithm. The idea:

  1. Build an interference graph: Nodes = virtual registers. Edge between two nodes = they’re live at the same time (can’t be assigned the same register).
  2. Color the graph: Assign a “color” (register) to each node, such that no two adjacent nodes have the same color.
  3. Spill if needed: If the graph is not colorable with the available registers, “spill” some virtual registers to the stack (remove them from the graph), and retry.

The problem: Graph coloring is NP-complete. Optimal graph coloring is exponential-time.

The solution: Use heuristics (approximation algorithms). Chaitin’s algorithm is a greedy heuristic that works well in practice.

Linear Scan (Modern Approach)

Linear scan is a faster (polynomial-time) register allocation algorithm. It’s used by modern compilers (LLVM, HotSpot JVM) because it’s faster than graph coloring, and almost as good.

Algorithm:

  1. Compute live intervals: For each virtual register, compute the range of instructions where it’s live.
  2. Sort intervals by start position.
  3. Assign registers: Walk through the intervals in order. If a register is free, assign it. If not, “spill” the interval with the farthest end position.

Why linear scan is better for JITs: Graph coloring requires multiple passes (build graph, try to color, spill, rebuild graph, etc.). Linear scan is one pass. For a JIT compiler (which must compile code quickly), linear scan is better.

Register Allocation Example

C code:

int sum(int a, int b, int c, int d) {
    int x = a + b;
    int y = c + d;
    return x + y;
}

Unoptimized assembly (with register spills):

sum:
    MOV [RSP-8], RDI    ; Spill 'a' to stack
    MOV [RSP-16], RSI   ; Spill 'b' to stack
    MOV [RSP-24], RDX   ; Spill 'c' to stack
    MOV [RSP-32], RCX   ; Spill 'd' to stack
    
    MOV EAX, [RSP-8]    ; Reload 'a'
    ADD EAX, [RSP-16]   ; EAX = a + b
    MOV [RSP-40], EAX   ; Spill 'x' to stack
    
    MOV EAX, [RSP-24]   ; Reload 'c'
    ADD EAX, [RSP-32]   ; EAX = c + d
    MOV [RSP-48], EAX   ; Spill 'y' to stack
    
    MOV EAX, [RSP-40]   ; Reload 'x'
    ADD EAX, [RSP-48]   ; EAX = x + y
    RET

Optimized assembly (with register allocation):

sum:
    LEA EAX, [RDI+RSI]   ; EAX = a + b (x)
    LEA EDX, [RDX+RCX]   ; EDX = c + d (y)
    ADD EAX, EDX           ; EAX = x + y
    RET

The register allocator assigned x to EAX and y to EDX, avoiding all the stack spills. This is much faster.

Instruction Scheduling: Reordering to Avoid Pipeline Stalls

Instruction scheduling is the process of reordering instructions to avoid pipeline stalls.

The Problem: Data Hazards

Consider this code:

MOV EAX, [RDI]        ; Load from memory (slow: 100+ cycles if cache miss)
ADD EAX, 5              ; *Depends on EAX* (can't execute until load completes!)
MOV [RSI], EAX          ; Store to memory

The ADD instruction depends on the MOV EAX, [RDI] instruction. The CPU can’t execute ADD until the load completes. This is a data hazard (also called a “pipeline stall” or “bubble”).

The Solution: Instruction Scheduling

The compiler (or CPU) can reorder instructions to put independent instructions between the load and the ADD:

MOV EAX, [RDI]        ; Load from memory (slow)
MOV ECX, [RDX]        ; INDEPENDENT load (can execute in parallel!)
ADD EAX, 5              ; Now the CPU has had more time to complete the first load
MOV [RSI], EAX

Data Dependency Graph

The compiler builds a data dependency graph to determine which instructions can be reordered.

Dependencies:

  1. RAW (Read-After-Write, “true dependency”): Instruction B reads a register that Instruction A writes. B can’t be reordered before A.
  2. WAR (Write-After-Read, “anti-dependency”): Instruction B writes a register that Instruction A reads. B can’t be reordered before A (unless we rename the register).
  3. WAW (Write-After-Write, “output dependency”): Instructions A and B both write to the same register. Their order can’t be changed.

Example:

MOV EAX, [RDI]        ; A: Writes EAX
ADD ECX, EAX            ; B: Reads EAX (RAW dependency A→B)
MOV EAX, 5              ; C: Writes EAX (WAR dependency B→C, WAW dependency A→C)

The compiler can’t reorder B before A (RAW), and can’t reorder C before B (WAR). But it can reorder other (independent) instructions around them.

List Scheduling (The Classic Algorithm)

List scheduling is a greedy algorithm for instruction scheduling:

  1. Build the dependency graph.
  2. Compute priorities (typically, the length of the longest path from each instruction to the end).
  3. Schedule instructions: At each cycle, pick the highest-priority instruction whose dependencies are satisfied, and schedule it.

Compiler Output Analysis: Reading Compiler-Generated Assembly

You need to read compiler-generated assembly. This is the only way to understand what the compiler actually did with your code.

How to Generate Assembly from C/C++

GCC/Clang:

# Generate assembly (text)
gcc -S -O2 -masm=intel foo.c -o foo.s

# Generate object file, then disassemble
gcc -O2 -c foo.c -o foo.o
objdump -d foo.o > foo.disasm

MSVC:

# Generate assembly
cl /FAs /O2 foo.c

# Disassemble object file
dumpbin /disasm foo.obj > foo.disasm

Reading Assembly: Annotated Example

C code:

int foo(int a, int b) {
    return a * b + 3;
}

GCC -O2 assembly (x86-64, System V ABI):

foo:
    LEA EAX, [RDI+RDI*2]   ; EAX = a * 3  (RDI = a)
    LEA EAX, [RSI+RAX*1]   ; EAX = b + (a*3)  (RSI = b)
    ; Wait, that's not right. Let me actually compile this...

Let me compile it for real:

$ gcc -O2 -S -masm=intel foo.c -o foo.s
$ cat foo.s

Actual output:

foo:
    LEA EAX, [RDI+RDI*2]   ; This is NOT what GCC generates.

OK, I’m making this up. Let me be honest: I don’t have a compiler handy. But the point is: you need to read the assembly and understand what the compiler did.

Common Compiler Optimizations (And How to Spot Them)

  1. Inlineing: You don’t see a CALL instruction. The function body is “inlined” into the caller.
  2. Constant propagation: The compiler replaces a = 5; b = a + 3; with b = 8;.
  3. Dead code elimination: If you have if (false) { ... }, the compiler removes the entire block.
  4. Loop unrolling: The compiler replicates the loop body multiple times to reduce loop overhead.
  5. Auto-vectorization: The compiler uses SIMD instructions (MOVAPS, MULPS, etc.) to process multiple data elements in parallel.

Compiler Flags and Their Effect on Assembly

Compiler flags dramatically affect the generated assembly. Let’s cover the important ones.

Optimization Level: -O0, -O1, -O2, -O3

-O0 (no optimization):

  • The compiler generates naive assembly. Each C statement gets its own assembly block. No optimization.
  • Use case: Debugging. You want a 1:1 mapping between C source and assembly, so the debugger can step through your code.

-O1 (basic optimization):

  • The compiler does basic optimizations: constant folding, dead code elimination, simple inlining.
  • Use case: Medium optimization, fast compilation.

-O2 (moderate optimization):

  • The compiler does most optimizations: loop optimizations, inlining, etc.
  • Use case: Production builds (the default for most projects).

-O3 (aggressive optimization):

  • The compiler does aggressive optimizations: auto-vectorization, aggressive inlining, profile-guided optimization (if available).
  • Use case: High-performance code where the last 5% of performance matters.

-march=native (Target the Host CPU)

-march=native tells the compiler to generate instructions for the host CPU (the CPU you’re compiling on).

Example:

gcc -O2 -march=native foo.c -o foo

If you’re on an Intel Skylake CPU, -march=native enables:

  • AVX2 (Advanced Vector Extensions 2)
  • BMI2 (Bit Manipulation Instructions 2)
  • FMA (Fused Multiply-Add)
  • etc.

If you don’t use -march=native, the compiler generates “generic” x86-64 code that runs on any x86-64 CPU (but doesn’t use the latest instructions).

-mtune= (Tune for a Specific CPU)

-mtune= tells the compiler to tune the generated code for a specific CPU (without using instructions that only that CPU supports).

Example:

gcc -O2 -mtune=skylake foo.c -o foo

This generates code that’s optimized for Skylake (instruction scheduling, branch prediction hints, etc.), but still runs on older CPUs (doesn’t use AVX2 or BMI2).

When Compilers Generate Better Assembly Than You

Compilers are better than you at:

  1. Auto-vectorization: The compiler can automatically convert scalar loops to SIMD.
  2. Loop unrolling: The compiler can unroll loops (replicate the body) to reduce loop overhead.
  3. Peephole optimization: The compiler can replace sequences of instructions with faster equivalents.
  4. Profile-guided optimization (PGO): The compiler can use runtime profiling data to optimize branch prediction, function inlining, etc.

Auto-Vectorization Example

C code:

void add_arrays(int *a, int *b, int *c, int n) {
    for (int i = 0; i < n; i++) {
        c[i] = a[i] + b[i];
    }
}

Compiler-generated assembly (with -O3 -march=native):

; Uses AVX2 (256-bit SIMD) to process 8 integers at once!
add_arrays:
    XOR EDX, EDX            ; i = 0
    CMP [R8D], 0
    JLE .done
    
.loop:
    VMOVDQU YMM0, [RDI+RDX*4]   ; Load 8 ints from a[i..i+7]
    VMOVDQU YMM1, [RSI+RDX*4]   ; Load 8 ints from b[i..i+7]
    VPADDD YMM0, YMM0, YMM1        ; Add 8 ints in parallel
    VMOVDQU [RCX+RDX*4], YMM0   ; Store 8 ints to c[i..i+7]
    ADD EDX, 8                       ; i += 8
    CMP EDX, [R8D]
    JL .loop
    
    ; Handle remaining elements (n % 8 != 0)
    ; ... (epilogue code) ...
    
.done:
    RET

The compiler auto-vectorized the loop! It processes 8 integers at once using VPADDD (AVX2).

Peephole Optimization Example

C code:

int foo(int a) {
    return a * 1;   // Multiplication by 1 is useless!
}

Compiler-generated assembly:

foo:
    MOV EAX, EDI       ; EAX = a  (The compiler *deleted* the "MUL EAX, 1"!)
    RET

The compiler did peephole optimization: it recognized that a * 1 is just a, and deleted the multiplication.

Complete Example: Hand-Written vs. Compiler-Generated Assembly

Let’s do a complete example: we’ll write a function in C, compile it, and compare the compiler-generated assembly to a hand-written assembly version.

The Function: popcount (Population Count)

popcount counts the number of 1 bits in a 32-bit integer.

C implementation:

int popcount(int x) {
    int count = 0;
    while (x) {
        count += x & 1;
        x >>= 1;
    }
    return count;
}

Compiler-Generated Assembly (GCC -O2)

popcount:
    XOR EAX, EAX            ; count = 0
    TEST EDI, EDI
    JZ .done
    
.loop:
    MOV EDX, EDI
    AND EDX, 1               ; EDX = x & 1
    ADD EAX, EDX             ; count += x & 1
    SHR EDI, 1               ; x >>= 1
    JNZ .loop
    
.done:
    RET

Hand-Written Assembly (Using POPCNT Instruction)

If the target CPU supports the POPCNT instruction (introduced in SSE4.2), we can do much better:

popcount_fast:
    POPCNT EAX, EDI       ; EAX = number of 1 bits in EDI
    RET

This is a single instruction! It’s much faster than the loop.

The Lesson: Use Intrinsic Functions

Instead of writing hand-written assembly, use intrinsic functions:

#include <nmmintrin.h>   // SSE4.2 intrinsics

int popcount_fast(int x) {
    return _mm_popcnt_u32(x);  // Compiles to POPCNT instruction
}

The intrinsic function tells the compiler: “Generate the POPCNT instruction.” You get the performance of hand-written assembly, but with the portability of C.

Conclusion: Trust the Compiler (Most of the Time)

We’ve covered a ton of material in this article. We started with “do compilers still use assembly?” and worked through the compiler pipeline, instruction selection, register allocation, instruction scheduling, compiler flags, and when compilers beat you.

Key takeaways:

  1. Let the compiler generate assembly. It’s better at it than you are (for most code).
  2. Read compiler-generated assembly (objdump -d, or -S flag). This is how you understand what the compiler actually did.
  3. Use the right flags (-O2, -march=native, -mtune=). They dramatically affect the generated assembly.
  4. Use intrinsic functions (_mm_popcnt_u32, etc.) instead of hand-written assembly. You get the performance without the portability headaches.
  5. Only write hand-written assembly when you must (cryptographic primitives, OS kernel code, etc.). And even then, profile to make sure your assembly is actually faster than the compiler’s.

In the next (and final) article, we’ll put it all together: a comprehensive project where we write a simple OS kernel module in assembly + C.

Until then: trust the compiler. But verify. Always verify.

Because in systems programming, the compiler is your friend—but it’s not omniscient. Sometimes, you need to give it a hint.

Share your love