All Courses C Notes
C Advanced

Command Line Arguments & Error Handling

Graphical applications are a modern luxury. The vast majority of serious software — compilers, version control systems, web servers, database engines, scientific computing tools — communicates with its users through a text terminal. Mastering command-line argument parsing and error handling transforms a "toy" program into a professional tool that integrates into shell pipelines, scripts, and automated workflows. This lesson covers both the mechanics and the philosophy of building robust, user-friendly CLI programs in C.

1. The main() Function Signature: A Deeper Look

The C standard specifies two portable forms for main:

int main(void);                  /* no command-line arguments */
int main(int argc, char *argv[]); /* with command-line arguments */

A third, less common form includes the environment variables:

int main(int argc, char *argv[], char *envp[]);
/* envp is a NULL-terminated array of "KEY=VALUE" strings.
   This is not in the C standard but is widely supported (POSIX). */

1.1 argc and argv: Parsing the Input

argc (argument count) is always at least 1 — the program name itself counts. argv is an array of C strings. argv[0] is the program invocation name (not necessarily the full path — it's whatever the shell passed). argv[argc] is guaranteed to be NULL, which enables iteration patterns without using argc:

#include <stdio.h>

int main(int argc, char *argv[]) {
    /* Method 1: index-based iteration (safe, explicit) */
    printf("Program: %s\n", argv[0]);
    printf("Argument count: %d\n", argc);
    for (int i = 1; i < argc; i++) {
        printf("  argv[%d] = \"%s\"\n", i, argv[i]);
    }

    /* Method 2: pointer-based iteration (elegant, relies on NULL sentinel) */
    printf("\n--- Pointer iteration ---\n");
    char **arg = argv + 1;  /* skip program name */
    while (*arg) {
        printf("  arg = \"%s\"\n", *arg);
        arg++;
    }
    return 0;
}
/* Run: ./prog hello world 42
   Output:
     Program: ./prog
     Argument count: 4
       argv[1] = "hello"
       argv[2] = "world"
       argv[3] = "42" */

1.2 Converting Strings to Numbers: The Right Way

Every command-line argument arrives as a string. Converting to numbers is the first pain point. atoi() and atof() are tempting but dangerous: they silently return 0 for invalid input ("abc" becomes 0, indistinguishable from a valid zero). The robust alternatives are strtol() and strtod():

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

int parse_int_or_die(const char *str) {
    char *endptr;
    errno = 0;  /* reset errno before call */

    long val = strtol(str, &endptr, 10);

    /* Check for conversion errors */
    if (errno == ERANGE) {
        fprintf(stderr, "Error: '%s' is out of range for long\n", str);
        exit(EXIT_FAILURE);
    }
    if (endptr == str) {
        fprintf(stderr, "Error: '%s' is not a valid number\n", str);
        exit(EXIT_FAILURE);
    }
    if (*endptr != '\0') {
        fprintf(stderr, "Error: '%s' has trailing garbage after number\n", str);
        exit(EXIT_FAILURE);
    }
    return (int)val;
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s <number>\n", argv[0]);
        return EXIT_FAILURE;
    }
    int num = parse_int_or_die(argv[1]);
    printf("Valid number: %d\n", num);
    return 0;
}

The key insight: strtol sets endptr to point at the first unconverted character. If endptr == str, no conversion happened at all. If *endptr != '\0', there's trailing garbage. Both atoi and sscanf miss these cases.

2. Structured Argument Parsing with getopt

Real programs need flags (-v, --verbose), options with values (-o output.txt), and positional arguments. POSIX provides getopt() (in <unistd.h> on Unix, or available via gnulib on Windows) for this:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>   /* getopt on POSIX */

