The Preprocessor and Macros

The C preprocessor is a separate, textually-substituting mini-language that runs before the compiler even sees your code. It has no idea about types, scopes, or the C grammar — it operates on tokens. This means macros can do things functions cannot (create new syntax, generate code from a data table, stringify identifiers), and it also means they can silently produce garbage that a function-shaped macro would not. This file makes you fluent in the good uses (headers, header guards, do-while(0), X-macros, debug macros with __FILE__/__LINE__) and warns you off the bad ones.

#include Semantics: Textual, Not Semantic

#include "foo.h" and #include <foo.h> both do exactly one thing: paste the contents of the named file into the current translation unit at this point. The only difference is the search path:

  • #include "foo.h" — search current directory (or -I paths) first, then system.

  • #include <foo.h> — search only system paths (/usr/include, compiler builtins, -isystem paths).

Convention: quotes for your own headers, angles for system/third-party. Not enforced by the language, just universally expected.

When foo.h contains #include "bar.h" and bar.h contains #include "foo.h", you get an infinite include loop — which is why header guards exist.

Header Guards vs #pragma once

The classic idiom:

// foo.h
#ifndef FOO_H
#define FOO_H

/* declarations */

#endif  /* FOO_H */

And the newer non-standard-but-universally-supported alternative:

#pragma once

/* declarations */

Both prevent multiple inclusion. #pragma once is one line, uses no macro namespace, and is faster on some compilers (they can detect the guard and skip the file entirely on repeat includes). It’s supported by GCC, Clang, MSVC, and every mainstream compiler for the last 15 years. The C standard does not require it. For portable open-source library headers, the #ifndef guard is the safe default. For your own code, #pragma once is fine.

Modern (2020+) GCC and Clang can also detect the #ifndef X ... #define X ... #endif pattern and treat it as a #pragma once, so the perf argument is mostly moot.

Use #pragma once. Fall back to #ifndef guards if a code-review culture requires it.

Object-like and Function-like Macros

#define PI 3.14159265358979    // object-like
#define MAX(a, b) ((a) > (b) ? (a) : (b))   // function-like

Rules that will save you:

  1. Parenthesize every parameter and the whole body. Without them, precedence bites:

    #define SQ(x) x*x
    int y = SQ(1+2);   // expands to 1+2*1+2 = 5, not 9.
    
  2. Function-like macros evaluate arguments multiple times. MAX(f(), g()) calls f and g twice each. If either has side effects, disaster.

  3. Prefer static inline functions when types allow. A function has real scope, real types, real single evaluation. Reach for a macro only when you need to defeat the type system (generic max, container_of) or generate code from a data table.

The do { ... } while(0) Idiom

Multi-statement macros must be wrapped so they behave as single statements in if/else:

// WRONG:
#define LOG(x) puts(x); fflush(stdout)

if (verbose)
    LOG("hi");        // Only puts is inside the if!
                      // fflush always runs. Compiler doesn't warn.

// RIGHT:
#define LOG(x) do { puts(x); fflush(stdout); } while (0)

if (verbose)
    LOG("hi");        // The whole thing is one statement, semicolon-terminated.

Why do-while(0) and not just {} braces? Because a plain block leaves a dangling brace-vs-semicolon issue with if/else. do-while(0) compiles to nothing (optimized out) and makes the macro syntactically a statement that requires a trailing semicolon, matching function-call syntax. Memorize this. Every multi-line macro you write for the next 12 months will use it.

Stringification, Concatenation, Line/File Macros

#define STR(x) #x           // # stringifies its argument
#define CONCAT(a, b) a##b   // ## pastes tokens

puts(STR(hello world));     // puts("hello world")
int CONCAT(var_, 42) = 7;   // int var_42 = 7

One indirection quirk: to stringify a macro’s value rather than its name, you need two levels:

#define VERSION 3
#define STR(x) #x
#define XSTR(x) STR(x)

puts(STR(VERSION));    // "VERSION"
puts(XSTR(VERSION));   // "3"

