09 · Preprocessor & Multi-file Compilation¶
🎥 Video walkthrough¶
Before a single line of C is actually compiled, a separate pass called the
preprocessor runs over your source, handling every line that starts with
#. It textually includes files, substitutes constants, and expands macros —
all before the compiler ever sees "real" C syntax. Understanding the
preprocessor is also the key to splitting a program across multiple files,
which is how every non-trivial C project is organized.
#include¶
#include tells the preprocessor to paste the contents of another file in
right where the directive appears:
#include <stdio.h> // angle brackets: search the system/standard library paths
#include "helpers.h" // quotes: search relative to the current file first
- Angle brackets (
<stdio.h>) are for standard library or system headers. - Quotes (
"helpers.h") are for your own project headers — the preprocessor looks in the same directory as the including file first, then falls back to the system paths.
#define — constants and simple macros¶
#define creates a text substitution that the preprocessor performs
everywhere the name appears:
#include <stdio.h>
#define MAX_STUDENTS 30
#define PI 3.14159
// A simple function-like macro
#define SQUARE(x) ((x) * (x))
int main(void) {
printf("Max students: %d\n", MAX_STUDENTS);
printf("Circle area with r=2: %.2f\n", PI * SQUARE(2));
return 0;
}
// Output:
// Max students: 30
// Circle area with r=2: 12.57
Notice the extra parentheses in ((x) * (x)) — the preprocessor does pure
text substitution, so SQUARE(x) without those parentheses would expand
SQUARE(1 + 2) into 1 + 2 * 1 + 2 (wrong) instead of ((1 + 2) * (1 + 2))
(right). This is a classic macro pitfall; for anything beyond a trivial
constant, a real function is usually clearer and safer than a macro.
Header guards: #ifndef / #define / #endif¶
If the same header ends up #included more than once in a single compiled
file (easy to happen once headers include other headers), the compiler sees
the same declarations twice and fails with "redefinition" errors. A header
guard prevents this by making the second inclusion a no-op:
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
The first time this file is included, MATH_UTILS_H isn't defined yet, so the
preprocessor defines it and includes everything between #ifndef and
#endif. Any later #include "math_utils.h" in the same compilation sees
MATH_UTILS_H already defined and skips the body entirely. Every header you
write should have a guard, using a name unlikely to collide with anything
else (commonly the filename in uppercase, as above).
Splitting a program across files¶
A header (.h) declares what exists (function signatures, constants); one
or more source files (.c) define how it works. This separation lets
main.c use functions it doesn't itself contain the code for.
math_utils.h — declares what's available:
// math_utils.h
#ifndef MATH_UTILS_H
#define MATH_UTILS_H
int add(int a, int b);
int multiply(int a, int b);
#endif
math_utils.c — implements it:
// math_utils.c
#include "math_utils.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
main.c — uses it:
// main.c
#include <stdio.h>
#include "math_utils.h"
int main(void) {
int sum = add(3, 4);
int product = multiply(3, 4);
printf("Sum: %d\n", sum);
printf("Product: %d\n", product);
return 0;
}
// Output:
// Sum: 7
// Product: 12
main.c never sees the implementation of add and multiply — only the
declarations from math_utils.h. That's enough for the compiler to check the
calls are used correctly; the actual code gets tied together at the linking
step.
Compiling multiple files with gcc¶
To build a program made of several .c files, list all of them on the gcc
command line:
gcc compiles each .c file and then links them together into a single
executable named program. math_utils.h doesn't need to be listed — it's
pulled in automatically wherever a .c file #includes it.
A preview of separate compilation¶
For a two-file project like this, compiling everything at once with a single
gcc command is fine. But recompiling every file every time you change
one line in a large project wastes a lot of time. gcc -c compiles a single
file into an object file (.o) — machine code that isn't yet linked into
a full program — without needing the rest of the project:
gcc -c math_utils.c -o math_utils.o # compile only
gcc -c main.c -o main.o # compile only
gcc main.o math_utils.o -o program # link the object files together
If you only change main.c, you only need to recompile main.o and re-link —
math_utils.o doesn't need to be touched. Automating this file-by-file
recompilation is exactly what Makefiles are for, covered in
Level 2, Module 8.
| Directive / concept | Purpose |
|---|---|
#include <...> |
Include a standard/system header |
#include "..." |
Include a project header (searched locally first) |
#define NAME value |
Define a constant or simple macro |
#ifndef / #define / #endif |
Header guard — prevent duplicate inclusion |
.h file |
Declarations shared across .c files |
gcc a.c b.c -o prog |
Compile and link multiple source files at once |
gcc -c a.c -o a.o |
Compile one file to an object file, without linking |
How It Actually Works¶
The preprocessor is a genuinely separate program (cpp, invoked
automatically as the first stage of gcc) that operates purely on text —
it has no concept of C syntax, types, or scoping at all. #include "helpers.h"
literally deletes that line and splices the entire contents of helpers.h
in its place, byte for byte, before the real compiler ever runs. This is
why a syntax error inside a header shows up with the including file's
name and a line number that reflects the pasted-in position — the compiler
genuinely never sees separate files, only one enormous, fully expanded
token stream. You can watch this happen directly with gcc -E main.c,
which stops after preprocessing and dumps the expanded source; for a file
that includes <stdio.h>, this is typically hundreds of lines even though
your own file might be five.
#define macros are expanded by simple textual substitution with no
awareness of operator precedence or types — which is exactly the mechanism
behind the SQUARE(x) parenthesization pitfall. SQUARE(1 + 2) without
inner parens literally becomes the token sequence 1 + 2 * 1 + 2 because
the preprocessor performs find-and-replace, not function application; C's
normal precedence rules then parse that expanded text arithmetically,
giving 1 + 2 + 2 = 5 instead of 9. There is no macro "call" at
runtime — by the time the compiler proper sees the code, SQUARE no
longer exists anywhere; it's already gone, replaced by the literal
substituted text.
Header guards work by exploiting the preprocessor's own state: #ifndef
checks whether a macro name has been #defined yet, using the exact same
text-substitution machinery. The first time math_utils.h is pasted in,
MATH_UTILS_H isn't defined, so the preprocessor defines it and keeps the
body; if the same header gets pasted a second time (transitively, through
another header), MATH_UTILS_H is now already defined and the entire body
between #ifndef and #endif is skipped — preventing the compiler from
seeing the same int add(int a, int b); prototype twice, which would
otherwise be a redefinition error.
Separate compilation is what actually makes multi-file projects
practical: gcc -c math_utils.c -o math_utils.o runs the full
preprocess → compile → assemble pipeline but stops before linking,
producing an object file containing machine code for add and multiply
plus a symbol table entry naming them (visible with nm math_utils.o).
When main.c calls add(3, 4) without ever seeing its implementation, the
compiler only checks the call against the prototype from the header (are
the argument types compatible?) and emits a call instruction to an
as-yet-unresolved symbol named add. The linker's job, in the final step
(gcc main.o math_utils.o -o program), is purely to scan every object
file's symbol table, find where add is actually defined, and patch the
placeholder address in main.o's call instruction to point at that real
address — which is exactly why only files that actually change need
recompiling: math_utils.o's machine code and symbol table don't change
just because main.c did.
🔀 See this in another language¶
Exercise¶
Create a small two-file library: a header strings_utils.h with a header
guard declaring a function int countVowels(const char *text);, and a source
file strings_utils.c implementing it (loop over the string, as in
Module 5, counting a/e/i/o/u, both cases). Write a
main.c that includes your header, calls countVowels on a couple of test
strings, and prints the results. Compile it with a single gcc command that
lists both .c files.