C
Preprocessor & Macros
Introduction to C mentioned the preprocessor as the
first stage of compilation — a pure text-substitution pass with no understanding of C syntax at all. This
chapter is everything that pass actually does: #include, #define, macros, and
conditional compilation. This is also the final chapter of this domain — everything from here builds on
everything before it.
- What it is
- Directives (lines starting with
#) processed by pure text substitution before real compilation begins. - Mental model
- An aggressive find-and-replace pass over your source text — it has no idea what a variable or a function is, it just swaps text for other text, exactly as instructed.
- Key numbers
- Every directive starts with
#and has no trailing semicolon — a stray semicolon on a#defineline becomes part of the substituted text, a classic bug. - Reach for it when
- Including other files, defining compile-time constants, writing small inlined helpers, or compiling different code for different targets/configurations.
#include
#include is replaced, before compilation, by the entire contents of the named file — it's
literally a copy-paste operation performed by the preprocessor:
#include <stdio.h> // angle brackets: search the compiler's standard include paths
#include "myheader.h" // quotes: search relative to this file first
#define for constants
#define MAX_RETRIES 3
#define PI 3.14159
int attempts = 0;
while (attempts < MAX_RETRIES) { // becomes: while (attempts < 3)
attempts++;
}
Every occurrence of MAX_RETRIES in the rest of the file is textually replaced with
3 before the compiler ever sees it — the compiler never knows MAX_RETRIES
existed as a name at all. This is different from declaring const int MAX_RETRIES = 3;, which
creates a real, typed variable the compiler is aware of; a macro constant is pure text substitution with
zero type information.
Function-like macros
A macro can also take arguments, making it look like a function call — but it's still pure text substitution, not an actual function call:
#define SQUARE(x) ((x) * (x))
int result = SQUARE(5); // becomes: ((5) * (5)) → 25
Because it's textual, every parenthesis in a macro definition matters far more than it looks — see Gotchas for exactly what goes wrong without them.
Conditional compilation
#ifdef/#ifndef/#endif let you include or exclude entire blocks of
code at compile time, based on whether a name is defined — the standard way to compile different code
for different targets or build configurations:
#define DEBUG_MODE
#ifdef DEBUG_MODE
printf("Debug: entering main loop\n");
#endif
#ifndef DEBUG_MODE
// this block is entirely skipped — removed before compilation even sees it
fast_path_only();
#endif
This is exactly how embedded firmware commonly compiles different code for different target boards from one shared codebase, or strips out debug logging entirely from a release build with zero runtime cost — the excluded code never even reaches the compiler.
Header guards
A header file included by two different files (directly or indirectly) would otherwise get pasted in twice, causing "redefinition" errors. A header guard uses conditional compilation to make a header safe to include more than once:
// myheader.h
#ifndef MYHEADER_H
#define MYHEADER_H
// ... declarations ...
#endif // MYHEADER_H
The first time this file is included, MYHEADER_H isn't defined yet, so its contents are
included and MYHEADER_H gets defined. Every subsequent #include of the same
file sees MYHEADER_H already defined and skips the contents entirely — every
.h file you write should have one of these.
Real-world applications
- Header guards — in every
.hfile in any real C codebase, without exception. - Target-specific firmware builds —
#ifdef BOARD_REV_A/#ifdef BOARD_REV_Bblocks compiling different pin configurations from one shared source file. - Debug vs. release builds — wrapping verbose logging in
#ifdef DEBUGso it costs literally nothing (not even a skippedifcheck at runtime) in a release build.
Gotchas
- Missing parentheses in a macro is the single most notorious macro bug:
#define SQUARE(x) x * xlooks fine until called asSQUARE(2 + 3), which expands to2 + 3 * 2 + 3— due to operator precedence, that's11, not the expected25. Always wrap both the whole macro body and every individual use of a parameter in parentheses, as the working example above does. - A macro has no scope and no type checking — it's replaced everywhere its name appears in the rest of the file, with zero regard for whether that use makes sense, which is exactly why an ordinary variable or function is almost always preferable when either would work equally well.
- A trailing semicolon inside a
#definebecomes part of every substitution —#define MAX 5;used asif (x < MAX)expands toif (x < 5;), a syntax error. - The preprocessor runs before the compiler sees any actual C syntax — a macro can produce text that's syntactically invalid C, and the resulting compiler error will point at the expanded code, not your original macro line, which can be genuinely confusing to debug.