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;
.cfile = 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. externglobals: declareextern int g_count;in the header, defineint g_count = 0;in exactly one.c. Prefer accessor functions over shared globals — globals hide data flow.staticin a.cfile = private to that file. Astatichelper or variable insensor.ccannot 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.cand a file that includes onlysensor.hshould both compile. #pragma onceis 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. Preferstatic inlinefunctions whenever you do not need token pasting or#stringification. #and##:#xturns an argument into a string literal;a ## bglues tokens together (e.g. building register names). Both work only in macros.- Constants: prefer
enumorstatic constover#definefor typed constants — they appear in the debugger and obey scope. Keep#definefor 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#ifvs#ifdef:#ifdef Xtests whetherXis defined at all;#if X > 2tests its value. An undefined macro in#ifcounts as 0 — a typo silently takes the wrong branch. Use#if defined(X)when you mean “defined”.- Keep conditionals shallow. Nested
#ifblocks across files become untestable. Centralise target selection in oneconfig.h/ build flag (-DTARGET_X), and keep the#ifat module boundaries. - Never hide a whole function body behind
#ifdefwithout 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
| Keyword | Means | Embedded use |
|---|---|---|
| const | Do not modify through this name | Lookup tables in flash, read-only parameters, const uint8_t * buffers |
| volatile | This memory can change outside the program — never cache it, never drop a read/write | Hardware registers, ISR-shared flags, memory-mapped I/O |
| static | Internal 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
usuffix on1uis load-bearing. - Mask after shifting in.
(field << 8) & 0xFF00uguarantees stray high bits infieldcannot corrupt neighbouring fields. - Bit-fields (
unsigned x : 3;) are convenient and non-portable. The standard does not fix their layout, packing order, or whether plainintbit-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_tstored 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
- Preprocess —
gcc -E: paste headers, expand macros, strip comments. Output is one big translation unit. - Compile —
gcc -c: translate each.cfile to an object file (.o). This is where syntax, type, and most warning errors appear. - Assemble — usually folded into compilation: object files hold machine code + unresolved symbols.
- 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.cDrop -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.