Loading header...

Defensive C, Debugging, and Testing

Defensive embedded C is not about adding noise to every function. It is about making assumptions visible, containing failures, and structuring code so important behavior can be tested before hardware is available. A defensive module tells the caller what it expects, reports what went wrong, and avoids corrupting memory when inputs or hardware responses are not ideal.

In embedded systems, many failures are expensive to reproduce: timing races, brownouts, noisy sensors, connector faults, watchdog resets, and release-build optimization issues. Good defensive design turns those into evidence instead of guesses.

Learning Objectives

By the end of this lesson, you should be able to:

  • choose between assertions, return codes, error flags, and safe fallback states;
  • write interfaces that reject invalid inputs without hiding real faults;
  • separate hardware access from logic so host tests can cover most behavior;
  • design a repeatable debugging process based on observations;
  • identify common C mistakes that become embedded failures.

Defensive Interfaces

A defensive interface validates what the function can reasonably check and makes the result explicit.

#include <stdbool.h>
#include <stdint.h>

typedef enum {
    SENSOR_OK = 0,
    SENSOR_ERR_NULL,
    SENSOR_ERR_BUS,
    SENSOR_ERR_CRC,
    SENSOR_ERR_RANGE
} sensor_status_t;

sensor_status_t sensor_read(sensor_sample_t *out_sample)
{
    if (out_sample == 0) {
        return SENSOR_ERR_NULL;
    }

    if (!sensor_bus_ready()) {
        return SENSOR_ERR_BUS;
    }

    sensor_status_t status = sensor_read_registers(out_sample);
    if (status != SENSOR_OK) {
        return status;
    }

    if (!sensor_sample_in_range(out_sample)) {
        return SENSOR_ERR_RANGE;
    }

    return SENSOR_OK;
}

Returning a status code gives the caller a decision: retry, use the last valid sample, enter a fault state, or report an error.

Assertions Versus Runtime Errors

Assertions are for programmer mistakes and impossible states. Runtime error handling is for expected field conditions.

#include <assert.h>

void queue_push(queue_t *q, uint8_t value)
{
    assert(q != 0);
    assert(q->capacity <= QUEUE_MAX_CAPACITY);

    if (q->count == q->capacity) {
        q->overflow = true;
        return;
    }

    q->data[q->head] = value;
    q->head = (uint8_t)((q->head + 1u) % q->capacity);
    q->count++;
}

A null queue pointer is a programming error. A full queue can happen in a real system, so the code records overflow and returns. Do not rely on assert() for normal runtime protection if assertions may be compiled out in release builds.

Fault Containment

Fault handling should be deliberate. A motor controller, heater, battery charger, or actuator must usually move to a safe state when measurement or communication fails.

Fault Typical response
invalid command reject command and keep previous safe state
sensor timeout use fault state, stop actuator, report diagnostic
ADC out of range clamp only for display; fault for control
queue overflow drop newest or oldest by policy, set overflow flag
brownout reset log reset reason and reinitialize outputs safely

Never silently convert dangerous data into normal-looking data. For example, a temperature sensor CRC failure should not become 0 deg C unless the system explicitly treats that value as invalid.

Host-Testable Design

Code that separates hardware access from logic can be tested on a PC.

flowchart LR TEST["host tests"] --> LOGIC["portable logic"] FAKE["fake registers"] --> LOGIC LOGIC --> HAL["hardware access layer"] HAL --> MCU["MCU peripherals"]

Good host-test targets include:

  • ring buffers;
  • command parsers;
  • packet encoders and decoders;
  • fixed-point math;
  • state machines;
  • register mask helpers;
  • debounce and timeout logic.

Example: Testing a Command Parser

typedef struct {
    bool led_enabled;
    uint32_t blink_ms;
} app_config_t;

bool parse_blink_command(const char *line, app_config_t *cfg)
{
    uint32_t value;

    if ((line == 0) || (cfg == 0)) {
        return false;
    }

    if (!parse_prefix_u32(line, "BLINK ", &value)) {
        return false;
    }

    if ((value < 50u) || (value > 10000u)) {
        return false;
    }

    cfg->blink_ms = value;
    cfg->led_enabled = true;
    return true;
}

Host tests should cover valid input, too-small values, too-large values, missing numbers, trailing characters, null pointers, and boundary values such as 50 and 10000.

Debugging Process

flowchart TD EXPECT["state expected behavior"] --> OBS["capture actual behavior"] OBS --> REDUCE["reduce test case"] REDUCE --> MEASURE["measure inputs state outputs"] MEASURE --> CHANGE["change one thing"] CHANGE --> RECORD["record result"] RECORD --> FIXED{"fixed?"} FIXED -->|no| MEASURE FIXED -->|yes| REGRESS["add test or checklist"]

Changing several things at once may make the failure disappear without explaining it. A good debug note includes firmware version, build flags, board revision, input conditions, observed output, and the exact change tested.

Useful Embedded Evidence

  • compiler warnings and static-analysis output;
  • reset reason and fault status registers;
  • linker map file and stack/heap placement;
  • GPIO timing pulses measured by oscilloscope or logic analyzer;
  • UART logs with timestamps and event IDs;
  • debugger watchpoints on corrupted variables;
  • bus captures for I2C, SPI, UART, CAN, or USB;
  • power-rail measurements during failure.

For timing-sensitive bugs, logging can change the behavior. Use GPIO timing pins or hardware trace when prints disturb the system.

Defensive Build Settings

Treat compiler warnings as design feedback, not decoration.

-Wall -Wextra -Wconversion -Wshadow -Werror

Not every project can enable every warning immediately, but new code should be warning-clean. Use fixed-width integer types, avoid unchecked casts, and make signed/unsigned conversions intentional.

Common Mistakes

  • Ignoring a function's return value.
  • Using assert() for field failures that must be handled in release builds.
  • Trusting input lengths before checking buffer capacity.
  • Debugging with optimization disabled and shipping optimized code without retesting.
  • Adding prints that change interrupt timing.
  • Treating intermittent failures as random instead of evidence-starved.
  • Clearing a fault without recording why it occurred.

Practical Checks

  • Add host tests for boundary values and invalid inputs.
  • Compile both debug and release configurations.
  • Enable warnings and fix new warnings before hardware testing.
  • Force each error path at least once.
  • Confirm watchdog, brownout, and reset behavior on the real board.
  • Review every buffer write for capacity and termination.

Summary

Defensive embedded C makes assumptions explicit and failures diagnosable. Use assertions for programmer errors, status codes for expected runtime failures, safe states for hazardous outputs, and host tests for logic that does not need real hardware. Debugging improves when every experiment starts with expected behavior and ends with recorded evidence.

Further Reading

Mind Map

mindmap root((Defensive C)) Core idea State assumptions Contain faults Test before hardware Evidence over guesses Interfaces Null checks Status codes Boundary checks Safe fallback Assertions Programmer errors Impossible states May vanish in release Not field handling Testing Host tests Fake registers Parser cases State machine cases Boundary values Debug evidence Warnings Reset reason Fault registers GPIO timing Bus capture Practical checks Force errors Test release build Review buffers Record changes Common mistakes Ignored returns Prints alter timing Hidden overflow No regression test