CFoundations

C Error Handling & errno: Defensive Programming for Industrial-Grade Systems

TT
TopicTrick Team
C Error Handling & errno: Defensive Programming for Industrial-Grade Systems

C Error Handling & errno: Defensive Programming for Industrial-Grade Systems


Table of Contents


Why C Has No Exceptions

In languages with exceptions (Java, Python, C++), when a function fails, it throws an object that unwinds the call stack automatically, calling destructors and freeing resources. C provides none of this.

This is a deliberate design choice, not an oversight:

  1. Exception unwinding has non-trivial runtime overhead (exception tables, dynamic dispatch).
  2. Real-time systems need deterministic execution — no hidden stack unwinding at unpredictable times.
  3. Kernel code runs in a context where many OS services used for exception handling are unavailable.
  4. C's philosophy: the programmer controls everything explicitly.

The result is that C error handling is explicit: every call to a fallible function is followed by an explicit check, and every failure path is an explicit code path. This makes C error handling verbose, but also perfectly predictable and auditable.


Return Codes: The Primary Mechanism

The C convention (used by the POSIX standard and the Linux kernel API):

  • Functions return 0 or positive value on success.
  • Functions return -1 on failure (integer functions) or NULL (pointer functions).
  • After failure, errno is set to indicate the specific error.
c
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>

int main(void) {
    int fd = open("/etc/secret", O_RDONLY);
    if (fd == -1) {
        // open() failed — errno tells us why
        if (errno == ENOENT) {
            fprintf(stderr, "File not found\n");
        } else if (errno == EACCES) {
            fprintf(stderr, "Permission denied\n");
        } else {
            fprintf(stderr, "Unexpected error: %d\n", errno);
        }
        return 1;
    }
    
    // Use fd...
    close(fd);
    return 0;
}

errno: The Global Error Register

errno is defined in <errno.h> as a macro expanding to a modifiable lvalue. In modern C (thread-safe implementations), it is actually a thread-local variable — each thread has its own independent errno.

c
#include <errno.h>
#include <stdio.h>
#include <string.h>

// Key errno values (POSIX)
// ENOENT  (2)  - No such file or directory
// EACCES  (13) - Permission denied
// ENOMEM  (12) - Out of memory
// EINVAL  (22) - Invalid argument
// EBUSY   (16) - Device or resource busy
// EEXIST  (17) - File exists (when exclusive create fails)
// ETIMEDOUT (110) - Connection timed out
// EINTR   (4)  - Interrupted by signal

void demonstrate_errno(void) {
    // Reset errno before a call (good practice — it may not be reset on success)
    errno = 0;
    
    FILE *f = fopen("/nonexistent/path/file.txt", "r");
    if (!f) {
        int saved_errno = errno; // Save immediately — next call might change it
        fprintf(stderr, "errno = %d\n",    saved_errno);   // 2
        fprintf(stderr, "Meaning: %s\n",   strerror(saved_errno)); // No such file...
    }
}

[!IMPORTANT] Save errno immediately after the failed call. The very next library function called (even fprintf) can overwrite errno. Always copy it to a local variable before any other function call.


perror and strerror: Human-Readable Errors

Two standard functions convert numeric error codes to descriptive strings:

c
#include <stdio.h>
#include <errno.h>
#include <string.h>

// perror("prefix"): prints "prefix: <errno description>" to stderr
FILE *f = fopen("missing.txt", "r");
if (!f) {
    perror("fopen"); // Prints: fopen: No such file or directory
}

// strerror(errno): returns a string — use when you need more control
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
    fprintf(stderr, "socket creation failed: %s (code %d)\n",
            strerror(errno), errno);
}

// Thread-safe version: strerror_r (POSIX) or strerror_s (C11 Annex K)
char errbuf[256];
strerror_r(errno, errbuf, sizeof(errbuf)); // Writes to caller-provided buffer
fprintf(stderr, "Error: %s\n", errbuf);

The goto Cleanup Pattern: Resource Safety

This is the most important C error handling pattern. Without it, error paths lead to either deeply nested if-statements or duplicated cleanup code:

c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>

