RayBench EmbeddedInteractive engineering labs
EMBEDDED C

Embedded C and Firmware Systems

A complete path from C syntax and object lifetime to registers, interrupts, memory ownership, testable drivers, and production firmware.

Reviewed 2026-08-224,763 wordsStudents preparing for embedded interviews and engineers moving from application C into hardware-facing firmware.

Learn the C machine before the microcontroller

Embedded C is still C. Integer promotions, object lifetime, aliasing, alignment, sequence rules, and undefined behavior determine what the compiler may generate. The course begins with executable examples and memory diagrams so that keywords are connected to objects, addresses, values, and legal operations. This prevents hardware symptoms from being blamed on peripherals when the program already violated the language model.

Treat registers as external contracts

A peripheral register has access width, reset value, reserved bits, write semantics, timing requirements, and side effects. Volatile communicates that accesses are observable, but it does not make an operation atomic and it does not create a concurrency protocol. Register labs require masked updates, named fields, bounded polling, explicit error paths, and references to the relevant hardware behavior.

Design for asynchronous events

Interrupts, DMA, timers, and other execution contexts can change state between ordinary C statements. Reliable firmware minimizes interrupt work, defines ownership, protects compound state, and selects communication primitives based on loss, latency, and backpressure requirements. Ring buffers and state machines are presented as systems with invariants rather than copied snippets.

Prove production behavior

The advanced path covers watchdogs, reset-cause capture, linker-controlled memory, CRC-protected records, safe updates, fault injection, and host-based tests. Each solution states assumptions and boundary behavior. Learners are expected to test full and empty buffers, counter rollover, partial messages, power interruption, unexpected reset, and invalid peripheral state.

What you will be able to do

  • Explain integer conversion, pointers, storage duration, and undefined behavior
  • Write register access and interrupt code with explicit hardware contracts
  • Design bounded queues, state machines, and ownership rules
  • Defend firmware against reset, concurrency, timing, and malformed-input failures

Objects, types, declarations, and initialization

An object is a region of storage with a type, value and lifetime. A declaration introduces a name/type; a definition also creates the object or function body.

  • Initialize before reading.
  • Use stdint.h widths at wire/register boundaries and size_t for object sizes.
  • Use const when an API must not mutate through a pointer.
#include <stdint.h>

static uint32_t boot_count;        /* definition, static duration, zero-initialized */
extern volatile uint32_t ticks;    /* declaration; definition lives elsewhere */

int main(void)
{
    const uint16_t limit = UINT16_C(1000);
    uint16_t sample = UINT16_C(42);
    return (sample < limit) ? 0 : 1;
}
Output
The program returns success because 42 is less than 1000.

Pitfalls

  • Reading an uninitialized automatic object
  • Assuming int is always 32 bits
  • Putting non-static definitions in headers

Fixed-width integers: stdint.h, stdbool.h, size_t, and printf formats

The width of int is a target property, not a contract. Fixed-width types from stdint.h state the contract explicitly, size_t states object sizes, and inttypes.h PRI macros keep printf honest across targets.

  • Use uint32_t/int16_t at register, wire and storage boundaries.
  • Print fixed-width types with inttypes.h PRI macros; cast size_t for printf.
  • Reserve plain int for small target-local values, never for protocols.
#include <inttypes.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint32_t ticks = UINT32_C(1000000);   /* width is contractual, not "whatever int is" */
    int16_t raw = INT16_C(-42);
    size_t frame_len = 3U;                /* size_t: object sizes and counts */
    bool ready = true;                    /* stdbool.h */

    printf("ticks=%" PRIu32 " raw=%" PRId16 " len=%lu ready=%d\n",
           ticks, raw, (unsigned long)frame_len, (int)ready);
    printf("sizeof(int)=%lu on this target\n", (unsigned long)sizeof(int));
    return 0;
}
Output
Prints ticks=1000000 raw=-42 len=3 ready=1, then sizeof(int)=4 on this target (2 on AVR-class targets: the width of int is not contractual).

Pitfalls

  • Assuming int is 32 bits everywhere
  • Using %u for uint32_t where it is unsigned long
  • Treating bool as a numeric range type

Operator precedence and sizeof in practice

Precedence bugs survive code review because the expression still compiles. Bitwise tests, shifts and mixed arithmetic must be parenthesized; sizeof is a compile-time operator on types and objects, not a runtime function.

  • Parenthesize every bitwise test: (flags & MASK) != 0U.
  • Parenthesize shifts combined with + or -.
  • sizeof(type) needs parentheses; sizeof object does not, but use them anyway.
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint32_t flags = UINT32_C(0x0A);
    if ((flags & UINT32_C(0x02)) != 0U) puts("bit1 set");   /* == binds TIGHTER than & */
    printf("sizeof uint32_t = %zu\n", sizeof(uint32_t));
    printf("masked=%" PRIu32 "\n", (flags & UINT32_C(0x0F)) >> 1);
    return 0;
}
Output
Prints: bit1 set / sizeof uint32_t = 4 / masked=5.

Pitfalls

  • flags & 0x02 == 0 parses as flags & (0x02 == 0): always false
  • a << 1 + 1 is a << (1 + 1)
  • sizeof(buf) on a function parameter gives the pointer size, not the array

Integer promotion and mixed-sign comparisons

