C Part I · Module 11 · Building Multi-File C Programs
Conditional Compilation
The preprocessor's other ability is to produce no code at all — to test a condition and delete a whole region of the file before the compiler can look at it. That is how one source tree builds for two targets, how a debug build differs from a release one, and how a header adapts to the compiler reading it. It is also, used carelessly, how a file becomes unreadable, how a branch nobody has built in two years quietly stops being valid C, and how a feature turns itself off because somebody mistyped a name. All three of those are measured in this chapter, along with the flag that catches the third one and is not enabled by anything you are currently passing.
Intermediate18 min readCPart IFundamentalsBuild
Part I · Module 11 · Chapter 11.7 · Conditional Compilation
1. The Directives
#if constant-expression
#ifdef NAME /* short for #if defined(NAME) */
#ifndef NAME /* short for #if !defined(NAME) */
#elif constant-expression
#else
#endifEach block keeps at most one region and deletes the rest. Chapter 11.2 §3 measured the deletion: the unselected text is simply absent from the translation unit.
#if evaluates an integer constant expression using the preprocessor's own arithmetic. It can use defined(NAME), arithmetic, comparisons and the logical operators — but not sizeof, not enumeration constants, and not anything that requires knowing about types, because none of that exists yet.
#if defined(FEATURE_A) && !defined(FEATURE_B)
#if VERSION >= 3
#if __STDC_VERSION__ >= 201112L /* C11 or later */Three spellings mean three different things and the difference matters:
| Form | Asks |
|---|---|
#ifdef NAME | does this macro exist? |
#if NAME | does it exist and evaluate non-zero? |
#if defined(NAME) && NAME | both, explicitly |
#define FEATURE 0 is defined and zero: #ifdef FEATURE is true, #if FEATURE is false. Pick the one that matches your intent, and be consistent — a project that mixes them will eventually have a macro that is set to 0 to turn a feature off and an #ifdef that turns it on anyway.
2. Hazard One: A Dead Branch Is Not Checked
#include <stdio.h>
int main(void)
{
int n = 1;
#if 0
this is not even close to C;
retrun n+++++;
#endif
#ifdef NEVER_DEFINED
also_completely_invalid ((( ;
#endif
printf("n=%d\n", n);
return 0;
}$ cc -std=c17 -Wall -Wextra -Wpedantic c1.c -o c1
$ ./c1
n=1Not one diagnostic. Neither branch is tolerated — neither branch exists. By the time the compiler runs, the text is gone.
Code inside an unselected branch is not compiled, not type-checked, not warned about, and not counted by any tool that works on compiled code. It is prose.
The consequence is slow and reliable: a configuration nobody builds rots. A function it calls gets renamed, a struct member it uses is removed, a signature changes — and none of it is reported, because the code referencing those things was deleted before anything could check. The day someone enables that configuration, they do not get their feature; they get a list of compile errors accumulated over however long it has been since the branch was last built.
The defence is to build every configuration you ship. If a branch exists and no build ever selects it, it is already broken or will be, and you will find out at the worst possible moment.
3. Hazard Two: An Undefined Name Is Zero
#include <stdio.h>
#define ENABLE_LOGGING 1
int main(void)
{
#if ENABLE_LOGGING
printf("logging on\n");
#else
printf("logging off\n");
#endif
#if ENABLE_LOGGNIG /* misspelled */
printf("second block on\n");
#else
printf("second block off\n");
#endif
return 0;
}$ cc -std=c17 -Wall -Wextra -Wpedantic c2.c -o c2
$ ./c2
logging on
second block offNo warning at the curriculum's standard level, and the second block is off. #if replaces any identifier that is not a defined macro with 0, so a typo is indistinguishable from a deliberate "off".
There is a flag for it, and it is not one you are already passing:
$ cc -std=c17 -Wall -Wextra -Wpedantic -Wundef c2.c -o /dev/null
c2.c:10:5: warning: 'ENABLE_LOGGNIG' is not defined, evaluates to 0 [-Wundef]4. Making a Missing Configuration Fail Loudly
#include <stdio.h>
#if !defined(TARGET)
# error "TARGET must be defined: use -DTARGET=1 or -DTARGET=2"
#elif TARGET == 1
# define TARGET_NAME "one"
#elif TARGET == 2
# define TARGET_NAME "two"
#else
# error "TARGET must be 1 or 2"
#endif
int main(void) { printf("built for %s\n", TARGET_NAME); return 0; }$ cc -std=c17 c3.c -o c3
c3.c:3:4: error: "TARGET must be defined: use -DTARGET=1 or -DTARGET=2"
$ cc -std=c17 -DTARGET=2 c3.c -o c3 && ./c3
built for two
$ cc -std=c17 -DTARGET=9 c3.c -o c3
c3.c:9:4: error: "TARGET must be 1 or 2"Three outcomes, all useful. #error stops the build with your message — and note the final #else, which catches a value that is supplied but wrong. Without it, -DTARGET=9 would define no TARGET_NAME and produce a confusing error about an undeclared identifier several lines later.
Every
#if/#elifchain that selects a configuration should end in an#elsewith an#error. A missing branch is not a default; it is a hole, and#errorturns it into a sentence someone can act on.
-D on the command line is how a build system supplies these. -DNAME defines it as 1; -DNAME=value gives it a value. Chapter 11.10 covers where those flags come from.
5. The Legitimate Uses
Conditional compilation earns its place when the code genuinely cannot be compiled in the other configuration.
Include guards. Chapter 11.3. The canonical use, and the one you will write most often.
Platform differences where the alternative does not exist. Code calling an API that is absent on another platform cannot be compiled there, so there is no choice.
Standard-version differences.
#if __STDC_VERSION__ >= 201112L
/* use something C11 added */
#endifCompiling a feature out entirely, when its code must not be present — size-constrained builds, or a diagnostic facility that must not ship.
Temporarily removing code, with #if 0, which is better than block comments because it nests. Treat it as a few minutes' tool, not a place to leave code.
6. The Alternative That Keeps the Code Checked
For most "turn this on or off" cases, there is a better technique, and the reason it is better is measurable.
#include <stdio.h>
#ifndef ENABLE_TRACE
# define ENABLE_TRACE 0
#endif
static void trace(const char *m) { printf("[trace] %s\n", m); }
int main(void)
{
if (ENABLE_TRACE) { trace("starting"); }
printf("work done\n");
return 0;
}$ cc -std=c17 -Wall -Wextra -Wpedantic -O2 c4.c -o c4 && ./c4
work done
$ cc -std=c17 -O2 -S c4.c -o - | grep -c "_trace"
0
$ cc -std=c17 -Wall -Wextra -Wpedantic -DENABLE_TRACE=1 -O2 c4.c -o c4b && ./c4b
[trace] starting
work doneRead the middle result. At -O2 with the default, the emitted assembly contains zero occurrences of _trace — the condition folded to false and the call was removed entirely. The run-time cost is identical to #if.
And the body was compiled. It was parsed, type-checked, and warned about, exactly like every other line in the file. Hazard one is gone.
Prefer
if (FEATURE)over#if FEATUREwhenever the disabled code is still valid C for the target you are building. You get the same generated code and you keep the compiler checking a branch you are not currently using. Reach for#ifonly when the code genuinely cannot compile in the other configuration — which §5 lists and which is a much shorter list than most codebases assume.
Two refinements worth knowing:
#if is still needed when the declarations differ, not just the statements. An if cannot choose between two incompatible function signatures.
C23 adds if constexpr-like facilities and __has_include, which narrow the gap further. This curriculum's baseline is C17 (Chapter 1.3), so the technique above is the portable one.
7. Avoiding the Maze
The readability problem is not the number of directives. It is the number of configurations: n independent feature macros produce 2^n possible programs, and nobody has read more than a handful of them.
Four rules that keep it manageable.
Put variation at the edges, not through the logic. A function whose body is half directives is unreadable in every configuration. Two complete functions selected by one #if are readable in both.
/* harder to read */ /* easier to read */
void send(const char *m) #if TARGET == 1
{ void send(const char *m) { ... }
#if TARGET == 1 #else
port_write(m); void send(const char *m) { ... }
#else #endif
queue_push(m);
#endif
log_sent();
}Concentrate configuration in one header. One config.h that defines every feature macro with a default (§3) means the rest of the project tests values rather than existence, and there is one place to read to know what a build does.
Never nest more than two deep. Three levels of directive is a sign that the logic wants to be split into separate files or chosen at run time.
Comment the #endif when the block is longer than a screen:
#endif /* TARGET == 1 */The compiler does not care; the reader three hundred lines below does.
And the test that catches the rest: can you still read this file in a configuration you did not write it for? If understanding the code requires knowing which macros are set, the variation has leaked too far into the logic.
8. What You Should Be Able to Say Now
#if/#ifdef/#ifndef/#elif/#else/#endifkeep at most one region and delete the rest.#ifevaluates an integer constant expression with the preprocessor's own arithmetic — nosizeof, no enumeration constants, no types.#ifdef NAME,#if NAMEand#if defined(NAME) && NAMEask different questions.#define FEATURE 0is defined and false.- An unselected branch is not compiled, type-checked, or warned about — measured: deliberate garbage compiled clean.
- Therefore a configuration nobody builds rots. Build every configuration you ship.
- An undefined identifier in
#ifis silently0— measured: a typo turned a feature off with no diagnostic. -Wundefcatches it and is not in-Wall -Wextra.- Default every feature macro in one header so
#ifnever tests an undefined name. - End every configuration chain with
#else+#error— measured: a missing-Dand a wrong value both became clear build failures. - Measured: an ordinary
if (FEATURE)compiled to zero occurrences of the call at-O2— same cost as#if, and the code stays checked. - Prefer
ifto#ifunless the disabled code genuinely cannot compile. - The maze is about configurations, not directives — push variation to the edges, centralise it, do not nest deeply, label your
#endifs.
9. Exercises
Exercise 1 — give the result. For each, say whether the block is kept, with #define A 0 and B not defined at all.
#ifdef A
#ifdef B
#if A
#if B
#if defined(A) && A
#if !defined(B)
#if A || BExercise 2 — explain the silence. A colleague adds #if ENABLE_METRICS around new code, builds, sees no metrics, and concludes the feature is broken. Give the three most likely causes from §3, the flag that would have reported it, and the header change that prevents all three.
Exercise 3 — make it fail loudly. Rewrite this so a missing or unrecognised MODE stops the build with a useful message.
#if MODE == 1
# define MODE_NAME "fast"
#elif MODE == 2
# define MODE_NAME "safe"
#endifExercise 4 — rot. A branch guarded by #ifdef LEGACY_PROTOCOL has not been built for two years. List four things that could have broken it without anyone noticing, and say what would have prevented each.
Exercise 5 — convert to a runtime if. Rewrite this using §6's technique, and say what you gain, what you give up, and one case where the conversion would not be possible.
#ifdef ENABLE_CHECKS
if (index >= count) { report_error("range"); return -1; }
#endifExercise 6 — move the variation. Rewrite this so each configuration is readable on its own.
int open_port(const char *name)
{
#if TARGET == 1
int fd = native_open(name);
#else
int fd = sim_open(name);
#endif
if (fd < 0) { return -1; }
#if TARGET == 1
native_configure(fd);
#endif
return fd;
}Exercise 7 — count the configurations. A project has six independent feature macros. How many distinct programs can it build, how many does a typical CI run build, and what follows from the difference?
Exercise 8 — choose the tool. For each, say whether you would use #if, a runtime if, or neither.
- calling an API that exists only on one operating system
- a verbose logging mode switched on for debugging
- omitting a diagnostic subsystem from a size-constrained build
- using a C11 feature when the compiler supports it
- a customer-specific calculation, selected at install time
10. Summary
- The directives keep one region and delete the rest — the deleted text never reaches the compiler.
#ifuses integer constant expressions only.#ifdef NAME≠#if NAME—#define FEATURE 0is defined and false.- A dead branch is never checked — measured: garbage compiled clean. Configurations you do not build, rot.
- An undefined name in
#ifis0, silently — measured, a typo switched a feature off. -Wundefcatches it and is not in-Wall -Wextra. Default every feature macro.- End every chain with
#else+#error. - Measured: a runtime
if (FEATURE)emitted zero occurrences of the call at-O2and kept the code checked. Prefer it. - Use
#ifonly when the other configuration cannot compile. - The maze is configurations, not directives: variation at the edges, one config header, no deep nesting, labelled
#endifs.
This chapter and the two before it have described three different things that happen to your source — macros replaced, branches deleted, declarations checked — and treated them as though they occur in some obvious order. They do, and the order is worth seeing laid out, because every error message in this module arrives from a specific stage and tells you something different depending on which one. A preprocessing error, a compile error and a link error are not three severities of the same thing; they are three separate programs objecting to three separate inputs, with different information available to each. The next chapter follows several source files all the way to one executable, stage by stage, and establishes what each stage can and cannot check — which is the frame for reading any build failure, including the two that the chapter after it is entirely about.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
