Volume 15 Beginner 6 sub-modules ~30 min read

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.

You will learn
  • 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
You need
  • Volume 09: object files, symbols and the linker
  • Volume 05: static, and what a header may contain

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.

Remember

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.

Common mistake

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.

Quick check

#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)
A change worth knowing about

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
Common mistake

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.

Quick check

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 */
Turn on -Wundef

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.

Common mistake

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.

Quick check

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.

The four stages from one source file to a finished binary preprocessor compiler assembler linker gcc -E gcc -S gcc -c gcc *.o main.i main.s main.o program text, headers pasted in assembly for this file alone machine code, addresses missing every address filled in missing header syntax, types undefined reference where each kind of error comes from
Figure 15.1 - Each stage takes the previous one's output. The preprocessor works on text, the compiler on one translation unit at a time, the assembler on instructions, and the linker on everything at once. An error tells you which stage it came from, which halves the search.

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.

Remember

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.

Quick check

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.

The symptom

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.

Common mistake

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.

Quick check

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.

Remember

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.

  1. A missing volatile, so a register read is cached or a write deleted - Volume 10 showed the assembly
  2. A delay loop with no side effect, removed entirely
  3. Reading a variable an interrupt writes, without volatile
  4. Signed overflow, strict aliasing, or reading an uninitialised variable - all undefined, so all fair game
  5. 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.

Common mistake

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.

Quick check

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

Practice

Practice 1

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;
}
Practice 2

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.

Practice 3

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.

Practice 4

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.

Practice 5

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

Interview question 1

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."

Interview question 2

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."

Interview question 3

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."

Interview question 4

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.