int main(int argc, char *argv[]) {
    int verbose = 0;
    char *output_file = NULL;
    int opt;

    /* "vo:" means:
       -v is a flag (no argument)
       -o takes a required argument (indicated by ':') */
    while ((opt = getopt(argc, argv, "vo:")) != -1) {
        switch (opt) {
            case 'v':
                verbose = 1;
                break;
            case 'o':
                output_file = optarg;  /* optarg points to the argument */
                break;
            case '?':  /* getopt already printed an error message */
                fprintf(stderr, "Usage: %s [-v] [-o output]\n", argv[0]);
                return EXIT_FAILURE;
        }
    }

    /* optind now indexes the first non-option argument */
    printf("Verbose: %s\n", verbose ? "yes" : "no");
    printf("Output:  %s\n", output_file ? output_file : "(stdout)");

    for (int i = optind; i < argc; i++) {
        printf("Positional arg %d: %s\n", i - optind, argv[i]);
    }
    return 0;
}
/* Usage examples:
   ./app -v -o out.txt file1.txt file2.txt
   ./app -vo out.txt file1.txt    (flags can be combined) */

For GNU-style long options (--verbose, --output=file), use getopt_long():

#include <getopt.h>  /* GNU extension, also in BSD/macOS */

static struct option long_options[] = {
    {"verbose", no_argument,       0, 'v'},
    {"output",  required_argument, 0, 'o'},
    {"help",    no_argument,       0, 'h'},
    {0, 0, 0, 0}  /* sentinel */
};

/* Usage: while ((opt = getopt_long(argc, argv, "vo:h",
                                    long_options, NULL)) != -1) */

3. Error Handling: A Systematic Approach

C has no exceptions. Error handling must be explicit and disciplined. There are four mechanisms, each with its proper domain.

3.1 Return Codes — The Primary Mechanism

Functions signal failure through their return value. The convention: return a sentinel value (NULL, -1, EOF) and set errno to indicate why:

FILE *f = fopen("data.bin", "rb");
if (f == NULL) {
    /* errno now contains ENOENT, EACCES, etc. */
    perror("fopen failed");
    return EXIT_FAILURE;
}

3.2 errno — Thread-Safe Error Diagnostics

errno is a thread-local integer (macro that expands to a function call in modern implementations). Key rules for using it correctly:

  1. Check errno only after a function reports failure. Successful functions may leave garbage in errno.
  2. Reset errno to 0 before calling a function that uses it to distinguish success from failure (like strtol).
  3. Do not call other library functions between the failing call and your errno check — they may overwrite it.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>

int main() {
    FILE *f = fopen("/nonexistent/file.txt", "r");
    if (!f) {
        fprintf(stderr, "Error opening file: %s (errno=%d)\n",
                strerror(errno), errno);
        return EXIT_FAILURE;
    }
    fclose(f);
    return EXIT_SUCCESS;
}

3.3 assert() — Catching Programmer Errors

assert(condition) is for logic errors that should be impossible — bugs in your code, not runtime conditions. If the condition is false, it prints the expression, file, and line number, then calls abort(). In release builds (-DNDEBUG), asserts compile to nothing:

#include <assert.h>

void process_array(int *arr, int len) {
    assert(arr != NULL);   /* programmer error if NULL */
    assert(len > 0);       /* programmer error if non-positive */
    /* ... processing ... */
}

Critical rule: Never use assert for runtime error handling (e.g., validating user input or checking if malloc returned NULL). Assertions are stripped in release builds, so your error handling would vanish.

3.4 exit() and atexit() — Graceful Termination

exit(status) performs cleanup before terminating: flushes and closes stdio streams, calls functions registered with atexit(). Use EXIT_SUCCESS (0) and EXIT_FAILURE (1) for portability:

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

void cleanup(void) {
    printf("Cleaning up resources...\n");
}

int main() {
    if (atexit(cleanup) != 0) {
        fprintf(stderr, "Cannot register cleanup handler\n");
        return EXIT_FAILURE;
    }
    /* ... do work ... */
    /* cleanup() will be called automatically when we exit */
    return EXIT_SUCCESS;  /* or exit(EXIT_SUCCESS) */
}

