Procedural Programming with C · Intermediate · Week 23

Program Arguments and Input

A program that only talks to a human at a prompt cannot be scripted, tested, or combined with anything else. This week connects your programs to the command line and to standard input — and takes validation seriously, because every one of those inputs comes from outside your control.

By the end of this week you can
  • Read command-line arguments and explain the structure of argv.
  • Convert text to numbers with full error detection, and say why atoi cannot.
  • Reject malformed, out-of-range, and missing input with useful messages.
  • Read an arbitrary number of lines from standard input safely.
  • Write a program that behaves correctly inside a shell pipeline.

1argc and argv

int main(int argc, char *argv[])
{
    for (int i = 0; i < argc; i++) {
        printf("argv[%d] = \"%s\"\n", i, argv[i]);
    }
    return 0;
}
$ ./prog hello 42 "two words"
argv[0] = "./prog"
argv[1] = "hello"
argv[2] = "42"
argv[3] = "two words"

argv is an array of pointers to strings — exactly the shape week 15 introduced. Four facts about it:

  • argv[0] is the program name as invoked. Use it in error messages so they identify themselves.
  • Real arguments start at argv[1], so argc is always at least 1.
  • argv[argc] is guaranteed to be NULL, which allows a pointer-walk loop as an alternative to counting.
  • Everything is a string. The shell has already split the command line on whitespace and removed quotes; 42 arrives as two characters and a terminator, not as an integer.
/* two equivalent traversals */
for (int i = 1; i < argc; i++)        { use(argv[i]); }
for (char **p = argv + 1; *p; p++)    { use(*p);      }

Always check argc before indexing. Reading argv[1] when the user supplied no arguments reads the guaranteed NULL and then dereferences it.

2Text to number, done properly

Week 16 introduced strtol; here is the full argument for it.

atoistrtol
Reports "not a number"No — returns 0Yes, via endptr
Reports trailing rubbishNo — "12abc" gives 12Yes, via endptr
Reports overflowNo — undefined behaviorYes, via errno
Handles other basesNoYes

atoi("abc") returns 0, indistinguishable from atoi("0"). There is no combination of checks that recovers the difference. Treat atoi as unusable for input you did not generate yourself.

The complete strtol pattern

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

bool parse_int(const char *text, int *out)
{
    if (text == NULL || *text == '\0') {
        return false;                       /* empty */
    }

    errno = 0;
    char *end;
    long value = strtol(text, &end, 10);

    if (end == text)                        return false;  /* no digits   */
    if (*end != '\0')                       return false;  /* trailing    */
    if (errno == ERANGE)                    return false;  /* long range  */
    if (value < INT_MIN || value > INT_MAX) return false;  /* int range   */

    *out = (int)value;
    return true;
}

Every line earns its place. errno = 0 before the call is necessary because strtol only ever sets errno; a stale ERANGE from an earlier call would otherwise be misread. And the final range check is separate because strtol returns long, which on Linux is wider than int — without it, "3000000000" parses successfully and then truncates.

strtod follows the identical pattern for floating point, and strtoul for unsigned values — with one trap: strtoul("-1", …) succeeds and returns ULONG_MAX, because negation is applied after conversion. Reject a leading minus yourself.

3Reading standard input

Week 8 established that scanf is difficult to use safely. The professional pattern is to read a whole line and then parse it, which separates two concerns that scanf conflates.

char line[256];
while (fgets(line, sizeof line, stdin) != NULL) {
    line[strcspn(line, "\n")] = '\0';     /* strip the newline */
    process(line);
}

fgets reads at most size - 1 characters and always terminates, so it cannot overflow. It returns NULL at end of input, which makes the loop condition natural. And because it keeps the trailing newline when the line fits, the strcspn idiom removes it: strcspn(line, "\n") returns the index of the first newline, or the length if there is none, so the assignment is correct either way.

Detecting an over-long line

If no newline is present, the line was longer than the buffer and the rest is still waiting:

if (strchr(line, '\n') == NULL && !feof(stdin)) {
    /* line was truncated: consume the remainder */
    int c;
    while ((c = getchar()) != '\n' && c != EOF) { }
}

Silently processing a truncated line is a real defect — in a configuration parser it can turn a long entry into a different, valid one. Week 26 covers stream state in full.