// Pattern: acquire resources in order, label cleanup points in reverse order
int process_file(const char *input_path, const char *output_path) {
    int   result   = -1;      // Assume failure
    FILE *infile   = NULL;
    FILE *outfile  = NULL;
    char *buffer   = NULL;
    
    infile = fopen(input_path, "r");
    if (!infile) {
        perror("fopen input");
        goto done; // Jump to cleanup (buffer and outfile are NULL — safe to skip free)
    }
    
    buffer = malloc(65536); // 64KB read buffer
    if (!buffer) {
        perror("malloc");
        goto close_input;
    }
    
    outfile = fopen(output_path, "w");
    if (!outfile) {
        perror("fopen output");
        goto free_buffer;
    }
    
    // === Main logic ===
    size_t n;
    while ((n = fread(buffer, 1, 65536, infile)) > 0) {
        if (fwrite(buffer, 1, n, outfile) != n) {
            perror("fwrite");
            goto close_output;
        }
    }
    
    if (ferror(infile)) {
        perror("fread");
        goto close_output;
    }
    
    result = 0; // Success!
    
    // === Cleanup labels (executed in reverse acquisition order) ===
close_output:
    fclose(outfile);
free_buffer:
    free(buffer);
close_input:
    fclose(infile);
done:
    return result;
}

Properties of this pattern:

  • Resources are always acquired in forward order and released in reverse order.
  • No resource is freed twice — each label is reached only if the preceding resource was successfully acquired.
  • The main logic reads naturally without nested if-statements ("arrow code").
  • All error paths lead to the same cleanup labels, enforcing DRY code.

Custom Result Types: Error + Value in One Return

For APIs that need to return both a value and an error status, define a tagged result type:

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

// Result type: either value or error
typedef struct {
    bool     is_error;
    int32_t  error_code;
    int64_t  value;       // Meaningful only when !is_error
} Result64;

#define OK(v)  ((Result64){ .is_error = false, .value = (v) })
#define ERR(e) ((Result64){ .is_error = true,  .error_code = (e) })

Result64 safe_divide(int64_t a, int64_t b) {
    if (b == 0) return ERR(-1); // Division by zero
    return OK(a / b);
}

Result64 safe_sqrt(double x, double *out) {
    if (x < 0) return ERR(-2); // Negative input
    *out = __builtin_sqrt(x);
    return OK(0);
}

int main(void) {
    Result64 r = safe_divide(100, 5);
    if (!r.is_error) {
        printf("100 / 5 = %lld\n", r.value); // 20
    }
    
    Result64 bad = safe_divide(10, 0);
    if (bad.is_error) {
        printf("Division failed: error %d\n", bad.error_code);
    }
    
    return 0;
}

This pattern is similar to Rust's Result<T, E> type — a well-typed alternative to errno-based error communication.


Error Propagation Discipline

In multi-layer systems, errors must propagate upward:

c
// Layer 3: Low-level I/O
static int read_config_file(const char *path, ConfigData *data) {
    FILE *f = fopen(path, "r");
    if (!f) return -ENOENT;
    // ...
    fclose(f);
    return 0;
}

// Layer 2: Configuration loading
static int load_configuration(const char *path, Config *cfg) {
    ConfigData raw = {0};
    int err = read_config_file(path, &raw);
    if (err < 0) return err; // Propagate upward
    // ...
    return 0;
}

// Layer 1: Application initialization
int initialize_application(void) {
    Config cfg = {0};
    int err = load_configuration("config.json", &cfg);
    if (err < 0) {
        fprintf(stderr, "Failed to load config: %s\n", strerror(-err));
        return -1;
    }
    return 0;
}

The Linux kernel convention uses negative errno values (-ENOENT, -ENOMEM) propagated upward through call chains, converted to user-space positive errno only at the syscall boundary.


setjmp and longjmp: Non-Local Error Handling

setjmp/longjmp from <setjmp.h> enable jumping across multiple stack frames — the only C mechanism for something resembling exception handling:

c
#include <setjmp.h>
#include <stdio.h>
#include <stdlib.h>

static jmp_buf error_recovery;

// Safe version of malloc that never returns NULL — jumps to error handler instead
void* xmalloc(size_t size) {
    void *ptr = malloc(size);
    if (!ptr) {
        longjmp(error_recovery, 1); // Jump back to setjmp site with value 1
    }
    return ptr;
}

void do_complex_work(void) {
    void *a = xmalloc(1000);
    void *b = xmalloc(1000);
    void *c = xmalloc(1000);
    // ... use a, b, c ...
    free(c); free(b); free(a);
}

int main(void) {
    if (setjmp(error_recovery) == 0) {
        // Normal path: setjmp returns 0 on setup
        do_complex_work();
        printf("Work completed successfully\n");
    } else {
        // Error path: setjmp returns non-zero when longjmp is called
        fprintf(stderr, "Fatal: memory allocation failed\n");
        return 1;
    }
    return 0;
}

