Chapter 8: Idioms, Pitfalls, and 'Weird' C Practices

Updated on 2026-08-30ai-generated

Embedded C codebases are full of patterns that look strange to developers coming from other languages—or even from desktop C. Preprocessor macros that span multiple lines, structs initialized with {0}, do { } while(0) loops that don't loop, and header guards that seem redundant. This chapter explains these idioms, why they exist, and the subtle language behaviors that can cause real hardware failures.

Preprocessor Mastery

The C preprocessor is more than #include and #define. In embedded systems, it is a metaprogramming tool that generates code, configures hardware, and manages conditional compilation.

Header Guards: #ifndef vs #pragma once

Every header file must prevent double inclusion. The traditional method:

// uart.h
#ifndef UART_H
#define UART_H

// Header contents...

#endif

The modern alternative:

// uart.h
#pragma once

// Header contents...

#pragma once is shorter, less error-prone (no typo in the guard macro), and supported by all major compilers (GCC, Clang, MSVC, IAR, Keil). It is not part of the C standard, but it is universally accepted in practice.

Some codebases use both:

#pragma once
#ifndef UART_H
#define UART_H
// ...
#endif

This is redundant but harmless. The practical difference is negligible; choose one and be consistent.

Function-Like Macros: Dangers and Mitigations

Macros that look like functions are common for register access and simple operations:

#define SET_BIT(reg, bit) ((reg) |= (1 << (bit)))
#define CLEAR_BIT(reg, bit) ((reg) &= ~(1 << (bit)))
#define READ_BIT(reg, bit) (((reg) >> (bit)) & 1)

But function-like macros are text substitution, not real functions. They have pitfalls:

Problem 1: Operator precedence

#define SQUARE(x) x * x

int y = SQUARE(2 + 3);  // Expands to: 2 + 3 * 2 + 3 = 11, not 25!

Fix: Parenthesize everything

#define SQUARE(x) ((x) * (x))

Problem 2: Side effects evaluated twice

#define MAX(a, b) ((a) > (b) ? (a) : (b))

int x = 5;
int y = MAX(x++, 10);  // x++ evaluated once in comparison, once in result
// Result: undefined behavior or unexpected value

Fix: Use inline functions instead

static inline int max_int(int a, int b) {
    return (a > b) ? a : b;
}

Inline functions have the performance of macros (no function call overhead when optimized) with type safety and single evaluation of arguments.

The do { } while(0) Idiom

Multi-statement macros need to behave like a single statement. Consider:

#define INIT_PERIPHERAL(p) \
    (p)->CR1 = 0; \
    (p)->CR2 = 0; \
    (p)->init = 1;

if (condition)
    INIT_PERIPHERAL(peripheral);
else
    handle_error();

This expands to:

if (condition)
    (peripheral)->CR1 = 0;
(peripheral)->CR2 = 0;  // Always executed!
(peripheral)->init = 1; // Always executed!
else  // Syntax error!
    handle_error();

The do { } while(0) wrapper makes the macro a single statement:

#define INIT_PERIPHERAL(p) \
    do { \
        (p)->CR1 = 0; \
        (p)->CR2 = 0; \
        (p)->init = 1; \
    } while(0)

Now it works correctly in all contexts. The while(0) ensures the loop executes exactly once; the do wrapper absorbs the trailing semicolon.

Stringification and Token Pasting

The # operator converts a macro argument to a string. The ## operator concatenates tokens:

#define STRINGIFY(x) #x
#define TOSTRING(x) STRINGIFY(x)

#define CONCAT(a, b) a##b

#define PIN_NUMBER 5
#define PIN_NAME(pin) CONCAT(GPIO_PIN_, pin)

// Usage
const char *s = TOSTRING(PIN_NUMBER);  // "5" (not "PIN_NUMBER")
uint16_t pin = PIN_NAME(5);            // GPIO_PIN_5

The two-level STRINGIFY/TOSTRING is required to expand macros before stringification. Without it, STRINGIFY(PIN_NUMBER) produces "PIN_NUMBER" instead of "5".

Conditional Compilation for Hardware Variants

Embedded code must often support multiple hardware revisions or MCU variants:

// board_config.h
#define BOARD_REVISION 3

#if BOARD_REVISION >= 2
    #define LED_PIN GPIO_PIN_12
    #define LED_PORT GPIOD
#else
    #define LED_PIN GPIO_PIN_5
    #define LED_PORT GPIOA
#endif

#if defined(STM32F407xx)
    #define MCU_FLASH_SIZE (1024 * 1024)
