Command-Line Interfaces and the Environment
Week 23 read arguments. This week designs the interface they belong to: options with conventional spellings, configuration layered from several sources, exit codes a script can branch on, and diagnostics that identify themselves.
- Parse short and long options, by hand and with
getopt. - Use
exit,_Exit, andatexit, and say how each differs from returning frommain. - Read and set environment variables safely.
- Layer configuration from defaults, a file, the environment, and the command line.
- Design a tool that behaves correctly when driven by a script.
1The conventions
Unix option syntax is not a standard, but it is so consistent that departing from it makes a tool feel broken.
| Form | Meaning |
|---|---|
-v |
A short flag |
-vqn |
Several short flags combined |
-o file or -ofile
|
A short option with an argument |
--verbose |
A long flag |
--output=file or --output file
|
A long option with an argument |
-- |
End of options; everything after is an operand |
- |
By convention, standard input or output |
The -- separator matters more than it looks: it is the only way to pass a filename that begins with a dash. A tool that does not honour it cannot open a file called -report.txt.
Two options every tool should have: --help, printing usage and exiting successfully, and --version. Both are what a person tries first.
2getopt
POSIX provides a parser. It is not in the C standard, but it is available on Linux, macOS, and the BSDs, and it handles combined flags and attached arguments correctly.
#include <unistd.h>
int opt;
while ((opt = getopt(argc, argv, "vo:n:h")) != -1) {
switch (opt) {
case 'v': verbose = true; break;
case 'o': output_path = optarg; break; /* : means takes an argument */
case 'n': count_text = optarg; break;
case 'h': usage(); return EXIT_SUCCESS;
case '?': usage(); return EXIT_FAILURE; /* unknown or missing arg */
}
}
/* optind now indexes the first operand */
for (int i = optind; i < argc; i++) {
process_file(argv[i]);
}
| Name | Meaning |
|---|---|
optarg |
The argument of the option just returned |
optind |
Index of the next argument; after the loop, the first operand |
opterr |
Set to 0 to suppress getopt's own error messages |
optopt |
The offending character when '?' is returned |
A leading : in the option string — ":vo:n:h" — makes a missing argument return ':' rather than '?', letting you distinguish "unknown option" from "option needs a value". Combined with opterr = 0, that gives you full control of the messages.
For long options, GNU provides getopt_long in <getopt.h>. It is not POSIX, so portable code either hand-parses the long forms or accepts the dependency.
3Termination
| Way out | Runs atexit handlers |
Flushes streams |
|---|---|---|
return from main
|
Yes | Yes |
exit(status) |
Yes | Yes |
_Exit(status) |
No | No |
abort() |
No | No; raises SIGABRT
|
exit is what you call from deep inside a call chain when there is no sensible way to propagate the failure — though in a library that is almost always the wrong choice, because a library must let its caller decide.
#include <stdlib.h>
static void cleanup(void)
{
remove(temp_path);
}
atexit(cleanup); /* registered handlers run in reverse order */
atexit handlers run on normal termination only. They do not run on _Exit, on abort, or on a fatal signal — so they are not a substitute for real cleanup on the error path, and a temporary file can survive a crash. Week 39 covers signal handling.
Exit codes
Zero for success, non-zero for failure. Beyond that, a few conventions are widely followed:
| Code | Conventional meaning |
|---|---|
| 0 | Success |
| 1 | General failure |
| 2 | Misuse: bad options or arguments |
| >128 | Killed by signal (128 + signal number) |
Only the low eight bits reach the shell, so exit(256) is seen as 0. Keep codes in 0–125.
4The environment
#include <stdlib.h>
const char *home = getenv("HOME"); /* NULL if not set */
if (home == NULL) {
home = "/tmp"; /* a default */
}
Three rules. getenv returns NULL for an unset variable, so always have a default. The returned pointer points into the environment block and may be invalidated by a later setenv or putenv, so copy it if you keep it. And the value is entirely under the user's control — treat it exactly as you would treat command-line input, which means validating it.
setenv and unsetenv are POSIX rather than standard C:
setenv("LANG", "C", 1); /* 1 = overwrite if already set */
unsetenv("LANG");
Environment variables are untrusted input. A PATH or a config path taken from the environment and used without validation is a classic privilege-escalation vector, which is why setuid programs are required to scrub their environment. Week 38 returns to this; for now, validate environment values with the same care as argv.
5Layered configuration
A setting can come from four places. Apply them in this order, each overriding the last:
- Built-in defaults — the program always works with no configuration at all.
- A configuration file — system-wide, then per-user.
- Environment variables — per-session.
- Command-line options — this invocation, highest priority.
The ordering is the whole design. Anything more specific wins, so a user can set a default in a file, override it for a shell session with an environment variable, and override that for one run with a flag — without editing anything.
Config cfg;
config_set_defaults(&cfg); /* 1 */
config_load_file(&cfg, "/etc/tool.conf"); /* 2 */
config_load_file(&cfg, user_config_path); /* 2 */
config_load_env(&cfg); /* 3 */
config_apply_options(&cfg, argc, argv); /* 4 */
Give the tool a way to show the result — --show-config — so a user can find out which layer won. Debugging a setting that "does not take effect" is otherwise miserable.
6Worked example: a properly optioned filter
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <errno.h>
#include <limits.h>
#include <unistd.h>
#define VERSION "1.0"
#define EXIT_USAGE 2
static const char *program_name = "filter";
typedef struct {
bool verbose;
long maximum;
const char *output_path;
const char *prefix;
} Config;
static void usage(FILE *to)
{
fprintf(to,
"usage: %s [options] [file...]\n"
"\n"
" -v verbose diagnostics on stderr\n"
" -n LIMIT stop after LIMIT lines (default 0 = unlimited)\n"
" -o FILE write to FILE instead of stdout\n"
" -p PREFIX prefix every output line\n"
" -h show this help and exit\n"
" -V show the version and exit\n"
"\n"
" With no file operands, or with '-', reads standard input.\n"
" Use '--' to end options before a filename beginning with '-'.\n"
"\n"
"Environment:\n"
" FILTER_PREFIX default for -p\n"
" FILTER_MAX default for -n\n"
"\n"
"Exit status: 0 success, 1 runtime error, %d usage error.\n",
program_name, EXIT_USAGE);
}
static bool parse_long(const char *text, long *out)
{
if (text == NULL || *text == '\0') return false;
errno = 0;
char *end;
long value = strtol(text, &end, 10);
if (end == text || *end != '\0' || errno == ERANGE) return false;
*out = value;
return true;
}
/* Layer 1: built-in defaults. */
static void config_defaults(Config *c)
{
c->verbose = false;
c->maximum = 0;
c->output_path = NULL;
c->prefix = "";
}
/* Layer 3: the environment, overriding defaults. */
static bool config_from_env(Config *c)
{
const char *prefix = getenv("FILTER_PREFIX");
if (prefix != NULL) {
c->prefix = prefix;
}
const char *max_text = getenv("FILTER_MAX");
if (max_text != NULL) {
long value;
if (!parse_long(max_text, &value) || value < 0) {
fprintf(stderr, "%s: FILTER_MAX is not a valid count: \"%s\"\n",
program_name, max_text);
return false; /* environment input is validated too */
}
c->maximum = value;
}
return true;
}
/* A registered cleanup handler. */
static char temp_marker[64];
static void on_exit_cleanup(void)
{
if (temp_marker[0] != '\0') {
remove(temp_marker);
}
}
static bool process_stream(FILE *in, FILE *out, const Config *c,
const char *label, long *emitted)
{
char line[512];
long count = 0;
while (fgets(line, sizeof line, in) != NULL) {
if (c->maximum > 0 && *emitted >= c->maximum) {
break;
}
if (strchr(line, '\n') == NULL && !feof(in)) {
fprintf(stderr, "%s: %s: line too long, truncating\n",
program_name, label);
int ch;
while ((ch = fgetc(in)) != '\n' && ch != EOF) { }
}
line[strcspn(line, "\n")] = '\0';
if (fprintf(out, "%s%s\n", c->prefix, line) < 0) {
perror("write");
return false;
}
count++;
(*emitted)++;
}
if (ferror(in)) {
fprintf(stderr, "%s: %s: read error\n", program_name, label);
return false;
}
if (c->verbose) {
fprintf(stderr, "%s: %s: %ld lines\n", program_name, label, count);
}
return true;
}
int main(int argc, char *argv[])
{
if (argc > 0 && argv[0] != NULL) {
program_name = argv[0];
}
atexit(on_exit_cleanup);
Config cfg;
config_defaults(&cfg); /* layer 1 */
if (!config_from_env(&cfg)) { /* layer 3 */
return EXIT_USAGE;
}
/* Layer 4: command-line options, highest priority. */
opterr = 0; /* our messages, not getopt's */
int opt;
while ((opt = getopt(argc, argv, ":vn:o:p:hV")) != -1) {
switch (opt) {
case 'v':
cfg.verbose = true;
break;
case 'n':
if (!parse_long(optarg, &cfg.maximum) || cfg.maximum < 0) {
fprintf(stderr, "%s: -n needs a non-negative number, got \"%s\"\n",
program_name, optarg);
return EXIT_USAGE;
}
break;
case 'o':
cfg.output_path = optarg;
break;
case 'p':
cfg.prefix = optarg;
break;
case 'h':
usage(stdout); /* help goes to stdout */
return EXIT_SUCCESS;
case 'V':
printf("%s %s\n", program_name, VERSION);
return EXIT_SUCCESS;
case ':':
fprintf(stderr, "%s: option -%c requires an argument\n",
program_name, optopt);
usage(stderr); /* errors go to stderr */
return EXIT_USAGE;
case '?':
default:
fprintf(stderr, "%s: unknown option -%c\n", program_name, optopt);
usage(stderr);
return EXIT_USAGE;
}
}
if (cfg.verbose) {
fprintf(stderr, "%s: config: verbose=%d max=%ld out=%s prefix=\"%s\"\n",
program_name, cfg.verbose, cfg.maximum,
cfg.output_path ? cfg.output_path : "<stdout>", cfg.prefix);
}
FILE *out = stdout;
if (cfg.output_path != NULL) {
out = fopen(cfg.output_path, "w");
if (out == NULL) {
fprintf(stderr, "%s: %s: %s\n",
program_name, cfg.output_path, strerror(errno));
return EXIT_FAILURE;
}
}
int status = EXIT_SUCCESS;
long emitted = 0;
if (optind == argc) {
/* No operands: read standard input, as a filter should. */
if (!process_stream(stdin, out, &cfg, "-", &emitted)) {
status = EXIT_FAILURE;
}
} else {
for (int i = optind; i < argc; i++) {
if (strcmp(argv[i], "-") == 0) {
if (!process_stream(stdin, out, &cfg, "-", &emitted)) {
status = EXIT_FAILURE;
}
continue;
}
FILE *in = fopen(argv[i], "r");
if (in == NULL) {
fprintf(stderr, "%s: %s: %s\n",
program_name, argv[i], strerror(errno));
status = EXIT_FAILURE;
continue; /* keep going, like grep */
}
if (!process_stream(in, out, &cfg, argv[i], &emitted)) {
status = EXIT_FAILURE;
}
fclose(in);
}
}
if (out != stdout && fclose(out) != 0) {
perror("closing output");
status = EXIT_FAILURE;
}
return status;
}
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o filter filter.c
Exercise every path
printf 'a\nb\nc\n' | ./filter
printf 'a\nb\nc\n' | ./filter -p '> '
printf 'a\nb\nc\n' | ./filter -n 2
printf 'a\nb\nc\n' | ./filter -vp '# ' # combined short options
./filter -h ; echo "status $?"
./filter -V ; echo "status $?"
./filter -x ; echo "status $?" # unknown
./filter -n ; echo "status $?" # missing argument
./filter -n abc ; echo "status $?" # bad argument
./filter no_such_file ; echo "status $?"
FILTER_PREFIX='env: ' printf 'x\n' | ./filter # environment layer
FILTER_PREFIX='env: ' ./filter -p 'flag: ' <<< x # flag wins
| Case | Expected |
|---|---|
-h |
Usage on stdout, status 0 — help is the requested output |
-x |
Message and usage on stderr, status 2 |
-n with nothing |
"requires an argument", status 2 |
| Missing file | Named error, continues to the next file, status 1 |
FILTER_PREFIX and -p
|
The flag wins: layer 4 over layer 3 |
| No operands | Reads standard input |
Why help goes to stdout and errors go to stderr
./filter -h | head -3 # works: help is on stdout
./filter -x 2> errors.txt # the error is captured, not the output
A user who typed --help asked for that text, so it is the program's output and belongs on stdout where it can be piped to a pager. A user who mistyped an option did not ask for anything, so the message is a diagnostic. Getting this backwards is one of the most common flaws in homemade tools.
The -- separator
touch -- -weird.txt
./filter -weird.txt # fails: parsed as options
./filter -- -weird.txt # works
getopt handles -- for you, stopping option parsing and leaving optind pointing at the operand. Hand-written parsers usually forget it.
Confirm it composes
seq 1 10 | ./filter -p 'n=' -n 3 | wc -l # 3
seq 1 10 | ./filter -v -n 3 2>/dev/null | wc -l # 3, diagnostics discarded
./filter missing1 missing2 2>/dev/null; echo $? # 1, after trying both
The last is deliberate: like grep and cat, the tool reports each failure and continues, then exits non-zero. Stopping at the first missing file would be a defensible alternative — but it must be a decision, and it must be documented.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
Help printed to stderr
|
Cannot be piped to a pager |
--help goes to stdout; errors to stderr. |
Not honouring --
|
Cannot process a file starting with a dash | Use getopt, which handles it. |
Ignoring optind
|
Operands are missed or options reprocessed | Start the operand loop at optind. |
| Trusting an environment variable | Injection or crash on bad input | Validate exactly as you would argv. |
Keeping the pointer from getenv
|
Invalidated by a later setenv
|
Copy the value if you retain it. |
Relying on atexit for cleanup |
Does not run on a crash or signal | Clean up explicitly; week 27's single exit path. |
exit called from a library |
Removes the caller's ability to recover | Return a status; only main decides to exit. |
| Exit codes above 255 | Truncated to the low eight bits | Stay within 0–125. |
8Check yourself
Why does --help output belong on stdout while an unknown-option message belongs on stderr?
Because --help is what the user asked for — it is the program's output, and they will want to pipe it to a pager or grep it. An error message is a diagnostic about a request that failed; putting it on stderr keeps it visible when output is redirected and keeps it out of any pipeline consuming the results.
What does -- do, and why must a tool honour it?
It ends option parsing: everything after it is treated as an operand even if it begins with a dash. Without it there is no way to name a file called -report.txt, because the parser would read it as options. getopt implements this automatically and leaves optind pointing past the separator.
Why should environment variables be validated as carefully as command-line arguments?
Because they are equally under the user's control, and often under the control of whoever launched the process. An unvalidated path or numeric setting taken from the environment can crash the program or, in a privileged context, redirect it to attacker-chosen files. The only safe assumption is that both sources are hostile.
When does an atexit handler fail to run?
On _Exit, on abort, and on any fatal signal — a segmentation fault, or the user pressing Ctrl-C without a handler installed. So atexit is a convenience for normal termination, not a guarantee. Anything that must be cleaned up reliably needs explicit handling on the error path and, for signals, the treatment in week 39.
Why layer configuration defaults, file, environment, then command line?
Because specificity should win. A default makes the program work with no setup; a file records a persistent preference; an environment variable scopes a change to one session; a flag applies to exactly one invocation. Each layer overriding the previous means a user can change any setting at the narrowest scope that suits them, without editing anything more permanent.
9Where this leads
Week 32 returns inside the program to build linked data structures — the first place where week 21's self-referential types and week 19's allocation meet. From there week 33 adds trees and hash tables behind the opaque interface of week 28, completing the set of containers a C programmer is expected to be able to write from memory.