[!CAUTION] longjmp is dangerous: it does NOT call destructors, does NOT flush file buffers, and does NOT free heap memory allocated after the setjmp. Resources acquired between setjmp and longjmp are leaked. Use only when the consequence of longjmp is controlled failure termination, not recovery with continued operation.


Defensive Assertions with abort()

For programmer errors (violated invariants, impossible states) — not user input errors — use assertions that call abort():

c
#include <stdio.h>
#include <stdlib.h>

// Custom assertion with file/line info and message
#define ASSERT(condition) \
    do { \
        if (!(condition)) { \
            fprintf(stderr, \
                "[ASSERTION FAILED] %s\n" \
                "  Condition: %s\n" \
                "  Location:  %s:%d in %s()\n", \
                "Bug detected — aborting. Please file a bug report.", \
                #condition, __FILE__, __LINE__, __func__); \
            abort(); \
        } \
    } while (0)

// Non-null assertion
#define ASSERT_NOT_NULL(ptr) ASSERT((ptr) != NULL)

void process_record(Record *rec) {
    ASSERT_NOT_NULL(rec);               // Programmer contract: never pass NULL
    ASSERT(rec->version == RECORD_V2);  // State invariant
    // ...
}

Compile with -DNDEBUG to disable assertions in release builds (same as C's standard assert() macro from <assert.h>).


Real-World Case Study: Linux System Call Error Handling

The Linux kernel and POSIX system calls exemplify production-grade C error handling:

c
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
#include <string.h>

int create_listening_socket(uint16_t port) {
    int sockfd = -1;
    int optval = 1;
    struct sockaddr_in addr = {0};
    
    sockfd = socket(AF_INET, SOCK_STREAM, 0);
    if (sockfd < 0) {
        fprintf(stderr, "socket(): %s\n", strerror(errno));
        return -1;
    }
    
    // SO_REUSEADDR: prevent "Address already in use" after restart
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &optval, sizeof(optval)) < 0) {
        fprintf(stderr, "setsockopt(): %s\n", strerror(errno));
        goto cleanup;
    }
    
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port        = htons(port);
    
    if (bind(sockfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
        fprintf(stderr, "bind(port %u): %s\n", port, strerror(errno));
        goto cleanup;
    }
    
    if (listen(sockfd, 128) < 0) {
        fprintf(stderr, "listen(): %s\n", strerror(errno));
        goto cleanup;
    }
    
    printf("Listening on port %u\n", port);
    return sockfd;

cleanup:
    close(sockfd);
    return -1;
}

Frequently Asked Questions

Is it safe to use errno in multi-threaded code? Yes — modern POSIX implementations make errno thread-local (each thread has its own copy). However, always save errno to a local variable immediately after a failed call, before calling any other function that might overwrite it.

When should I use perror vs fprintf(stderr, strerror(errno))? Use perror("operation_name") for quick diagnostics — it automatically prepends the string and appends the colon and error description. Use strerror(errno) when you need to include the error in a larger message, log it with additional context, or use a non-global errno value (e.g., one you saved earlier).

Is there a standard pattern for custom error codes? Define error codes as negative numbers (Linux kernel style) or as an enum with a specific range. Mixing your error codes with POSIX errno values (always positive) allows consistent signed-return-code APIs.

Can I recover from abort()? No. abort() sends SIGABRT to the process, which terminates it immediately (after writing a core dump if enabled). It is intended for unrecoverable programmer errors, not user-input errors. For recoverable errors, return error codes or use setjmp/longjmp.

What do I do if malloc fails in deeply nested code? Option A: Propagate -ENOMEM back up through every return value (most robust). Option B: Use setjmp/longjmp with a top-level error handler. Option C: Use a pre-allocated memory pool/arena allocator that never fails after initialization. Option A is preferred in library code; C is often acceptable in application code with careful resource management.


Key Takeaway

C error handling is Explicit Discipline. Every fallible call must be checked; every resource must be tracked; every failure path must be clean. The goto cleanup pattern enforces resource safety. errno and strerror make system errors human-readable. Custom result types make APIs self-documenting.

This explicit discipline produces code that survives hardware failures, resource exhaustion, and adversarial conditions — which is exactly why C is the language of choice for operating systems, database engines, and anything that must operate continuously for months or years without crashing.

Read next: Binary Trees & Recursive Algorithms in C →


Part of the C Mastery Course — 30 modules from C basics to expert systems engineering.