Before almost any arithmetic, C promotes small integer types to int. So uint8_t + uint8_t is computed as int and never wraps at 8 bits; but mixing signed and unsigned of the same rank silently converts the signed value to a huge unsigned one.

  • Promotions go to int first; the result type follows the operands after promotion.
  • Never compare signed against unsigned without an explicit, justified cast.
  • Build with -Wall -Wextra -Wconversion and read every warning.
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    uint8_t a = UINT8_C(200), b = UINT8_C(100);
    int sum = a + b;                     /* promoted to int: 300, no 8-bit wrap */
    uint8_t wrapped = (uint8_t)(a + b);  /* explicit narrow: 44 */
    int32_t si = -1;
    uint32_t ui = UINT32_C(1);
    /* same rank: si converts to unsigned 4294967295, so si < ui is false */
    printf("sum=%d wrapped=%u cmp=%d\n", sum, wrapped, (si < ui) ? 1 : 0);
    return 0;
}
Output
Prints: sum=300 wrapped=44 cmp=0 (the -1 became a huge unsigned value).

Pitfalls

  • Expecting uint8_t arithmetic to wrap at 255: it promotes first
  • if (-1 < 1U) is false, and some compilers do not warn by default
  • A cast after the overflow cannot recover the lost value

const objects, const pointers, and const volatile registers

const is a promise about who may write, read right-to-left: const uint16_t *p is a pointer to a const uint16_t (data frozen, pointer movable); uint16_t * const p is a const pointer (address frozen, data writable). Combined with volatile it describes read-only hardware registers.

  • Read declarations from the identifier outward, right to left.
  • Function inputs that must not change take pointer-to-const.
  • const does not place data in flash; that is a linker/compiler decision.
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

static uint32_t raw_status = UINT32_C(3);

int main(void)
{
    const uint16_t limit = UINT16_C(500);          /* object may not change */
    uint16_t a = UINT16_C(10), b = UINT16_C(20);
    const uint16_t *p = &a;                         /* pointer to const: *p frozen */
    p = &b;                                         /* moving p itself is fine */
    uint16_t * const q = &a;                        /* const pointer: address fixed */
    *q = UINT16_C(11);                              /* but *q is writable */
    const volatile uint32_t * const status = &raw_status; /* read-only HW register */
    printf("limit=%u p=%u a=%u status=%" PRIu32 "\n", limit, *p, a, *status);
    return 0;
}
Output
Prints: limit=500 p=20 a=11 status=3.

Pitfalls

  • Confusing const uint16_t * with uint16_t * const
  • Expecting const data to land in flash automatically
  • Casting away const to write: undefined if the object was defined const

Enumerations for states and tables

An enum names a small set of integer constants. Use it for states, modes and error codes: the compiler can then warn when a switch forgets a case, and a trailing COUNT enumerator sizes lookup tables automatically.

  • Add a trailing COUNT sentinel to size tables and loops.
  • Switch over enums WITHOUT default so -Wswitch flags missing states.
  • Never send a raw enum over a wire protocol: its width is implementation-defined.
#include <stdio.h>

typedef enum {
    MOTOR_STOPPED = 0,
    MOTOR_STARTING,
    MOTOR_RUNNING,
    MOTOR_FAULT,
    MOTOR_STATE_COUNT            /* sentinel: number of real states */
} motor_state_t;

static const char *const names[MOTOR_STATE_COUNT] = {
    "stopped", "starting", "running", "fault"
};

static motor_state_t step(motor_state_t s)
{
    switch (s) {                 /* -Wswitch warns if a state is missing */
    case MOTOR_STOPPED:  return MOTOR_STARTING;
    case MOTOR_STARTING: return MOTOR_RUNNING;
    case MOTOR_RUNNING:  return MOTOR_FAULT;
    case MOTOR_FAULT:    return MOTOR_STOPPED;
    case MOTOR_STATE_COUNT: break;
    }
    return MOTOR_FAULT;
}

int main(void)
{
    motor_state_t s = MOTOR_STOPPED;
    int i;
    for (i = 0; i < 4; ++i) { puts(names[s]); s = step(s); }
    return 0;
}
Output
Prints: stopped / starting / running / fault.

Pitfalls

  • A default: case hides the missing-state warning from -Wswitch
  • Assuming enum values are 1 byte on the wire
  • Reading an out-of-range integer into an enum from untrusted data

Unions and the tagged-variant pattern

Union members share the same storage: only the last one written is valid to read. The useful firmware pattern is a tagged union: an enum tag says which member is live, so one event type can carry different payloads in the same bytes.

  • Always pair a union with a tag that says which member is valid.
  • Check the tag before every read of a payload member.
  • A union is NOT a portable serialization: padding and endianness still apply.
#include <stdint.h>
#include <stdio.h>

typedef enum { EV_NONE, EV_BUTTON, EV_ADC } event_kind_t;

typedef struct {
    event_kind_t kind;              /* tag: which member is valid */
    union {
        uint8_t button_id;          /* valid when kind == EV_BUTTON */
        uint16_t adc_counts;        /* valid when kind == EV_ADC */
    } payload;
} event_t;

int main(void)
{
    event_t ev;
    printf("sizeof event_t = %zu\n", sizeof(ev));
    ev.kind = EV_ADC;
    ev.payload.adc_counts = UINT16_C(2048);
    if (ev.kind == EV_ADC) printf("adc=%u\n", ev.payload.adc_counts);
    ev.kind = EV_BUTTON;
    ev.payload.button_id = UINT8_C(2);
    if (ev.kind == EV_BUTTON) printf("button=%u\n", ev.payload.button_id);
    return 0;
}
Output
Prints: sizeof event_t = 8 (typical) / adc=2048 / button=2. The union is only as large as its biggest member.

Pitfalls

  • Reading a member other than the last one written (type punning traps)
  • Using a union to re-interpret protocol bytes portably
  • Forgetting to update the tag when the payload changes

Bit-fields: what they are and where they belong