And the built-in location macros, which are gold for debug logging:

Macro

Value

__FILE__

current source file name (string)

__LINE__

current source line number (int)

__func__

current function name (identifier, since C99)

__DATE__

compile date

__TIME__

compile time

__STDC_VERSION__

C standard version (e.g. 202311L for C23)

A debug-log macro you’ll write once and use forever:

#define LOG(fmt, ...) \
    fprintf(stderr, "[%s:%d %s] " fmt "\n", __FILE__, __LINE__, __func__, __VA_ARGS__)

LOG("user=%s id=%d", name, id);

Note on __VA_ARGS__ with zero variadic args: C11 requires at least one variadic arg. C23 fixed this with __VA_OPT__(...) — lets you conditionally include a comma when args are present. GCC and Clang have long had ##__VA_ARGS__ as an extension for the same thing. In C23 you can write:

#define LOG(fmt, ...) fprintf(stderr, fmt __VA_OPT__(,) __VA_ARGS__)

X-Macros: Data-Driven Code Generation

When you have a table of things that need parallel definitions in multiple places (an enum + a name lookup + a handler dispatch), X-macros generate all of them from one source of truth:

#define OPCODES \
    X(OP_ADD,  "add") \
    X(OP_SUB,  "sub") \
    X(OP_MUL,  "mul") \
    X(OP_LOAD, "load") \
    X(OP_HALT, "halt")

// 1. Enum:
typedef enum {
#define X(op, name) op,
    OPCODES
#undef X
} opcode;

// 2. Name lookup:
const char *op_name(opcode o) {
    switch (o) {
#define X(op, name) case op: return name;
        OPCODES
#undef X
    }
    return "unknown";
}

Add a new opcode by adding one line to OPCODES — the enum entry and the name-lookup case appear automatically. This is a genuinely useful pattern; you’ll use it in the bytecode VM project.

When Not to Use a Macro

  • Typed operations that a function can express. static inline int max_int(int a, int b) { return a > b ? a : b; } is safer than MAX.

  • Constants that don’t need macro-time semantics. enum { PORT = 8080 }; or static const int PORT = 8080; are typed. Macros are typeless — #define PORT 8080 is int-ish by default but #define PI 3.14 doesn’t tell you if it’s float or double.

  • Anything you can express with _Generic (C11+). Type-generic max via _Generic:

    #define MAX(a, b) _Generic((a),         \
        int:    max_int,                     \
        double: max_dbl,                     \
        default: max_dbl                     \
    )((a), (b))
    

    Not a full substitute for macros, but for numeric type-dispatch it’s cleaner.

What Most People Get Wrong About the Preprocessor

They use it for everything and it becomes a shadow language layered over C. Then they debug a segfault whose real cause is a macro that expanded to something surprising, and there’s no help from the debugger because by the time gdb sees the code, the macro is gone. Prefer static inline functions to macros wherever possible. Reach for a macro only when you need one of: (a) type-agnostic behavior, (b) code generation (X-macros), (c) __FILE__/__LINE__ capture at call site, (d) header guards.

The second mistake: writing #define VALUE 42 when they mean enum { VALUE = 42 }; or static const int VALUE = 42;. The typed forms show up in the debugger with a name; the macro form doesn’t. enum values in particular are int-typed and scope-respecting.

Exercises

  1. Write a LOG(fmt, ...) macro with __FILE__/__LINE__/__func__. Handle the zero-varargs case using either ##__VA_ARGS__ (GCC/Clang) or __VA_OPT__ (C23).

  2. Design an X-macro for the HTTP status codes (200 OK, 404 Not Found, etc). Generate an enum, a name-lookup, and a struct { int code; const char *reason; } table.

  3. Take a function-like macro from an existing codebase and rewrite it as static inline. Note what type-safety you gained.

  4. Deliberately write a macro without proper parentheses (#define SQ(x) x*x) and observe the wrong output. Fix it.


Return to README.md · Next: 05_undefined_behavior_catalog.md