4Behaving well in a pipeline

A Unix tool is expected to follow four conventions. Following them costs nothing and makes your program composable:

ConventionWhy
Results to stdout, diagnostics to stderrprog > out.txt keeps errors visible
Exit 0 on success, non-zero on failureprog && next works
Read stdin when given no filecat data | prog works
Say nothing extra on successOutput can be piped into another tool

The last is the one people find surprising. A program that prints "Processing complete!" to stdout has corrupted its own output for any consumer. If the message matters, send it to stderr.

fprintf(stderr, "%s: cannot open '%s'\n", argv[0], path);
return EXIT_FAILURE;

Prefixing with argv[0] is the standard form: in a long pipeline, an unattributed error message is nearly useless.

5Worked example: a numeric filter

A tool that reads numbers from standard input, applies a scale and an offset given as arguments, and prints the results — validating everything and composing properly.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <errno.h>
#include <limits.h>

static const char *program_name = "scale";

static void usage(void)
{
    fprintf(stderr,
        "usage: %s SCALE OFFSET\n"
        "  reads one number per line from standard input,\n"
        "  prints value * SCALE + OFFSET to standard output\n",
        program_name);
}

/* Full validation: empty, non-numeric, trailing text, out of range. */
static bool parse_double(const char *text, double *out)
{
    if (text == NULL || *text == '\0') {
        return false;
    }
    errno = 0;
    char *end;
    double value = strtod(text, &end);

    if (end == text)     return false;      /* no conversion   */
    while (*end == ' ' || *end == '\t') end++;   /* allow trailing spaces */
    if (*end != '\0')    return false;      /* trailing rubbish */
    if (errno == ERANGE) return false;      /* overflow/underflow */

    *out = value;
    return true;
}

static bool parse_int(const char *text, int *out)
{
    if (text == NULL || *text == '\0') {
        return false;
    }
    errno = 0;
    char *end;
    long value = strtol(text, &end, 10);

    if (end == text)                        return false;
    if (*end != '\0')                       return false;
    if (errno == ERANGE)                    return false;
    if (value < INT_MIN || value > INT_MAX) return false;

    *out = (int)value;
    return true;
}

int main(int argc, char *argv[])
{
    if (argc > 0 && argv[0] != NULL) {
        program_name = argv[0];
    }

    if (argc != 3) {
        fprintf(stderr, "%s: expected 2 arguments, got %d\n",
                program_name, argc - 1);
        usage();
        return EXIT_FAILURE;
    }

    double scale, offset;
    if (!parse_double(argv[1], &scale)) {
        fprintf(stderr, "%s: SCALE is not a number: \"%s\"\n",
                program_name, argv[1]);
        return EXIT_FAILURE;
    }
    if (!parse_double(argv[2], &offset)) {
        fprintf(stderr, "%s: OFFSET is not a number: \"%s\"\n",
                program_name, argv[2]);
        return EXIT_FAILURE;
    }

    char line[256];
    long line_number = 0;
    long accepted = 0, rejected = 0;

    while (fgets(line, sizeof line, stdin) != NULL) {
        line_number++;

        /* Detect a line too long for the buffer. */
        if (strchr(line, '\n') == NULL && !feof(stdin)) {
            fprintf(stderr, "%s: line %ld too long, skipping\n",
                    program_name, line_number);
            int c;
            while ((c = getchar()) != '\n' && c != EOF) { }
            rejected++;
            continue;
        }

        line[strcspn(line, "\n")] = '\0';

        if (line[0] == '\0' || line[0] == '#') {
            continue;                        /* blank line or comment */
        }

        double value;
        if (!parse_double(line, &value)) {
            fprintf(stderr, "%s: line %ld: not a number: \"%s\"\n",
                    program_name, line_number, line);
            rejected++;
            continue;
        }

        printf("%g\n", value * scale + offset);   /* result to stdout */
        accepted++;
    }

    if (ferror(stdin)) {
        fprintf(stderr, "%s: error reading input\n", program_name);
        return EXIT_FAILURE;
    }

    fprintf(stderr, "%s: %ld accepted, %ld rejected\n",
            program_name, accepted, rejected);

    return rejected == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
}
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o scale scale.c

Exercise it from the shell