#elif defined(STM32F411xx)
    #define MCU_FLASH_SIZE (512 * 1024)
#else
    #error "Unsupported MCU"
#endif

The #error directive is a useful safety net: it prevents compilation on unsupported configurations rather than producing broken code.

Initialization Idioms

Zero-Initializing Structs with {0}

typedef struct {
    uint32_t baud;
    uint8_t parity;
    uint8_t stop_bits;
    uint8_t *buffer;
} uart_config_t;

uart_config_t config = {0};  // All members set to zero/NULL

This initializes all members to zero (or NULL for pointers). It is simpler and less error-prone than listing every member. If new members are added to the struct, {0} still works; explicit initializers would need updating.

Note: {0} is valid C and works universally. Some compilers warn about missing initializers; use memset if this is an issue:

uart_config_t config;
memset(&config, 0, sizeof(config));

Designated Initializers

C99 introduced designated initializers, which are widely used in embedded code:

uart_config_t config = {
    .baud = 115200,
    .parity = 0,
    .stop_bits = 1,
    // .buffer left as NULL (zero)
};

Designated initializers:

  • Make code self-documenting
  • Allow initialization in any order
  • Allow skipping members (which are zero-initialized)
  • Generate compile-time errors if member names are wrong

Static vs. Automatic Initialization

static uint32_t counter;  // Zero-initialized once at startup (.bss)
uint32_t local_counter;   // Uninitialized (contains garbage!) on stack

Global and static variables are zero-initialized by the startup code (Chapter 2). Local automatic variables are not initialized unless you explicitly do so. Using an uninitialized local is undefined behavior.

The Compiler Is Not Your Friend: Common UB in Embedded C

Undefined behavior is not just a theoretical concern; it causes real hardware failures. Here are the most common UB sources in embedded code.

Signed Integer Overflow

int32_t count = INT32_MAX;
count++;  // UB: signed overflow

// Compiler may assume count never overflows and optimize:
if (count > 0) {
    // Compiler may assume this is always true
}

The fix: use unsigned types for counters that wrap, or check before incrementing:

uint32_t count = UINT32_MAX;
count++;  // Defined: wraps to 0

Shift by Invalid Amounts

uint32_t value = 1;
uint32_t shifted = value << 32;  // UB: shift by type width
uint32_t shifted2 = value << -1; // UB: negative shift

Some CPUs (ARM) define these operations in hardware, but the C compiler may optimize based on the assumption that they never happen. Always validate shift amounts.

Dereferencing NULL or Invalid Pointers

uint32_t *ptr = NULL;
*ptr = 5;  // UB: dereferencing null

uint32_t *reg = (uint32_t *)0x40000000;  // Valid on STM32
*reg = 5;  // Defined behavior for memory-mapped registers

Note: 0x40000000 is a valid peripheral address on STM32, but dereferencing NULL (address 0) is UB. The difference is whether the address is actually mapped.

Strict Aliasing Violations

The C standard assumes pointers of different types do not point to the same memory (with some exceptions). Violating this is UB:

uint32_t value = 0x12345678;
uint16_t *p = (uint16_t *)&value;  // UB: aliasing violation
uint16_t low = *p;  // Compiler may optimize assuming no aliasing

The fix: use memcpy or unions for type punning:

union {
    uint32_t as_uint32;
    uint16_t as_uint16[2];
} value;

value.as_uint32 = 0x12345678;
uint16_t low = value.as_uint16[0];  // Defined behavior

Unsequenced Modifications

int i = 0;
int arr[10];
arr[i++] = i++;  // UB: i modified twice without sequence point

int x = i++ + ++i;  // UB: undefined evaluation order

In practice, compilers may evaluate left-to-right or right-to-left. The result varies by compiler, optimization level, and context.

Volatile Deep Dive

Chapter 3 introduced volatile for registers. It also matters for variables shared with ISRs (Chapter 5). Here are additional subtleties.

Volatile Does Not Mean Atomic

volatile uint64_t counter;  // 64-bit on 32-bit MCU

// ISR increments counter
void ISR(void) {
    counter++;  // Read-modify-write: not atomic!
}

// Main loop reads counter
uint64_t snapshot = counter;  // May read partially updated value

On a 32-bit MCU, a 64-bit read is two 32-bit reads. The ISR can modify counter between them, producing a torn read. volatile prevents compiler optimization but not hardware-level races.

Volatile and Optimization

volatile uint32_t status;

void wait_for_ready(void) {
    while (!(status & READY_FLAG)) {
        // Compiler must re-read status each iteration
    }
}

