How Is Error Handling Implemented in an Embedded System

How is error handling implemented in an embedded system

One of the biggest mindset shifts I had moving from desktop software to embedded development was realizing that “just crash and show an error dialog” isn’t an option. An embedded device might be inside a car dashboard, a medical monitor, or buried in a wall — there’s often no screen, no user watching, and no easy way to just restart it. Error handling in embedded systems has to be proactive, layered, and often autonomous. In this article, I’ll walk through how error handling is actually implemented in real embedded firmware, from simple return-code checking up to hardware fault handlers and system-wide fault recovery strategies.

Why Error Handling Is Different in Embedded Systems

In desktop or web development, an unhandled error might show a stack trace and the application closes — annoying, but rarely dangerous. In embedded systems, an unhandled error can mean a motor keeps spinning when it shouldn’t, a medical pump keeps dosing when it should stop, or a vehicle’s sensor reports stale data as if it were live. Error handling isn’t optional polish — it’s often a core safety requirement, especially in automotive (ISO 26262), medical (IEC 62304), and industrial (IEC 61508) certified systems.

flowchart TB
    A[Error Source] --> B{Error Category}
    B -->|Hardware Fault| C[Fault Handlers<br/>HardFault, MemManage]
    B -->|Peripheral/Communication Error| D[Return Code / Status Flag]
    B -->|Software Logic Error| E[Assertions / Defensive Checks]
    B -->|Timing/Hang| F[Watchdog Timer]
    C --> G[Error Recovery Strategy]
    D --> G
    E --> G
    F --> G
    G --> H[Log / Report / Safe State]

Layer 1: Return Codes and Status Checking

The most basic form of error handling in embedded C code is checking function return values, since embedded C typically doesn’t use exceptions the way higher-level languages do (and many embedded coding standards, like MISRA C, actively discourage exception-like constructs for determinism reasons).

typedef enum {
    STATUS_OK = 0,
    STATUS_ERROR_TIMEOUT,
    STATUS_ERROR_INVALID_PARAM,
    STATUS_ERROR_HARDWARE_FAULT,
    STATUS_ERROR_CRC_MISMATCH
} Status_t;

Status_t sensor_read_temperature(float *out_temp) {
    if (out_temp == NULL) {
        return STATUS_ERROR_INVALID_PARAM;
    }

    uint16_t raw_value;
    if (i2c_read_register(SENSOR_ADDR, TEMP_REG, &raw_value) != HAL_OK) {
        return STATUS_ERROR_TIMEOUT;
    }

    *out_temp = convert_raw_to_celsius(raw_value);
    return STATUS_OK;
}

void main_loop(void) {
    float temperature;
    Status_t result = sensor_read_temperature(&temperature);

    if (result != STATUS_OK) {
        log_error("Temperature read failed: %d", result);
        handle_sensor_failure(result);
        return;
    }

    process_temperature(temperature);
}

I always design driver-level functions to return a status code rather than silently failing or returning a “magic number” like -1 or 0xFFFF, which can be ambiguous with legitimate sensor readings. Explicit status enums make error paths obvious at every call site.

Layer 2: Defensive Programming and Assertions

Defensive programming means validating inputs, checking array bounds, and verifying assumptions explicitly, rather than trusting that data is always well-formed.

#define ASSERT(condition) \
    do { \
        if (!(condition)) { \
            assert_failed_handler(__FILE__, __LINE__); \
        } \
    } while (0)

void assert_failed_handler(const char *file, int line) {
    log_error("Assertion failed at %s:%d", file, line);
    // In production, could trigger a safe shutdown or controlled reset
    enter_safe_state();
    NVIC_SystemReset();
}

void set_motor_speed(uint8_t percent) {
    ASSERT(percent <= 100);   // Catches programming errors during development
    if (percent > 100) {
        percent = 100;        // Defensive clamp for production safety
    }
    pwm_set_duty_cycle(percent);
}