A bit-field packs small unsigned members into a storage unit. The catch: ordering, padding and straddling are implementation-defined, so bit-fields are fine for RAM flags but forbidden as a hardware register or protocol layout — use explicit masks there.

  • Use bit-fields only for space-saving flags inside RAM structs.
  • Use mask/shift macros for registers and wire formats: defined, portable, testable.
  • Never take the address of a bit-field; it has none.
#include <inttypes.h>
#include <stdint.h>
#include <stdio.h>

typedef struct {
    unsigned enabled : 1;   /* RAM flags: legitimate bit-field use */
    unsigned mode    : 2;   /* layout is implementation-defined... */
    unsigned level   : 5;   /* ...so never overlay this on a register */
} led_flags_t;

int main(void)
{
    led_flags_t f = { 1U, 2U, 17U };
    printf("enabled=%u mode=%u level=%u bytes=%zu\n",
           f.enabled, f.mode, f.level, sizeof(f));
    uint32_t moder = UINT32_C(0);   /* hardware register: explicit masks */
    moder = (moder & ~(UINT32_C(3) << 4)) | (UINT32_C(1) << 4);  /* bits 5:4 = 01 */
    printf("moder=0x%08" PRIx32 "\n", moder);
    return 0;
}
Output
Prints: enabled=1 mode=2 level=17 bytes=4 / moder=0x00000010.

Pitfalls

  • Bit-field order differs between compilers and ABIs
  • A packed bit-field struct sent over UART is not portable
  • Signed bit-fields of width 1 hold only 0 and -1

typedef aliases and readable function pointers

typedef creates an alias for a type, not a new type: counts_t and uint16_t remain interchangeable. Its real power is documentation (units in the name), opaque handles, and making function-pointer declarations readable.

  • typedef does not create a distinct type; the compiler still sees the alias target.
  • Use typedef for function-pointer signatures you pass around.
  • Hide implementation with typedef struct foo foo_t plus pointers in the header.
#include <stdint.h>
#include <stdio.h>

typedef uint16_t counts_t;                 /* alias: units in the name */
typedef int32_t (*filter_fn_t)(int32_t sample, void *ctx);

typedef struct { int32_t prev; } ema_ctx_t;

static int32_t ema(int32_t sample, void *ctx)
{
    ema_ctx_t *s = (ema_ctx_t *)ctx;
    int32_t out = (sample + s->prev) / 2;
    s->prev = out;
    return out;
}

int main(void)
{
    counts_t raw = UINT16_C(100);
    ema_ctx_t state = { 0 };
    filter_fn_t f = ema;                     /* readable function pointer */
    printf("raw=%u y1=%ld y2=%ld\n", raw, (long)f((int32_t)raw, &state), (long)f(300, &state));
    return 0;
}
Output
Prints: raw=100 y1=50 y2=175 (each output is the average of sample and previous output).

Pitfalls

  • typedef char *str; str a, b; makes both pointers, but char *a, b makes b a plain char
  • Expecting typedef to add type safety between two uint16_t aliases
  • Hiding a pointer inside a typedef without documenting ownership

Storage duration, static, and extern

Every object has a lifetime (storage duration) and every name has visibility (linkage). static at file scope hides a name inside one .c file; static inside a function keeps the value across calls; extern declares an object defined somewhere else.

  • static at file scope: private to this translation unit, lives forever.
  • static in a function: initialized once, keeps its value between calls.
  • extern in a header declares; exactly one .c file defines.
#include <stdint.h>
#include <stdio.h>

static uint32_t file_scope_count;   /* file-scope static: private, zero-initialized */

static uint32_t next_call_number(void)
{
    static uint32_t calls;          /* function static: set up once, persists */
    return ++calls;
}

int main(void)
{
    uint32_t stack_var = UINT32_C(7);  /* automatic: dies at the closing brace */
    file_scope_count += stack_var;
    printf("%u %u %u\n", (unsigned)next_call_number(),
           (unsigned)next_call_number(), (unsigned)file_scope_count);
    return 0;
}
Output
Prints: 1 2 7 (the call counter survives between calls).

Pitfalls

  • Defining a variable in a header: every includer gets its own copy or a linker clash
  • Function-static state is shared and not reentrant/thread-safe
  • Confusing static (linkage/lifetime) with const (writability)

volatile: hardware observation, not synchronization

volatile tells the compiler the value can change outside the current code: every abstract read and write must actually happen. Use it for memory-mapped registers and ISR-shared flags — but it provides no atomicity and no ordering, so it is not a lock and not a queue.

  • MMIO registers and ISR-shared flags are volatile.
  • volatile does not make multi-byte or multi-step access atomic.
  • volatile does not order memory; use atomics or critical sections for cross-context data.
#include <stdint.h>
#include <stdio.h>

/* pretend this address is a GPIO input data register */
static uint32_t fake_idr_storage = UINT32_C(0x5A);
#define GPIO_IDR (*(volatile uint32_t *)&fake_idr_storage)

static void wait_for_pin_high(void)
{
    unsigned spins = 0U;
    while ((GPIO_IDR & UINT32_C(1)) == 0U) {   /* volatile: re-read every loop */
        fake_idr_storage |= UINT32_C(1);       /* fake hardware sets the pin */
        ++spins;
    }
    printf("pin high after %u spin(s)\n", spins);
}

int main(void)
{
    wait_for_pin_high();
    return 0;
}
Output
Prints: pin high after 1 spin(s). Without volatile the compiler could cache the register and loop forever.

Pitfalls

  • Believing volatile makes an ISR-to-main byte queue safe
  • Read-modify-write on a volatile register is still two separate accesses
  • Marking everything volatile 'to be safe' destroys optimization and hides real races

static inline versus function-like macros

