The Case Against Smartass C Macros
Compact code can look like evidence of mastery. An elaborate macro construction may earn admiration for the ingenuity it displays, even when its behaviour has not been fully understood.
Compact code can look like evidence of mastery. An elaborate macro construction may earn admiration for the ingenuity it displays, even when its behaviour has not been fully understood.
Programming lyricism
Successive macro expansions, token concatenation and conditional definitions can compress substantial amounts of C into remarkably little source text. X-macros can push the subtleties further by expanding the same material in different ways, depending on the surrounding macro definitions. With enough macro machinery, the project develops its own language on top of C.
Then comes the reviewer or auditor, who must mentally perform the preprocessor’s job: substitute arguments, follow expansions, reconstruct identifiers and determine which declarations or operations emerge.
Meanwhile, they must retain the functional requirements, check types and conversions, and consider failure paths. A mistaken reconstruction can lead them to approve behaviour that the actual program does not have.
Attention spent reconstructing the expanded code is attention unavailable for checking its consequences.
At its worst, this becomes smartass programming: an excess of ego mistaken for technical virtuosity. Straightforward code may feel boring to write; elaborate macros can make it dangerous to review, audit and maintain.
One call in the source, two calls at runtime
Even a small macro can create a surprising gap between what the reader sees and what executes. For example, consider:
#define MIN(a, b) (((a) < (b)) ? (a) : (b)) The parentheses address operator precedence. They do not make the macro behave like a function.
Now use it to cap a sensor reading:
int value = MIN(read_sensor(), 100); The preprocessor expands this into:
int value = (((read_sensor()) < (100))
? (read_sensor())
: (100)); If the first reading is below 100, the sensor is read again. The value used for the comparison is discarded; the second reading becomes the result.
Suppose the first call returns 40 and the second returns 120. The expression returns 120, despite apparently limiting the reading to 100.
Both the macro invocation and the expanded C are syntactically valid. Yet the invocation is misleading: it suggests a single sensor reading capped at 100, while the expansion can perform two readings and return a value above that limit.
The mechanism works exactly as defined. The danger is how easily its notation invites an incorrect understanding of its behaviour.
Should read_sensor() access a hardware register, the second read may also acknowledge an event, consume another FIFO entry or observe a different hardware state.
The performance argument can run backwards
Repeated evaluation can repeat expensive work. If read_sensor() performs a bus transaction, the macro may perform two transactions where the reader expected one.
Nesting makes this less obvious. Consider a compact way to constrain a reading between 0 and 100:
#define MAX(a, b) (((a) > (b)) ? (a) : (b))int value = MIN(MAX(read_sensor(), 0), 100); If each reading returns 40, the expression calls read_sensor() four times. The outer macro evaluates MAX(...) twice, and each of those evaluations reads the sensor twice.
Reading the sensor first avoids the problem:
int sample = read_sensor();
int value = MIN(MAX(sample, 0), 100); The issue is how easily the original expression suggests a single reading followed by two comparisons. Using the macro correctly requires knowing that its arguments may be evaluated repeatedly. Its function-like appearance does not communicate that constraint.
When an argument performs an expensive operation, overlooking this distinction can also increase execution time. Observable hardware accesses cannot simply be assumed away by the optimiser.
A typed function gives the caller predictable evaluation rules:
static inline int clamp_int(int value, int low, int high)
{
if (value < low) {
return low;
}
if (value > high) {
return high;
}
return value;
} At the call site:
int value = clamp_int(read_sensor(), 0, 100); The sensor is read once, and the function operates on that value.
An optimising compiler may inline the function, eliminate the call overhead and produce the same instructions as an equivalent macro. It can also propagate constants and simplify expressions across the inlined body.
The compiler has access to types in both cases: macro expansion happens before semantic analysis and optimisation. The advantage of the function is its explicit interface and evaluation rules.
Admittedly, the inline keyword does not guarantee inlining. But neither does using a macro establish a performance advantage. Where timing matters, the good practice is to inspect the generated code and measure on the target.
Repeated evaluation is a risk of function-like macros, not an inherent property of every X-macro. It illustrates how preprocessing can conceal work behind apparently simple notation.
No typed interface
A function prototype declares the types it accepts, allowing the compiler to check arguments against that interface.
A macro has no parameter types. The compiler checks the C produced after expansion, but there is no macro-level contract against which to check the arguments.
An unsuitable argument may therefore produce valid C while performing an unintended operation. Type conversions hidden in a macro can make the mistake harder to notice. If the operation accesses memory through an inappropriate pointer type or uses an incorrect size, the consequences can include buffer overruns, memory corruption or segmentation faults. On a bare-metal target, an invalid access may trigger a processor fault or silently corrupt data.
This leaves more responsibility with the author and reviewer, and more room for programming errors to go unnoticed.
Harder to follow in the debugger
A macro is expanded before compilation. It has no executable identity of its own. Its expansion may define functions or objects with symbols, but the macro itself has no callable symbol, stack frame or separate entry in a backtrace.
You cannot place a function breakpoint on the macro itself or step into it as a distinct call. The debugger follows the instructions produced by its expansion, potentially grouping several operations under a single source line.
GDB can display macro definitions and expansions when suitable debug information is available, such as that produced by GCC’s -g3. The maintainer must still work through the expanded code to understand its execution. Nested macros make that task even harder.
Moreover, different invocations of the same macro can produce different machine instructions. After expansion, the compiler optimises each occurrence in its surrounding context: it may simplify expressions, eliminate branches or remove operations altogether. Understanding the instructions generated for one invocation therefore does not necessarily explain another.
Auditability requires more than a short source file
For critical software, the team must be able to connect requirements to their implementation and verification evidence.
When a macro produces several functions or inserts a safety check at many locations, that relationship needs to remain identifiable. Which expansion implements the required behaviour? Which tests exercise it? What else changes when the shared definition is modified?
Coverage tools can handle macro expansions, but the reports need appropriate detail. A single source line can contain several conditions and data paths after expansion. Seeing that the line executed does not establish that all those conditions were exercised.
Clang’s coverage documentation specifically recommends displaying macro expansions when reviewing branch coverage because macros can contain hidden conditions.
Macro-generated code can therefore be properly traced, tested and audited. However, the additional machinery creates additional work to establish and maintain those links. Three lines of preprocessing may represent many distinct behaviours that require separate examination.
Once expanded, source compactness does not reduce the amount of evidence needed to justify the implementation.
Coding standards discourage this for a reason
MISRA C explicitly favours functions in this situation. Directive 4.9 of MISRA C:2012 recommends using a function instead of a function-like macro whenever the two are interchangeable.
The simple examples above explain the engineering value of that preference: declared parameter types, predictable argument evaluation and code that can be examined directly.
These restrictions are sometimes dismissed with “MISRA-compliant code can still be wrong.”
Of course it can. MISRA’s own compliance guidance acknowledges that following its guidelines does not guarantee error-free software. Coding rules cannot establish that requirements are correct, that an algorithm meets them or that the complete system behaves safely.
Their contribution is to reduce particular sources of error and support a more disciplined verification process. Rejecting that contribution because it does not guarantee correctness leaves those sources of error in place.
Directive 4.9 is advisory, and it does not prohibit every macro. Where an ordinary function does the job, however, choosing preprocessor machinery needs an engineering justification.
What should count as a technical achievement?
Macros can prevent inconsistencies, support conditional compilation or express something that an ordinary function cannot. Those benefits should be weighed against the effort needed to understand, verify and maintain the result.
An unusual construction should earn its place through a concrete benefit. Its author should be able to explain what it simplifies, which checks it preserves and how another developer will modify it safely.
The temptation can be particularly strong early in a developer’s career, when demonstrating technical virtuosity may seem like a way to earn recognition. Experience should shift that ambition towards code that others can truly understand and maintain, although seniority alone is no guarantee.
Management also influences the outcome. If recognition goes to the most impressive trick, the team will produce tricks. If reviews reward explicit contracts, understandable behaviour and straightforward diagnosis, those qualities become part of the engineering culture.
Macros work as defined. The problem is how easily they can mislead: an expression that looks straightforward can behave differently from what its author or reviewer expects. That gap between apparent and actual behaviour is an engineering risk.
For a critical product, reviewability and maintainability are engineering requirements. The company must retain the ability to understand and change its software when the original author is unavailable.
Beyond functionality, the technical achievement is code that remains understandable, verifiable and maintainable after its author has moved on.
Complex macro machinery is one of the things I examine when reviewing an embedded codebase. Through Embedded Expertise, I help teams identify hidden risks, simplify fragile interfaces and regain control of software that has become difficult to maintain.
If making a small change requires unravelling layers of clever code, let’s discuss how to make your next change safer.
Enjoyed this article?
Embedded Notes is an occasional, curated selection of similar content, delivered to you by email. No strings attached, no marketing noise.
Related resources
- Reviews: The Key to Right-First-Time Designs
- Code Reviews: How We Do It and Why It Works
- Review & Project Recovery · en | fr