The C Path — learn C, visually

⚙️ Compiler & Toolchain Mastery

The pipeline, under the microscope

⏱ 15 min · free interactive lesson · quizzes, visualizations & a real compiler

▶ Open the interactive lesson — free, no signup
Why you're learning this

Some errors from gcc make no sense however long you stare at your code — "undefined reference to ..." points at nothing you can see. That's because gcc is really four programs running a relay — the same four-step journey you met back in Part 0 — and the complaint came from a different runner than the one you were watching. Stop the race at every handoff — exactly what you'll do in this lesson — and you'll always know which program to blame, and you'll get to read the actual instructions your CPU executes.

Way back in Part 0 you learned that gcc hello.c is secretly four programs in a trench coat. You've now written every kind of C there is — time to pop the hood for real. This lesson you'll stop the pipeline at every stage, read the assembly gcc produces (the human-readable spelling of your CPU's instructions), and dissect an object file — the halfway-there binary gcc normally hides — byte by byte.

This spot has an interactive flow widget — open the interactive lesson to play with it.

Stopping the assembly line wherever you like

GCC has a flag to halt after each stage — and one to keep every intermediate file:

flagstops afteroutput
-Epreprocessingexpanded source on stdout (.i)
-Scompilation properassembly text (.s)
-cassemblingobject file (.o)
-save-tempsnothing — runs it allkeeps .i, .s, .o and the executable
poking each stage
$ gcc -E hello.c | wc -l
731
# 731 lines! stdio.h and friends, fully pasted in. Your code is at the bottom:
$ gcc -E hello.c | tail -4
# 4 "hello.c"
int main(void) {
    printf("Hello, World!\n");
    return 0;
}
$ gcc -save-temps hello.c -o hello
$ ls hello*
hello  hello.c  hello.i  hello.o  hello.s

That # 4 "hello.c" line is a linemarker — it's how the compiler still reports errors against your file and line numbers even though it's actually chewing through 700+ lines of expanded headers. The preprocessor's whole output is just text; nothing has parsed your C yet.

🧠 Checkpoint: Which file does gcc -S hello.c produce, and what is in it?

  • hello.i — preprocessed C
  • hello.s — human-readable assembly
  • hello.o — machine code
  • hello — a runnable executable
Show answer

hello.s — human-readable assembly-S stops after the compiler proper: you get assembly text. Mnemonic: capital -S = .s file, lowercase -c = compile to object.

Reading the compiler's mind: -S

Here's a tiny function, and what gcc -S -O1 -masm=intel turns it into (Intel syntax reads nicer than the default AT&T: destination first, no % sigils):

square_add.c
int square_add(int a, int b) {
    return a * a + b;
}
square_add.s — gcc -S -O1 -masm=intel
square_add:
        imul    edi, edi          ; edi = a * a   (a arrived in edi)
        lea     eax, [rdi+rsi]    ; eax = a*a + b (b arrived in esi)
        ret                       ; result returns in eax

Three instructions, no stack frame, no ceremony. The x86-64 calling convention (System V) passes the first two integer arguments in edi and esi, and the return value travels back in eax — so ret alone is the whole "return statement". Note the compiler's cheeky trick: lea ("load effective address") is an address calculator, abused here to do an addition and write the result to a third register in one instruction.

🤔 What single instruction do you think -O2 emits for the body of int times5(int x) { return x * 5; }? (Hint: multiplication is slower than address arithmetic…)

Think first

lea eax, [rdi+rdi*4] — "address" = x + x*4 = 5x, computed by the address-generation unit in one cycle. No imul at all. Compilers strength-reduce multiplications by small constants into lea/shift combinations; check it on godbolt.org!

💡

Do this constantly: Compiler Explorer (godbolt.org) shows the assembly for your C live as you type, with source lines color-matched to instructions, across dozens of compilers and versions. It is the single best tool for building intuition about what compilers actually do. Bookmark it.

Inside an object file

After gcc -c, you hold an ELF relocatable object file: real machine code, plus a symbol table saying what it defines and what it still needs. Two classic tools crack it open — objdump -d disassembles, nm lists symbols:

objdump -d: disassembly
$ gcc -c -O1 square_add.c
$ objdump -d -M intel square_add.o

square_add.o:     file format elf64-x86-64

Disassembly of section .text:

0000000000000000 <square_add>:
   0:   0f af ff                imul   edi,edi
   3:   8d 04 37                lea    eax,[rdi+rsi]
   6:   c3                      ret
# left column: the actual bytes. imul edi,edi IS the bytes 0f af ff.
nm: the symbol table
$ gcc -c -O1 demo.c        # demo.c calls printf and square_add
$ nm demo.o
0000000000000000 T main
                 U printf
                 U square_add
# T = defined here (text section), U = undefined: the linker's to-do list.
$ gcc demo.o -o demo
/usr/bin/ld: demo.o: in function `main':
demo.c:(.text+0x16): undefined reference to `square_add'
collect2: error: ld returned 1 exit status
# forgot to link square_add.o — every U must be resolved!

Read nm's middle column: T = defined in the text (code) section, U = undefined — a promise the linker must fulfill. Unlike C++, C does no name mangling: the function square_add becomes the symbol square_add, verbatim (C++ would emit something like _Z10square_addii to encode the parameter types — one reason C is the universal glue language for libraries).

🧠 Checkpoint: In nm output, what does U printf mean?

  • printf is unused and will be removed
  • printf is defined here but unexported
  • printf is referenced here but defined elsewhere — the linker must resolve it
  • printf is an uninitialized variable
Show answer

printf is referenced here but defined elsewhere — the linker must resolve it — U = undefined symbol: this object uses it but doesn’t contain it. The linker searches other objects and libraries (libc, for printf) to patch in the real address.

ELF sections: the memory map, foreshadowed

Remember the stack/heap/data/text map from the memory-model lesson? It's not an accident — the linker builds the executable out of named sections that the OS loader maps straight into those segments:

readelf -S (abridged)
$ readelf -S hello | grep -E '\.text|\.rodata|\.data|\.bss'
  [16] .text      PROGBITS   0000000000001060  00001060  000000f5
  [18] .rodata    PROGBITS   0000000000002000  00002000  00000012
  [24] .data      PROGBITS   0000000000004010  00003010  00000008
  [25] .bss       NOBITS     0000000000004018  00003018  00000fa8
# .bss is NOBITS: 4000 bytes of zeros that cost 0 bytes of disk.

🧠 Checkpoint: The string literal in printf("Hello, World!\n") ends up in which ELF section?

  • .text
  • .rodata
  • .data
  • .bss
Show answer

.rodata — String literals are read-only data → .rodata, mapped without write permission. That’s the mechanical reason why char *s = "hi"; s[0] = ’H’; segfaults.

Try it yourself

This spot has an interactive editor widget — open the interactive lesson to play with it.

You can now watch the pipeline work — next, let's learn to steer it with the GCC flags every C programmer should know by heart.

▶ Practice this lesson interactively (with live gcc)