C · lesson 16 of 25
The C Preprocessor
The C preprocessor in practice: #include, #define, macro pitfalls seen through gcc -E, # and ##, conditional compilation and include guards.
By SkillByExample · Updated
What the Preprocessor Does
Before the compiler proper runs, the preprocessor rewrites your source text: it pastes in #include files, expands macros and removes #if’d-out code. gcc -E stops after this step and shows the result:
#include <stdio.h>
int main(void) {
printf("hello\n");
return 0;
}
$ wc -l < hello.c
6
$ gcc -E hello.c | wc -l
816
$ gcc -E hello.c | tail -5
# 3 "hello.c"
int main(void) {
printf("hello\n");
return 0;
}
A 6-line file became 816 lines, nearly all of it the text of <stdio.h> and the headers it includes in turn. #include is literal copy-and-paste, which is why a header can declare printf for your file. <stdio.h> (angle brackets) searches the system include directories, and "myheader.h" (quotes) looks next to the including file first. The # 3 "hello.c" line is a marker that lets the compiler report errors against your original line numbers.
#define Constants
An object-like macro is plain text replacement:
#include <stdio.h>
#define PI 3.14159265358979
#define MAX_BUFFER 4096
#define APP_VERSION "2.1.0"
#define TWO_PI (2 * PI) /* macros can use other macros */
int main(void) {
char buf[MAX_BUFFER]; /* a compile-time constant array size */
printf("circumference of r=5: %.4f\n", TWO_PI * 5.0);
printf("buffer: %zu bytes, version %s\n", sizeof buf, APP_VERSION);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o defines defines.c
$ ./defines
circumference of r=5: 31.4159
buffer: 4096 bytes, version 2.1.0
The compiler never sees MAX_BUFFER, only 4096. That’s why it works as an array size, but also why a macro has no type and no scope, and doesn’t exist in the debugger. The parentheses in TWO_PI are a habit worth keeping: without them, 100 / TWO_PI would become 100 / 2 * PI, which is 157.08, not 15.9.
Function-Like Macros and Their Traps
A macro with parameters substitutes the argument text. Here are three real traps, and how each is avoided:
#include <stdio.h>
#define BAD_SQUARE(x) x * x
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) ((a) > (b) ? (a) : (b))
#define SWAP(a, b, type) do { \
type swap_tmp_ = (a); \
(a) = (b); \
(b) = swap_tmp_; \
} while (0)
int main(void) {
printf("BAD_SQUARE(1 + 2) = %d\n", BAD_SQUARE(1 + 2));
printf("SQUARE(1 + 2) = %d\n", SQUARE(1 + 2));
printf("100 / SQUARE(5) = %d\n", 100 / SQUARE(5));
int x = 3, y = 7;
if (x < y)
SWAP(x, y, int); /* safe after an unbraced if, thanks to do/while */
else
printf("no swap\n");
printf("after SWAP: x=%d, y=%d\n", x, y);
int a = 5, b = 1;
int m = MAX(a++, b); /* a++ appears twice in the expansion */
printf("MAX(a++, b) = %d, and a is now %d\n", m, a);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o macros macros.c
$ ./macros
BAD_SQUARE(1 + 2) = 5
SQUARE(1 + 2) = 9
100 / SQUARE(5) = 4
after SWAP: x=7, y=3
MAX(a++, b) = 6, and a is now 7
gcc -E -P shows exactly what the compiler received:
$ gcc -E -P expand.c
int r1 = 1 + 2 * 1 + 2;
int r2 = ((1 + 2) * (1 + 2));
int r3(int a, int b) { return ((a++) > (b) ? (a++) : (b)); }
BAD_SQUARE(1 + 2) became 1 + 2 * 1 + 2, which is 5, so every parameter use needs parentheses. The outer parentheses matter too: 100 / SQUARE(5) is 4 only because the whole expansion is wrapped. MAX(a++, b) increments a twice, once in the test and once in the chosen branch, so it returns 6, not 5, and leaves a at 7. No compiler warning catches that. The do { ... } while (0) wrapper turns SWAP’s several statements into a single statement, so the if/else above still parses correctly. With bare braces, the ; after SWAP(...) would end the if and orphan the else.
Passing a++ to SQUARE is worse: it modifies a twice in one expression, which is undefined behavior:
$ gcc -Wall -Wextra -std=c17 -o sq_ub sq_ub.c
sq_ub.c: In function 'main':
sq_ub.c:6:21: warning: operation on 'a' may be undefined [-Wsequence-point]
6 | int r = SQUARE(a++);
| ^
sq_ub.c:2:27: note: in definition of macro 'SQUARE'
2 | #define SQUARE(x) ((x) * (x))
| ^
$ ./sq_ub
r = 30, a = 7
30 is 5 * 6, one possible answer among many. A static inline int square(int x) function evaluates its argument exactly once, and has none of these problems.
Stringizing # and Token Pasting ##
These two operators are the things only a macro can do. # turns an argument’s source text into a string, and ## builds a new identifier:
#include <stdio.h>
#define PRINT_INT(x) printf(#x " = %d\n", (x))
#define STRINGIFY(x) #x
#define DECLARE_COUNTER(n) static int counter_##n = 0
DECLARE_COUNTER(requests); /* becomes: static int counter_requests = 0 */
DECLARE_COUNTER(errors);
int main(void) {
int speed = 120;
PRINT_INT(speed);
PRINT_INT(speed * 2 + 1);
printf("%s\n", STRINGIFY(Hello World));
counter_requests += 3;
counter_errors++;
printf("requests=%d errors=%d\n", counter_requests, counter_errors);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o stringize stringize.c
$ ./stringize
speed = 120
speed * 2 + 1 = 241
Hello World
requests=3 errors=1
PRINT_INT prints both the expression as you wrote it and its value, which no function could do, since a function only ever receives the value. #x becomes "speed * 2 + 1", and string-literal concatenation joins it to " = %d\n". Stringizing collapses runs of whitespace to one space, so Hello World printed as Hello World. counter_##n created two separate variables, counter_requests and counter_errors, from one macro.
Conditional Compilation
#if, #ifdef and friends include or remove code before compilation. Macros can also be defined from the command line with -D:
#include <stdio.h>
#define VERSION 2
#ifdef DEBUG
#define LOG(...) fprintf(stderr, "[DEBUG] " __VA_ARGS__)
#else
#define LOG(...) ((void)0) /* compiled out entirely */
#endif
#if VERSION >= 2
#define GREETING "Hello from v2"
#else
#define GREETING "Hello from v1"
#endif
int main(void) {
LOG("starting, VERSION=%d\n", VERSION);
printf("%s\n", GREETING);
#if defined(__linux__)
printf("built for Linux\n");
#elif defined(_WIN32)
printf("built for Windows\n");
#else
printf("built for something else\n");
#endif
LOG("done\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o cond cond.c
$ ./cond
Hello from v2
built for Linux
$ gcc -Wall -Wextra -std=c17 -DDEBUG -o cond cond.c
$ ./cond
[DEBUG] starting, VERSION=2
[DEBUG] done
Hello from v2
built for Linux
The same source produced two different programs. -DDEBUG works as if #define DEBUG were written at the top of the file. In the release build, the LOG calls expand to ((void)0), so they cost nothing at runtime. The debug lines printed first because they went to unbuffered stderr, while stdout is buffered when it isn’t a terminal. __linux__ and _WIN32 are predefined by the compiler for the target platform. The branches not taken are never compiled at all, so they can call functions that don’t exist on this platform.
Compile-Time Assertions
C11’s _Static_assert checks a constant condition at compile time, which is ideal for checking layout assumptions:
#include <stdio.h>
#include <stdint.h>
struct Header {
uint8_t type;
uint32_t length;
};
_Static_assert(sizeof(int) == 4, "this code assumes a 4-byte int");
_Static_assert(sizeof(struct Header) == 5, "Header must be exactly 5 bytes");
int main(void) {
printf("compiled\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o sassert sassert.c
sassert.c:10:1: error: static assertion failed: "Header must be exactly 5 bytes"
10 | _Static_assert(sizeof(struct Header) == 5, "Header must be exactly 5 bytes");
| ^~~~~~~~~~~~~~
The first assertion held. The second caught a real mistake: padding makes struct Header 8 bytes, not the 5 its author assumed (the structs lesson explains why). An assumption that would have corrupted a file format at runtime was caught before the program even existed. <assert.h> also provides static_assert as a friendlier spelling.
Include Guards
When two headers both include a third, that third header ends up pasted into your file twice, and duplicate type definitions are an error:
/* point_noguard.h */
typedef struct { int x, y; } Point;
/* shapes_noguard.h */
#include "point_noguard.h"
typedef struct { Point a, b; } Line;
$ gcc -Wall -Wextra -std=c17 -o main_noguard main_noguard.c
In file included from shapes_noguard.h:1,
from main_noguard.c:2:
point_noguard.h:1:30: error: conflicting types for 'Point'; have 'struct <anonymous>'
1 | typedef struct { int x, y; } Point;
| ^~~~~
In file included from main_noguard.c:1:
point_noguard.h:1:30: note: previous declaration of 'Point' with type 'Point'
1 | typedef struct { int x, y; } Point;
| ^~~~~
main_noguard.c includes point_noguard.h directly and then again through shapes_noguard.h. An include guard makes the second copy disappear:
/* point.h */
#ifndef POINT_H
#define POINT_H
typedef struct { int x, y; } Point;
#endif /* POINT_H */
/* main.c */
#include <stdio.h>
#include "point.h"
#include "shapes.h" /* point.h is included again, but skipped */
int main(void) {
Line l = {{0, 0}, {3, 4}};
printf("line from (%d,%d) to (%d,%d)\n", l.a.x, l.a.y, l.b.x, l.b.y);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o main main.c
$ ./main
line from (0,0) to (3,4)
The first time through, POINT_H isn’t defined, so the body is kept and POINT_H gets defined. The second time, #ifndef POINT_H is false and everything up to #endif is skipped (shapes.h is guarded the same way). #pragma once at the top of a header does the same job in one line. It isn’t in the C standard, but GCC, Clang and MSVC all support it.
#pragma and Predefined Macros
#pragma passes compiler-specific instructions. Two common ones control struct packing and individual warnings:
#include <stdio.h>
#include <stdint.h>
struct Natural {
uint8_t type;
uint32_t length;
uint16_t checksum;
};
#pragma pack(push, 1)
struct Packed {
uint8_t type;
uint32_t length;
uint16_t checksum;
};
#pragma pack(pop)
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wunused-function"
static void kept_for_later(void) { } /* warning suppressed */
#pragma GCC diagnostic pop
static void forgotten(void) { } /* still warned about */
int main(void) {
printf("sizeof(struct Natural) = %zu\n", sizeof(struct Natural));
printf("sizeof(struct Packed) = %zu\n", sizeof(struct Packed));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o pragma pragma.c
pragma.c:23:13: warning: 'forgotten' defined but not used [-Wunused-function]
23 | static void forgotten(void) { } /* still warned about */
| ^~~~~~~~~
$ ./pragma
sizeof(struct Natural) = 12
sizeof(struct Packed) = 7
pack(1) removed all 5 padding bytes. That’s handy for matching a wire format, but members are then misaligned, which is slower, and on some CPUs a fault. Only one of the two unused functions was reported, because the diagnostic pragma suppresses a warning only when the compiler issues it within the push/pop region. That caveat is real: wrapping just an unused local variable this way has no effect in GCC, because it reports those at the end of the whole function, after the pop.
The compiler also predefines macros that describe where and how code was built:
#include <stdio.h>
#define TRACE() printf(" at %s:%d in %s()\n", __FILE__, __LINE__, __func__)
void helper(void) {
TRACE();
}
int main(void) {
printf("compiled on %s at %s\n", __DATE__, __TIME__);
printf("__STDC_VERSION__ = %ldL\n", __STDC_VERSION__);
TRACE();
helper();
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o predefined predefined.c
$ ./predefined
compiled on Sep 23 2026 at 16:26:07
__STDC_VERSION__ = 201710L
at predefined.c:12 in main()
at predefined.c:6 in helper()
__LINE__ reports the line where TRACE() was used, not where it was defined, because the macro expands in place. That’s why logging and assert are macros rather than functions. __func__ isn’t a macro but a predefined variable (C99), and it works the same way here. 201710L confirms the -std=c17 mode. The date and time are frozen at compile time, which is why reproducible-build setups often override them.
Practice
- Write an
ARRAY_LEN(a)macro that returns an array’s element count, and test it on anintarray and aconst char *array.
Solution
#include <stdio.h>
#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
int main(void) {
int primes[] = {2, 3, 5, 7, 11};
const char *names[] = {"x", "y"};
printf("%zu %zu\n", ARRAY_LEN(primes), ARRAY_LEN(names));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
5 2
A macro works on any element type because sizeof is evaluated at the call site. Passed a pointer instead of an array, it silently returns a wrong answer (sizeof(int *) / sizeof(int) is 2 on 64-bit), so only use it where the array is actually in scope.
- Write
CHECK(cond)that, when the condition fails, prints the condition’s source text with the file and line, without aborting.
Solution
#include <stdio.h>
#define CHECK(cond) \
do { if (!(cond)) printf("CHECK failed: %s (%s:%d)\n", #cond, __FILE__, __LINE__); } while (0)
int main(void) {
int balance = 50, withdrawal = 80;
CHECK(balance >= 0);
CHECK(withdrawal <= balance);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
CHECK failed: withdrawal <= balance (p2.c:9)
#cond supplies the readable text and __LINE__ the call site, and neither is possible from a function. The do { } while (0) lets CHECK(...); sit safely under an unbraced if. This is essentially how the standard assert is built.
- Use an “X-macro” to define a
Colorenum and a matching array of names from a single list, so the two can never get out of sync.
Solution
#include <stdio.h>
#define COLORS(X) X(RED) X(GREEN) X(BLUE)
#define AS_ENUM(name) COLOR_##name,
#define AS_STRING(name) #name,
enum Color { COLORS(AS_ENUM) COLOR_COUNT };
static const char *color_names[] = { COLORS(AS_STRING) };
int main(void) {
for (int c = 0; c < COLOR_COUNT; c++) printf("%d = %s\n", c, color_names[c]);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
0 = RED
1 = GREEN
2 = BLUE
COLORS takes another macro as its argument and applies it to each entry. Expanded with AS_ENUM, it produces COLOR_RED, COLOR_GREEN, COLOR_BLUE,, and with AS_STRING it produces "RED", "GREEN", "BLUE",. Adding X(YELLOW) to the list updates the enum, the names and COLOR_COUNT together.
- Make the
sassert.cexample compile by fixing the struct rather than deleting the assertion.
Solution
Wrap struct Header in #pragma pack(push, 1) / #pragma pack(pop) so it really is 5 bytes, or change the assertion to what the layout actually is and serialize the fields one at a time when writing the file. With the pragma, both assertions pass:
$ gcc -Wall -Wextra -std=c17 -o sassert sassert.c
$ ./sassert
compiled
The assertion did its job either way: it forced a decision about the layout at compile time instead of letting a 3-byte disagreement surface later as a corrupt file.