The preprocessor, multi-file programs and make · about 40 min

Conditional compilation

Why this matters

One source tree often has to produce several different programs: a debug build full of checks and logging, and a lean release build; a version for Linux and one for Windows; a build with an optional feature and one without its dependency. C handles this before compilation even starts: the preprocessor can include or drop whole regions of code based on conditions. Every serious C project uses it, starting with the include guard at the top of every header.

The directives

#if expression        /* keep the following lines if expression is nonzero */
#elif expression      /* else if */
#else
#endif

#ifdef NAME           /* same as  #if defined(NAME)  */
#ifndef NAME          /* same as  #if !defined(NAME) */

Lines in a region whose condition is false are removed by the preprocessor. The compiler never sees them: they are not compiled, not type-checked, and take no space in the program. This is the big difference from an ordinary if (debug) in C, which is compiled and checked at run time.

#if expressions are evaluated by the preprocessor, so they may only use integer constants, macros that expand to integer constants, arithmetic, comparisons, &&, ||, !, and the special operator defined(NAME). They cannot use variables, sizeof, casts or enum constants, because those do not exist yet at this stage.

  source with #if            preprocessor             what the compiler sees
  ----------------           ------------             ----------------------
  a();                                                a();
  #ifdef VERBOSE             VERBOSE not defined
  log("x");          ---->   drop these lines  ---->
  #endif
  b();                                                b();

Defining macros from the command line: -D

gcc -DNAME defines NAME as 1 before the first line of the file, and gcc -DNAME=value gives it a value. -UNAME undefines it. This is how build systems switch configurations without editing code:

$ gcc -DVERBOSE -DBUFSIZE=256 main.c -o prog

A worked example

#include <stdio.h>

#ifndef BUFSIZE
#define BUFSIZE 128
#endif

#if BUFSIZE < 64
#error "BUFSIZE must be at least 64"
#endif

#ifdef VERBOSE
#define DEBUG_LOG(...) fprintf(stderr, "debug: " __VA_ARGS__)
#else
#define DEBUG_LOG(...) do { } while (0)
#endif

int main(void)
{
    DEBUG_LOG("BUFSIZE is %d\n", BUFSIZE);
    printf("C version: %ld\n", __STDC_VERSION__);
#if defined(__linux__)
    printf("built for Linux\n");
#elif defined(_WIN32)
    printf("built for Windows\n");
#else
    printf("built for something else\n");
#endif
#if 0
    this is not even valid C, but it is skipped entirely
#endif
    return 0;
}

Three builds of the same file:

$ gcc -std=c17 -o c1 cond.c && ./c1
C version: 201710
built for Linux
$ gcc -std=c99 -DVERBOSE -DBUFSIZE=256 -o c2 cond.c && ./c2
debug: BUFSIZE is 256
C version: 199901
built for Linux
$ gcc -DBUFSIZE=16 cond.c -o c3
cond.c:8:2: error: #error "BUFSIZE must be at least 64"
    8 | #error "BUFSIZE must be at least 64"
      |  ^~~~~

What each part does:

Include guards

The most common conditional of all sits at the top of every header file:

#ifndef GEOMETRY_H
#define GEOMETRY_H

struct point { double x, y; };
double distance(struct point a, struct point b);

#endif

The first time the header is included, GEOMETRY_H is not defined, so the contents are kept and the macro gets defined. If the same header is included again into the same file (directly, or through another header), #ifndef is false and everything is skipped. Without the guard, the second copy of struct point causes error: redefinition of 'struct point'. The next lesson uses guards in a multi-file program. Many compilers also support #pragma once with the same effect; it is not part of standard C but is widely used.

Keeping it under control

Conditional compilation is easy to overdo. Every #ifdef doubles the number of program variants, and code in a disabled branch can rot unnoticed because it is never compiled. Good practice:

Common mistakes

Recap