printf '1\n2\n3\n' | ./scale 2 10
12
14
16
scale: 3 accepted, 0 rejected

Note that the summary went to stderr, so it appeared on the terminal but is not part of the output. Prove it:

printf '1\n2\n3\n' | ./scale 2 10 > results.txt
cat results.txt
12
14
16

The results file contains only numbers — which is what lets the tool be chained:

printf '1\n2\n3\n' | ./scale 2 0 | ./scale 10 0 | paste -sd+ | bc

Every failure mode, checked

./scale                        ; echo "status $?"   # too few arguments
./scale 2                      ; echo "status $?"   # still too few
./scale two 10                 ; echo "status $?"   # bad SCALE
printf 'x\n' | ./scale 2 10    ; echo "status $?"   # bad data line
printf '' | ./scale 2 10       ; echo "status $?"   # empty input
printf '1e400\n' | ./scale 2 0 ; echo "status $?"   # overflow
yes 1 | head -1000 | ./scale 1 0 > /dev/null ; echo "status $?"
InputExpected
No argumentsUsage on stderr, status 1
two as SCALENamed error, status 1
A non-numeric data lineLine number reported, other lines still processed
Empty inputNo output, status 0 — zero lines is not an error
1e400Rejected via ERANGE, not silently turned into infinity
A 500-character lineReported as too long; the remainder is discarded, not misparsed

Why the over-long line matters

Remove the length check and feed it a long line:

python3 -c "print('1' * 300)" | ./scale 1 0

Without the check, fgets returns the first 255 characters — a perfectly valid number — and the remaining 45 characters become a second line, also a valid number. One input line silently becomes two outputs. In a billing system or a sensor log that is a data-corruption bug, not a formatting one.

The atoi comparison

Replace parse_double with atof and try the failure cases again. atof("two") returns 0.0 and atof("1e400") returns infinity, both silently. Every rejection in the table above becomes an accepted wrong answer.

6Common mistakes

MistakeWhat happensFix
Reading argv[1] without checking argcDereferences NULLCheck argc first, always.
Using atoi on user inputFailure indistinguishable from 0strtol with endptr and errno.
Forgetting errno = 0 before strtolA stale ERANGE misreportedClear it immediately before the call.
Not range-checking the long resultSilent truncation into intCompare against INT_MIN/INT_MAX.
Accepting "12abc"Trailing rubbish ignoredRequire *end == '\0'.
Diagnostics on stdoutCorrupts piped outputEverything but results goes to stderr.
Always returning 0Scripts cannot detect failureNon-zero on any error.
Ignoring a truncated fgets lineOne line silently becomes two recordsCheck for the newline and drain the rest.

7Check yourself

What is argv[argc], and why is it useful?

It is guaranteed to be NULL. That lets you walk the arguments with a pointer loop terminating on the null entry instead of counting, and it means an out-of-range read of argv[argc] yields NULL rather than garbage — though dereferencing it is still a crash, so check argc before indexing.

Why can't atoi be made safe with extra checks?

Because it discards the information you would need. It returns 0 both for the string "0" and for anything unparseable, stops silently at the first non-digit, and has undefined behavior on overflow. No check applied afterwards can recover which case occurred. strtol reports all three through endptr and errno.

Why must errno be set to 0 before calling strtol?

Because library functions only ever set errno; nothing clears it. A value left over from an unrelated earlier failure would still be there, so a successful conversion could appear to have overflowed. Clearing it immediately before the call makes the subsequent test meaningful.

Why should a successful program print nothing beyond its results?

Because stdout is the channel another program reads. A "done!" line becomes a record in the next tool's input and corrupts it. Progress and summary messages belong on stderr, which stays on the terminal when output is redirected and is ignored by a pipeline.

What goes wrong if you ignore that fgets may have truncated a line?

The remainder of the line is still in the stream and is returned by the next call, so one input line becomes two apparently valid records. If both halves happen to parse — as they do for a long run of digits — the program produces wrong output with no error at all. Check for the newline and discard the remainder explicitly.

8Where this leads

Week 24 surveys the rest of the standard library — the mathematics, time, and random-number facilities you have not needed until now — and teaches you to read its documentation rather than memorize it. Week 31 then returns to the command line with getopt, environment variables, and layered configuration, turning the filter you just wrote into a properly optioned tool.