A function-like macro pastes text and evaluates arguments as many times as they appear in the body. A static inline function in a header is type-checked, evaluates each argument exactly once, and usually compiles to the same code.

  • Prefer static inline in headers over multi-statement macros.
  • Macro arguments with side effects (x++) can be evaluated multiple times.
  • inline is a hint; the compiler decides, and static inline never wastes flash on an unused copy.
#include <stdint.h>
#include <stdio.h>

static inline uint32_t min_u32(uint32_t a, uint32_t b)
{
    return (a < b) ? a : b;   /* each argument evaluated exactly once */
}

#define MIN_BAD(a, b) ((a) < (b) ? (a) : (b))  /* winner evaluated TWICE */

int main(void)
{
    uint32_t x = UINT32_C(3), y = UINT32_C(4);
    uint32_t good = min_u32(x++, y);
    uint32_t x2 = UINT32_C(3);
    uint32_t bad = MIN_BAD(x2++, y);   /* x2 increments twice */
    printf("good=%u x=%u bad=%u x2=%u\n",
           (unsigned)good, (unsigned)x, (unsigned)bad, (unsigned)x2);
    return 0;
}
Output
Prints: good=3 x=4 bad=3 x2=5 (the macro incremented x2 twice).

Pitfalls

  • MIN(x++, y) style bugs: silent double side effects
  • Macros do not type-check: a float argument compiles and misbehaves
  • Forgetting static on a header inline causes multiple-definition link errors

Function pointers, callbacks, and dispatch tables

A function pointer stores the address of a function and calls it later: callbacks, state machines and command tables all build on it. Always typedef the signature, keep tables const so they live in flash, and validate the lookup before the call.

  • typedef the signature; raw declarations are unreadable.
  • Put dispatch tables in static const storage.
  • Validate name/index before dereferencing the pointer.
#include <stdio.h>
#include <string.h>

typedef int (*command_fn_t)(const char *args);

static int cmd_led(const char *args)   { printf("led %s\n", args);   return 0; }
static int cmd_motor(const char *args) { printf("motor %s\n", args); return 0; }

typedef struct { const char *name; command_fn_t run; } command_t;

static const command_t COMMANDS[] = {
    { "led",   cmd_led },
    { "motor", cmd_motor },
};

int main(void)
{
    const char *input = "motor";
    size_t i;
    int found = 0;
    for (i = 0U; i < sizeof(COMMANDS) / sizeof(COMMANDS[0]); ++i) {
        if (strcmp(input, COMMANDS[i].name) == 0) {
            found = 1;
            (void)COMMANDS[i].run("on");   /* call through the pointer */
        }
    }
    if (!found) puts("unknown command");
    return 0;
}
Output
Prints: motor on.

Pitfalls

  • Calling through a NULL or uninitialized function pointer jumps to garbage
  • Mismatching the signature is undefined behavior, not just a warning
  • Tables in RAM waste bytes: const moves them to flash

Floating point costs and fixed-point arithmetic

On an MCU without an FPU every float operation is a library call and every double is worse; the literal 1.5 is double unless you write 1.5f. Fixed-point keeps integers, deterministic cost and exact control of rounding.

  • Append f to constants; double math on a Cortex-M0 is emulated and huge.
  • Compare floats with tolerance, never ==, when values come from computation.
  • Use fixed-point (e.g. Q16.16) when you need deterministic fractional math.
#include <stdint.h>
#include <stdio.h>

int main(void)
{
    /* 1.5f is float; 1.5 is double: on soft-float targets double is very slow */
    float volts = 3.3f * (float)UINT16_C(2048) / 4095.0f;
    printf("volts=%.3f\n", (double)volts);

    /* Q16.16 fixed-point: 1.5 == 98304; pure integer math, deterministic */
    int32_t q_one_half = 98304;
    int32_t adc = 2048;
    int32_t q_result = (int32_t)(((int64_t)adc * q_one_half) >> 16);  /* 3072 */
    printf("q_result=%ld\n", (long)q_result);
    return 0;
}
Output
Prints: volts=1.650 / q_result=3072 (2048 * 1.5 in fixed-point).

Pitfalls

  • 1.5 silently promotes the whole expression to double
  • printf %f pulls in large float formatting code; often disabled on purpose
  • int32 * int32 fixed-point multiplication overflows: widen to int64 first

Macros, conditional compilation, and preprocessor operators

The preprocessor runs before the compiler and works on text, not types. Object-like macros name constants; function-like macros need full parenthesization and the do-while(0) wrapper to behave like statements; #if selects code at build time.

  • Parenthesize every macro parameter and the whole expansion.
  • Wrap multi-statement macros in do { ... } while (0).
  • Prefer const objects and static inline when the preprocessor is not required.
#include <stdint.h>
#include <stdio.h>

#define FW_VERSION 3
#define ARRAY_LEN(a) (sizeof(a) / sizeof((a)[0]))
#define STR(x) #x                          /* stringize */
#define RB_UNUSED(x) ((void)(x))
#define LOG_ERR(msg) do { printf("ERR line %d: %s\n", __LINE__, msg); } while (0)

#define RB_ENABLE_LOG 1
#if RB_ENABLE_LOG
#  define LOG(msg) puts(msg)
#else
#  define LOG(msg) RB_UNUSED(msg)
#endif

int main(void)
{
    uint16_t buf[16];
    LOG("boot");
    printf("len=%zu name=%s\n", ARRAY_LEN(buf), STR(rb_buffer));
    if (FW_VERSION < 0) LOG_ERR("bad version");   /* do-while(0) keeps if/else safe */
    return 0;
}
Output
Prints: boot / len=16 name=rb_buffer. The LOG_ERR branch never runs because FW_VERSION is 3.

Pitfalls

  • #define SQR(x) x*x with SQR(a+1) expands to a+1*a+1
  • A multi-statement macro without do-while(0) breaks if/else pairing
  • #ifdef DEBUG left enabled in a production build

