The Preprocessor and the Build
The compiler never sees the file you wrote. Something else gets there first, pasting headers in and replacing macros with text, and what it hands over is often not what you meant. This volume follows one file all the way from source to binary, and shows what each tool along the way is really doing.
- Why a macro needs brackets in two places, shown by what happens without them
- What an include guard prevents, and what modern C now allows without one
- How conditional compilation removes code before the compiler ever sees it
- The four build stages, and what each leaves behind
- How to read the two link errors everybody meets
- What -O0, -Os, -O2 and -O3 actually cost in bytes, and what optimisation breaks
15.1 #define and macros
The preprocessor runs before the compiler and works on text. It does not know about types, precedence, or how many times something should happen.
A macro is a name and some replacement text. When the preprocessor meets the name it pastes the text in, and that is the whole mechanism.
#define LED_PIN 5u
#define BIT(n) (1u << (n))
#define SET(reg, m) do { (reg) |= (m); } while (0)
void led_on(void)
{
SET(odr, BIT(LED_PIN));
}
Run only the preprocessor with gcc -E and you can read exactly what the compiler will be handed:
unsigned odr;
void led_on(void)
{
do { (odr) |= ((1u << (5u))); } while (0);
}
void extra_feature(void) { odr = 0u; }
Every macro is gone, replaced by its text. This command is worth remembering: when a macro is
misbehaving, gcc -E ends the argument in seconds.
Trap one: brackets round the parameters
#define SQUARE_BAD(x) x * x
1. brackets round the parameters
#define SQUARE_BAD(x) x * x
SQUARE_BAD(2 + 3) expands to 2 + 3 * 2 + 3, which is 11
SQUARE_OK(2 + 3) expands to ((2 + 3) * (2 + 3)), which is 25
The argument is pasted in as text, so 2 + 3 lands either side of the * and multiplication binds
tighter than addition. Brackets round each use of the parameter fix it.
Trap two: brackets round the whole thing
2. brackets round the whole expansion
#define HALF_BAD(x) (x) / 2
12 / HALF_BAD(2) expands to 12 / (2) / 2, which is 3
12 / HALF_OK(2) expands to 12 / ((2) / 2), which is 12
The parameters were bracketed this time. The expansion itself was not, so the surrounding expression reached inside it. Both sets of brackets are needed, always.
Trap three: an argument used twice
#define MAX_MACRO(a, b) ((a) > (b) ? (a) : (b))
Every bracket is present. It is still wrong, because a appears twice in the replacement text, so
whatever you pass happens twice.
3. an argument that appears twice
int i = 5, j = 3; MAX_MACRO(i++, j++) gave 6
afterwards i is 7 and j is 4 - i was incremented twice
the same with a function gave 5
afterwards i is 6 and j is 4 - each was incremented once
The function gets it right because arguments are evaluated once, before the call. That is the general answer to this trap: use a function.
Use an inline function rather than a macro whenever you can. You get real types, real scope,
single evaluation, and the compiler still pastes it in. Macros are for what functions cannot do:
naming constants, compiling code conditionally, and using # and ##.
Trap four: more than one statement
#define TWO_THINGS_BAD(p) led = (p); count++;
#define TWO_THINGS_OK(p) do { led = (p); count++; } while (0)
Write if (x) TWO_THINGS_BAD(9); without brackets and only the first statement belongs to the if.
The second runs unconditionally.
4. a macro holding two statements
with brackets round it, the bad version works: led 9, count 1
without them, count++ escapes the if: led 0, count 1
the do-while version cannot escape: led 0, count 0
The do { } while (0) wrapper looks strange and is exactly right: it makes the macro one statement,
and it still needs the semicolon that everybody writes after it.
Giving a macro a lower-case name. #define max(a,b) ... will silently replace every max in every
file that includes the header, including one somebody meant as a variable. Macro names are
shouted in capitals for a reason.
#define DOUBLE(x) (x) + (x). What does 3 * DOUBLE(4) give?
Show the answer
Answer: C. It expands to 3 * (4) + (4), which is 12 + 4, or 16. The parameters were bracketed but the
expansion was not, so the multiplication reached inside it. Writing ((x) + (x)) gives 24.
15.2 Include guards and header design
#include pastes a whole file in. An include guard stops that happening twice, which matters because most things cannot be defined twice.
Two headers that both include a third is all it takes, and in any real project that happens within a week.
static inline int doubled(int x)
{
return 2 * x;
}
Include that twice in one file and the function is defined twice:
In file included from vol15_twice_fn.c:5:
vol15_inline.h:3:19: error: redefinition of 'doubled'
In file included from vol15_twice_fn.c:4:
vol15_inline.h:3:19: note: previous definition of 'doubled' with type 'int(int)'
Note the two "In file included from" lines. The compiler is telling you where each copy came from, which is how you find the pair of headers responsible.
The guard
#ifndef VOL15_GUARD_H
#define VOL15_GUARD_H
struct reading {
int id;
int value;
};
#endif /* VOL15_GUARD_H */
The first time through, VOL15_GUARD_H is not defined, so the body is used and the macro is
defined. The second time, the macro exists, so everything down to #endif is skipped.
$ gcc -c vol15_twice_ok.c # the guarded header, included twice
(compiled cleanly)
Modern C is more relaxed than it was. C23 allows an identical struct definition to appear twice,
and gcc 15 defaults to it. So a header containing only struct definitions may now compile without a
guard - and fail on an older compiler, or under -std=c11:
$ gcc -c vol15_twice.c # unguarded header defining only a struct
(accepted: C23 allows an identical struct definition twice)
$ gcc -std=c11 -c vol15_twice.c # the same file, older standard
vol15_noguard.h:3:8: error: redefinition of 'struct sensor'
Guard every header anyway. Function definitions, variables with initialisers and typedefs in older standards all still fail, and a header that is safe today may not be after somebody adds to it.
What belongs in a header
A header is a promise, not an implementation. It should contain what other files need in order to call your code, and nothing else.
| Belongs in the header | Belongs in the .c file |
|---|---|
| function declarations | function definitions |
typedef, struct and enum definitions |
static helper functions |
| constants shared between files | register structs and base addresses |
static inline functions, when small |
anything that owns memory |
Defining a variable in a header. int counter; in a header gives every file that includes it its
own copy, or a link error, depending on the standard and the compiler. Declare it extern in the
header and define it in exactly one .c file.
A header contains only #define lines and function declarations. Does it need an include guard?
Show the answer
Answer: B. Right now it would survive double inclusion. The guard is insurance against the next person adding
a struct, an inline function or a variable, and against older compilers. It is three lines.
15.3 Conditional compilation
Conditional compilation does not skip code at run time. The branch not taken never reaches the compiler at all, so it costs no flash and no cycles.
#define BOARD_REV 2
#if BOARD_REV >= 2
void extra_feature(void) { odr = 0u; }
#else
void extra_feature(void) { odr = 1u; }
#endif
The preprocessor output from Module 1 shows what survived: one version of the function, with no trace that the other ever existed.
The two families
#ifdef NAME asks whether a macro is defined at all. #if EXPRESSION evaluates a constant
expression. Prefer the second, because it can be wrong in useful ways.
#ifdef ENABLE_DEBUG
log_state();
#endif
/* a typo in the name silently
means "not defined", and the
code vanishes with no warning */
#if ENABLE_DEBUG
log_state();
#endif
/* with -Wundef, a typo is a
warning rather than silence */
By default, #if TYPO_HERE treats the unknown name as zero and compiles the #else branch without
complaint. -Wundef makes it a warning. It is one flag, and it catches a class of bug that is
otherwise invisible.
Keep it out of the middle of functions
Conditional compilation is at its worst scattered through logic, because the code you are reading is not the code being built.
void send(uint8_t b)
{
#if USE_DMA
dma_queue(b);
#else
uart_put(b);
#endif
#if LOG_LEVEL > 2
log_byte(b);
#endif
}
#if USE_DMA
#define send_byte dma_queue
#else
#define send_byte uart_put
#endif
void send(uint8_t b)
{
send_byte(b);
log_byte(b); /* empty when
logging is off */
}
The second version has one place where the choice is made, and the function itself reads the same in every build. A logging macro that expands to nothing is the usual way to make the second line free.
#if LOG_LEVEL > 2
#define log_byte(b) do { printf("byte %02X\n", (b)); } while (0)
#else
#define log_byte(b) do { (void)(b); } while (0)
#endif
The (void)(b) matters. Without it, a variable used only in logging becomes unused when logging is
off, and -Wunused complains in exactly the build you were not testing.
Letting the debug and release builds diverge until only one of them works. Every conditional is two versions of the program, and only the one you build gets compiled - so a syntax error in the branch you never build sits there for months.
Why does #if LOG_LEVEL > 2 with LOG_LEVEL never defined compile without complaint?
Show the answer
Answer: A. The preprocessor substitutes zero for any identifier it does not know, so the condition is false and
the code disappears. -Wundef turns that silence into a warning.
15.4 Compiling, linking and object files
Four tools run in sequence, and each one leaves a file behind. Knowing which tool produced an error tells you where to look for it.
Run them by hand once and the sizes tell the story:
vol15_main.c 123 bytes
main.i 199 bytes
main.s 587 bytes
main.o 1360 bytes
program 15840 bytes
What an object file knows
A translation unit is one source file plus everything it included. The compiler sees one at a time, and so an object file knows only about itself.
U helper
0000000000000000 T main
T main means this file defines main. U helper means it uses helper and has no idea where it
is. Volume 09 met these letters. The linker's job is to turn every U into an address.
The two link errors
$ gcc main.o -o program # linking without lib.o
/usr/bin/x86_64-linux-gnu-ld.bfd: main.o: in function `main':
vol15_main.c:(.text+0xe): undefined reference to `helper'
collect2: error: ld returned 1 exit status
$ gcc main.o lib.o dup.o # two definitions of the same function
/usr/bin/x86_64-linux-gnu-ld.bfd: dup.o: in function `helper':
vol15_dup.c:(.text+0x0): multiple definition of `helper'; lib.o:vol15_lib.c:(.text+0x0): first defined here
collect2: error: ld returned 1 exit status
Between them these two cover most link failures anyone meets. "Undefined reference" means you
declared something and never defined it, or forgot to add a file to the build. "Multiple definition"
means two files define the same name, which is usually a function that should have been static, or
a variable defined in a header.
A compiler error names a line in your code. A linker error names a symbol. If the message is about a symbol, stop reading the source and start looking at which files went into the build.
undefined reference to 'uart_init'. Which is not a possible cause?
Show the answer
Answer: D. A missing header gives a compiler error, not a linker one, because the compiler would not know the function existed. By the time the linker runs, the declaration was found; the definition was not.
15.5 Makefiles
A Makefile records what depends on what. That is all it is, and it is why changing one file does not rebuild forty.
Each rule names a target, the things it is made from, and the command to make it.
CC = arm-none-eabi-gcc
CFLAGS = -mcpu=cortex-m4 -Os -Wall -Wextra -std=c11 -ffreestanding
LDFLAGS = -T link.ld -Wl,-Map=firmware.map
OBJS = main.o gpio.o uart.o timer.o
firmware.elf: $(OBJS) link.ld
$(CC) $(OBJS) $(LDFLAGS) -o $@
%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
clean:
rm -f $(OBJS) firmware.elf firmware.map
$@ is the target being built, $< is the first thing it is made from. The %.o: %.c rule says
how to make any object file from the C file of the same name, so adding a source file means adding
one word to OBJS.
Make compares timestamps. If uart.c is newer than uart.o, it rebuilds uart.o and then relinks.
Nothing else is touched.
The dependency everybody forgets
That rule says uart.o depends on uart.c. It does not mention uart.h, or any other header. So
edit a header, and nothing rebuilds - and you get a binary built from two different versions of the
same structure.
Impossible behaviour after editing a header. A struct that seems to have the wrong size, a
function called with the wrong arguments, values that make no sense. make clean fixes it, which is the
clue. Anything that a make clean fixes is a dependency you have not declared.
The compiler can write the dependencies out for you:
CFLAGS += -MMD -MP
-include $(OBJS:.o=.d)
-MMD makes the compiler emit a .d file listing every header the source actually included, in
Makefile syntax. The -include line reads them back. Now editing a header rebuilds exactly the
files that included it.
Reaching for make clean whenever something is odd. It works, which is why it hides the real
problem. If a clean build behaves differently from an incremental one, the dependency information is
wrong, and that is worth ten minutes to fix properly.
You change a struct in sensor.h and the build does not rebuild main.c. Why?
Show the answer
Answer: B. Make only knows the dependencies it is told about. The pattern rule mentions only the .c file, so
the header is invisible to it until -MMD generates the real list.
15.6 Optimisation levels and what they break
An optimisation level changes how hard the compiler works, never what correct code means. If a program only works at -O0, the optimiser found a bug rather than causing one.
Here is the same file compiled five times and weighed:
level text data bss
-O0 829 0 0
-O1 536 0 0
-Os 370 0 0
-O2 539 0 0
-O3 1835 0 0
Read that from the top. Level -O0 is largest because nothing is tidied, so every variable goes
to the stack and comes back. Levels -O1 and -O2 get steadily better at removing work. -Os is smallest, because it
is -O2 with the transformations that trade size for speed switched off.
And -O3 is five times the size of -Os. It unrolls loops and inlines aggressively. That is often
faster on a desktop with megabytes of cache, and usually the wrong trade on a chip where flash is
the scarce thing.
Use -Os for embedded work unless you have measured a reason not to. Build with warnings on and
warnings as errors, and keep the optimisation level the same in debug and release builds if you can
bear it, so the two behave alike.
What optimisation exposes
The compiler is allowed to assume your program has no undefined behaviour. Where that assumption is false, higher levels make it visible.
- A missing
volatile, so a register read is cached or a write deleted - Volume 10 showed the assembly - A delay loop with no side effect, removed entirely
- Reading a variable an interrupt writes, without
volatile - Signed overflow, strict aliasing, or reading an uninitialised variable - all undefined, so all fair game
- A race that was hidden by slower code, and appears when the window narrows
Every one of those is a bug in the source. The optimiser did not introduce it; it removed the padding that was hiding it.
Why debugging at -Os is harder, and worth doing anyway
With optimisation on, variables live in registers rather than on the stack, so a debugger may report a value as "optimised out". Lines get reordered and merged, so stepping jumps about, and a function that was inlined does not appear in the call stack at all.
That is real friction. The usual answer, develop at -O0 and ship at -Os, means shipping
something you never debugged. A better compromise is -Og: most of the optimisation, with the
transformations that ruin debugging left off.
If a bug only appears in the optimised build, resist changing the level to make it go away. Turn on
-fsanitize=undefined if your toolchain has it, check every shared variable for volatile, and
look for undefined behaviour. The optimised build is telling you something true.
Shipping at a different optimisation level from the one you tested at. Timing changes, code size changes, and any latent undefined behaviour may change with it. If they must differ, test the one you are going to ship.
Your firmware works at -O0 and fails at -O2. What is the most likely explanation?
Show the answer
Answer: C. Optimisation may not change the meaning of correct code. When behaviour changes, the usual causes
are a register or shared variable that is not volatile, or something the standard leaves
undefined. The optimiser is entitled to assume the undefined cannot happen.
What you learned
- The preprocessor is text substitution:
gcc -Eshows exactly what the compiler is handed - A macro needs brackets round every parameter and round the whole expansion
- A macro argument that appears twice is evaluated twice, so prefer an
inlinefunction - Wrap a multi-statement macro in
do { } while (0)so it behaves like one statement - Guard every header, because most things cannot be defined twice
- C23 now allows an identical
structdefinition twice, but guards are still essential - Declare variables
externin headers and define them in exactly one.cfile - Code inside a false
#ifnever reaches the compiler, so it costs nothing and is never checked -Wundefturns a typo in an#iffrom silence into a warning- Preprocess, compile, assemble, link: the stage an error comes from tells you where to look
- Compiler errors name a line; linker errors name a symbol
- Make only knows the dependencies you declare, so use
-MMD -MPto generate them - Anything that
make cleanfixes is a dependency you have not declared -Osis the usual choice for firmware;-O3made the same file five times larger- If code only works at
-O0, the optimiser exposed a bug rather than causing one
Practice
This macro is wrong in two different ways. Find both, and write a version that is right.
#define SCALE(v, pct) v * pct / 100
Show the solution
One: the parameters are not bracketed. SCALE(a + b, 50) expands to a + b * 50 / 100, which
scales only b.
Two: the expansion is not bracketed. 1000 / SCALE(x, 50) expands to 1000 / x * 50 / 100,
which is a completely different calculation.
#define SCALE(v, pct) (((v) * (pct)) / 100)
There is a third problem worth mentioning, which brackets cannot fix: overflow. Suppose v is a
uint16_t holding 2000 and pct is 50. The product is 100000, which does not fit 16 bits on a
chip where int is 16 bits. That is exactly the ADC trap from Volume 13.
A function is better still, because it can say what it means about types:
static inline uint32_t scale(uint32_t v, uint32_t pct)
{
return (v * pct) / 100u;
}
utils.h is included by main.c and by driver.c. It contains
int shared_counter;. The build fails with "multiple definition of shared_counter". Explain and fix
it.
Show the solution
The header does not declare the variable, it defines it. So main.c and driver.c each end up with
a definition of shared_counter, and the linker finds two things with the same name.
extern int shared_counter; /* it exists, somewhere */
int shared_counter; /* here it is */
extern says "this name exists and is defined elsewhere", which is a declaration rather than a
definition, and may appear in as many files as you like.
You may have seen this work by accident on an older compiler. Before C23, a definition with no
initialiser was a tentative definition, and many linkers quietly merged duplicates - a GNU extension
called common symbols. Modern compilers default to -fno-common and report it properly, which is an
improvement.
A project builds cleanly, and the release build crashes while the debug build does not. The only
difference is -O0 versus -Os. Where would you look first, and what would you not do?
Show the solution
What not to do: ship at -O0, or sprinkle volatile until the symptom goes away. Both hide a
bug that is still there.
Where to look, in order:
Every variable shared between an interrupt handler and the main loop, and every peripheral register
access. Missing volatile is the single most common cause, and Volume 10 showed the compiler
deleting a write entirely.
Any delay or polling loop with no side effect, which the optimiser may remove completely.
Uninitialised variables. At -O0 a local often happens to be zero because that part of the stack
was unused; with optimisation it lives in a register holding something else.
Then the general undefined behaviours: signed overflow, reading a union member that was not written, out-of-bounds array access, strict aliasing violations from casting pointer types.
The tools worth reaching for. Build with -Wall -Wextra -Wundef, and turn on
-fsanitize=undefined if the toolchain supports it. Then try -Og, which gives a debuggable build
that is still optimised, so you can watch the failure happen.
Write the conditional compilation for a logging macro with three levels - off, errors only, and everything - such that the off build has no code and no unused-variable warnings.
Show the solution
#ifndef LOG_H
#define LOG_H
#define LOG_NONE 0
#define LOG_ERROR 1
#define LOG_ALL 2
#ifndef LOG_LEVEL
#define LOG_LEVEL LOG_ERROR /* a default, so -Wundef stays quiet */
#endif
#if LOG_LEVEL >= LOG_ERROR
#define log_error(...) do { printf(__VA_ARGS__); } while (0)
#else
#define log_error(...) do { } while (0)
#endif
#if LOG_LEVEL >= LOG_ALL
#define log_info(...) do { printf(__VA_ARGS__); } while (0)
#else
#define log_info(...) do { } while (0)
#endif
#endif /* LOG_H */
Three details earn their place. The #ifndef LOG_LEVEL default means the build works whether or not
the level was passed on the command line. The do { } while (0) in the empty versions keeps the
macro a single statement, so if (x) log_info("hi"); else ... still compiles. And the guard is
there, as it should be on every header.
The unused-variable problem is real but not solved above. printf takes its arguments by value, so
a variable used only inside log_info becomes unused when logging is off. Where that matters, add
(void) casts, or keep the call and let an empty printf be optimised away - measure before
assuming the second is free.
Your Makefile builds four objects. After editing a header, the program misbehaves in a way that
make clean cures. Diagnose it, and write the fix.
Show the solution
The object files were built against two different versions of the header. One was rebuilt after the edit and the others were not, so they disagree about a struct layout, a constant, or a function's arguments. Nothing in the build noticed, because nothing was told that the objects depend on the header.
CFLAGS += -MMD -MP
OBJS = main.o gpio.o uart.o timer.o
-include $(OBJS:.o=.d)
The -MMD flag writes a .d file next to each object, listing every header that source actually
included. The -MP flag adds a dummy rule for each header, so deleting one gives a sensible error
rather than "no rule to make target". The -include reads them if they exist and stays quiet if they do not, which
is what makes the very first build work.
The reason this matters more than it sounds: a mismatch like this does not produce an error. It produces a program where one file thinks a struct is 12 bytes and another thinks it is 16, which fails in a way that looks like memory corruption.
Interview corner
Macros
"What is wrong with #define SQUARE(x) x * x, and when would you use a macro at all?"
Show the solution
"It is text substitution, so SQUARE(2 + 3) becomes 2 + 3 * 2 + 3, which is 11 rather than 25.
The parameters need brackets, and so does the whole expansion, or a surrounding operator reaches
inside it.
Even with every bracket there is a second problem: the parameter appears twice, so
SQUARE(i++) increments twice. That one cannot be fixed with brackets.
So I would use a static inline function instead, which gives real types, single evaluation, and
usually the same generated code. I keep macros for what functions cannot do: naming compile-time
constants, conditional compilation, and the stringify and paste operators. And I wrap any
multi-statement macro in do { } while (0) so it behaves like one statement."
The build
"Walk me through what happens when I type gcc main.c uart.c -o firmware."
Show the solution
"Four stages. The preprocessor handles each file first: pastes in every #include, expands macros,
and drops the branches of #if that were not taken. What comes out is one translation unit per
source file.
The compiler turns each translation unit into assembly, on its own, knowing nothing about the other files. The assembler turns that into an object file: machine code with a symbol table, where anything defined elsewhere is marked as undefined.
Then the linker takes all the object files and the libraries. It resolves every undefined symbol against a definition somewhere, lays the sections out according to the linker script, and writes the final binary.
The practical value is knowing which stage an error came from. A missing header is the preprocessor, and a type error is the compiler. An undefined reference is the linker, which means the source is fine and a file is missing from the build."
Optimisation
"What optimisation level would you use for firmware, and what would you do if the code only worked at -O0?"
Show the solution
"-Os normally. Flash is usually the tight resource, and -Os is -O2 without the transformations
that trade size for speed. I measured a file at 370 bytes with -Os and 1835 with -O3, and -O3
is rarely worth that on a microcontroller.
If it only works at -O0, I would treat that as a bug in my code rather than in the compiler.
Optimisation is not permitted to change what correct code means. The usual causes are a missing
volatile on a register or a shared variable, or a delay loop with no side effect that got
deleted. Undefined behaviour such as signed overflow or an uninitialised read is the other family.
I would build with -Wall -Wextra, add -fsanitize=undefined if the target allows it, and debug at
-Og so I get a usable debugger without turning the optimiser off entirely. What I would not do is
ship at -O0, because then I would be shipping a build whose timing and size I had never checked."
Include guards
"What does an include guard do, and is #pragma once better?"
Show the solution
"It stops a header being pasted into the same translation unit twice. That matters because most things cannot be defined twice: a function definition, a variable with an initialiser, or a typedef on older standards. You reach it easily in any real project, because two headers including a third is enough.
#pragma once does the same thing in one line, and every compiler I would use supports it. It is
not in the standard, and it identifies files by path, which can behave oddly with symbolic links or
the same header reachable by two routes. I am happy with either; the traditional guard is what I
would choose for code that has to build on an unknown toolchain.
One thing worth knowing is that C23 relaxed this a little: an identical struct definition may now
appear twice. So a header can compile without a guard on a new compiler and fail on an older one,
which is a good reason not to rely on it."
Next, Volume 16 turns to the habits that keep firmware working when nobody is watching. Defensive coding, error handling that does not lie, assertions, watchdogs, and a coding standard worth following.
Key words from this volume
Every word below has a plain-English entry in the glossary.
- Preprocessor
- Macro
- Include guard
- Conditional compilation
- Translation unit
- Object file
- Makefile
- Optimisation level