A common practice is to compile assertions out of release builds (using #ifdef DEBUG) for performance, while keeping defensive clamps and boundary checks active in production, since those directly prevent unsafe hardware states regardless of build configuration.

Layer 3: Hardware Fault Handlers

ARM Cortex-M processors provide dedicated fault exception handlers that trigger automatically when the CPU encounters serious hardware-level problems — invalid memory access, executing an undefined instruction, or a stack overflow.

flowchart TB
    A[CPU Detects Fault Condition] --> B{Fault Type}
    B -->|Invalid Memory Access| C[MemManage Fault]
    B -->|Bus Error| D[Bus Fault]
    B -->|Invalid Instruction/Div by Zero| E[Usage Fault]
    B -->|Unhandled/Escalated| F[Hard Fault]
    C --> G[Fault Handler ISR]
    D --> G
    E --> G
    F --> G
    G --> H[Log Fault Registers<br/>Determine Cause]
    H --> I[Safe Recovery or Reset]
// Example HardFault handler that captures diagnostic info before reset
void HardFault_Handler(void) {
    __asm volatile (
        "TST LR, #4                \n"
        "ITE EQ                    \n"
        "MRSEQ R0, MSP              \n"
        "MRSNE R0, PSP              \n"
        "B hard_fault_handler_c     \n"
    );
}

void hard_fault_handler_c(uint32_t *stack_frame) {
    uint32_t pc = stack_frame[6];   // Program counter at fault
    uint32_t lr = stack_frame[5];   // Link register

    // Save fault info to a no-init RAM region so it survives reset
    fault_log.pc = pc;
    fault_log.lr = lr;
    fault_log.cfsr = SCB->CFSR;
    fault_log.valid = 1;

    NVIC_SystemReset();   // Recover via controlled reset
}

This pattern — capturing diagnostic information in a preserved RAM region before forcing a reset — is extremely valuable for field debugging, since the device can report “why” it last reset the next time it connects to a network or is inspected, even without a debugger attached.

Layer 4: Watchdog-Based Recovery

As covered in reset circuit design, the watchdog timer is a critical error-handling mechanism for detecting and recovering from software hangs that don’t trigger a hardware fault — infinite loops, deadlocks, or a task that never returns control.

void main_loop(void) {
    while (1) {
        Status_t sensor_status = read_all_sensors();
        Status_t comm_status = process_communication();

        // Only feed watchdog if all critical subsystems reported healthy
        if (sensor_status == STATUS_OK && comm_status == STATUS_OK) {
            watchdog_feed();
        } else {
            log_error("Subsystem unhealthy - watchdog not fed");
            // Let the watchdog expire and force a clean recovery reset
        }
    }
}

Layer 5: Communication Protocol Error Handling

Communication interfaces (UART, I2C, SPI, CAN) each have their own error detection mechanisms that firmware needs to explicitly handle rather than assume success.

// I2C error handling with retry logic and timeout
Status_t i2c_read_with_retry(uint8_t addr, uint8_t reg, uint8_t *data, uint8_t max_retries) {
    for (uint8_t attempt = 0; attempt < max_retries; attempt++) {
        HAL_StatusTypeDef result = HAL_I2C_Mem_Read(&hi2c1, addr, reg,
                                    I2C_MEMADD_SIZE_8BIT, data, 1, 100);
        if (result == HAL_OK) {
            return STATUS_OK;
        }

        if (result == HAL_ERROR) {
            // Bus may be stuck - attempt recovery before retrying
            i2c_bus_recovery();
        }

        HAL_Delay(10);  // Brief delay before retry
    }
    return STATUS_ERROR_TIMEOUT;
}

void i2c_bus_recovery(void) {
    // Manually toggle SCL to free a slave holding SDA low
    HAL_I2C_DeInit(&hi2c1);
    // ... bit-bang clock pulses to release stuck bus ...
    HAL_I2C_Init(&hi2c1);
}

I2C bus lock-ups (where a slave device holds SDA low, freezing the bus) are a classic real-world failure mode that simple retry logic alone won’t fix — proper error handling here requires an active bus recovery sequence, not just retrying the same failed transaction.

Error Logging and Reporting Strategies

For error handling to be useful beyond the moment it happens, embedded systems typically implement some form of persistent logging:

typedef struct {
    uint32_t timestamp;
    uint16_t error_code;
    uint8_t  module_id;
} ErrorLogEntry_t;

#define ERROR_LOG_SIZE 32
ErrorLogEntry_t error_log[ERROR_LOG_SIZE];
uint8_t error_log_index = 0;

void log_error_entry(uint16_t code, uint8_t module) {
    error_log[error_log_index].timestamp = get_system_time();
    error_log[error_log_index].error_code = code;
    error_log[error_log_index].module_id = module;
    error_log_index = (error_log_index + 1) % ERROR_LOG_SIZE;  // Circular buffer
}

Fail-Safe and Fail-Operational Design

Beyond just detecting and logging errors, well-designed embedded systems decide what to do once an error is detected — this is where the concepts of “fail-safe” and “fail-operational” design come in.

flowchart TB
    A[Error Detected] --> B{Criticality Assessment}
    B -->|Critical - Safety Risk| C[Fail-Safe State<br/>e.g. Cut motor power, alarm]
    B -->|Degraded but Non-Critical| D[Fail-Operational<br/>Continue with reduced function]
    B -->|Transient/Recoverable| E[Retry / Self-Correct]
    C --> F[Notify User/System]
    D --> F
    E --> G{Retry Successful?}
    G -->|No| B
    G -->|Yes| H[Resume Normal Operation]

For example, in an industrial temperature controller, if the primary temperature sensor fails, a fail-safe design might immediately cut heater power (preventing overheating), while a fail-operational design might switch to a redundant backup sensor and continue operating, logging the fault for later maintenance.

Real-World Example: Error Handling in a Battery Management System

Battery Management Systems (BMS) are a good example of layered error handling in practice:

  1. Continuous voltage/current/temperature monitoring with defined safe operating limits.
  2. Immediate hardware cutoff (via a protection MOSFET) if any parameter exceeds a critical threshold — implemented at the hardware level so it works even if firmware has crashed.
  3. Firmware-level graceful shutdown when parameters approach (but haven’t yet exceeded) critical limits, logging the event and notifying the host system.
  4. Redundant sensing where possible, cross-checking multiple temperature sensors to detect a faulty sensor rather than a genuine overheat condition.

This layered design — hardware-level protection as the last line of defense, with firmware-level graceful handling above it — is standard practice in any safety-relevant embedded system.

Performance, Reliability, and Security Considerations

Frequently Asked Questions

Q: Should embedded C code use exceptions like try/catch? Standard embedded C doesn’t support exceptions the way C++ or higher-level languages do, and even in C++ embedded projects, many safety-critical coding standards avoid exceptions due to unpredictable stack unwinding behavior — return codes and explicit status checking are the standard approach.

Q: What’s the difference between a HardFault and a watchdog reset? A HardFault is triggered immediately by the CPU hardware detecting an invalid operation (bad memory access, illegal instruction). A watchdog reset happens after a timeout period because the firmware failed to “feed” the watchdog, typically indicating a hang or infinite loop rather than an immediate hardware violation.

Q: How do I debug a fault that only happens in the field, not in my debugger? Implement a fault handler that captures diagnostic registers (program counter, fault status registers) into a preserved RAM region before resetting, then read that data back out after the device reconnects or is retrieved — this is far more effective than trying to reproduce intermittent field failures on a bench.

Q: Is it acceptable to just reset the system whenever an error occurs? For many non-critical errors, a controlled reset is a perfectly valid recovery strategy, but for safety-critical systems, a bare reset isn’t enough — you need to ensure actuators are driven to a safe state (like cutting motor power) before or during that reset, not just hope the reset alone makes things safe.

Summary

Error handling in embedded systems is implemented as a layered defense: return-code checking and defensive programming at the software level, hardware fault handlers for serious CPU-level violations, watchdog timers for catching hangs that don’t trigger explicit faults, and protocol-specific error recovery for communication interfaces. On top of detection sits the equally important question of response — deciding whether a system should fail safe, fail operational, or attempt automatic recovery, often backed by hardware-level protections that work even if firmware itself has failed. Because embedded devices frequently run unattended in the field, robust, layered error handling isn’t optional polish — it’s often the single biggest factor separating a reliable product from one that generates constant support tickets or, in safety-critical applications, genuine hazards.

References

Exit mobile version