C CLI Tool Building, Exit Codes & Environment Variables Masterclass
Welcome to Phase 16 (Chapter 43): C CLI Tool Building, Exit Codes & Environment Variables Masterclass! Production CLI tools communicate with the shell through exit status codes and can read environment variables for configuration. In this guide you master exit(), atexit(), getenv(), and patterns used by real Unix utilities like grep, ls, and wc.
Every C program returns an integer exit status to the operating system shell. This status code is the primary IPC mechanism between programs in Unix shell pipelines.
exit() vs return from main():
โข return 0; from main() โ Normal clean exit. Calls static destructors and fflush on all open streams.
โข exit(EXIT_SUCCESS); โ Same as return 0 from main(). Can be called from ANY function.
โข _Exit(0); โ Immediate process termination. Does NOT flush buffers or call atexit handlers. Use after fork() in child processes.
atexit() registers functions called automatically when the program exits normally (via exit() or return from main). Up to 32 handlers may be registered (POSIX minimum). They execute in LIFO (Last-In, First-Out) order โ reverse registration order.
| Function | Signature | Purpose |
|---|---|---|
getenv() | char *getenv(const char *name) | Read environment variable. Returns NULL if not set. |
putenv() | int putenv(char *string) | Set/modify env var. POSIX only. Unsafe (string ownership issues). |
setenv() | int setenv(const char *name, const char *value, int overwrite) | Safer POSIX alternative to putenv(). Makes internal copy. |
#include <stdio.h>
#include <stdlib.h>
#include <ctype.h>
#include <string.h>
static FILE *logfp = NULL;
static void cleanup(void) {
if (logfp) { fclose(logfp); printf("[atexit] Log file closed.\n"); }
}
typedef struct { long lines, words, chars; } WcResult;
static WcResult count_file(FILE *fp) {
WcResult r = {0, 0, 0};
int ch, in_word = 0;
while ((ch = fgetc(fp)) != EOF) {
r.chars++;
if (ch == '\n') r.lines++;
if (isspace(ch)) { in_word = 0; }
else if (!in_word) { in_word = 1; r.words++; }
}
return r;
}
int main(int argc, char *argv[]) {
atexit(cleanup);
/* Read log path from environment variable */
const char *log_path = getenv("WC_LOG");
if (log_path) {
logfp = fopen(log_path, "a");
if (!logfp) perror("Cannot open WC_LOG");
else fprintf(logfp, "wc invoked with %d args\n", argc);
}
FILE *fp = (argc > 1) ? fopen(argv[1], "r") : stdin;
if (!fp) { perror(argv[1]); return EXIT_FAILURE; }
WcResult r = count_file(fp);
printf("Lines: %ld Words: %ld Chars: %ld\n", r.lines, r.words, r.chars);
if (fp != stdin) fclose(fp);
return EXIT_SUCCESS;
}Q1: How does the shell check exit codes?
After any command, $? holds its exit code. Zero means success; non-zero means failure. Shell conditionals like if ./app; then... use exit codes automatically.
Q2: What order do atexit handlers run?
Handlers are called in LIFO order โ the last registered handler runs first. This mirrors C++ destructor ordering for cleanup safety.
Q3: Is getenv() thread-safe?
No โ getenv() returns a pointer to internal static storage that may be modified by setenv()/putenv() in another thread. Use with caution in multithreaded programs.
Q4: How do shell pipelines use exit codes?
In cmd1 | cmd2, by default only cmd2's exit code is checked. With set -o pipefail in Bash, any failing command in the pipeline causes pipeline failure.
Q5: What is the difference between exit() and abort()?
abort() sends SIGABRT signal, generating a core dump for debugging. It does NOT call atexit handlers or flush streams. Used when program detects a fatal internal inconsistency.