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.
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
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
- SEI CERT C Coding Standard
- ThrowTheSwitch Unity Test Framework
- Memfault Interrupt Blog: Firmware debugging articles