Error handling without exceptions

C has no exceptions: a function reports failure through a status return, an out-parameter carries the data, and callers either handle or propagate. assert documents programmer errors in debug builds; runtime status checks handle real-world failures in every build.

  • Return a status enum; put data in out-parameters.
  • Every caller handles the status or returns it upward; never silently drop it.
  • assert is for bugs and vanishes with NDEBUG; keep real checks as status returns.
#include <assert.h>
#include <stdint.h>
#include <stdio.h>

typedef enum { RB_OK = 0, RB_ERR_NULL, RB_ERR_FULL, RB_ERR_RANGE } rb_status_t;

_Static_assert(sizeof(uint32_t) == 4U, "driver assumes 32-bit words");

static rb_status_t ring_push(uint32_t *used, uint32_t cap, uint32_t *item)
{
    if ((used == NULL) || (item == NULL)) return RB_ERR_NULL;
    if (*used >= cap) return RB_ERR_FULL;
    *used += 1U;
    return RB_OK;
}

static rb_status_t set_gain(unsigned gain)
{
    if (gain > 16U) return RB_ERR_RANGE;
    return RB_OK;
}

int main(void)
{
    uint32_t used = 0U, item = UINT32_C(42);
    rb_status_t st = ring_push(&used, 1U, &item);
    assert(st == RB_OK);                    /* debug builds only */
    st = ring_push(&used, 1U, &item);       /* now full */
    printf("push2=%d gain=%d used=%u\n", (int)st, (int)set_gain(99U), (unsigned)used);
    return 0;
}
Output
Prints: push2=2 gain=3 used=1 (RB_ERR_FULL=2, RB_ERR_RANGE=3).

Pitfalls

  • A magic return value like -1 that collides with valid data
  • assert(input != NULL) as the only defense against bad runtime data
  • errno is not available or not thread-safe on freestanding targets

Operators, expressions, conversions, and sequencing

Operators combine values, but promotions and evaluation rules can change the type before the result is stored. Make widths and side effects explicit.

  • Parenthesize bit tests and mixed operators.
  • Never depend on signed overflow.
  • Do not modify one scalar multiple times without a defined sequence.
#include <stdbool.h>
#include <stdint.h>

bool bit_is_set(uint32_t value, uint8_t bit)
{
    if (bit >= 32U) return false;
    return (value & (UINT32_C(1) << bit)) != 0U;
}
Output
bit_is_set(8, 3) is true; bit_is_set(8, 32) safely returns false.

Pitfalls

  • Writing value & mask == 0
  • Shifting by the type width
  • Assuming a cast repairs prior overflow

if, switch, loops, and termination

Control flow chooses state transitions and repetition. Every loop needs a variant that moves toward termination and an invariant that remains true.

  • Validate before the loop.
  • Use break/continue only when they clarify the invariant.
  • Every switch over protocol/state values has an explicit unknown policy.
#include <stddef.h>
#include <stdint.h>

uint16_t max_u16(const uint16_t *values, size_t length)
{
    uint16_t best = 0U;
    size_t i;
    for (i = 0U; i < length; ++i) {
        if (values[i] > best) best = values[i];
    }
    return best;
}
Output
After each iteration, best is the maximum of the processed prefix.

Pitfalls

  • Using <= length for array iteration
  • Unsigned countdown conditions that never become negative
  • Missing switch default/invalid handling

Functions, status values, and out-parameters

A function contract names valid inputs, results, side effects and failure state. Status plus out-parameter keeps every data value available.

  • Check pointers before dereference.
  • Document whether outputs change on failure.
  • Keep target I/O outside pure algorithm functions where possible.
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

bool average_u16(const uint16_t *in, size_t n, uint16_t *out)
{
    uint64_t sum = 0U;
    size_t i;
    if ((in == NULL) || (out == NULL) || (n == 0U)) return false;
    for (i = 0U; i < n; ++i) sum += in[i];
    *out = (uint16_t)(sum / n);
    return true;
}
Output
Failure leaves *out unchanged; the wider accumulator prevents ordinary uint16_t summation overflow.

Pitfalls

  • Magic sentinel results
  • Partial output before final validation
  • Hidden global dependencies

Arrays, byte buffers, and C strings

Arrays own contiguous elements. After array-to-pointer decay, length is gone. A string is specifically a char sequence with an accessible zero terminator.

  • Pass pointer plus element count or capacity.
  • Use memmove for possible overlap.
  • Treat protocol payload as bytes, not text.
#include <stddef.h>

size_t bounded_text_length(const char *text, size_t capacity)
{
    size_t n = 0U;
    if (text == NULL) return 0U;
    while ((n < capacity) && (text[n] != '\0')) ++n;
    return n;
}
Output
Returning capacity means no terminator was found inside the permitted region.

Pitfalls

  • sizeof(parameter) as array length
  • strlen on untrusted unterminated data
  • Using memcpy on overlap

Pointers, const placement, and ownership

A pointer is a typed address-like value, not ownership by itself. The API must state whether memory is borrowed, mutable, retained or transferred.

  • Pointer arithmetic stays within one array object.
  • Do not retain borrowed pointers past their lifetime.
  • Validate alignment/lifetime rather than casting them away.
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>

typedef struct { const uint8_t *data; size_t length; } byte_view_t;

bool first_byte(byte_view_t view, uint8_t *out)
{
    if ((out == NULL) || (view.data == NULL) || (view.length == 0U)) return false;
    *out = view.data[0];
    return true;
}
Output
The view borrows immutable bytes; it does not allocate or extend their lifetime.

Pitfalls

  • Returning a pointer to an automatic local
  • Confusing const pointer with pointer-to-const
  • Ordering unrelated pointers

