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:
-
Check
errnoonly after a function reports failure. Successful functions may leave garbage inerrno. -
Reset
errnoto 0 before calling a function that uses it to distinguish success from failure (likestrtol). -
Do not call other library functions between the
failing call and your
errnocheck — 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/argvprovide the raw command line;getopt()/getopt_long()handle the common pattern of flags and options. Never parse arguments manually with ad-hoc string comparison whengetoptexists. -
Never use
atoi()/atof()for user input. Usestrtol()/strtod()witherrnoandendptrchecking to distinguish "0" from "not a number". -
errnois 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 explicitif-checks witherrnofor 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.