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
The dependency graph of the billing Makefile. The program billing depends on the object files main.o and tariff.o. main.o depends on main.c and tariff.h; tariff.o depends on tariff.c and tariff.h. A change to tariff.h therefore rebuilds both object files and the program; a change to tariff.c rebuilds only tariff.o and the program. billing main.o tariff.o main.c tariff.h tariff.c program object files sources
Arrows point from a target to its prerequisites. make walks the graph bottom-up and rebuilds every target older than something below it.

What the Makefile relies on:

  • Implicit rules. There is no recipe for main.o: GNU Make knows how to make X.o from X.c with $(CC) $(CFLAGS) $(CPPFLAGS) -c (the empty CPPFLAGS explains the three spaces in the output). The line main.o tariff.o: tariff.h only adds a prerequisite, so that editing the header rebuilds both objects.
  • Variables. CC and CFLAGS choose the compiler and flags in one place; make CFLAGS="-O0 -g" overrides them for one run.
  • .PHONY marks targets that are not files, such as clean, so a file named clean cannot stop the recipe from running.
  • The first target is the default. Plain make builds billing; make clean names another target. make -j4 runs 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.