Part 1 · The C Language31 min read
The Preprocessor, Used Well
Before the compiler sees a single line of your C, the preprocessor rewrites it: it pastes in headers, substitutes macros, and deletes code that is conditionally disabled. Firmware leans on it heavily. Vendor headers are thousands of macros, board support is selected with #if, logging macros capture file and line, and compile-time checks stop a bad struct layout from ever reaching flash. The preprocessor is also the easiest way to write code that looks right and is wrong, because it works on text, not on C. This lesson covers how includes are found, how to write function-like macros that survive real use, stringizing and token pasting, variadic macros, conditional compilation for boards and features, the predefined macros, X-macros for generating tables, _Static_assert and _Generic, how to look at the preprocessor’s output, and when to use something other than a macro.
What the preprocessor actually does
Section titled “What the preprocessor actually does”The preprocessor runs as the first stage of compilation (Hello, C: the compilation pipeline showed it with -E). It processes directives, lines starting with #, and replaces macro names with their definitions. It knows about tokens (identifiers, numbers, string literals, punctuation) but nothing about types, scopes or values. A macro has no type, does not respect block scope, and is expanded wherever its name appears after the definition, until an #undef.
You can always see exactly what the compiler will receive:
gcc -std=c11 -E -P main.c -o main.i # -P drops the line markersWhen a macro misbehaves, look at the -E output before reasoning about it. Most macro bugs become obvious once you see the expanded text.
#include and include guards
Section titled “#include and include guards”#include pastes the named file’s contents in place of the directive. There are two forms:
#include "uart.h"searches the directory containing the current file first, then the directories given with-iquoteand-I, then the system directories.#include <stdint.h>skips the current file’s directory and searches-Idirectories,-isystemdirectories, and the compiler’s system directories.
Use quotes for your project’s headers and angle brackets for the standard library, the toolchain, and third-party SDKs installed as system headers. Add project include directories with -I (-Iinclude -Idrivers). To see which headers a file actually pulls in, and from where, compile with -H, which prints the include tree with one dot per nesting level.
A header can easily be included twice through different paths (main.c includes uart.h and board.h, and board.h also includes uart.h). Defining the same struct twice is an error, so every header needs an include guard:
#ifndef UART_H#define UART_H
#include <stddef.h>#include <stdint.h>
void uart_write(const uint8_t *data, size_t len);
#endif /* UART_H */The first time, UART_H is undefined, so the body is processed and UART_H gets defined. Every later inclusion skips straight to #endif. Pick guard names from the file path so they are unique, and do not start them with an underscore followed by a capital letter or with two underscores (_UART_H, __UART_H__): those names are reserved for the implementation.
#pragma once does the same job in one line. It is not in the C standard, but GCC, Clang, IAR and Arm’s compilers all support it. Its weakness is that it identifies a header by file identity, so two copies of the same header in different directories both get included, where a guard macro would stop the second. Either is acceptable; be consistent within a project. Header design in general belongs to Organizing a real C codebase.
Object-like and function-like macros
Section titled “Object-like and function-like macros”An object-like macro is a name replaced by a token sequence:
#define GPIOD_BASE (0x40020000u + 0x0C00u)#define UART_RX_BUF_LEN 128uA function-like macro takes arguments. There must be no space between the name and the ( in the definition; with a space, it is an object-like macro whose replacement starts with (.
#define BIT(n) (1u << (n))Constants get a u suffix when they are used in bit operations or compared with unsigned values (Bit manipulation explains why), and any replacement that is an expression gets wrapped in parentheses. The next section shows what happens otherwise.
The three classic macro bugs
Section titled “The three classic macro bugs”1. Missing parentheses
Section titled “1. Missing parentheses”#define SQUARE_BAD(x) x * x#define DOUBLE_BAD(x) (x) + (x)
int a = SQUARE_BAD(1 + 2); /* expands to 1 + 2 * 1 + 2 = 5, not 9 */int b = 10 * DOUBLE_BAD(3); /* expands to 10 * (3) + (3) = 33, not 60 */The preprocessor substitutes text, and the compiler then applies normal precedence to the result. Two rules prevent this entire class of bug: parenthesize every use of every parameter, and parenthesize the whole replacement.
#define SQUARE(x) ((x) * (x))#define DOUBLE(x) ((x) + (x))2. Double evaluation
Section titled “2. Double evaluation”Even a perfectly parenthesized macro evaluates its argument once per appearance:
#include <stdint.h>
#define MAX(a, b) ((a) > (b) ? (a) : (b))
uint32_t next_sample(void); /* returns a new ADC sample on every call */
uint32_t clamp_low(uint32_t limit){ return MAX(next_sample(), limit); /* next_sample() may be called twice */}If next_sample() returns more than limit, it is called once for the comparison and again for the result, and the second call returns a different sample. With MAX(i++, j), i may be incremented twice. In firmware it gets worse: MAX(USART2_DR, threshold) reads the UART data register twice, and reading DR removes a byte from the receiver, so you lose data and return the wrong byte.
The fix is to not use a macro. A static inline function evaluates each argument exactly once, is type-checked, and generates the same code at -O1 and above:
#include <stdint.h>
static inline uint32_t max_u32(uint32_t a, uint32_t b){ return (a > b) ? a : b;}3. Multi-statement macros and the dangling else
Section titled “3. Multi-statement macros and the dangling else”A macro that expands to several statements, or to an if, breaks when used as the body of an if:
#include <stdbool.h>
void gpio_off(void);void gpio_on(void);void log_error(int code);void led_ok(void);extern bool log_enabled;
#define LED_PULSE() gpio_off(); gpio_on()#define REPORT(code) if (log_enabled) log_error(code)
void check(bool fault, int status){ if (fault) LED_PULSE(); /* only gpio_off() is conditional; gpio_on() always runs */
if (status != 0) REPORT(status); else led_ok(); /* this else binds to the macro's hidden if */}The second one is the dangling else. After expansion, the else attaches to the nearest if, which is the one inside the macro. led_ok() now runs when status != 0 and logging is disabled, and never when status == 0. GCC’s -Wparentheses (in -Wall) sometimes warns with “suggest explicit braces to avoid ambiguous ‘else’”; do not count on it.
Wrapping the body in braces is not enough, because the caller’s semicolon after the closing brace ends the if statement and orphans any following else, which is a compile error. The standard idiom is do { ... } while (0):
#define LED_PULSE() \ do { \ gpio_off(); \ gpio_on(); \ } while (0)
#define REPORT(code) \ do { \ if (log_enabled) { \ log_error(code); \ } \ } while (0)do { ... } while (0) is a single statement that requires a semicolon after it, runs its body exactly once, and nests correctly inside any if/else. The compiler removes the loop entirely. The trailing backslashes continue the macro onto the next line; nothing, not even a space, may follow a backslash.
Every multi-statement macro in your codebase should use this form, or better, be a static inline function.
Stringizing and token pasting
Section titled “Stringizing and token pasting”Two operators exist only inside macro definitions.
#param turns an argument into a string literal: #define STR(x) #x makes STR(hello) into "hello". a##b pastes two tokens into one: #define CAT(a, b) a##b makes CAT(GPIO, D) into GPIOD.
Both operate on the argument as written, before it is macro-expanded. That leads to the most confusing behavior in the preprocessor:
#include <stdio.h>
#define STR(x) #x#define XSTR(x) STR(x) /* expand x first, then stringize */
#define FW_VERSION_MAJOR 2#define FW_VERSION_MINOR 7
int main(void){ puts(STR(FW_VERSION_MAJOR)); puts(XSTR(FW_VERSION_MAJOR)); puts("firmware v" XSTR(FW_VERSION_MAJOR) "." XSTR(FW_VERSION_MINOR)); return 0;}FW_VERSION_MAJOR2firmware v2.7STR(FW_VERSION_MAJOR) stringizes the token as written. XSTR adds a level of indirection: arguments that are not used with # or ## are fully macro-expanded before substitution, so by the time XSTR’s body calls STR, the argument has already become 2. The third line also uses the fact that adjacent string literals are concatenated by the compiler, which builds a version string at compile time with no runtime formatting.
The same two-level trick applies to pasting. Here is how firmware builds register names from a board configuration:
#include <stdint.h>
#define CONCAT3_(a, b, c) a##b##c#define CONCAT3(a, b, c) CONCAT3_(a, b, c) /* expand arguments, then paste */
#define GPIOA_BASE 0x40020000u#define GPIOD_BASE 0x40020C00u
/* Board configuration: the green LED is PD12 */#define LED_PORT D#define LED_PIN 12
#define PORT_BASE(port) CONCAT3(GPIO, port, _BASE)#define LED_BSRR (*(volatile uint32_t *)(PORT_BASE(LED_PORT) + 0x18u))
static inline void led_on(void) { LED_BSRR = 1u << LED_PIN; }static inline void led_off(void) { LED_BSRR = 1u << (LED_PIN + 16); }PORT_BASE(LED_PORT) expands LED_PORT to D, then CONCAT3 pastes GPIO, D and _BASE into GPIOD_BASE, which is rescanned and replaced with 0x40020C00u. LED_BSRR becomes a pointer dereference at 0x40020C18, the GPIOD BSRR register. Change the board to an LED on PA5 by editing two lines.
Pasting all three pieces in one step matters. If you pasted GPIO and D first, the intermediate token GPIOD would be rescanned, and in a project that also includes vendor headers, GPIOD is itself a macro (a pointer to the GPIOD register struct). It would expand before the second paste and break it. Pasting must also produce a single valid token; CONCAT3_(+, +, x) is an error.
Variadic macros
Section titled “Variadic macros”A macro can take a variable number of arguments with ..., accessed as __VA_ARGS__. The classic use is a logging macro that captures where it was called:
#include <stdarg.h>#include <stdio.h>
__attribute__((format(printf, 3, 4)))static void log_write(const char *file, int line, const char *fmt, ...){ va_list ap;
printf("%s:%d: ", file, line); va_start(ap, fmt); vprintf(fmt, ap); va_end(ap); printf("\n");}
#define LOG(...) log_write(__FILE__, __LINE__, __VA_ARGS__)
int main(void){ LOG("boot"); LOG("adc ch%d = %d mV", 3, 1650); return 0;}log.c:20: bootlog.c:21: adc ch3 = 1650 mV__FILE__ and __LINE__ expand at the point where LOG is used, not where it was defined, which is the whole reason this must be a macro: a function cannot know its caller’s line number. The format attribute (a GCC/Clang extension) makes the compiler check the format string against the arguments, exactly as it does for printf. The variadic function mechanics are in Functions and the calling convention.
Notice the format string is part of .... The more obvious definition, #define LOG(fmt, ...) log_write(__FILE__, __LINE__, fmt, __VA_ARGS__), breaks for LOG("boot"): the expansion ends in "boot", ), a syntax error, and strict C11 also requires at least one argument for the .... Folding the format into __VA_ARGS__ avoids both problems in standard C11. Two other fixes exist, and you will see them in other code:
, ##__VA_ARGS__is a GNU extension: when__VA_ARGS__is empty, the##deletes the preceding comma. With-Wpedantic, GCC still complains that ISO C requires at least one variadic argument.__VA_OPT__(,)is the standard version from C23 (and C++20): it expands to its contents only when__VA_ARGS__is non-empty. With-std=c11 -Wpedantic, recent GCC versions warn that it is not available before C23.
Conditional compilation
Section titled “Conditional compilation”Directives that include or exclude code before compilation:
#if expression /* integer constant expression, evaluated by the preprocessor */#ifdef NAME /* same as #if defined(NAME) */#ifndef NAME /* same as #if !defined(NAME) */#elif expression#else#endif#if expressions may use integer literals, macros, arithmetic, comparisons, logical operators and defined(NAME). They cannot use sizeof, casts, enum constants or variables, because the preprocessor knows none of those. And here is the trap: an identifier that is not a macro evaluates to 0 in #if, silently. #if CONFIG_LOG_ENALBE (a typo) is just false. The -Wundef flag warns whenever an undefined identifier is evaluated in #if, and it belongs in every firmware build.
Feature flags
Section titled “Feature flags”Define features as 0 or 1 with a default, and test their value rather than their existence:
#ifndef CONFIG_LOG_ENABLE#define CONFIG_LOG_ENABLE 1 /* default; override with -DCONFIG_LOG_ENABLE=0 */#endif
#ifndef CONFIG_UART_BAUD#define CONFIG_UART_BAUD 115200u#endif#include "config.h"
#if CONFIG_LOG_ENABLE#define LOG(...) log_write(__FILE__, __LINE__, __VA_ARGS__)#else#define LOG(...) do { } while (0)#endifTesting the value with #if means a typo triggers -Wundef, and -DCONFIG_LOG_ENABLE=0 disables the feature explicitly. With #ifdef, a typo is silently false and there is no way to say “defined but off”. Release builds can turn off logging with one flag and the log strings disappear from flash entirely.
When the condition does not need to remove code (for example, the disabled branch still compiles fine), an ordinary if (CONFIG_LOG_ENABLE) in C is often better: both branches are parsed and type-checked in every configuration, so a configuration you rarely build cannot rot, and the optimizer deletes the dead branch anyway.
Board selection and #error
Section titled “Board selection and #error”#if defined(BOARD_DISCOVERY_F407) #define LED_PORT D #define LED_PIN 12 #define HSE_HZ 8000000u#elif defined(BOARD_CUSTOM_REV_B) #define LED_PORT A #define LED_PIN 5 #define HSE_HZ 25000000u#else #error "No board selected: build with -DBOARD_DISCOVERY_F407 or -DBOARD_CUSTOM_REV_B"#endifThe build system passes exactly one -DBOARD_... flag. #error stops compilation with your message, which turns “forgot to select a board” from a confusing pile of undefined-symbol errors into one clear line. Use #error for every configuration that must not build: an unsupported combination of features, a missing required setting, a clock value out of range.
#warning "message" prints a warning and continues. It was a common extension for years and is standard only from C23, so -std=c11 -Wpedantic warns that it is a GCC extension. Use it sparingly, for things like “debug UART enabled in this build”.
Predefined macros
Section titled “Predefined macros”The compiler defines a set of macros before reading your code:
| Macro | Value | Typical use |
|---|---|---|
__FILE__ | current file name, as a string literal | assert and log messages |
__LINE__ | current line number, as an integer | assert and log messages |
__DATE__, __TIME__ | build date and time strings | build stamps (they make builds non-reproducible) |
__STDC_VERSION__ | 201112L for C11, 201710L for C17 | requiring a minimum standard |
__STDC_HOSTED__ | 1 for hosted, 0 with -ffreestanding | library availability |
__GNUC__ | GCC major version (Clang defines it too) | guarding GNU extensions |
__clang__ | defined by Clang | Clang-specific code |
__ARM_ARCH | 6 for M0/M0+, 7 for M3/M4/M7, 8 for M23/M33 | architecture-specific code |
__ARM_ARCH_7EM__ | defined for ARMv7E-M (Cortex-M4/M7) | DSP instruction availability |
__ARM_FP | defined when hardware floating point is enabled | FPU-specific code |
__BYTE_ORDER__ | compare with __ORDER_LITTLE_ENDIAN__ (GCC/Clang) | endianness checks |
__func__ looks like one of these but is not a macro: it is a predefined identifier, behaving as if each function contained static const char __func__[] = "function-name";. You can pass it to a function, but you cannot stringize or paste it, and the preprocessor does not know it exists.
To see every predefined macro for your exact target and flags:
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -mfloat-abi=hard -mfpu=fpv4-sp-d16 -dM -E -x c /dev/null | sort#define __ARM_ARCH 7#define __ARM_ARCH_7EM__ 1#define __ARM_ARCH_PROFILE 77...#define __thumb2__ 1(77 is the character code of 'M', for the microcontroller profile.) Use these to select code paths, for example a builtin on GCC with a portable fallback:
#include <stdint.h>
static inline uint32_t bit_clz32(uint32_t x) /* x must be nonzero */{#if defined(__GNUC__) return (uint32_t)__builtin_clz(x);#else uint32_t n = 0u; while ((x & 0x80000000u) == 0u) { x <<= 1; n++; } return n;#endif}X-macros: one list, many tables
Section titled “X-macros: one list, many tables”Firmware is full of parallel lists that must stay in sync: an enum of commands, a table of their names, a table of their handlers, a table of help strings. Edit one and forget another, and the tables silently misalign. X-macros define the list once and generate every table from it.
The idea: write the list as a macro that applies some other macro, X, to each entry. Then expand the list several times with a different X each time.
Here is a complete command shell table:
#include <stddef.h>#include <stdio.h>#include <string.h>
/* The single source of truth: one line per command. */#define COMMAND_LIST(X) \ X(LED, "led", "led on|off switch the green LED", cmd_led) \ X(STATUS, "status", "status print system status", cmd_status) \ X(HELP, "help", "help list commands", cmd_help)
/* 1. An enum of command IDs: CMD_LED, CMD_STATUS, CMD_HELP, CMD_COUNT */#define AS_ENUM(id, str, desc, fn) CMD_##id,typedef enum { COMMAND_LIST(AS_ENUM) CMD_COUNT } cmd_id_t;#undef AS_ENUM
/* 2. A prototype for every handler */#define AS_PROTO(id, str, desc, fn) static void fn(const char *args);COMMAND_LIST(AS_PROTO)#undef AS_PROTO
/* 3. The dispatch table, indexed by command ID */struct command { const char *name; const char *help; void (*handler)(const char *args);};
#define AS_ENTRY(id, str, desc, fn) [CMD_##id] = { str, desc, fn },static const struct command commands[CMD_COUNT] = { COMMAND_LIST(AS_ENTRY)};#undef AS_ENTRY
/* The handlers themselves */static void cmd_led(const char *args){ printf("LED %s\n", args);}
static void cmd_status(const char *args){ (void)args; printf("uptime 42 s, 0 errors\n");}
static void cmd_help(const char *args){ (void)args; for (size_t i = 0; i < (size_t)CMD_COUNT; i++) { printf(" %s\n", commands[i].help); }}
static int run(const char *name, const char *args){ for (size_t i = 0; i < (size_t)CMD_COUNT; i++) { if (strcmp(commands[i].name, name) == 0) { commands[i].handler(args); return 0; } } printf("unknown command: %s\n", name); return -1;}
int main(void){ run("led", "on"); run("status", ""); run("help", ""); run("reboot", ""); return 0;}LED onuptime 42 s, 0 errors led on|off switch the green LED status print system status help list commandsunknown command: rebootRun it through gcc -E -P and the generated code is exactly what you would have typed by hand (reformatted here):
typedef enum { CMD_LED, CMD_STATUS, CMD_HELP, CMD_COUNT } cmd_id_t;
static void cmd_led(const char *args);static void cmd_status(const char *args);static void cmd_help(const char *args);
static const struct command commands[CMD_COUNT] = { [CMD_LED] = { "led", "led on|off switch the green LED", cmd_led }, [CMD_STATUS] = { "status", "status print system status", cmd_status }, [CMD_HELP] = { "help", "help list commands", cmd_help },};What this buys you:
- Adding a command is one line in
COMMAND_LISTplus the handler function. The enum, the prototypes and the table all update together. Forget to write the handler and the build fails (“used but never defined”). - The tables cannot misalign. Each entry is placed at its enum index by a designated initializer, and the enum and the table come from the same list.
- The table is
static const, so it lives in flash.
Two details in the example are deliberate. The macro parameters are named str and desc, not name and help, because inside the macro body every occurrence of a parameter name is substituted, and .name = name would become ."led" = "led". And each helper macro is #undefed right after use so its short name cannot leak into later code.
X-macros can also use stringizing to avoid typing names twice. An error-code list that produces both the enum and printable names:
#include <stdint.h>
#define ERROR_LIST(X) X(OK) X(TIMEOUT) X(CRC) X(OVERRUN) X(BUSY)
#define AS_ERR_ENUM(e) ERR_##e,#define AS_ERR_NAME(e) #e,
typedef enum { ERROR_LIST(AS_ERR_ENUM) ERR_COUNT } err_t;static const char *const err_names[] = { ERROR_LIST(AS_ERR_NAME) };
_Static_assert(sizeof err_names / sizeof err_names[0] == ERR_COUNT, "err_names out of sync");
const char *err_name(err_t e){ return ((uint32_t)e < (uint32_t)ERR_COUNT) ? err_names[e] : "UNKNOWN";}err_name(ERR_CRC) returns "CRC". The cost of X-macros is readability for newcomers and less helpful error messages, since an error inside an expansion points at the COMMAND_LIST(...) line. Use them where several tables must stay in sync, not for a single list.
Compile-time checks: _Static_assert
Section titled “Compile-time checks: _Static_assert”_Static_assert(constant-expression, "message") (C11) checks a condition at compile time and fails the build with your message if it is false. <assert.h> defines the friendlier spelling static_assert for it; in C23 static_assert is a keyword and the message is optional.
The preprocessor cannot do these checks because it does not know sizeof:
#if sizeof(uint32_t) == 4 /* error: missing binary operator before token "(" */#endif_Static_assert runs in the compiler proper, where sizeof, offsetof, enum constants and constant arithmetic are all available. It can appear at file scope or inside a function, and it generates no code. Firmware uses it to pin down every assumption that would otherwise fail silently on the device:
#include <assert.h>#include <stddef.h>#include <stdint.h>
/* Layout of a structure that is written to flash */struct cfg_record { uint32_t magic; uint16_t version; uint16_t length; uint32_t baud; uint32_t crc;};static_assert(sizeof(struct cfg_record) == 16, "cfg_record is a flash format");static_assert(offsetof(struct cfg_record, crc) == 12, "crc must be the last word");
/* Configuration sanity: USART needs a divider of at least 16 with oversampling by 16 */#define APB1_HZ 42000000u#define UART_BAUD 115200ustatic_assert(APB1_HZ / UART_BAUD >= 16u, "UART_BAUD too high for APB1 clock");
/* Ring buffer size must be a power of two for index masking */#define RX_BUF_LEN 128ustatic_assert((RX_BUF_LEN & (RX_BUF_LEN - 1u)) == 0u, "RX_BUF_LEN must be a power of two");When one fails, the error names the problem directly:
cfg.c:13:1: error: static assertion failed: "cfg_record is a flash format"A runtime assert reports a broken assumption on the device, maybe; a static assertion makes it impossible to build firmware with the broken assumption at all. Prefer it whenever the condition is a compile-time constant. Runtime assert and its firmware handler are covered in The C standard library on a microcontroller.
Type dispatch: _Generic
Section titled “Type dispatch: _Generic”_Generic (C11) selects an expression based on the type of a controlling expression, at compile time:
_Generic(expr, type1: result1, type2: result2, default: result_default)Only the selected result is evaluated, and the controlling expression itself is not evaluated at all, only its type is inspected (after the usual conversions: arrays decay, qualifiers like const are dropped). Wrapped in a macro, it gives you type-generic interfaces. A revealing first example prints the type of any expression:
#include <stdint.h>#include <stdio.h>
#define TYPE_NAME(x) _Generic((x), \ char: "char", signed char: "signed char", unsigned char: "unsigned char", \ short: "short", unsigned short: "unsigned short", \ int: "int", unsigned int: "unsigned int", \ long: "long", unsigned long: "unsigned long", \ long long: "long long", unsigned long long: "unsigned long long", \ float: "float", double: "double", default: "something else")
int main(void){ uint8_t a = 1u; uint32_t b = 2u;
printf("uint8_t -> %s\n", TYPE_NAME(a)); printf("uint32_t -> %s\n", TYPE_NAME(b)); printf("a + a -> %s\n", TYPE_NAME(a + a)); printf("'x' -> %s\n", TYPE_NAME('x')); printf("1.0f * 2.0 -> %s\n", TYPE_NAME(1.0f * 2.0)); return 0;}uint8_t -> unsigned charuint32_t -> unsigned inta + a -> int'x' -> int1.0f * 2.0 -> doubleThat output (from x86-64 Linux) confirms three things from earlier lessons in one run: uint8_t arithmetic is done in int (integer promotion), a character constant has type int in C, and mixing float with a double literal promotes to double. Built with arm-none-eabi-gcc, the second line says unsigned long, because that toolchain defines uint32_t as unsigned long.
That last fact is the main trap with _Generic in portable code. If you list both uint32_t and unsigned int as associations, the program fails to compile on any platform where they are the same type (duplicate association), and if you list only fixed-width typedefs, a plain int argument may match nothing on a platform where int32_t is long. A practical type-generic macro:
#include <stdint.h>
static inline uint32_t min_u32(uint32_t a, uint32_t b) { return (a < b) ? a : b; }static inline int32_t min_i32(int32_t a, int32_t b) { return (a < b) ? a : b; }static inline float min_f32(float a, float b) { return (a < b) ? a : b; }
#define MIN(a, b) _Generic((a), \ uint32_t: min_u32, \ int32_t: min_i32, \ float: min_f32)((a), (b))MIN(x, y) picks a function by the type of x and calls it, so each argument is evaluated once and type-checked, unlike the MAX macro from the start of this lesson. uint32_t, int32_t and float are distinct types everywhere, so there is no duplicate. But MIN(count, 10) with an int count fails to compile on arm-none-eabi (no association for int, because int32_t is long there) while compiling fine on x86. Test generic macros on the target toolchain, or add a default: association. _Generic is most useful in library-style code such as logging and math helpers; application code rarely needs it.
Macro, inline function, enum, or const?
Section titled “Macro, inline function, enum, or const?”The preprocessor is a blunt tool. Use it for what only it can do, and use the language for everything else.
| You need | Use | Why |
|---|---|---|
| A group of related integer constants | enum | typed for the debugger, scoped, enables -Wswitch |
A single integer constant used in #if or array sizes | #define with u suffix, parenthesized | #if cannot see enums or const variables |
| A constant object with an address (a table, a string) | static const | real object in flash, type-checked |
| A small computation | static inline function | arguments evaluated once, type-checked, debuggable |
The caller’s __FILE__/__LINE__, stringizing, pasting | macro | no function can do these |
| Several tables generated from one list | X-macro | keeps the tables in sync |
| Code included or excluded per board or feature | #if with -Wundef | removes code entirely |
| A check on sizes, offsets, configuration | _Static_assert | fails the build, generates no code |
| Type-dependent selection | _Generic, or one inline function per type | compile-time dispatch |
Note that in C, unlike C++, a const variable is not a constant expression, so const uint32_t n = 16; uint8_t buf[n]; at file scope is an error, and inside a function it creates a VLA (Variables, scope, lifetime, and storage classes covers this). That is why sizes still use #define or enum.
Common mistakes
Section titled “Common mistakes”- Unparenthesized macro parameters or bodies. Precedence rewrites your expression. Parenthesize every parameter use and the whole body.
- Passing expressions with side effects to macros.
MAX(i++, j)orMAX(USART2_DR, x)evaluates them twice. Use astatic inlinefunction. - Multi-statement macros without
do { } while (0). Breaksif/elsesilently or with confusing errors. Wrap every multi-statement macro. - Testing feature flags with
#ifdefand no-Wundef. Typos silently disable features. Define flags as 0 or 1 and test with#if. - Expecting
#xora##bto expand macro arguments. They use the argument as written. Add a second level of macro. - Using
sizeofor enum constants in#if. The preprocessor cannot evaluate them. Use_Static_assert. - Guard names with leading underscores. Reserved for the implementation. Use
UART_H. - Listing two typedefs that may be the same type in
_Generic. Compiles on one target, fails on another. Use distinct types and build on every target.
Exercises
Section titled “Exercises”- Break and fix. Write
SQUARE_BAD,MAXand a two-statementLED_PULSEwithoutdo/while. Write a test program that demonstrates each failure (wrong arithmetic, double call of a function that increments a counter, theelsemisbinding). Rungcc -E -Pand point to the line in the output that causes each bug. Then fix all three and show the correct output. - Version string. Define
FW_MAJOR,FW_MINORandFW_PATCHand build the string"fw 1.4.2"entirely at compile time with stringizing and literal concatenation. Pass different values with-DFW_MINOR=5on the command line (guarding the defaults with#ifndef) and confirm the string changes. Then check withstringsorobjdump -sthat the string appears in the object file’s.rodataexactly as expected. - Board header. Write a
board.hthat supports two boards selected by-DBOARD_..., definesLED_PORT,LED_PINandHSE_HZ, stops with#errorif no board or both boards are selected, and contains a_Static_assertthatHSE_HZis between 4 MHz and 26 MHz (the STM32F407 HSE range). Build it four ways (each board, none, both) and record each result. - Extend the X-macro shell. Add a
resetcommand toCOMMAND_LISTand implement its handler. Then deliberately add a command without writing its handler and record the compiler error. Finally, add a fifth column with the minimum number of arguments and generate a check inrunthat rejects calls with too few arguments. - Read the target’s view. Run
arm-none-eabi-gcc -mcpu=cortex-m4 -mthumb -dM -E -x c /dev/nulland the same with-mcpu=cortex-m0. Compare the outputs withdiffand list five macros that differ, explaining each (look for__ARM_ARCH,__ARM_ARCH_7EM__,__ARM_FEATURE_DSP,__thumb2__, and the size and type macros). Then compile theTYPE_NAMEprogram for the target with-Sand find the string selected foruint32_tin the assembly.
Check yourself
Section titled “Check yourself”Question 1:Given #define DOUBLE(x) (x) + (x), what is the value of 3 * DOUBLE(2)?
The expansion is 3 * (2) + (2), and multiplication binds tighter, giving 6 + 2 = 8. The body must be wrapped in its own parentheses: ((x) + (x)).
Question 2:Why is do { ... } while (0) used for multi-statement macros instead of plain braces?
With plain braces, the caller's semicolon after the closing brace ends the if, and a following else becomes a syntax error. The do/while (0) form absorbs the semicolon and behaves as one statement. It does nothing about double evaluation.
Question 3:With #define STR(x) #x and #define VER 3, what does STR(VER) produce?
The # operator stringizes the argument as written, before macro expansion. To get "3" you need an extra level: #define XSTR(x) STR(x) and then XSTR(VER).
Question 4:A build defines nothing named CONFIG_DMA_ENABLE. What does #if CONFIG_DMA_ENABLE do?
In #if, identifiers that are not macros are replaced by 0 with no diagnostic. -Wundef makes the compiler warn about this, which catches misspelled feature flags.
Question 5:Which check can only be written with _Static_assert, not with #if?
The preprocessor does not know about types, so sizeof, offsetof and enum constants are unavailable in #if. Plain integer macro arithmetic works in both.
Question 6:MAX(USART2_DR, threshold) with #define MAX(a, b) ((a) > (b) ? (a) : (b)). What is the hardware-level problem?
The argument appears twice in the expansion, so when the received byte is larger, DR is read once for the comparison and again for the result. Reads of DR have side effects, so a byte is lost and the wrong value is returned.
Key takeaways
Section titled “Key takeaways”- The preprocessor rewrites text before compilation. When a macro misbehaves, read the
gcc -E -Poutput. - Every header gets an include guard (or
#pragma once); use""for project headers and<>for system and toolchain headers. - Parenthesize every macro parameter and the whole body; wrap multi-statement macros in
do { } while (0); never pass side effects to a macro. Preferstatic inlinefunctions for computation. #and##work on unexpanded arguments; add a second macro level to expand first.- Define feature flags as 0 or 1, test them with
#if, build with-Wundef, and use#errorto reject invalid configurations. - X-macros generate an enum, a name table and a handler table from one list so they can never drift apart.
_Static_assertturns layout and configuration assumptions into build failures. It costs nothing at runtime and catches what#ifcannot see.
Next: Memory: where everything lives follows every variable, constant and buffer from these lessons into the sections of the firmware image and the regions of RAM and flash that hold them.