The C preprocessor handles source files before the compiler analyzes types and expressions. It includes files, selects conditional code, and replaces macros, producing the translation unit the compiler sees.
Macros operate on preprocessing tokens, not typed values. Missing parentheses, repeated parameters, or testing a zero-valued switch with #ifdef can all produce code that compiles with the wrong meaning.
Reserve macros for work that requires preprocessing, and prefer functions or language constants for value computation. Inspect expansions, then build and test every supported configuration.
What it is and why it exists
The C preprocessor reads source files and handles preprocessing directives introduced by # before normal compilation. #include brings in file contents, #if selects tokens to keep, and #define creates a macro . The preprocessor doesn’t understand C object types, scopes, or runtime values.
Preprocessing solves composition and configuration problems that exist before compilation. Public declarations can live in headers, platform adapters can select an implementation at build time, and macros can generate repeated token patterns. You’ll meet it in public library headers, -D definitions from build systems, logging and assertion wrappers, and platform-detection code.
“Text substitution” is a useful approximation, but it isn’t precise enough. Macros operate on preprocessing tokens: they don’t replace characters inside string literals, and they can’t freely join half of one token to half of another. The # and ## operators have specific stringification and token-pasting rules.
Preprocessor output still isn’t an executable program. After includes and macro replacement, one .c file forms a translation unit . Only then does the compiler analyze declarations, types, and expressions before assembly and linking. gcc -E lets you inspect this boundary.
| Need | Suitable mechanism | What a macro can’t pretend to do |
|---|---|---|
| Share declarations | #include and include guards | Manage runtime object ownership |
| Select a build variant | #if, defined, and build definitions | Branch on runtime data |
| Generate repeated declarations | Function-like macros or X macros | Provide type checking |
| Report a source location | __FILE__ and __LINE__ | Prove a call is safe |
| Compute values | Prefer functions, enums, or language constants | Imitate function semantics with macro arguments |
How it works
This flow shows only the boundaries needed for everyday code. The standard specifies several translation phases; an implementation may combine them as long as the result follows the required behavior.
Before directives are handled
A backslash immediately followed by a newline joins two physical lines into one logical line. Source characters are then recognized as preprocessing tokens and whitespace, with each comment replaced by one space during that process. Nothing can trail a multiline macro’s backslash if it would stop that splice from occurring.
The # in a preprocessing directive must be the first preprocessing token on a logical line, although whitespace may precede it. Directives control preprocessing only; indenting #if inside a normal C block doesn’t make it a runtime if. A group excluded by conditionals never reaches the later compilation stage.
#include makes the implementation locate a named file and continue processing it as input at that point. Quoted and angle-bracket forms use different implementation-defined search sequences. Exact directories come from compiler defaults and options such as -I, so punctuation alone doesn’t reveal the full path on every toolchain.
Object-like and function-like macros
An object-like macro replaces its name with its replacement list. A function-like macro expands only when its name is followed by an invocation parenthesis; its parameters receive argument token sequences, not values that already have C types.
| Definition | Use | First replacement result |
|---|---|---|
#define LIMIT 8 | int a[LIMIT]; | int a[8]; |
#define TWICE(x) ((x) + (x)) | TWICE(3 + 1) | ((3 + 1) + (3 + 1)) |
#define NAME(x) #x | NAME(red) | "red" |
#define JOIN(a, b) a ## b | JOIN(item, 7) | item7 |
Ordinary parameters are macro-expanded before substitution into the replacement list. A parameter next to # or ## isn’t expanded first on that pass, which is why stringifying an expanded value usually needs an indirection macro. The replacement is rescanned afterward, allowing newly exposed macro names to expand.
A macro currently being expanded is temporarily unavailable for replacement at the corresponding points in that scan. This prevents direct or indirect self-reference from recursing forever, but the leftover macro name will usually make invalid C code. Macro recursion isn’t a general looping facility.
Conditionals and definition state
#ifdef NAME asks only whether the name is defined; it doesn’t care whether its replacement list is 0. #if NAME expands its expression first, and remaining ordinary identifiers take the value zero in the preprocessing constant expression. Combine defined(NAME) with a numeric check when “not supplied” and “explicitly disabled” must differ.
Build systems commonly provide definitions with -DNAME=value, while a header supplies a default with #ifndef NAME. Configuration needs one clear owner. If command-line options, a generated header, and an ordinary header define the same macro independently, the result can depend on include order and may produce a redefinition diagnostic.
#error fails the current preprocessing operation, which suits unsupported or contradictory configurations. C23 also standardizes #warning, but a warning shouldn’t enforce a condition that must stop the build. Use #error or a compilation-stage static assertion for mandatory constraints.
Predefined names and macro lifetime
The implementation supplies a set of standard predefined macros before it processes user source. They suit source-location reporting and checks on the language environment, but they don’t replace product configuration owned by a build system. __func__ is a predefined identifier inside a function body, not a preprocessing macro.
| Name | Meaning | Boundary |
|---|---|---|
__FILE__ | String for the currently reported file name | May be affected by #line |
__LINE__ | Integer for the currently reported line | May be affected by #line |
__STDC__ | The implementation claims C conformance | Doesn’t identify a particular version |
__STDC_VERSION__ | The implementation’s claimed C version number | May be undefined in older language modes |
__STDC_HOSTED__ | 1 for hosted, 0 for freestanding | Doesn’t identify an operating system |
When checking a version, first use defined(__STDC_VERSION__) to handle modes without that macro, then compare the required version number. Compiler identity macros only describe the current implementation. Treating __GNUC__ as sufficient proof of one feature can misclassify compatible compilers or other versions.
#undef NAME ends later macro replacement for that name. It can retire a local helper or permit controlled reconfiguration. Defining the same name again with a different replacement list while it remains defined normally requires a diagnostic; relying on the result turns include order into a hidden input.
The standard reserves several classes of identifiers for implementations, especially file-scope names beginning with an underscore and double-underscore forms. Project code should use an explicit prefix instead of copying the naming style of predefined macros.
Include guards
A header can arrive in one translation unit along several include paths. An include guard wraps its contents in a unique macro so later inclusions see the definition and skip the body.
#ifndef ACME_NET_PACKET_H
#define ACME_NET_PACKET_H
struct packet;
int packet_size(const struct packet *value);
#endifThe guard name must be unique across the project. If two unrelated headers happen to use the same guard, whichever is included first silently hides the other. #pragma once is supported by mainstream toolchains, but it isn’t a directive in the C23 language standard.
An include guard only prevents the same header body from being processed repeatedly in one translation unit. It doesn’t resolve mutual dependencies on complete types, and it doesn’t make an ordinary external definition in a header unique across the program. Break dependencies with forward declarations and put definitions that need one storage instance in a source file.
Examples
These four programs cover indirect stringification, statement macros, build switches, and X macros in that order. I compiled every file with GCC 13.3.0 using -std=c2x -Wall -Wextra -Wconversion -Wpedantic -Werror; the displayed text is the actual output.
Expand, then stringify
Writing STRINGIFY_RAW(API_MAJOR) directly produces "API_MAJOR" because # suppresses prescan for that parameter. The outer STRINGIFY expands the argument first, then passes the result to the inner macro that performs stringification.
#include <stdio.h>
#define API_MAJOR 4
#define API_MINOR 2
#define STRINGIFY_RAW(value) #value
#define STRINGIFY(value) STRINGIFY_RAW(value)
#define API_VERSION STRINGIFY(API_MAJOR) "." STRINGIFY(API_MINOR)
int main(void) {
printf("version: %s\n", API_VERSION);
printf("raw: %s\n", STRINGIFY_RAW(API_MAJOR));
printf("expanded: %s\n", STRINGIFY(API_MAJOR));
return 0;
}version: 4.2
raw: API_MAJOR
expanded: 4The compiler concatenates adjacent string literals, so API_VERSION becomes one string. The two macro layers aren’t decoration: removing the outer one changes the observable result.
This pattern also puts build definitions into diagnostic text. If a numeric value is needed in a C expression, keep using API_MAJOR itself instead of parsing the stringified form back into a number.
Wrap statements and optional arguments
The statement macro uses do { ... } while (0), so its call site can supply one semicolon like an ordinary statement. C23’s __VA_OPT__ inserts the comma only when the variadic arguments are nonempty.
#include <stdio.h>
#define AUDIT(format, ...) \
do { \
printf("[audit] " format __VA_OPT__(,) __VA_ARGS__); \
putchar('\n'); \
} while (0)
static void record_login(int accepted) {
if (accepted)
AUDIT("login accepted");
else
AUDIT("login rejected for user %d", 17);
}
int main(void) {
record_login(1);
record_login(0);
return 0;
}[audit] login accepted
[audit] login rejected for user 17The outer wrapper turns the expansion into one statement and prevents the caller’s else from binding to an if inside the macro. The definition doesn’t end with a semicolon; that punctuation belongs to the call syntax.
This macro still provides no format type safety by itself. A compiler may diagnose mismatches using its knowledge of printf, but the macro doesn’t validate the format string and arguments. Put richer logging policy in a function, leaving only source locations or conditional removal in a thin macro layer.
Select an implementation with a build definition
This file represents feature state with a numeric macro and rejects values other than zero and one. The output below comes from a build that also passes -DENABLE_METRICS=1.
#include <stdio.h>
#ifndef ENABLE_METRICS
#define ENABLE_METRICS 0
#endif
#if ENABLE_METRICS != 0 && ENABLE_METRICS != 1
#error "ENABLE_METRICS must be 0 or 1"
#endif
#if ENABLE_METRICS
static void record_metric(const char *name, int value) {
printf("metric %s=%d\n", name, value);
}
#else
#define record_metric(name, value) ((void)0)
#endif
int main(void) {
record_metric("requests", 3);
printf("metrics: %s\n", ENABLE_METRICS ? "enabled" : "disabled");
return 0;
}metric requests=3
metrics: enabledChanging the condition to #ifdef ENABLE_METRICS would also select the enabled branch when the default definition is zero. Use #if ENABLE_METRICS for a numeric switch; reserve #ifdef for flags whose presence is the whole meaning.
The disabled branch replaces the call with ((void)0), so it doesn’t evaluate name or value. If an argument has a side effect, enabled and disabled builds behave differently at runtime. Calling code shouldn’t depend on side effects in logging or metrics arguments.
Generate consistent declarations from one list
An X macro separates a data list from the action used on each traversal. The same status table generates enum members and switch arms here, avoiding two manually maintained lists that can drift apart.
#include <stdio.h>
#define STATUS_LIST(X) \
X(PENDING, 10) \
X(RUNNING, 20) \
X(COMPLETE, 30)
#define DECLARE_STATUS(name, code) STATUS_##name = code,
enum status {
STATUS_LIST(DECLARE_STATUS)
};
#undef DECLARE_STATUS
static const char *status_name(int value) {
switch (value) {
#define STATUS_CASE(name, code) case STATUS_##name: return #name;
STATUS_LIST(STATUS_CASE)
#undef STATUS_CASE
default:
return "UNKNOWN";
}
}
int main(void) {
printf("%d %s\n", STATUS_RUNNING, status_name(STATUS_RUNNING));
printf("%d %s\n", 99, status_name(99));
return 0;
}20 RUNNING
99 UNKNOWNUndefining each action macro after use limits how long it can affect later source. STATUS_LIST can remain as the public generation entry point, but its line format and argument count now form an interface that must be maintained.
This technique fits a small, stable declaration table. If the list needs conditionals, nested data, or consumption by external tools, one YAML or JSON input and a generator is usually easier to verify. Don’t stretch the preprocessor into a data language.
Pitfalls
Treating parentheses as a single-evaluation guarantee
Fix: prefer a static inline function for value computation, which evaluates each argument once before the call. If a macro truly must accept several types, document the “no side effects in arguments” contract and enforce its call sites with diagnostics and tests.
Reading a numeric switch with #ifdef
Fix: use #if FEATURE_X for a zero-or-one switch and supply its default with #ifndef. When an omitted state must remain distinct, first check defined(FEATURE_X), then validate the permitted numeric values separately.
Letting a statement macro leak control flow
Fix: wrap a statement macro in do { ... } while (0) and omit the final semicolon from its definition. Caller code should still brace if and else; any return, break, or continue hidden inside the macro must remain conspicuous and documented.
Colliding macro and guard names
Fix: prefix public macros with the project and component, derive guards from a stable project path, and #undef temporary helpers immediately after use. Don’t define implementation-reserved identifiers or leak generic names from public headers.
Depending on compiler extensions by default
Fix: start with functions, __VA_OPT__, and ordinary directives supplied by the target standard. When an extension is necessary, isolate it in an adapter header, guard it by compiler and version, and make CI build every toolchain the project promises to support.
Expansion order and boundaries
A function-like invocation first collects complete argument token sequences. Nested parentheses take part in grouping, and only a comma at the outer level ends the current argument. The preprocessor doesn’t check whether those tokens will later form an expression compatible with a parameter type, because no function parameter type exists at this stage.
An argument not adjacent to # or ## is fully macro-expanded before it enters the replacement list. Stringification joins its argument tokens into a string, collapses whitespace between tokens, and escapes quotes and backslashes within them. It records source token spelling, not the runtime text produced by evaluating an expression.
Token pasting combines the token on each side into one new preprocessing token, and that result must be valid. Add an indirection macro when inputs must expand before pasting, letting the outer layer perform prescan. Names generated by ## are harder for ordinary search tools to find, so use the technique only when the generation relationship is clearer than explicit declarations.
The replacement sequence is rescanned, expanding newly exposed names that are eligible at that point. A self-reference is suppressed at the relevant scan location during its own expansion, so #define LOOP LOOP doesn’t run forever. It only leaves LOOP, which the C compiler probably can’t interpret.
C23’s __VA_OPT__(tokens) can appear only in the replacement list of a variadic macro. It retains tokens when the argument corresponding to ... is nonempty and produces nothing when that argument is empty. This solves the optional-comma case for logging macros without GNU’s comma-swallowing extension.
Include boundaries and configuration ownership
Headers are input to translation units, not runtime modules. An include guard goes through its definition process afresh when each translation unit is preprocessed, so it suppresses duplicates only within that translation unit. Separate source files each have their own macro state.
A public header should compile when directly #included from an otherwise empty source file. It must include or declare the types it needs instead of relying on the caller to include another header first. This test finds include-order dependencies and makes generated headers easier to verify in isolation.
Quoted includes are commonly used for project headers, while angle brackets commonly use include directories supplied by the implementation or build, but the standard leaves search details to the implementation. A portable interface shouldn’t depend on the current working directory, one same-named file winning by accident, or system-directory order. Pass directories explicitly in the build and diagnose the actual include chain with gcc -H or dependency output.
Keep the default, valid range, and source of a feature switch in one place. When the build system chooses a deployment variant, it can provide the macro while a header only supplies a default and validates the range. A source file shouldn’t quietly redefine the switch after including the configuration header, or translation units in one program may see contradictory interface layouts.
When conditional compilation changes structure members, function signatures, or calling conventions, every affected translation unit must use a consistent configuration. Recompiling only one source file may still link while leaving the two sides with different layout assumptions. Configuration macros are therefore part of the build’s ABI contract, not merely local implementation details.
Diagnostics and portability
gcc -E file.c emits preprocessor output, -P removes line markers, and -dM -E lists macro definitions present at the end of processing. These outputs answer “what did the compiler actually see?”, but they are often large. Search a minimal reproducer around the relevant declaration instead of treating an entire system-header dump as review material.
Among standard predefined macros, __FILE__ and __LINE__ describe the current source location, while __DATE__ and __TIME__ describe translation time. The latter pair makes identical source produce different bytes across builds, so it doesn’t suit reproducible artifact identification. Take release versions from explicit build inputs instead of the compiler clock.
#line changes the values subsequently reported by __LINE__ and __FILE__, mainly so generators can map diagnostics back to original input. A generator must keep that mapping accurate; casual use makes logs and errors point investigators to the wrong place. It doesn’t change physical lines in the file system.
The implementation defines what a #pragma does, while _Pragma("tokens") allows macro replacement to produce a pragma operation. Warning suppression should use a narrow push, local disable, and pop structure with named compiler branches. Disabling a diagnostic globally can hide new defects in later generated code.
The preprocessor guarantees only the token transformations in its scope; it doesn’t guarantee that the generated C program has defined behavior. Once an expansion “looks right,” it still needs type checking, strict warnings, static analysis, tests, and suitable sanitizers. Crossing a macro boundary weakens none of those requirements.
Further reading
Start with the C23 working draft for preprocessing directives and replacement rules, then use the GCC manuals for local options and extensions. cppreference is useful for quick syntax lookup, but the draft and compiler documentation decide the final answer.
4 questions · 1 predict-the-output · 1 spot-the-bug