Bounded strings: copy, format, never overflow

A C string is only bytes plus a zero terminator, and the classic library (strcpy, strcat, sprintf) writes until the terminator with no idea of your buffer size. Firmware uses bounded variants: an explicit-capacity copy and snprintf for formatting.

  • strcpy, strcat and sprintf are banned: they cannot see the destination size.
  • snprintf(buf, sizeof buf, ...) always terminates when size is nonzero.
  • Protocol payloads are bytes, not strings: they may contain zero and have no terminator.
#include <stdio.h>

static size_t copy_text(char *dst, size_t cap, const char *src)
{
    size_t n = 0U;
    if ((dst == NULL) || (src == NULL) || (cap == 0U)) return 0U;
    while ((n + 1U < cap) && (src[n] != '\0')) { dst[n] = src[n]; ++n; }
    dst[n] = '\0';                        /* always terminated */
    return n;                             /* chars written, like strlcpy */
}

int main(void)
{
    char label[8];
    size_t n = copy_text(label, sizeof label, "sensor-42");
    printf("label='%s' copied=%zu\n", label, n);   /* truncated safely */
    char line[24];
    int len = snprintf(line, sizeof line, "adc=%u", 2048U);
    printf("line='%s' len=%d\n", line, len);
    return 0;
}
Output
Prints: label='sensor-' copied=7 / line='adc=2048' len=8. Truncation is safe and visible, never an overflow.

Pitfalls

  • strncpy may leave the destination unterminated: it pads, not protects
  • strlen on untrusted bytes without a terminator reads out of bounds
  • sizeof on a char* parameter gives pointer size, not capacity

Structs, enums, unions, and representation boundaries

Structs group objects but may contain padding. Enums name states. Unions share storage, but neither construct is a portable wire/register serialization format.

  • Use offsetof/static assertions for target layout contracts.
  • Serialize fields explicitly.
  • Reject unknown enum/protocol values.
#include <stddef.h>
#include <stdint.h>

typedef enum { SENSOR_IDLE, SENSOR_ACTIVE, SENSOR_FAULT } sensor_state_t;
typedef struct { uint32_t timestamp; int16_t value; sensor_state_t state; } sample_t;

_Static_assert(offsetof(sample_t, value) >= sizeof(uint32_t), "layout assumption");
Output
The assertion checks one required relationship without pretending the entire struct is packed.

Pitfalls

  • Sending a struct with write(fd, &s, sizeof s)
  • Portable bitfield ordering assumptions
  • Assuming enum storage width

Headers, translation units, static, and extern

A header declares a module contract; a source file owns private state and definitions. Opaque types and functions protect invariants across files.

  • Use include guards.
  • Place one external definition in one source file.
  • Use file-scope static for implementation-private names.
/* counter.h */
#ifndef COUNTER_H
#define COUNTER_H
#include <stdint.h>
void counter_reset(void);
uint32_t counter_next(void);
#endif

/* counter.c */
#include "counter.h"
static uint32_t value;
void counter_reset(void) { value = 0U; }
uint32_t counter_next(void) { return ++value; }
Output
Only the functions have external linkage; value is private to counter.c.

Pitfalls

  • Defining writable globals in headers
  • Circular includes
  • Exposing representation that callers can corrupt

Toolchain, machine model, and proof loop

Source is preprocessed, compiled, assembled and linked into an image. Learn to read diagnostics, run host tests, inspect an ELF/map file and use a debugger before trusting target behavior.

Every claim travels through a chain: source contract → compiler interpretation → machine instructions → linker placement → target observation.

The preprocessor expands includes and macros before the compiler type-checks C. The compiler emits assembly/object code; the linker resolves symbols and places sections according to a script. A flashing tool moves the final image to the target.

Host tests give fast feedback but do not prove target alignment, atomics, MMIO, cache or timing. Keep portable algorithm tests on the host and add target evidence where architecture matters.

Use warnings as design feedback. A clean build with strict conversion, shadow, prototype and bounds diagnostics is a gate—not proof of correctness, but a valuable baseline.

Debugging is hypothesis testing: preserve the failing input, inspect state at the first violated invariant and make the fix reproducible in a test.

What you should be able to do

  • Explain preprocess → compile → assemble → link
  • Build with strict warnings
  • Run a test executable
  • Read symbols/sections from a map file

Common mistakes

  • The compiler executes C line by line
  • A clean compile proves correct firmware
  • Host success proves target timing

Objects, expressions, control flow, and functions

C programs manipulate typed objects through expressions and control flow. Functions need explicit contracts, status reporting and testable boundaries; scope and linkage decide which names and state are visible.

A function is a state transformer with named inputs, outputs, allowed side effects and failure behavior—not a bag of statements.

Separate declaration from definition and value from object. An expression has a type and value category; assignments and calls can modify objects as side effects.

Control flow should make the valid state space visible. Prefer early validation and small single-purpose functions over deep nesting and hidden global state.

A status plus out-parameter represents all data values without magic sentinels. Document whether outputs change on failure.

Enums improve state names, but their representation is implementation-defined. Use fixed-width serialization at wire/storage boundaries.

What you should be able to do

  • Write loops and branches with terminating invariants
  • Design status + out APIs
  • Use scope/static/extern intentionally
  • Unit-test pure functions

Common mistakes

  • Every function needs a return-value data result
  • Global variables are required for peripherals
  • Enums are always one byte

Integers, bytes, bits, and undefined behavior

Use fixed-width unsigned types where representation matters, validate shifts and arithmetic, decode byte streams explicitly and distinguish defined unsigned wrap from correct application behavior.

The C abstract machine sets legal operations; the target supplies widths, alignment and instruction costs. Portable firmware states both contracts.