Without volatile, the compiler might load status once into a register and loop forever. With volatile, it generates a load instruction each iteration.

Volatile Pointer vs. Pointer to Volatile

volatile uint32_t *ptr1;  // Pointer to volatile uint32_t (what you want)
uint32_t *volatile ptr2;  // Volatile pointer to uint32_t (rarely needed)
volatile uint32_t *volatile ptr3;  // Volatile pointer to volatile uint32_t

For registers, use volatile uint32_t *—the pointer itself doesn't change, but the pointed-to data does.

Integer Promotion Pitfalls

C promotes small integers to int in expressions. This causes subtle bugs:

uint8_t a = 200;
uint8_t b = 100;
uint8_t c = a + b;  // 300? No: overflow to 44

// a + b is computed as int (300), then truncated to uint8_t (44)

More dangerous:

uint16_t counter = 65535;
counter = counter + 1;  // 65536? No: wraps to 0 (if uint16_t)

uint8_t result = (uint8_t)(counter >> 8);  // Result depends on promotion

Always be explicit about types and casts:

uint16_t counter = 65535U;
counter = (uint16_t)(counter + 1U);  // Explicit wrap

Common Embedded C Idioms

Static Assertions

Compile-time checks catch configuration errors:

#define STATIC_ASSERT(condition, name) \
    typedef char static_assert_##name[(condition) ? 1 : -1]

STATIC_ASSERT(sizeof(uint32_t) == 4, uint32_must_be_4_bytes);
STATIC_ASSERT(UART_BUFFER_SIZE >= 64, uart_buffer_too_small);

If the condition is false, the array size is -1, causing a compile error. C11 provides _Static_assert natively, but the macro version works everywhere.

Anonymous Structs and Unions

C11 allows anonymous structs and unions inside structs:

typedef struct {
    union {
        struct {
            uint8_t low;
            uint8_t high;
        };
        uint16_t value;
    };
    uint8_t flags;
} register_pair_t;

register_pair_t reg;
reg.low = 0x34;   // Access low byte directly
reg.high = 0x12;  // Access high byte directly
uint16_t full = reg.value;  // 0x1234

This is cleaner than casts or bit manipulation for accessing sub-fields.

The const Placement Convention

const uint8_t *ptr1;  // Pointer to const uint8_t (data is read-only)
uint8_t const *ptr2;  // Same: pointer to const uint8_t
uint8_t *const ptr3;  // Const pointer to uint8_t (pointer is fixed)
const uint8_t *const ptr4;  // Const pointer to const uint8_t

For flash-resident data, use const to place data in .rodata (Chapter 2):

const char version_string[] = "Firmware v1.0.0";  // Stored in flash

The restrict Keyword

restrict tells the compiler that a pointer is the only way to access the pointed-to data. This enables optimization but is a promise you must keep:

void copy_buffer(uint8_t *restrict dst, const uint8_t *restrict src, size_t len) {
    for (size_t i = 0; i < len; i++) {
        dst[i] = src[i];  // Compiler can optimize: no aliasing possible
    }
}

If dst and src overlap, the behavior is undefined. Use memmove for overlapping buffers.

What to Actually Learn vs. Look Up

Look up (as needed):

  • Specific preprocessor tricks for exotic use cases
  • Compiler-specific pragmas and attributes
  • C standard library implementation details

Deeply understand:

  • The do { } while(0) macro idiom and why it exists
  • Header guard options and their trade-offs
  • The common undefined behaviors and how to avoid them
  • Integer promotion rules and how they affect embedded arithmetic
  • volatile semantics for registers and shared variables
  • {0} and designated initializers for predictable initialization

Key Takeaways

  1. The preprocessor is a metaprogramming tool. Macros generate code but require careful parenthesization and the do { } while(0) idiom.
  2. Header guards are mandatory. #pragma once is universally supported; #ifndef guards are more traditional.
  3. Zero-initialization with {0} is idiomatic. It is simpler and more maintainable than listing all members.
  4. Undefined behavior has hardware consequences. Signed overflow, invalid shifts, and aliasing violations can corrupt registers and timing.
  5. volatile prevents optimization but not races. It is necessary but not sufficient for interrupt-safe code.
  6. Integer promotion causes subtle bugs. Be explicit about types and casts in arithmetic.

Chapter 9 will shift focus from writing code to verifying it: debugging techniques, analyzing crashes, and the verification mindset that matters in an AI-assisted future.

Found an issue? Open an issue or submit a pull request on GitHub