The Preprocessor and Build Tools
#include, #define and conditional compilation, macros and their pitfalls, include guards, and building multi-file programs with make and CMake.
What the preprocessor does
Building a C program starts with a text-processing step. The C preprocessor reads a source file and produces new source text: it removes comments, inserts the contents of included files, expands macros and keeps or drops blocks of code depending on conditions. Only then does the compiler proper translate the result. The full pipeline from source file to executable is described in Compiling and Running C.
The preprocessor is controlled by
preprocessor directives: lines whose first non-blank character is #, such as
#include, #define and #ifdef.
A directive ends at the end of its line (a backslash just before the
line break continues it), needs no semicolon, and knows nothing
about C types or scope — it works on tokens of text.
gcc -E stops after this step and prints the result;
-P leaves out the line markers:
cat > cost.c <<'C'
#define PRICE 2700 /* €0.2700 per kWh, in ten-thousandths */
#define CENTS(kwh) (((kwh) * PRICE + 50) / 100)
int main(void)
{
#ifdef DEBUG
trace("computing cost");
#endif
return CENTS(350) > 0 ? 0 : 1; /* comments disappear too */
}
C
gcc -E -P cost.c
echo "--- with -DDEBUG:"
gcc -E -P -DDEBUG cost.c | grep trace
int main(void)
{
return (((350) * 2700 + 50) / 100) > 0 ? 0 : 1;
}
--- with -DDEBUG:
trace("computing cost");
The macros are gone, replaced by their text; the comments are gone;
and the trace call exists only when
DEBUG is defined. For a file that includes
<stdio.h>, gcc -E prints several
hundred lines — about 550 for a hello-world program compiled with
-std=c17 on this machine, still over 200 with
-P — because the whole header is pasted in.
#include: pasting in headers
#include replaces itself with the entire contents of
another file. That is how declarations from a header reach every
source file that needs them (see
Functions and Header Files). There are two forms:
-
#include <stdio.h>searches the system include directories: the C library's headers and those of installed libraries. -
#include "tariff.h"first looks in the directory of the file that contains the directive, then in the system directories. Use it for your own project's headers.
-I dir adds a directory to the search path, which is
how a project with its headers in include/ is built;
pkg-config --cflags prints the -I options
an installed library needs:
gcc -std=c17 -Wall -Wextra -I include -c src/tariff.c -o build/tariff.o
Because inclusion is plain text pasting, a header must not define things that would then be defined twice, and it must survive being included more than once; both points come back under include guards.
#define: macros
Object-like macros
A macro is a name that the preprocessor
replaces with a fixed piece of text wherever it appears as a
separate token in the rest of the file (not inside string literals).
The simplest form names a constant:
#define TARIFF_TENTHOUSANDTHS 2700. By convention,
macro names are written in capitals, so that readers know text
substitution is going on. #undef NAME removes a
definition again.
Function-like macros
A
function-like macro takes
arguments: #define SQUARE(x) ((x) * (x)). The opening
parenthesis must follow the name directly — with a space, it becomes
an object-like macro whose text starts with (x). The
arguments are not evaluated first; their text is
substituted for the parameters, and only then does the compiler see
the expression. That is why the parentheses matter:
#include <stdio.h>
#define TARIFF_TENTHOUSANDTHS 2700 /* €0.2700 per kWh */
#define SQUARE_BAD(x) x * x /* no parentheses */
#define SQUARE(x) ((x) * (x)) /* parentheses everywhere */
int main(void)
{
printf("tariff: %d\n", TARIFF_TENTHOUSANDTHS);
printf("SQUARE_BAD(2 + 3) = %d\n", SQUARE_BAD(2 + 3));
printf("SQUARE(2 + 3) = %d\n", SQUARE(2 + 3));
printf("100 / SQUARE_BAD(5) = %d\n", 100 / SQUARE_BAD(5));
printf("100 / SQUARE(5) = %d\n", 100 / SQUARE(5));
return 0;
}
tariff: 2700 SQUARE_BAD(2 + 3) = 11 SQUARE(2 + 3) = 25 100 / SQUARE_BAD(5) = 100 100 / SQUARE(5) = 4
SQUARE_BAD(2 + 3) becomes 2 + 3 * 2 + 3,
and multiplication binds tighter than addition: 11.
100 / SQUARE_BAD(5) becomes 100 / 5 * 5,
which is evaluated left to right: 100. Parenthesize each parameter
and the whole body, every time.
Arguments are evaluated as often as they appear
Parentheses do not help with the second classic pitfall. A parameter that appears twice in the body is evaluated twice, so an argument with a side effect runs twice:
#include <stdio.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
static int calls = 0;
/* Pretends to read the next value from the meter. */
static int next_reading(void)
{
calls++;
return 400 + calls;
}
int main(void)
{
int peak = MAX(next_reading(), 250);
printf("peak = %d, next_reading() was called %d times\n", peak, calls);
return 0;
}
peak = 402, next_reading() was called 2 times
The meter was read once for the comparison and once more for the
result, so the "peak" is a value that was never compared at all.
With i++ as the argument, it gets worse:
((i++) * (i++)) modifies i twice without a
sequence point in between, which is undefined behavior (see
Undefined Behavior and Safer C). GCC's -Wall catches this case, even through the
macro:
#include <stdio.h>
#define SQUARE(x) ((x) * (x))
int main(void)
{
int i = 3;
int square = SQUARE(i++);
printf("%d %d\n", square, i);
return 0;
}
example.c:8:26: error: operation on ‘i’ may be undefined [-Werror=sequence-point]
Stringizing, line information and multi-statement macros
Macros can do things functions cannot. Inside a function-like macro,
#param turns the argument's text into a string literal
(## glues two tokens into one). The predefined macros
__FILE__ and __LINE__, together with the
identifier __func__, give the position in the source —
useful for log messages. A macro that expands to several statements
is wrapped in do { … } while (0), so that it behaves
like a single statement, also after an if without
braces:
#include <stdio.h>
/* #expr turns the argument into a string literal. */
#define SHOW(expr) printf("%s = %d\n", #expr, (expr))
/* do { ... } while (0) makes a multi-statement macro behave like
one statement, also after an if without braces. */
#define LOG(msg) \
do { \
printf("%s:%d in %s(): %s\n", __FILE__, __LINE__, __func__, \
(msg)); \
} while (0)
static void check_meter(int kwh)
{
if (kwh > 10000)
LOG("reading looks too high");
else
LOG("reading ok");
}
int main(void)
{
SHOW(350 * 27);
SHOW(7 / 2);
check_meter(412);
check_meter(99999);
return 0;
}
350 * 27 = 9450 7 / 2 = 3 example.c:19 in check_meter(): reading ok example.c:17 in check_meter(): reading looks too high
__LINE__ is the line where the macro is used,
not where it is defined — that is exactly what makes it useful for
logging and for assert (see
Error Handling).
Prefer the language where you can
Macros ignore types, scope and the debugger, and their errors appear in the expanded code. For most jobs the language has a safer tool:
| Instead of | Consider | Why |
|---|---|---|
#define MAX_METERS 400000 |
enum { MAX_METERS = 400000 }; or (C23)
constexpr int MAX_METERS = 400000;
|
has a type and a scope; visible in the debugger |
#define SQUARE(x) ((x) * (x)) |
static inline long square(long x) { return x * x; }
|
argument evaluated once, type-checked; just as fast |
| a macro per type | _Generic (C11), shown below |
chooses by type at compile time |
Macros remain the right tool for conditional compilation, for
include guards and for code that needs
__FILE__/__LINE__ or stringizing.
Predefined macros
Every compiler defines macros that describe the language version and
the platform. __STDC_VERSION__ tells code which C
standard it is compiled as: 201710L for C17, 202311L for C23. The
same source gives a different answer depending on the
-std option; the first example is compiled with GCC as
C17, the second with Clang as C23:
#include <stdio.h>
int main(void)
{
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
printf("C23 or later (%ld)\n", __STDC_VERSION__);
#elif __STDC_VERSION__ >= 201710L
printf("C17 (%ld)\n", __STDC_VERSION__);
#else
printf("older than C17 (%ld)\n", __STDC_VERSION__);
#endif
return 0;
}
C17 (201710)
#include <stdio.h>
int main(void)
{
#if defined(__STDC_VERSION__) && __STDC_VERSION__ >= 202311L
printf("C23 or later (%ld)\n", __STDC_VERSION__);
#elif __STDC_VERSION__ >= 201710L
printf("C17 (%ld)\n", __STDC_VERSION__);
#else
printf("older than C17 (%ld)\n", __STDC_VERSION__);
#endif
return 0;
}
C23 or later (202311)
gcc -dM -E on an empty input lists everything the
compiler predefines — a few hundred macros. A selection on this
64-bit Linux machine (ARM64):
gcc -dM -E -x c /dev/null | grep -E '__(aarch64|x86_64|LP64|CHAR_UNSIGNED|SIZEOF_LONG|GNUC|STDC_VERSION)__ ' | sort
#define __CHAR_UNSIGNED__ 1 #define __GNUC__ 12 #define __LP64__ 1 #define __SIZEOF_LONG__ 8 #define __STDC_VERSION__ 201710L #define __aarch64__ 1
__CHAR_UNSIGNED__ is the compiler admitting that plain
char is unsigned here; on most x86-64 systems it is
signed, and the macro is absent (and __x86_64__ is
defined instead of __aarch64__). Names starting with
two underscores, or an underscore and a capital letter, are reserved
for the compiler and the C library — do not define your own.
Choosing by type: _Generic
C11 added _Generic, which picks one of several
expressions at compile time based on the type of its first operand.
Wrapped in a macro, it gives C a limited form of overloading — the
kind of type-generic call that
<tgmath.h> provides, so that
sqrt works on float,
double and long double.
#include <stdio.h>
/* C11 _Generic chooses an expression by the type of its argument. */
#define TYPE_NAME(x) _Generic((x), \
int: "int", \
long: "long", \
double: "double", \
char *: "char *", \
default: "something else")
int main(void)
{
printf("%s\n", TYPE_NAME(350));
printf("%s\n", TYPE_NAME(3504000000));
printf("%s\n", TYPE_NAME(0.27));
printf("%s\n", TYPE_NAME("KHP1-000231"));
printf("%s\n", TYPE_NAME('K'));
return 0;
}
int long double char * int
Two results are worth a second look. 3504000000 does not fit in an
int, so the literal gets the type long.
And a character constant such as 'K' has type
int in C (unlike C++, where it is a char).
Conditional compilation
Conditional compilation
keeps or removes parts of the source before compilation, based on
conditions the preprocessor can evaluate: whether a macro is
defined, or the value of an integer constant expression built from
macros. The directives are #if, #ifdef,
#ifndef, #elif, #else and
#endif (C23 adds #elifdef and
#elifndef). The option -D NAME or
-D NAME=value defines a macro from the command line, so
one source file can produce different builds:
cat > report.c <<'C'
#include <stdio.h>
#ifndef LOG_LEVEL
#define LOG_LEVEL 1 /* default when -D does not set it */
#endif
int main(void)
{
#ifdef DEBUG
printf("debug build: extra checks on\n");
#endif
if (LOG_LEVEL >= 2) {
printf("log level %d: verbose\n", LOG_LEVEL);
}
printf("report done\n");
return 0;
}
C
gcc -std=c17 -Wall -Wextra -o report report.c && ./report
echo "---"
gcc -std=c17 -Wall -Wextra -DDEBUG -DLOG_LEVEL=2 -o report report.c && ./report
report done --- debug build: extra checks on log level 2: verbose report done
Typical uses are debug and release builds (NDEBUG
switches assert off), features that are compiled in or
out, and code for different platforms — firmware frameworks such as
ESP-IDF select chip-specific code this way (see
Firmware and Embedded Programming). An identifier that is not a macro counts as 0 inside
#if, so a typo in a macro name silently selects the
wrong branch; GCC's -Wundef warns about it.
#error stops the build with a message when a
requirement is not met. Compiled as C99, this file refuses to build:
#include <stdio.h>
#if __STDC_VERSION__ < 201112L
#error "this code needs C11 or later (it uses _Generic)"
#endif
int main(void)
{
printf("ok\n");
return 0;
}
example.c:4:2: error: #error "this code needs C11 or later (it uses _Generic)"
Include guards
Headers include other headers, so the same header easily ends up in
one translation unit twice. Declarations of functions may be
repeated, but before C23 a struct definition may not
(C23 allows an identical redefinition, GCC 14 and later; the example
is compiled as C17):
cat > meter.h <<'C'
struct meter {
int id;
long kwh;
};
C
cat > reading.h <<'C'
#include "meter.h"
long reading_total(const struct meter *m, int count);
C
cat > main.c <<'C'
#include <stdio.h>
#include "meter.h"
#include "reading.h" /* includes meter.h a second time */
int main(void)
{
struct meter m = {30004, 412};
printf("M-%d: %ld kWh\n", m.id, m.kwh);
return 0;
}
C
gcc -std=c17 -Wall -Wextra -c main.c || echo "build failed"
echo "--- adding an include guard to meter.h"
{ printf '#ifndef METER_H\n#define METER_H\n\n'; cat meter.h; printf '\n#endif /* METER_H */\n'; } > guarded.h
mv guarded.h meter.h
cat meter.h
gcc -std=c17 -Wall -Wextra -o main main.c && ./main
In file included from reading.h:1,
from main.c:3:
meter.h:1:8: error: redefinition of ‘struct meter’
1 | struct meter {
| ^~~~~
In file included from main.c:2:
meter.h:1:8: note: originally defined here
1 | struct meter {
| ^~~~~
build failed
--- adding an include guard to meter.h
#ifndef METER_H
#define METER_H
struct meter {
int id;
long kwh;
};
#endif /* METER_H */
M-30004: 412 kWh
An include guard wraps the
whole header in #ifndef NAME … #endif and
defines NAME on the first pass. The second time the
header is included, the macro exists and the preprocessor skips the
contents. The name must be unique per header; the usual pattern is
the file name in capitals, METER_H. Many compilers,
including GCC, Clang and Microsoft's, also accept
#pragma once at the top of a header for the same
effect. It is shorter, but it is not part of the C standard; include
guards work everywhere.
Building with make
A program of a few files can be built with one
gcc command, but that recompiles everything each time.
make, a build tool from the Unix world (GNU Make on Linux), rebuilds
only what changed. A Makefile lists
rules: a target (a file to produce), the prerequisites it
depends on and, on the following lines, the recipe — the commands
that build it. make compares modification times and runs a recipe
only when the target is missing or older than one of its
prerequisites.
Here is the billing toy from Functions and Header Files — a header, two source files and a Makefile — built and changed a few times. Prices are integers in ten-thousandths of a euro, so €0.2700 per kWh is 2700:
cat > tariff.h <<'EOF'
#ifndef TARIFF_H
#define TARIFF_H
/* Prices in ten-thousandths of a euro: 2700 means 0.2700 per kWh. */
long cost_cents(long kwh, long price);
#endif /* TARIFF_H */
EOF
cat > tariff.c <<'EOF'
#include "tariff.h"
/* Only visible in this file (internal linkage). */
static long round_to_cents(long tenthousandths)
{
return (tenthousandths + 50) / 100;
}
long cost_cents(long kwh, long price)
{
return round_to_cents(kwh * price);
}
EOF
cat > main.c <<'EOF'
#include <stdio.h>
#include "tariff.h"
int main(void)
{
long cents = cost_cents(350, 2700);
printf("350 kWh at EUR 0.2700: EUR %ld.%02ld\n", cents / 100, cents % 100);
return 0;
}
EOF
cat > Makefile <<'EOF'
CC = gcc
CFLAGS = -std=c17 -Wall -Wextra -O2
billing: main.o tariff.o
$(CC) $(CFLAGS) -o $@ $^
main.o tariff.o: tariff.h
.PHONY: clean
clean:
rm -f billing *.o
EOF
make
./billing
make
echo "--- after editing tariff.c"
touch tariff.c
make
echo "--- after editing tariff.h"
touch tariff.h
make
make clean
gcc -std=c17 -Wall -Wextra -O2 -c -o main.o main.c gcc -std=c17 -Wall -Wextra -O2 -c -o tariff.o tariff.c gcc -std=c17 -Wall -Wextra -O2 -o billing main.o tariff.o 350 kWh at EUR 0.2700: EUR 94.50 make: 'billing' is up to date. --- after editing tariff.c gcc -std=c17 -Wall -Wextra -O2 -c -o tariff.o tariff.c gcc -std=c17 -Wall -Wextra -O2 -o billing main.o tariff.o --- after editing tariff.h gcc -std=c17 -Wall -Wextra -O2 -c -o main.o main.c gcc -std=c17 -Wall -Wextra -O2 -c -o tariff.o tariff.c gcc -std=c17 -Wall -Wextra -O2 -o billing main.o tariff.o rm -f billing *.o
What the Makefile relies on:
-
Implicit rules. There is no recipe for
main.o: GNU Make knows how to makeX.ofromX.cwith$(CC) $(CFLAGS) $(CPPFLAGS) -c(the emptyCPPFLAGSexplains the three spaces in the output). The linemain.o tariff.o: tariff.honly adds a prerequisite, so that editing the header rebuilds both objects. -
Variables.
CCandCFLAGSchoose the compiler and flags in one place;make CFLAGS="-O0 -g"overrides them for one run. -
.PHONYmarks targets that are not files, such asclean, so a file namedcleancannot stop the recipe from running. -
The first target is the default. Plain
makebuildsbilling;make cleannames another target.make -j4runs up to four independent recipes in parallel.
Automatic variables
Inside a recipe, make sets a few variables for the current rule. The
billing recipe uses two of them; a leading @ stops make
from echoing the command itself:
cat > Makefile <<'MK'
CC = gcc
CFLAGS = -std=c17 -Wall -Wextra
show: main.c tariff.c
@echo "target: $@"
@echo "first prereq: $<"
@echo "all prereqs: $^"
MK
touch main.c tariff.c
make show
target: show first prereq: main.c all prereqs: main.c tariff.c
Recipes start with a tab
Every recipe line must begin with a tab character. An editor that turns tabs into spaces produces one of make's best-known errors:
printf 'int main(void) { return 0; }\n' > hello.c
# The recipe line below starts with four spaces instead of a tab.
cat > Makefile <<'MK'
hello: hello.c
gcc -std=c17 -Wall -o hello hello.c
MK
make
Makefile:2: *** missing separator. Stop.
Configure your editor to keep tabs in files named
Makefile (most do this by default), and check with
cat -A Makefile, which shows a tab as ^I.
CMake
Handwritten Makefiles become hard to maintain as a project grows,
and they do not work on systems without make, such as a Windows
machine with Visual Studio.
CMake is a cross-platform build
generator: you describe the project — its targets, source files, C
standard and dependencies — in a file called
CMakeLists.txt, and CMake generates the actual build
files for make, Ninja or an IDE. It also tracks header dependencies
automatically. As of October 2026 the current release is CMake
4.4.4. CMake is not installed on the machine used for this page, so
the following is illustrative:
cmake_minimum_required(VERSION 3.21) project(billing LANGUAGES C) set(CMAKE_C_STANDARD 17) set(CMAKE_C_STANDARD_REQUIRED ON) set(CMAKE_C_EXTENSIONS OFF) # -std=c17, not -std=gnu17 add_executable(billing main.c tariff.c) target_compile_options(billing PRIVATE -Wall -Wextra)
The build happens in a separate directory, so generated files never mix with the sources:
cmake -S . -B build # configure: generate the build files
cmake --build build # build (calls make or Ninja)
./build/billing
Other build tools you will meet are Meson and Ninja (Ninja is a
fast, low-level build runner that CMake and Meson generate files
for), and Autotools (./configure && make) in
older open-source projects. ESP-IDF, the C framework for ESP32
chips, builds firmware with CMake and Ninja behind its
idf.py command; see
Firmware and Embedded Programming. For comparison, Java's build tools are covered in
Packages, Modules and Build Tools.