Describe the picture

You write a C program, type gcc hello.c -o hello, and a moment later you can run ./hello.

It feels like one step. In reality, gcc runs four separate tools one after another. Knowing what each of them does helps you understand error messages, answer a very common exam question, and later work with programs made of many files.

Let's follow this small program on its journey:

// hello.c
#include <stdio.h>
#define GREETING "Hello, World!"

int main(void)
{
    printf("%s\n", GREETING);   // say hello
    return 0;
}

The big picture

hello.c  →  preprocessor  →  hello.i  (C code, headers pasted in)
         →  compiler      →  hello.s  (assembly language)
         →  assembler     →  hello.o  (machine code, not runnable yet)
         →  linker        →  hello    (the program you can run)

You can stop gcc after each stage and look at the result yourself.

Stage 1: The preprocessor

The preprocessor does not understand C. It is a text processor that handles every line starting with # and a few other jobs:

  • #include <stdio.h> → the whole stdio.h file is copied into your file.
  • #define GREETING "Hello, World!" → every GREETING is replaced by the text "Hello, World!".
  • Comments are removed.
  • #ifdef … #endif blocks are kept or thrown away.

See its output:

gcc -E hello.c -o hello.i

Open hello.i. Your six lines of code are at the very bottom — after hundreds of lines copied from stdio.h, including the declaration of printf. The line with printf now reads:

printf("%s\n", "Hello, World!");

GREETING is gone, and so is the comment. The compiler never sees them.

This is why a missing #include <stdio.h> causes trouble later: without it, the compiler does not know what printf is.

Stage 2: The compiler

The compiler is the part that really understands C. It:

  • checks the syntax (missing semicolons, unmatched brackets)
  • checks types (are you passing the right kind of values?)
  • translates your C code into assembly language for your processor
  • optimises it if you ask (-O2)
gcc -S hello.i -o hello.s

hello.s is a text file with instructions such as mov, lea and call printf. Different processors (Intel/AMD, ARM in phones) have different assembly languages — that is why the same C code can be compiled for many machines.

Most of the error messages you see as a beginner come from this stage:

hello.c:7:5: error: expected ';' before 'return'

The numbers are the line and column. Always read the first error first — later errors are often caused by the first one.

Stage 3: The assembler

The assembler turns the assembly text into machine code — the binary instructions the processor actually executes.

gcc -c hello.c -o hello.o

-c means "compile and assemble, but do not link". The result, hello.o, is an object file.

You cannot run it yet. It contains the machine code of main, but where the code calls printf there is only a note: "call printf — address unknown". The real printf is not inside your file. It lives in the C standard library.

Stage 4: The linker

The linker puts the pieces together:

  • your object file(s)
  • the needed parts of the C standard library (where printf really is)
  • a small piece of start-up code that prepares everything and then calls your main

It fills in every "address unknown" note and writes the final executable:

gcc hello.o -o hello
./hello
Hello, World!

On Windows the file is called hello.exe and you run it with hello.

Errors from different stages look different

This is where the four stages become really useful. Make a typo in a function name:

prinft("%s\n", GREETING);

With an older compiler you get two messages from two different stages:

warning: implicit declaration of function 'prinft'
undefined reference to `prinft'
  • The warning comes from the compiler: it has never heard of prinft, but it guesses and carries on.
  • The error comes from the linker: it searched every object file and library and could not find a function called prinft.

Newer compilers (GCC 14 and later) no longer guess: the first message is already an error, and the program stops at the compiler stage. You will still meet the linker's "undefined reference" whenever a function is declared (for example in a header) but its code was never written or its file was not compiled in.

Here is a table worth remembering:

Problem Example Found by
Header not found fatal error: stdoi.h: No such file Preprocessor
Syntax error expected ';' before 'return' Compiler
Function never defined undefined reference to 'prinft' Linker
Two definitions of main multiple definition of 'main' Linker
Crash while running Segmentation fault Running the program (run-time error)
Wrong answer prints 3 instead of 3.5 Nobody but you (logic error)

Why the stages matter for bigger programs

Real programs are split into many files:

gcc -c main.c      # → main.o
gcc -c maths.c     # → maths.o
gcc main.o maths.o -o calculator

If you change only maths.c, you only need to recompile that file and link again. In a project with hundreds of files this saves a lot of time — and it is exactly what build tools like make do for you.

This is also why we write header files (maths.h) with function prototypes: main.c needs to know what add() looks like while compiling, even though its code is in another file. The linker connects the call to the code later.

Watch your code go through the steps

In our free C course the very first lesson shows these stages with an animation, the setup lesson explains every gcc command and error message, and the preprocessor lesson lets you watch #define replace text before compiling:

Quick summary

  • Preprocessor: pastes headers, replaces macros, removes comments → gcc -E
  • Compiler: checks your C and turns it into assembly → gcc -S
  • Assembler: turns assembly into machine code (an object file) → gcc -c
  • Linker: joins object files and libraries into a runnable program → gcc file.o -o program
  • "undefined reference" is a linker error; "expected ';'" is a compiler error.

Try it yourself

  1. Run gcc -E on a program with a #define and find the replaced text in the output.
  2. Run gcc -S and look at the assembly for a small loop.
  3. Misspell printf on purpose and read both messages.
  4. Split a program into main.c and maths.c with a header maths.h, compile both with -c and link them.

Once you know what happens behind gcc, error messages stop being scary — they tell you exactly which stage is unhappy.