Stage 10

Preprocessor, Modules & Build

The preprocessor runs before the compiler: it pastes headers, expands macros, and switches code on and off. It knows nothing about C — it only manipulates text. That is exactly why it bites.

10.1 Headers, include guards & linkage

sensor.h — the module contract
#ifndef SENSOR_H
#define SENSOR_H          /* include guard: header is pasted at most once */

#include <stdint.h>
#include <stdbool.h>

typedef struct Sensor Sensor;   /* opaque: definition lives in sensor.c */

Sensor *Sensor_Create(void);
void    Sensor_Destroy(Sensor *s);
bool    Sensor_Read(Sensor *s, int32_t *out_mdeg);

#endif /* SENSOR_H */
  • Header = declaration; .c file = definition. A function is defined once (in one .c) and declared in the header wherever it is used. Defining a function in a header and including it twice gives a duplicate-symbol link error.
  • extern globals: declare extern int g_count; in the header, define int g_count = 0; in exactly one .c. Prefer accessor functions over shared globals — globals hide data flow.
  • static in a .c file = private to that file. A static helper or variable in sensor.c cannot be named from any other file. That is the module boundary in C.
  • Include order: each header should include what it needs and compile standalone. Test: gcc -c sensor.c and a file that includes only sensor.h should both compile.
  • #pragma once is widely supported and shorter, but it is not standard C. Include guards are the portable choice; many codebases use both.

10.2 Macros: text substitution with sharp edges

macros-bad.c
#define SQUARE(x) x * x
SQUARE(2 + 3);     /* → 2 + 3 * 2 + 3 = 11, not 25 */

#define MAX(a, b) a > b ? a : b
int m = MAX(x, y) + 1;   /* → x > y ? x : y + 1 — wrong branch value */

#define SET_BIT(reg, bit) reg |= 1 << bit   /* unparenthesised bit too */
macros-good.c
#define SQUARE(x) ((x) * (x))
#define MAX(a, b) (((a) > (b)) ? (a) : (b))
#define SET_BIT(reg, bit) ((reg) |= (1u << (bit)))

/* Multi-statement macro: do-while(0) makes it behave like one statement */
#define SWAP(a, b) do { int _t = (a); (a) = (b); (b) = _t; } while (0)

🔍 Macro expansion playground

  • Parenthesise every parameter and the whole body. The preprocessor does not know operator precedence — it pastes text.
  • Double evaluation: a macro that mentions its argument twice evaluates side effects twice. SQUARE(i++) increments twice and is undefined behavior. Prefer static inline functions whenever you do not need token pasting or # stringification.
  • # and ##: #x turns an argument into a string literal; a ## b glues tokens together (e.g. building register names). Both work only in macros.
  • Constants: prefer enum or static const over #define for typed constants — they appear in the debugger and obey scope. Keep #define for conditional compilation and things that must be compile-time constants (array sizes in C89-style code, bit masks).

10.3 Conditional compilation: one codebase, many targets

config.h
/* Feature selection at build time — no runtime cost, no dead code shipped. */
#define LOG_LEVEL 2   /* 0=off, 1=errors, 2=info, 3=debug */

#if LOG_LEVEL >= 2
#  define LOG_INFO(...) printf(__VA_ARGS__)
#else
#  define LOG_INFO(...) ((void)0)   /* compiled out entirely */
#endif

#ifdef UNIT_TEST
#  define FLASH_PAGE_SIZE 256
#else
#  define FLASH_PAGE_SIZE 2048
#endif
  • #if vs #ifdef: #ifdef X tests whether X is defined at all; #if X > 2 tests its value. An undefined macro in #if counts as 0 — a typo silently takes the wrong branch. Use #if defined(X) when you mean “defined”.
  • Keep conditionals shallow. Nested #if blocks across files become untestable. Centralise target selection in one config.h / build flag (-DTARGET_X), and keep the #if at module boundaries.
  • Never hide a whole function body behind #ifdef without a fallback — callers break on the configuration where it vanishes. Provide a stub with the same signature.

10.4 const, volatile & static: the three qualifiers that matter

KeywordMeansEmbedded use
constDo not modify through this nameLookup tables in flash, read-only parameters, const uint8_t * buffers
volatileThis memory can change outside the program — never cache it, never drop a read/writeHardware registers, ISR-shared flags, memory-mapped I/O
staticInternal linkage (file scope) or preserved value (local)Module-private helpers/state, one-time initialisation
registers.c — volatile is not optional
/* Memory-mapped status register: the hardware changes it. */
#define STATUS_REG (*(volatile uint32_t *)0x40001000u)

while ((STATUS_REG & READY_BIT) == 0) { /* poll */ }
/* Without volatile, the compiler reads STATUS_REG ONCE and
   optimises the loop into an infinite loop or nothing at all. */

/* ISR-shared flag: volatile + atomicity still matters. */
volatile bool data_ready = false;
/* A 1-byte bool flag is atomic on most MCUs; a 32-bit counter
   shared with an ISR needs a critical section or atomic access. */
✖

volatile is not a concurrency primitive. It stops the compiler from caching a value. It does not make multi-byte reads atomic, does not order memory accesses across cores, and does not replace a mutex or critical section. On multi-core / DMA systems, you need barriers and proper synchronisation on top.

10.5 Bit manipulation: masks, shifts & bit-fields

bits.c
uint32_t reg = 0;
reg |=  (1u << 5);          /* set bit 5 */
reg &= ~(1u << 5);         /* clear bit 5 */
reg ^=  (1u << 5);          /* toggle bit 5 */
bool on = (reg >> 5) & 1u;   /* test bit 5 */

reg = (reg & ~0xFF00u) | ((field << 8) & 0xFF00u);  /* replace bits 8–15 */
  • Use unsigned types for all bit work. Signed shifts and signed bit-fields are implementation-defined or UB. The u suffix on 1u is load-bearing.
  • Mask after shifting in. (field << 8) & 0xFF00u guarantees stray high bits in field cannot corrupt neighbouring fields.
  • Bit-fields (unsigned x : 3;) are convenient and non-portable. The standard does not fix their layout, packing order, or whether plain int bit-fields are signed. Use them for in-memory flags you control; use explicit masks and shifts for wire formats and hardware registers.
  • Endianness: a uint32_t stored as bytes is little-endian on ARM Cortex-M and x86, big-endian on many network protocols and some MCUs. Serialise with shifts ((v >> 24) & 0xFF, …), never by casting a struct over a byte buffer.

10.6 From .c to firmware: the build in one picture

  1. Preprocess — gcc -E: paste headers, expand macros, strip comments. Output is one big translation unit.
  2. Compile — gcc -c: translate each .c file to an object file (.o). This is where syntax, type, and most warning errors appear.
  3. Assemble — usually folded into compilation: object files hold machine code + unresolved symbols.
  4. Link — resolve every symbol, lay out flash/RAM, produce the .elf / .bin. Duplicate definitions and undefined references surface here, not earlier.
build.sh — a sane starting command line
gcc -std=c11 -Wall -Wextra -Wconversion -Wsign-conversion \
    -Wimplicit-fallthrough -Wshadow -pedantic \
    -O2 -g -fsanitize=address,undefined \
    -o app main.c sensor.c uart.c

Drop -fsanitize for on-target firmware builds (it needs a hosted runtime), and add your MCU flags (-mcpu=… -mthumb, linker script, startup code). Keep the warning flags everywhere — they are the cheapest bug-finder you will ever own.