When a linker error first interrupted code that had “compiled fine,” the vocabulary suddenly mattered. GCC had translated each source file successfully; the failure happened while combining those pieces. The easiest way to make that distinction stick is to keep every intermediate and look inside it.

The files produced at each boundary

  • hello.c — human-written C source.

  • hello.i — preprocessed C after headers, macros, and conditional directives are handled.

  • hello.s — target-specific assembly emitted by the compiler proper.

  • hello.o — relocatable ELF object containing machine code, sections, symbols, and relocation records.

  • hello — linked ELF executable with a resolved layout and any declared shared-library dependencies.

  • Running process — virtual-memory mappings, initialized runtime state, stack, heap, libraries, and threads created during execution.

Use a small program with a real external reference

hello.cc
#include <stdio.h>
 
#define MESSAGE "Hello from the compiler pipeline"
 
static int twice(int value)
{
    return value * 2;
}
 
int main(void)
{
    printf("%s: %d\n", MESSAGE, twice(21));
    return 0;
}

Why this tiny source is revealing

  • #include and #define give the preprocessor visible work.

  • static gives twice internal linkage, so it is not exported as a global definition from this translation unit.

  • printf is declared by the header but defined by the C library, leaving the object file with an external symbol to resolve.

  • main returns an integer status to the C runtime, which ultimately reports it to the operating system.

Ask GCC to show the programs it invokes

Project directorybash
gcc -v -O0 -o hello hello.c
... cc1 ...
... as ...
... collect2 ... ld ...

GCC is acting as a driver

  • gcc selects tools and options appropriate for C and the target platform.

  • -v prints the subprocesses and search paths; exact output varies by distribution and GCC build.

  • -O0 keeps the learning example less optimized, though it does not guarantee a one-to-one source-to-instruction mapping.

  • -o hello names the final output; no sudo is needed to compile inside a writable project directory.

Stage 1: preprocessing creates a translation unit

Project directorybash
gcc -E hello.c -o hello.i
grep -n "Hello from the compiler pipeline" hello.i | tail -1
... printf("%s: %d\n", "Hello from the compiler pipeline", twice(21));

What changed before C was compiled

  • -E stops after preprocessing and -o saves output that would otherwise go to standard output.

  • Header contents are included according to the implementation’s include search rules.

  • Object-like macro MESSAGE is replaced by its string literal token sequence.

  • Conditional compilation is evaluated and comments are normally removed.

  • The .i file is often large because system declarations are now part of one translation unit.

Stage 2: compilation proper emits assembly

Project directorybash
gcc -S -O0 hello.i -o hello.s
sed -n '1,80p' hello.s
...
twice:
...
main:
...
call printf@PLT
...

This is where language semantics become target code

  • -S stops after compilation proper, before the assembler creates an object.

  • GCC parses and diagnoses C, builds internal representations, applies requested optimizations, and emits target assembly.

  • Assembly syntax and instructions depend on architecture, ABI, compiler version, and options.

  • A call through printf@PLT is common in position-independent Linux output, but its spelling is not portable or guaranteed.

Stage 3: assembly creates a relocatable ELF object

Project directorybash
gcc -c hello.s -o hello.o
file hello.o
readelf -h hello.o
readelf -sW hello.o | grep -E '(main|printf|twice)'
readelf -rW hello.o
hello.o: ELF ... relocatable ...
... FUNC ... LOCAL ... twice
... FUNC ... GLOBAL ... main
... NOTYPE ... GLOBAL ... UND printf
Relocation section ...

The object has code but no final addresses

  • -c compiles or assembles but deliberately skips linking.

  • file identifies the object format and architecture; readelf reports ELF structures without executing the file.

  • main is defined globally, twice is local, and printf remains undefined in this object.

  • Relocation records tell the linker where symbol-dependent instruction or data fields must be adjusted.

  • A relocatable object is not normally executable because its sections have not been assigned their final program layout.

Stage 4: the linker resolves and lays out the program

Project directorybash
gcc hello.o -o hello
file hello
readelf -hW hello
readelf -lW hello
readelf -dW hello | grep NEEDED
hello: ELF ... pie executable ... dynamically linked ...
... INTERP ...
... LOAD ...
... Shared library: [libc.so.6]
  • The linker combines object and archive inputs, assigns addresses, lays out sections, applies relocations, and ties up symbol references.

  • The GCC driver supplies startup objects, runtime libraries, architecture options, and the correct linker invocation for the toolchain.

  • Modern Linux distributions commonly build a position-independent executable by default; confirm with file rather than assuming.

  • Program headers describe segments the kernel maps at execution; section headers mainly describe linking and inspection units.

  • DT_NEEDED records direct shared-library dependencies. It does not copy those libraries into the executable.

Execution: the kernel and dynamic linker load the program

Project directorybash
readelf -lW hello | grep -A1 INTERP
ldd ./hello
./hello
printf 'exit status: %d\n' "$?"
[Requesting program interpreter: /lib64/ld-linux-x86-64.so.2]
... libc.so.6 => ...
Hello from the compiler pipeline: 42
exit status: 0

Loading begins only when the file is executed

  • execve replaces a process image; the kernel validates ELF program headers and maps loadable segments into virtual memory.

  • For a dynamic ELF executable, the .interp path selects the runtime linker/loader.

  • The loader maps required shared objects, performs dynamic relocations and symbol resolution, prepares runtime state, and transfers control through the process entry path.

  • ldd output and interpreter paths vary by architecture and libc; run it only on binaries you trust.

  • The shell receives the status returned through the C runtime after main finishes.

answer.cc
int answer(void)
{
    return 42;
}

One source file forms one translation unit

  • The function has external linkage because it is not declared static.

  • Compiling this file does not require the definition of main.

  • A header should normally declare answer so callers and the definition share a checked signature.

Project directorybash
gcc -Wall -Wextra -Wpedantic -c main.c -o main.o
gcc -Wall -Wextra -Wpedantic -c answer.c -o answer.o
gcc main.o answer.o -o answer-app

Separate compilation is what makes incremental builds practical

  • Each -c command produces one object without demanding that every external reference is resolved.

  • The final command links both objects and reports duplicate or unresolved global symbols.

  • Changing only answer.c allows a build system to rebuild answer.o and relink without recompiling main.c.

  • Warnings belong on compilation commands; link-specific options belong on the final driver invocation.

Read failures by the stage that reported them

  • Header not found or malformed macro: preprocessing/search-path problem; inspect -I, conditional directives, and gcc -E output.

  • Syntax error or incompatible type: compiler proper understood tokens but rejected C semantics.

  • Assembler rejects an instruction: generated or hand-written assembly does not match the selected target or assembler syntax.

  • Undefined reference: linking could not find a required definition; add the correct object/library and respect library ordering where applicable.

  • Multiple definition: more than one linked input exports the same strong symbol, often because a definition was placed in a header.

  • Shared library cannot be opened: execution-time loader search failed; inspect DT_NEEDED, RPATH/RUNPATH, loader cache, and deployment.

  • Illegal instruction at runtime: the binary executed but contains an instruction unsupported by that CPU, often from an overly aggressive -march.

Optimization and LTO blur the teaching boundaries

The conceptual pipeline remains useful, but modern toolchains can integrate stages. Optimization may inline or delete functions, assembler integration can avoid a durable .s file, and link-time optimization keeps an intermediate representation in objects so whole-program optimization happens during linking. Debug real builds with their actual flags, not only the neat classroom sequence.

Primary references