Why this matters
When a build fails, the error message tells you which stage failed, and each stage fails for different reasons. A missing header, a syntax error and an "undefined reference" look alike to a beginner, but they come from three different programs and need three different fixes. Knowing the pipeline also explains things that otherwise look like magic: what #include really does, why you need -lm for math functions, and how large programs are built from many files.
The four stages
When you run gcc main.c -o prog, gcc is really a driver that runs four tools in turn:
main.c your source text
| 1. preprocessor (cpp) handles # lines: #include, #define, #if
v
main.i pure C text, headers pasted in, macros expanded
| 2. compiler (cc1) checks types and grammar, translates C to assembly
v
main.s assembly text for your processor
| 3. assembler (as) assembly to machine code bytes
v
main.o object file: machine code with "holes" for outside names
| 4. linker (ld) joins object files + libraries, fills the holes
v
prog executable
Normally the intermediate files are temporary and deleted. You can stop after any stage to see them, and doing it once is the best way to understand the pipeline.
Here is a small program, hello.c:
#include <stdio.h>
#define GREETING "Hello"
int main(void)
{
printf("%s, world\n", GREETING);
return 0;
}
Stage 1: the preprocessor (gcc -E)
The preprocessor is a text processor that knows almost nothing about C. It handles every line starting with #:
#include <stdio.h>is replaced by the entire contents of the file/usr/include/stdio.h(and whatever it includes). That header contains declarations such asint printf(const char *format, ...);which tell the compiler thatprintfexists and what arguments it takes. It does not contain the code ofprintf.#define GREETING "Hello"defines a macro; every laterGREETINGin the text is replaced by"Hello".- Comments are removed.
$ gcc -E hello.c -o hello.i
$ wc -l hello.i
819 hello.i
$ tail -6 hello.i
int main(void)
{
printf("%s, world\n", "Hello");
return 0;
}
Your nine lines became over 800, almost all from stdio.h, and GREETING is gone. Errors from this stage are about # lines, for example fatal error: stdoi.h: No such file or directory.
Stage 2: the compiler proper (gcc -S)
The compiler reads the preprocessed text, checks it against the rules of C (grammar, types, declarations), and translates it into assembly language. This is where almost all of your error messages come from: missing semicolons, unknown names, wrong argument types.
$ gcc -S -O0 -fno-asynchronous-unwind-tables hello.c -o hello.s
$ grep -A11 '^main:' hello.s
main:
pushq %rbp
movq %rsp, %rbp
leaq .LC0(%rip), %rax
movq %rax, %rsi
leaq .LC1(%rip), %rax
movq %rax, %rdi
movl $0, %eax
call printf@PLT
movl $0, %eax
popq %rbp
ret
(The -fno-asynchronous-unwind-tables flag just hides bookkeeping lines starting with .cfi that would otherwise clutter the listing.)
You do not need to read assembly, but notice two things: your function became a short list of instructions, and printf is just a call to a name. The compiler does not know where printf's code is. It only knows, from the header, how to call it.
Stage 3: the assembler (gcc -c)
The assembler converts the assembly text into machine code bytes and writes an object file (.o). An object file contains your machine code plus a symbol table: the names it defines (main) and the names it uses but does not define (printf).
$ gcc -c hello.c -o hello.o
$ nm hello.o
0000000000000000 T main
U printf
T means "defined here, in the code (text) section". U means "undefined: used here, defined somewhere else". An object file cannot run on its own: the call to printf still points nowhere.
Stage 4: the linker (ld)
The linker takes one or more object files plus libraries and produces the executable. Its job is to resolve every U symbol to a definition somewhere. printf is defined in the C standard library (libc), which gcc links automatically. For most of the C library, linking is dynamic: the executable records "I need libc.so.6", and the operating system loads that shared library when the program starts.
$ gcc hello.o -o hello
$ ./hello
Hello, world
$ ldd hello
linux-vdso.so.1 (0x00007fc7610cf000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fc760ecb000)
/lib64/ld-linux-x86-64.so.2 (0x00007fc7610d1000)
If a symbol cannot be resolved, the linker fails with undefined reference. The math functions (sqrt, sin, pow) live in a separate library, libm, which is not linked by default on Linux. That is what -lm is for: "link library m". The -l flags go after the source files, because the linker resolves names left to right.
Declarations versus definitions
The pipeline explains one of the most important distinctions in C:
- A declaration tells the compiler a name exists and what its type is:
void greet(void);This is enough to compile a call to it. - A definition provides the actual thing: the function body, which becomes machine code. This is what the linker needs.
Headers like stdio.h are mostly declarations. The definitions are already compiled, in libraries. When you split your own programs into several files later in the track, you will write headers full of declarations and .c files full of definitions, and the linker will join them.
The build command used in this track
$ gcc -std=c17 -Wall -Wextra -g -O0 -o prog main.c -lm
| flag | meaning |
|---|---|
-std=c17 | Use the C17 standard (the 2017/2018 revision of ISO C). Fixes the language rules. |
-Wall -Wextra | Turn on most useful warnings. Despite the name, -Wall is not all of them. |
-g | Include debugging information so gdb can map machine code back to your lines. |
-O0 | No optimization: the machine code follows your source closely, good for learning and debugging. Real builds often use -O2. |
-o prog | Name the output prog (the default is a.out). |
main.c | The source file. |
-lm | Link the math library. |
Common mistakes
Misspelled header (preprocessor):
main.c:1:10: fatal error: stdoi.h: No such file or directory
Syntax error (compiler). The compiler reports where it noticed the problem, which is often the line after the real mistake:
main.c:6:5: error: expected ';' before 'return'
Calling a function with no declaration. With GCC 14 (the version in the sandbox) this is an error:
main.c:5:5: error: implicit declaration of function 'prnitf'; did you mean 'printf'? [-Wimplicit-function-declaration]
Older compilers only warned and then guessed the function's type, which is why old tutorials seem to "get away with" missing includes.
Missing definition (linker): a declaration was present, so the compiler was happy, but no definition exists:
/usr/bin/ld: /tmp/cc1aB2.o: in function `main':
main.c:7: undefined reference to `greet'
collect2: error: ld returned 1 exit status
Forgetting -lm gives undefined reference to 'sqrt' in larger programs. (With constant arguments like sqrt(2.0) gcc may compute the result at compile time and not need the library at all, which makes this mistake appear and disappear mysteriously.)
Recap
gccdrives four stages: preprocess (-E), compile to assembly (-S), assemble to an object file (-c), link into an executable.#includepastes a header's text in; headers mostly hold declarations.- Object files list defined (
T) and undefined (U) symbols; the linker resolves them against other object files and libraries such as libc and libm (-lm). - Error messages tell you the stage: "No such file" (preprocessor), "expected ..." or "implicit declaration" (compiler), "undefined reference" (linker).
Going further: in the sandbox terminal, write a hello program and run gcc -E, gcc -S, gcc -c and nm on it yourself. Then try gcc -v hello.c to watch the driver print every command it runs.