Integer promotions occur before many operations on uint8_t/uint16_t. Signed overflow is undefined; unsigned arithmetic wraps modulo 2^N but may still violate a size or deadline contract.

Shift counts greater than or equal to the promoted width are undefined. Build full-width masks through a separate branch and use unsigned operands.

Byte buffers are not native structs. Decode using bounded byte loads to avoid alignment, padding, lifetime, aliasing and endian assumptions.

A CRC is defined by a complete parameter tuple, not just a polynomial; it detects accidental corruption, not malicious modification.

What you should be able to do

  • Reason about promotions
  • Perform checked add/multiply/align
  • Decode endian-safe fields
  • Write safe masks and field updates

Common mistakes

  • uint8_t arithmetic stays 8-bit
  • A cast makes an unaligned pointer valid
  • Unsigned means safe
  • CRC authenticates data

Arrays, strings, pointers, const, and bounds

An array owns a fixed sequence of objects; most expressions decay it to a pointer with no length. APIs must carry lengths/capacities, validate geometry before forming pointers and distinguish text strings from arbitrary bytes.

Pointer + length is a view; array + capacity is storage. Never infer one from the other after decay.

sizeof(array) works only where the operand still has array type. A function parameter written T a[] is adjusted to T *a and carries no element count.

Pointer arithmetic is defined only within one array object or one-past it. Validate indices and remaining length before forming/accessing a derived pointer.

A C string is a byte sequence terminated by zero within accessible storage. Network/sensor data may contain zero and is not automatically a string.

const placement documents which object may change; it does not guarantee flash placement, thread safety or lifetime.

What you should be able to do

  • Design span/buffer APIs
  • Use const correctly
  • Handle overlap deliberately
  • Test empty, boundary and alias cases

Common mistakes

  • sizeof(pointer) finds buffer size
  • All char buffers are strings
  • One-past pointers may be dereferenced
  • const means stored in flash

Structs, lifetime, modules, memory sections, and linkage

Storage duration controls lifetime; linkage controls cross-file identity; alignment and padding control layout. Headers declare interfaces, source files define implementation, and linker/startup rules place and initialize sections.

Type layout, object lifetime and linker placement are three different layers that happen to meet at an address.

Automatic objects usually use stack storage, static-duration objects exist for the program lifetime, and dynamically allocated objects follow allocator rules. The C language does not mandate physical stack/heap regions.

Struct padding satisfies member alignment; packed layouts can create slow or faulting accesses. Serialize wire formats by bytes instead of overlaying packed structs.

A header is a contract shared by translation units. Use include guards, declarations and opaque types; put one external definition in a source file.

The linker script and startup code decide whether custom sections are loaded, zeroed, retained or discarded. An attribute alone is not a placement system.

What you should be able to do

  • Read a map file
  • Predict struct padding
  • Build a multi-file module
  • Explain .text/.rodata/.data/.bss

Common mistakes

  • C guarantees a stack and heap
  • packed makes protocols portable
  • static always means local lifetime
  • A section attribute completes linker work

MMIO, volatile, registers, and driver boundaries

Memory-mapped registers require target-defined addresses, widths, access semantics and volatile observation. Masks/inline helpers encode fields; driver APIs isolate register effects. Volatile does not supply atomicity, ordering between threads or DMA coherence.

A register is an external state machine observed through load/store transactions—not ordinary RAM with a fancy address.

Read/write, read-only, write-only, write-one-to-clear and read-to-clear registers require different operations. Generic read-modify-write can destroy status bits or replay side effects.

volatile requires an access in the abstract machine; compiler barriers, CPU barriers and device/cache maintenance solve different ordering problems.

Use generated/vendor addresses where possible, static assertions for offsets, and named masks. Avoid implementation-defined bitfield layout as the canonical register API.

A driver separates policy from mechanism: initialize validated configuration, expose status/errors, and make interrupt/callback ownership explicit.

What you should be able to do

  • Decode a register map
  • Avoid unsafe RMW
  • Design a testable driver boundary
  • Explain volatile versus atomic/barrier

Common mistakes

  • volatile makes code thread-safe
  • Bitfields are portable register maps
  • All registers can be read back
  • A memory barrier flushes every cache

Callbacks, FSMs, interrupts, time, sleep, and watchdogs

Event-driven firmware converts interrupts and time into bounded work. ISRs capture minimal state, foreground code owns policy, FSMs preserve progress, wrap-safe time handles counters, and watchdogs prove system progress rather than merely being fed.

Interrupts announce facts; queues transfer ownership; state machines decide what happens next.

An ISR should not block, allocate or perform unbounded formatting. It acknowledges hardware and publishes the minimum event/data through a proven mechanism.

A single flag can coalesce idempotent events but loses multiplicity/data. Byte streams need a bounded queue with an explicit overflow policy.

Non-blocking state machines store the state that a blocking call would hide on the stack. Every update performs bounded work and returns.

Wrap-safe tick comparison is valid only within a documented half-range horizon. Watchdog service must depend on real subsystem progress.

What you should be able to do

  • Design ISR/foreground handoff
  • Implement an FSM
  • Compare wrap-safe deadlines
  • Specify overflow and watchdog policy

Common mistakes

  • ISR-safe equals thread-safe
  • One volatile flag can buffer bytes
  • O(1) guarantees low latency
  • Feeding the watchdog proves health

C atomics, ISR/RTOS ownership, DMA, and cache coherency

CPU threads/ISRs use a proven language/platform synchronization contract; DMA adds device visibility, cache maintenance, descriptors and completion. Exactly one owner mutates a buffer and every handoff names the ordering action.

For every buffer write a table: current owner, allowed readers/writer, completion evidence, visibility action and next owner.