Going further: look at the top of /usr/include/stdio.h in the sandbox: it starts with an include guard (_STDIO_H) and uses #if throughout to adapt to the compiler and standard version. gcc -E -dM main.c lists every macro defined at the end of preprocessing your file.

Exercises

Exercise 1 · quiz

Defined as zero

#define DEBUG 0

#ifdef DEBUG
    printf("debug build\n");
#endif

Is the printf line compiled into the program?

Answer
  • Yes, because DEBUG is defined, whatever its value

#ifdef only asks whether the name is defined. To test the value, write #if DEBUG, which is false for 0. Projects pick one convention and stick to it: either "define it or not" (#ifdef, -DDEBUG) or "always define it to 0 or 1" (#if, -DDEBUG=1). Mixing them is a classic source of debug code leaking into release builds.

Exercise 2 · write code

Debug output only in debug builds

The program computes 5 factorial and prints result: 120. While developing it, the author printed each step to stderr. Make those step messages appear only when the program is compiled with -DVERBOSE; in a normal build they must not be compiled in at all.

With -DVERBOSE, stderr must contain exactly these lines (and stdout still just result: 120):

debug: i=1 acc=1
debug: i=2 acc=2
debug: i=3 acc=6
debug: i=4 acc=24
debug: i=5 acc=120

The check builds your file normally (stderr must then be empty) and again with -DVERBOSE.

File: main.c

Starting point

#include <stdio.h>

int main(void)
{
    long acc = 1;
    for (int i = 1; i <= 5; i++) {
        acc *= i;
        fprintf(stderr, "debug: i=%d acc=%ld\n", i, acc);
    }
    printf("result: %ld\n", acc);
    return 0;
}

Example

expected output

result: 120
Hint 1

Surround the fprintf line with #ifdef VERBOSE and #endif, each on its own line.

Hint 2

Directives can appear anywhere, even inside a loop body; they just must start their own line.

Reference solution
#include <stdio.h>

int main(void)
{
    long acc = 1;
    for (int i = 1; i <= 5; i++) {
        acc *= i;
#ifdef VERBOSE
        fprintf(stderr, "debug: i=%d acc=%ld\n", i, acc);
#endif
    }
    printf("result: %ld\n", acc);
    return 0;
}

Exercise 3 · quiz

Testing a name nobody defined

FEATURE_X is not defined anywhere. What does the preprocessor do with this?

#if FEATURE_X
int feature_enabled = 1;
#endif
Answer
  • Treats FEATURE_X as 0, so the line is skipped

In #if, any identifier that is not a macro is replaced by 0. That is convenient and dangerous: a typo such as #if FEATRUE_X silently disables code. gcc's -Wundef warns about it ("FEATURE_X" is not defined, evaluates to 0). When you mean "is it defined", say so with #ifdef or

#if defined(FEATURE_X).

Exercise 4 · write code

A default you can override, with a safety check

The program prints the size of its buffer. Make BUFSIZE configurable from the command line:

The program prints exactly one line:

buffer size: 128

(with the actual value). The checker builds your file normally, with -DBUFSIZE=256 (expects buffer size: 256 and no warnings about redefinition), and with -DBUFSIZE=16 (expects the build to fail with your message).

File: main.c

Starting point

#include <stdio.h>

#define BUFSIZE 128

int main(void)
{
    printf("buffer size: %d\n", BUFSIZE);
    return 0;
}

Example

expected output

buffer size: 128
Hint 1

#ifndef BUFSIZE ... #define BUFSIZE 128 ... #endif only defines it when nobody else did.

Hint 2

#if BUFSIZE < 64 works because #if can compare integer values of macros.

Hint 3

#error "BUFSIZE must be at least 64" stops the build and prints the text.

Reference solution
#include <stdio.h>

#ifndef BUFSIZE
#define BUFSIZE 128
#endif

#if BUFSIZE < 64
#error "BUFSIZE must be at least 64"
#endif

int main(void)
{
    printf("buffer size: %d\n", BUFSIZE);
    return 0;
}