🎩 The Preprocessor
#error, #warning, #pragma, #line — and a taste of #embed
▶ Open the interactive lesson — free, no signupSometimes an install fails with one clear line — "requires a 64-bit system" — instead of pages of gibberish. That's a program refusing to build on purpose, with a message a human wrote, and #error is how you'll do the same. Along the way you'll learn how to make a struct match a file format or network message byte-for-byte, and how C23 finally lets you drop an image straight into a program with one line.
You've now met the two big preprocessor jobs: pasting (#include, #define) and choosing (#if). This lesson collects the remaining directives — the ones that talk to the compiler and to whoever is reading its output.
#error: refuse to compile
Sometimes the right move is to stop the build with a human-readable message. Paired with #if, it turns silent assumptions into loud requirements:
#include <limits.h>
#if !defined(__STDC_VERSION__) || __STDC_VERSION__ < 201112L
#error "This project requires C11 or newer (try -std=c17)"
#endif
#if LONG_MAX < 9223372036854775807
#error "This code assumes 64-bit long — 32-bit targets unsupported"
#endif
int main(void) { return 0; }$ gcc -std=c17 requirements.c && echo OK OK $ gcc -std=c90 requirements.c requirements.c:4:6: error: #error "This project requires C11 or newer (try -std=c17)" # the build stops immediately, with YOUR message
Details worth knowing: with -std=c90, __STDC_VERSION__ isn't defined at all, so it evaluates to 0 and the check fires. And notice we tested LONG_MAX from <limits.h> rather than sizeof(long) — because the preprocessor cannot evaluate sizeof. It runs before the compiler, so it has no idea how big anything is; limit macros are the workaround. (Part 5's static_assert handles compile-time checks that do need real type knowledge.)
#warning "message" is the gentler sibling: it prints the message and compilation continues. It was a common extension for decades and became standard in C23. Classic use: #warning "TODO: replace this stub before release".
🧠 Checkpoint: Why check LONG_MAX instead of sizeof(long) in a #if?
- LONG_MAX is faster to evaluate
- sizeof is spelled differently in the preprocessor
- The preprocessor runs before the compiler and cannot evaluate sizeof at all
- sizeof(long) is undefined behavior
Show answer
The preprocessor runs before the compiler and cannot evaluate sizeof at all — Preprocessing is pure text-and-integer work with no type knowledge; sizeof in a #if is just an undefined identifier (= 0) followed by a syntax error. Limit macros from limits.h are the preprocessor-friendly mirror of type sizes.
#pragma: vendor-specific dials
#pragma is the standard's official escape hatch: implementation-defined instructions to the compiler, ignored if unrecognized. Three you'll actually meet:
1. #pragma once
The include-guard alternative from the #include lesson — first line of a header, done.
2. #pragma pack: squeezing struct padding
Compilers insert invisible padding bytes into structs so each member sits at its natural alignment (full story in Part 5). #pragma pack(1) tells the compiler: no padding, pack tight — essential when a struct must mirror a file format or network packet byte-for-byte:
#include <stdio.h>
struct loose { /* natural alignment (default) */
char tag; /* 1 byte + 3 padding */
int value; /* 4 bytes */
short id; /* 2 bytes + 2 tail padding */
};
#pragma pack(push, 1) /* save state, then pack tight */
struct tight {
char tag;
int value;
short id;
};
#pragma pack(pop) /* restore normal alignment */
int main(void) {
printf("loose: %zu bytes\n", sizeof(struct loose));
printf("tight: %zu bytes\n", sizeof(struct tight));
return 0;
}$ gcc pack.c -o pack && ./pack loose: 12 bytes tight: 7 bytes # 5 of the 12 bytes were invisible padding
▶ This spot has an interactive memgrid widget — open the interactive lesson to play with it.
Packed structs are not free: on some CPUs, misaligned loads are slow; on a few (and with vector instructions) they can crash outright. Worse, taking a pointer to a misaligned member and passing it around is undefined behavior territory. Pack only at the I/O boundary, then copy into normal structs.
🧠 Checkpoint: When is #pragma pack(1) genuinely the right tool?
- Always — padding wastes memory
- When a struct must match an on-disk or on-wire byte layout exactly
- To speed up member access
- To make sizeof portable across compilers
Show answer
When a struct must match an on-disk or on-wire byte layout exactly — Packing exists for I/O boundaries: file headers, network packets, memory-mapped hardware. Everywhere else, padding is your friend — aligned members are faster and pointer-safe.
3. #pragma GCC diagnostic: surgical warning control
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
legacy_init(cfg); /* we know, we know — scheduled for v3 */
#pragma GCC diagnostic pop
/* from here on, the warning is active again */push saves the current warning state, ignored silences one warning, pop restores everything — so the exception stays scoped to the few lines that earn it, instead of nuking the warning project-wide with a compiler flag.
_Pragma: the operator form
#pragma has one fatal flaw: a macro can't expand into a directive — # lines aren't produced by expansion. C99 added the operator _Pragma("string"), which is usable in macros:
/* impossible with #pragma: macros cannot expand into '#' lines */
#define SILENCE_DEPRECATED \
_Pragma("GCC diagnostic push") \
_Pragma("GCC diagnostic ignored \"-Wdeprecated-declarations\"")
#define RESTORE_DIAGNOSTICS \
_Pragma("GCC diagnostic pop")
SILENCE_DEPRECATED
/* ... legacy calls ... */
RESTORE_DIAGNOSTICS#line: lying about where you are
#line 42 "original.src" sets what __LINE__ and __FILE__ report from that point on. Why would anyone want that? Code generators. Tools like bison/yacc, or anything that compiles another language into C, emit #line markers pointing back at the user's source — so when the generated C fails to compile, the error message points at parser.y:17 (the file the human wrote), not parser.tab.c:3841 (the machine-made noise). The linemarkers you saw in gcc -E output are the same mechanism.
#include <stdio.h>
int main(void) {
printf("really at %s:%d\n", __FILE__, __LINE__);
#line 500 "grammar.y"
printf("now 'at' %s:%d\n", __FILE__, __LINE__);
return 0;
}$ gcc lineliar.c -o lineliar && ./lineliar really at lineliar.c:4 now 'at' grammar.y:500 # compile errors after the #line would ALSO blame grammar.y
🧠 Checkpoint: Why do code-generating tools (bison, etc.) emit #line directives into the C they produce?
- To make the generated file shorter
- So compiler errors point at the file the human wrote, not the generated C
- To speed up preprocessing
- To renumber lines after macros expand
Show answer
So compiler errors point at the file the human wrote, not the generated C — A syntax error in generated C is almost always caused by the source the tool consumed. #line redirects __FILE__/__LINE__ — and therefore every diagnostic — back to that original file and line.
C23 teaser: #embed
For fifty years, shipping a binary asset (icon, font, firmware blob) inside a C program meant writing a script to convert it into a giant {0x89, 0x50, ...} initializer. C23 finally builds that in:
/* C23: the file's bytes become the initializer, at preprocess time */
static const unsigned char logo_png[] = {
#embed "logo.png"
};
/* before C23 you needed xxd -i logo.png > logo.h, or objcopy */The preprocessor expands the file into a comma-separated list of byte values. Freshly landed in GCC 15 and Clang 19 — more C23 goodies await in Part 5.
Try it
▶ This spot has an interactive editor widget — open the interactive lesson to play with it.
That's every directive in the language — the finale of this part puts them all to work designing clean multi-file projects.
▶ Practice this lesson interactively (with live gcc)