A data race in ISO C is undefined. Volatile does not repair it. Atomics/critical sections publish CPU memory, but the chosen atomic may compile to non-lock-free library calls unsuitable for an ISR.

DMA completion does not automatically mean CPU cache visibility or peripheral-idle completion. Clean before device reads and invalidate before CPU reads where the platform requires it.

Cache maintenance rounds to lines; unrelated neighbors in the same line can be lost without alignment/ownership discipline. Compiler, CPU and device barriers are distinct.

RTOS queues/mutexes express ownership and blocking policy. Priority inversion, backpressure and ISR-callable API variants remain system contracts.

What you should be able to do

  • Classify shared state
  • Verify lock-free/platform atomics
  • Design DMA ownership FSM
  • Specify cache/barrier operations

Common mistakes

  • volatile synchronizes an ISR
  • C atomics flush device caches
  • DMA done means peripheral idle
  • Zero-copy means zero memory traffic

Persistence, fault injection, testing, and target evidence

Use layered tests, sanitizers/fuzzing on host, static analysis and target measurements. Persistent updates use integrity, sequence and commit protocols so recovery ignores torn state; every bounded resource is exhausted deliberately in tests.

Correctness is a portfolio of evidence: proof obligation + executable tests + static checks + target measurement + recovery experiment.

Unit tests cover examples; property/model tests compare whole operation sequences; fuzzing attacks parsers; sanitizers expose host memory/UB; target tests cover architecture-only behavior.

A flash record becomes committed only after payload/integrity metadata is durable. Recovery scans validated committed records and uses wrap-safe sequence rules.

Measure map-file RAM/flash, stack high-water and timing distributions on the target. Average time does not establish a real-time bound.

Fault injection is designed, not theatrical: fail each allocation, truncate every input offset, reset at each persistence step and delay each ownership transition.

What you should be able to do

  • Build adversarial tests
  • Use ASan/UBSan/fuzz concepts
  • Design torn-write recovery
  • Report resource/timing evidence

Common mistakes

  • 100% line coverage proves behavior
  • CRC makes updates secure
  • Average runtime is WCET
  • Static analysis replaces testing

Systems integration and engineering defense

Mastery is demonstrated by a complete bounded system: contracts, modules, data structures, concurrency, recovery, tests and measured target resources. You should be able to explain why each structure exists and how it fails.

Explain → trace → implement → test → break → measure → defend. A green checkbox is never the final proof.

Start from product constraints: rates, bursts, deadlines, RAM/flash, reset behavior and observability. Derive structure capacities and ownership before code.

Use existing proven modules; integration creates new failure surfaces at handoffs. Log sequence, owner, time and high-water at boundaries.

The final review includes map file, stack/RAM budget, timing evidence, static-analysis findings, fuzz corpus, fault matrix and one target trace.

Port one design to a changed constraint—smaller RAM, ISR producer, DMA cache, power-loss persistence—to demonstrate transfer rather than memorization.

What you should be able to do

  • Architect a bounded subsystem
  • Present resource evidence
  • Run fault injection
  • Defend tradeoffs under changed constraints

Common mistakes

  • Passing unit tests means production-ready
  • The most complex structure is most senior
  • Framework knowledge replaces C reasoning

More in Embedded C

  • Interrupts, Rings & ConcurrencyThe handler is a second thread you did not declare. What is and is not atomic on a single core, the lock-free ring buffer and the conditions it requires, DMA and cache coherency, low-power modes and their wake sources, priority inversion, and debugging a race you cannot reproduce.
  • Volatile RegistersMaster volatile keyword usage for memory-mapped registers in embedded C. Interactive simulator shows compiler optimization effects on register reads.
  • User-Defined TypesDeclare your own types in embedded C: struct, union, enum, typedef, bitfields, designated initialisers, flexible array members and opaque handles.
  • Bits, Fields & Fixed PointRegister fields and the mask conventions that silently disagree, Gray code and where one-bit-at-a-time matters, fixed-point arithmetic and the intermediate width a multiply needs, the undefined-behaviour traps in ordinary bit idioms, wire-format packing, and what each checksum detects.
  • Types / PromotionUnderstand integer promotion and type conversion in embedded C. Interactive lab demonstrates implicit and explicit casting with signed/unsigned types.
  • Compiler Workbench & TestingCompile real C for an embedded target and inspect what the compiler produced, then the discipline around it: where to draw the host-testable boundary, reading the generated assembly, undefined behaviour and the sanitizers, the warnings worth enabling, and measuring size and stack.
  • Functions & ContractsDesign production C functions and modules with explicit preconditions, postconditions, ownership, status codes, opaque types, reentrancy, HAL seams and failure-state behavior.
  • Arrays, Strings & BuffersArrays decay and the length does not travel; strings are a convention, not a type; and every length in a received packet is data rather than fact. Spans, the three string copies, framing and resynchronisation, serialisation, and parsing untrusted input safely.
  • Object Layout & StorageWhere an object lives and what it costs: storage duration and linkage, the sections a declaration lands in, struct layout and the padding that makes a struct larger than its members, allocation without a heap, and integrity checks over stored data.
  • C Basics & the Translation UnitFrom source text to a linked image: declarations against definitions, the translation unit the compiler actually sees, the preprocessor and what a macro can and cannot do, and the four build stages with the error vocabulary each one produces.
  • FSM / DispatchDesign finite state machines with dispatch tables in embedded C. Interactive lab demonstrates state transitions and event handling for firmware.
  • Embedded CA complete source-to-silicon workbench: C semantics, arrays and APIs, compiler and startup, MMIO, interrupts, DMA and caches, bounded systems, testing, safety evidence and production defense.

References and further reading