Quick Cap
The C preprocessor and the inline keyword are two separate mechanisms that both eliminate function-call overhead, but they work at completely different stages of compilation and have very different trade-offs. Macros are text substitution performed before the compiler sees the code; inline functions are real functions that the compiler may expand at the call site. Knowing when to use each -- and how to avoid the traps of each -- is a core embedded C skill.
Interviewers test whether you understand the dangers of function-like macros (double evaluation, no type safety), when static inline is the right tool, and how preprocessor directives control conditional compilation and header safety.
Key Facts:
- Object-like macros (
#define BUFFER_SIZE 256) are constants; function-like macros (#define MAX(a,b) ...) act like functions but with no type checking. - Double-evaluation trap:
MAX(++x, y)incrementsxtwice because the macro pastes++xinto two places. static inlineis the modern replacement for most function-like macros -- it gives you type safety, debuggability, and the compiler can still choose not to inline if inlining would hurt.- Include guards (
#ifndef/#define/#endif) and#pragma onceprevent double inclusion of headers. Guards are portable;#pragma onceis non-standard but supported by all major compilers. - MISRA C bans function-like macros (Rule 4.9) in safety-critical code and requires all macro names to be UPPER_CASE to distinguish them from functions.
- X-macros solve the "keep enum and string array in sync" problem by defining the list once and expanding it in multiple contexts.
Deep Dive
At a Glance
| Concept | Detail |
|---|---|
| Object-like macro | #define LED_PIN 5 -- simple text replacement, no parentheses |
| Function-like macro | #define ABS(x) ((x) >= 0 ? (x) : -(x)) -- parenthesize EVERYTHING |
| Stringification | #x turns macro arg into a string literal: #define STR(x) #x |
| Token pasting | ## concatenates tokens: #define REG(n) TIMER##n##_CR |
static inline | static inline uint8_t hi_nibble(uint8_t b) { return b >> 4; } |
| Include guard | #ifndef HEADER_H / #define HEADER_H / #endif |
| X-macro | Define a list once, expand it for enums, strings, and tables |
Object-Like Macros
Object-like macros define constants or shorthand names. They are replaced by the preprocessor before compilation, so they have no type and no scope -- they are global text substitution.
/* Constants -- prefer enum or static const for type safety */#define BUFFER_SIZE 256#define CLOCK_FREQ_HZ 16000000UL#define FIRMWARE_VERSION "1.3.2"/* Register addresses -- macros are appropriate here */#define GPIOA_BASE 0x40020000UL#define GPIOA_ODR (*(volatile uint32_t *)(GPIOA_BASE + 0x14))
For integer constants, enum or static const is often better because the compiler can type-check them. But for register address macros that involve casts to volatile pointers, #define is the standard practice.
Function-Like Macros
Function-like macros look like function calls but perform text substitution. They are powerful but treacherous.
/* Correct: every parameter and the whole expression parenthesized */#define MIN(a, b) ((a) < (b) ? (a) : (b))#define SET_BIT(reg, n) ((reg) |= (1U << (n)))#define ARRAY_SIZE(arr) (sizeof(arr) / sizeof((arr)[0]))
Three rules for safe function-like macros:
- Parenthesize every use of every parameter:
((a) < (b) ? (a) : (b)) - Parenthesize the entire expression: outer
( ... ) - Use the
do { ... } while(0)idiom for statement macros so they work withif/else:
#define LOG_ERROR(msg) do { \uart_puts("[ERR] "); \uart_puts(msg); \uart_puts("\r\n"); \} while (0)/* Works correctly with if/else */if (error)LOG_ERROR("sensor timeout");elsedo_normal_work();
Without do { ... } while(0), the else would bind to the wrong if -- a classic macro trap.
Pitfalls: Side Effects and Double Evaluation
The most dangerous macro bug is double evaluation: the macro pastes the argument text into multiple locations, so any side effect happens more than once.
#define MAX(a, b) ((a) > (b) ? (a) : (b))int x = 5, y = 3;int z = MAX(++x, y);/* Expands to: ((++x) > (y) ? (++x) : (y))x is incremented TWICE if x > y.z = 7, x = 7 -- not what you wanted. */
This is not a theoretical concern -- it causes real bugs in production embedded code. The only true fix is to use static inline instead:
static inline int max_int(int a, int b) {return a > b ? a : b;}/* max_int(++x, y) increments x exactly once. */
Any macro that uses a parameter more than once will evaluate side effects (like ++x, adc_read(), gpio_read()) multiple times. This causes double increments, double hardware reads, and bugs that only appear intermittently. If you cannot guarantee the argument is side-effect-free, use a static inline function.
Stringification and Token Pasting
The preprocessor provides two special operators for metaprogramming:
Stringification (#) -- converts a macro argument to a string literal:
#define STRINGIFY(x) #x#define TOSTRING(x) STRINGIFY(x) /* two-level trick for expanding macros */#define VERSION_MAJOR 2#define VERSION_MINOR 5/* TOSTRING(VERSION_MAJOR) -> STRINGIFY(2) -> "2" */const char *version = TOSTRING(VERSION_MAJOR) "." TOSTRING(VERSION_MINOR);/* version = "2.5" *//* Useful for debug assertions */#define ASSERT(cond) do { \if (!(cond)) { \fault_handler("ASSERT FAILED: " #cond \" at " __FILE__ ":" TOSTRING(__LINE__)); \} \} while (0)
Token pasting (##) -- concatenates two tokens into one:
#define REG(peripheral, reg) peripheral##_##reg/* REG(USART1, CR1) -> USART1_CR1 *//* REG(USART2, BRR) -> USART2_BRR *//* Useful for generating families of accessor functions */#define DEFINE_GPIO_SET(port) \static inline void gpio_set_##port(uint8_t pin) { \GPIO##port->ODR |= (1U << pin); \}DEFINE_GPIO_SET(A) /* generates gpio_set_A(uint8_t pin) */DEFINE_GPIO_SET(B) /* generates gpio_set_B(uint8_t pin) */
Inline Functions vs Macros
static inline functions are the modern alternative to function-like macros. The compiler treats them as real functions (type-checked, debuggable, scoped) but may expand them in-place like a macro for performance.
| Aspect | Function-like macro | static inline function |
|---|---|---|
| Type safety | None -- pure text substitution | Full compiler type checking |
| Debugging | Cannot step into; no symbol in debugger | Full breakpoint and step-through support |
| Side effects | Double evaluation if parameter used twice | Arguments evaluated exactly once |
| Scope | Global (no namespace) | File scope with static; follows C scoping rules |
| Code size | Always expanded | Compiler chooses: inline for small, call for large |
| Compiler optimization | No help (already substituted) | Compiler can constant-fold, dead-code-eliminate |
| Return value | Awkward (GCC statement expression or comma trick) | Natural return statement |
When to use static inline (almost always):
/* In a header file -- static inline is the correct pattern */static inline uint8_t hi_nibble(uint8_t byte) {return (byte >> 4) & 0x0F;}static inline uint8_t lo_nibble(uint8_t byte) {return byte & 0x0F;}static inline uint32_t ms_to_ticks(uint32_t ms, uint32_t tick_rate_hz) {return (ms * tick_rate_hz) / 1000U;}
When macros are still appropriate:
- Register address definitions with
volatilecasts ARRAY_SIZE,UNUSED,STATIC_ASSERT-- utility macros that cannot be functions- Conditional compilation (
#ifdef DEBUG) - X-macros and code generation patterns
- String literals and token pasting (the preprocessor can do things the compiler cannot)
When asked "inline functions or macros?", start with: "I default to static inline for anything that looks like a function, and reserve macros for things only the preprocessor can do -- register definitions, conditional compilation, token pasting, and compile-time assertions."
Preprocessor Directives
Beyond #define, the preprocessor provides directives for conditional compilation and header management.
Include guards prevent a header from being processed twice in the same translation unit:
/* Traditional include guard -- portable, standard-compliant */#ifndef SENSOR_DRIVER_H#define SENSOR_DRIVER_H/* ... header contents ... */#endif /* SENSOR_DRIVER_H */
/* #pragma once -- shorter, but not in the C standard */#pragma once/* ... header contents ... */
Both achieve the same result. #pragma once is simpler and avoids the risk of name collisions in the guard macro, but #ifndef guards are required by some coding standards (MISRA, CERT C) because #pragma once is technically non-standard.
Conditional compilation is essential for platform-specific code, debug builds, and feature flags:
/* Platform selection */#if defined(STM32F4)#include "stm32f4xx.h"#define SYSTEM_CLOCK 168000000UL#elif defined(NRF52)#include "nrf52.h"#define SYSTEM_CLOCK 64000000UL#else#error "No target platform defined"#endif/* Debug-only code -- compiled out in release builds */#ifdef DEBUG#define DBG_PRINT(fmt, ...) printf(fmt, ##__VA_ARGS__)#else#define DBG_PRINT(fmt, ...) /* expands to nothing */#endif/* Feature toggles */#if FEATURE_BLUETOOTH_ENABLEDvoid bt_init(void);#endif
MISRA C Guidelines on Macros
MISRA C (used in automotive, medical, and aerospace) has strict rules about macros because macros bypass the compiler's type system:
| MISRA Rule | Requirement | Rationale |
|---|---|---|
| Rule 4.9 | A function should be used in preference to a function-like macro where they are interchangeable | Type safety and debuggability |
| Rule 20.1 | #include directives should only be preceded by other preprocessor directives or comments | Predictable include ordering |
| Rule 20.3 | The #include directive shall be followed by either "filename" or <filename> | Prevent undefined behavior |
| Rule 20.4 | A macro shall not be defined with the same name as a keyword | Prevents confusing redefinitions |
| Rule 20.7 | Expressions resulting from macro expansion shall be parenthesized | Prevent operator precedence bugs |
The practical takeaway: in MISRA-compliant projects, you replace function-like macros with static inline functions wherever possible. Object-like macros for constants are acceptable but enum or const variables are preferred.
X-Macros Pattern
The X-macro pattern solves a common embedded problem: keeping an enum definition and its corresponding string table (or initialization table) perfectly in sync. You define the list once and expand it in multiple contexts.
/* Define the list ONCE */#define ERROR_LIST(X) \X(ERR_NONE, "OK") \X(ERR_TIMEOUT, "Timeout") \X(ERR_CRC, "CRC mismatch") \X(ERR_OVERRUN, "Buffer overrun")/* Expand for the enum */#define X_ENUM(name, str) name,typedef enum {ERROR_LIST(X_ENUM)ERR_COUNT /* always equals the number of entries */} error_t;#undef X_ENUM/* Expand for the string table */#define X_STRING(name, str) [name] = str,static const char *const error_strings[ERR_COUNT] = {ERROR_LIST(X_STRING)};#undef X_STRING/* Usage */void log_error(error_t e) {uart_puts(error_strings[e]);}
If you add a new error to ERROR_LIST, both the enum and the string table update automatically. No chance of forgetting one. This pattern is widely used in embedded firmware for error codes, register maps, command tables, and state machine states.
Debugging Story: The Sensor That Read Twice
A team building a battery-powered environmental monitor noticed that their ADC readings had twice the expected noise. The analog circuitry checked out fine, and single readings taken from a debugger breakpoint were clean. The bug only appeared when the firmware was running normally.
The root cause was a function-like macro:
#define FILTER(new_val, old_val, alpha) \((alpha) * (new_val) + (1 - (alpha)) * (old_val))/* Called as: */filtered = FILTER(adc_read(), filtered, 0.1f);
The macro expanded to ((0.1f) * (adc_read()) + (1 - (0.1f)) * (filtered)) -- calling adc_read() only once. But in a later commit, someone changed the macro to a "safer" version with a min/max clamp:
#define FILTER(new_val, old_val, alpha) \MIN(MAX(((alpha) * (new_val) + (1 - (alpha)) * (old_val)), 0), 4095)
Now new_val (i.e., adc_read()) was evaluated multiple times inside the nested MIN/MAX macros, causing multiple ADC conversions per call. Each conversion returned a slightly different value, injecting noise. Replacing the macro with a static inline function fixed the noise and reduced power consumption (fewer ADC conversions).
Lesson: Function-like macros that call other macros multiply the double-evaluation problem. A single static inline function eliminates the entire class of bugs.
What interviewers want to hear: You can explain the difference between macros and inline functions at the preprocessor-vs-compiler level. You know the double-evaluation trap and instinctively reach for static inline when writing anything that behaves like a function. You use macros for what only macros can do: register definitions, conditional compilation, stringification, token pasting, and X-macros. You know include guards versus #pragma once and can cite the MISRA rationale for preferring functions over macros. You place static inline functions in headers (not .c files) and understand that static prevents multiple-definition linker errors.
Interview Focus
Classic Interview Questions
Q1: "What is the difference between a macro and an inline function?"
Model Answer Starter: "A macro is text substitution done by the preprocessor before compilation -- it has no type checking, no scope, and arguments can be evaluated multiple times. An inline function is a real function processed by the compiler -- it has full type safety, follows scoping rules, evaluates arguments exactly once, and can be stepped through in a debugger. The compiler may choose to expand it in-place (like a macro) or generate a normal call if the function is too large. I default to static inline and only use macros for things the compiler cannot do: register definitions, conditional compilation, and token pasting."
Q2: "Show me the double-evaluation bug in a macro and how to fix it."
Model Answer Starter: "#define SQUARE(x) ((x) * (x)) looks safe, but SQUARE(++i) expands to ((++i) * (++i)), incrementing i twice and producing undefined behavior. The fix is to use static inline int square(int x) { return x * x; }. The function evaluates ++i once, passes the result to x, and multiplies correctly. If you absolutely must use a macro, GCC's statement expression ({ typeof(x) _x = (x); _x * _x; }) evaluates once, but it is non-standard."
Q3: "What is the X-macro pattern and when would you use it?"
Model Answer Starter: "An X-macro defines a list of items once as a macro that takes another macro as its argument. You then expand the list in multiple contexts -- once for an enum, once for a string array, once for a dispatch table, etc. This guarantees the enum values and their string names stay in sync. I use it for error code tables, command parsers, and state machine state definitions. Adding a new entry means editing one line; both the enum and the string table update automatically."
Q4: "Explain include guards and #pragma once. Which do you prefer?"
Model Answer Starter: "Include guards use #ifndef HEADER_H / #define HEADER_H / #endif to prevent a header from being processed twice in the same translation unit. #pragma once does the same thing with a single line. #pragma once is cleaner and avoids name-collision risks in the guard macro, but it is not part of the C standard -- it is a compiler extension supported by GCC, Clang, MSVC, and IAR. In MISRA-compliant projects I use traditional include guards because the standard requires it. Otherwise I use #pragma once."
Q5: "Why does MISRA C restrict the use of function-like macros?"
Model Answer Starter: "MISRA Rule 4.9 says to prefer functions over function-like macros wherever they are interchangeable. The rationale is that macros bypass the type system -- the preprocessor does not check argument types, does not evaluate arguments once, and does not respect scope. A typo in a macro can produce an error message that points to the expanded code, not the macro definition, making debugging difficult. static inline functions give you the same zero-overhead expansion with full type safety and debugger support."
Trap Alerts
- Don't say: "Macros are always faster than functions" -- modern compilers inline small functions automatically at
-O2and above, often producing identical or better code than a macro. - Don't forget: The
do { ... } while(0)idiom for statement macros -- without it, a macro used afterifwithout braces causes a silent control-flow bug. - Don't ignore: The double-evaluation problem -- interviewers will ask you to find the bug in
MAX(adc_read(), threshold)and expect you to explain thatadc_read()is called twice.
Follow-up Questions
- "Where does a
static inlinefunction end up if the compiler decides not to inline it?" - "What is the difference between
inlineandstatic inlinein C99?" - "How would you check the preprocessor output to debug a macro expansion?"
- "Can you use
__attribute__((always_inline))and when is it appropriate?"
Practice
❓ What is wrong with `#define SQUARE(x) x * x` when called as `SQUARE(2 + 3)`?
❓ Which approach should you prefer for a small helper that converts milliseconds to timer ticks?
❓ What does the `##` operator do in a macro definition?
❓ In the X-macro pattern, what problem does defining the list in one place solve?
❓ Why does MISRA C Rule 4.9 recommend functions over function-like macros?
Real-World Tie-In
Automotive ECU Register Abstraction -- A powertrain ECU vendor defined all peripheral register accesses as #define macros with volatile casts (e.g., #define TIM2_CNT (*(volatile uint32_t *)0x40000024)). But higher-level operations like timer_start() and pwm_set_duty() were written as static inline functions. This split followed MISRA C: macros for the one thing only macros can do (volatile-cast register addresses), inline functions for everything else. The approach passed MISRA static analysis and ISO 26262 tool qualification without waivers.
IoT Firmware Error Logging -- A smart-home gateway used the X-macro pattern to define 47 error codes. Each error had an enum value, a severity level, a human-readable string, and a telemetry tag -- all generated from a single ERROR_TABLE(X) macro. When a field failure required adding five new error codes, the engineer edited five lines in the X-macro list and all four tables updated automatically. Without X-macros, the previous approach had caused three production incidents from mismatched enum-to-string mappings.
Medical Device Debug Build -- A patient monitor used #ifdef DEBUG to conditionally compile UART trace output. In debug builds, DBG_PRINT() expanded to printf() over the debug UART. In release builds, it expanded to nothing, saving 12 KB of flash and eliminating the timing perturbation that printf() introduced in the real-time control loop. The conditional compilation also removed assert() checks in release, replacing them with a lightweight fault handler that logged to non-volatile memory instead of halting.