4. Common Mistakes and Anti-Patterns

4.1 Using atoi() Without Validation

/* WRONG: "abc" silently becomes 0, indistinguishable from valid "0" */
int port = atoi(argv[1]);

/* RIGHT: use strtol with proper error checking */

4.2 Forgetting to Reset errno

/* WRONG: errno may have a stale value from a previous call */
long val = strtol(argv[1], NULL, 10);
if (errno == ERANGE) { /* bug: errno wasn't reset */ }

/* RIGHT: always reset errno before the call */
errno = 0;
long val = strtol(argv[1], NULL, 10);
if (errno == ERANGE) { /* now correct */ }

4.3 Using assert() for Input Validation

/* WRONG: in release builds (-DNDEBUG), this check disappears! */
int process_file(const char *path) {
    assert(path != NULL); /* user could pass NULL — this is a runtime error! */
    FILE *f = fopen(path, "r");
    /* ... */
}

/* RIGHT: use an explicit if-check, which exists in all builds */
int process_file(const char *path) {
    if (path == NULL) {
        fprintf(stderr, "process_file: NULL path\n");
        return -1;
    }
    /* ... */
}

5. Key Takeaways

  • argc/argv provide the raw command line; getopt()/getopt_long() handle the common pattern of flags and options. Never parse arguments manually with ad-hoc string comparison when getopt exists.
  • Never use atoi()/atof() for user input. Use strtol()/strtod() with errno and endptr checking to distinguish "0" from "not a number".
  • errno is meaningful only after a function indicates failure. Reset it to 0 before calls that use it to signal errors. Check it immediately, before calling other functions.
  • Use assert() for catching programmer errors (logic bugs that should never happen). Use explicit if-checks with errno for runtime errors (user input, I/O failures, allocation failures).
  • Return meaningful exit codes: EXIT_SUCCESS (0) for success, EXIT_FAILURE (or specific non-zero codes) for failures. Shell scripts and CI tools depend on this.

6. Practice Exercises

Exercise 1: Build mini-wc

Write a C program that mimics a simplified wc (word count). It should accept flags -l (lines), -w (words), -c (bytes) and a filename as positional argument. If no flags are given, show all three counts. If no filename, read from stdin. Use getopt().

/* Usage:
   ./mini-wc -lw file.txt    # show line and word counts
   ./mini-wc file.txt        # show all three
   cat file.txt | ./mini-wc  # read from stdin */

Exercise 2: Robust Config File Parser

Write a function int parse_config(const char *filename, int *port, char *host, size_t host_size) that reads a simple key=value config file. Handle all error cases: file not found, permission denied, malformed lines, missing keys, and values that don't fit. Return distinct error codes for each failure mode.

enum ConfigError {
    CFG_OK = 0,
    CFG_FILE_NOT_FOUND,
    CFG_PERMISSION_DENIED,
    CFG_MALFORMED_LINE,
    CFG_MISSING_KEY,
    CFG_VALUE_OVERFLOW
};

int parse_config(const char *filename, int *port, char *host, size_t host_size);
const char* cfg_strerror(int code);

Exercise 3: Find the Error Handling Bugs

Identify at least three error-handling mistakes in this code:

int copy_file(const char *src, const char *dst) {
    FILE *in = fopen(src, "r");
    FILE *out = fopen(dst, "w");
    assert(in != NULL);
    assert(out != NULL);

    char buf[1024];
    size_t n;
    while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
        fwrite(buf, 1, n, out);
    }
    fclose(in);
    fclose(out);
    return 0;
}

Exercise 4: Chained Error Wrapper

Write a set of wrapper functions for common C library calls (fopen, malloc, fread) that: (a) check for errors, (b) print a descriptive message including the file/line of the caller, and (c) return an error code or exit (configurable via a global flag). Use macros and __FILE__/__LINE__ to capture the call site.