Help improve SovranCode?

Google Analytics can measure anonymous usage after you choose Allow. Essential account and progress features work either way.

SovranCode
C: Foundations for Systems Programming Files and Command-Line Arguments
This device
Course contentsFiles and Command-Line Arguments · 15 titles

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
C: Foundations for Systems Programming15 complete lessons

C foundations

What C Is and How C Programs RunYour First C ProgramVariables, Types, and OperatorsInput and Output with printf and scanfConditions and Loops

Program structure and data

Functions, Headers, and ScopeArrays and StringsPointers and Memory AddressesStructures, Enumerations, and UnionsFiles and Command-Line Arguments

Memory, tooling, and practice

Dynamic Memory AllocationPreprocessor Directives and Separate CompilationErrors, Debugging, and Undefined BehaviorBuild Tools and MakefilesProject: Command-Line Inventory Manager
PREVIOUS LESSONStructures, Enumerations, and Unions
NEXT LESSONDynamic Memory Allocation
Program structure and data 220 min

Files and Command-Line Arguments

A C program becomes useful when it can work with data beyond its own source code. Files let a program persist and consume data; command-line arguments let a person or script choose what the program should do. Both are external boundaries, so every handle, byte, and argument needs an explicit contract.

What you will leave with

You will open text files with the correct mode, detect and report failures, read character and line streams without losing EOF, write output deliberately, close every successful handle, explain argc and argv, validate argument counts before indexing, and parse a numeric argument with a real error check.

External data is a boundary, not a promise

OPENAcquire a file handlefopen can fail. Test the returned pointer before use.
PROCESSRead or write in a bounded loopPreserve EOF, check conversion results, and keep buffer capacity visible.
CLOSERelease the handleEvery successful open has a matching fclose on every exit path.
VALIDATEInterpret arguments explicitlyCheck argc before argv[index] and parse text before calculation.

Files are one kind of stream

Standard I/O uses the same FILE * interface for regular files, the keyboard, terminal output, pipes, and redirected commands. Your program already starts with three streams: stdin for input, stdout for normal results, and stderr for diagnostics. This separation is what makes a command composable.

int main(void) {
  fprintf(stdout, "42\n");       /* data another command can consume */
  fprintf(stderr, "read 42 rows\n"); /* progress or diagnostic detail */
  return 0;
}

/* Useful shell compositions:
   ./line_count notes.txt > result.txt
   ./line_count missing.txt 2> errors.txt
   ./line_count notes.txt | sort
*/

Do not print a human status banner to stdout when another program may need machine-readable output. Keep data on stdout, errors and usage guidance on stderr, and let the shell redirect either stream without changing your C source.

A FILE pointer represents an open stream

The standard I/O header, <stdio.h>, declares the FILE type and functions that operate on streams. A FILE * is an opaque handle managed by the C library; do not invent one, copy its internal representation, or use it after closing it.

#include <stdio.h>

int main(void) {
  FILE *input = fopen("scores.txt", "r");
  if (input == NULL) {
    perror("scores.txt");
    return 1;
  }

  /* Read from input here. */

  if (fclose(input) == EOF) {
    perror("scores.txt");
    return 1;
  }
  return 0;
}

fopen returns a valid pointer on success and NULL on failure. Failure is normal: the file may not exist, permissions may deny access, a directory may be supplied instead of a regular file, or the operating system may run out of descriptors. perror prints your contextual label followed by the current system error message.

Never call file functions through NULL

fgetc(input), fputs(text, input), and fclose(input) all require a successfully opened stream. Make the failure return immediately or use one clear cleanup path so the success-only operations cannot run after a failed open.

Choose a mode that matches the intended operation

"r"

Read existing data

Fails if the path does not exist. It does not create or modify the file.

"w"

Write a new complete result

Creates a new file or truncates an existing one. Do not use it when old content must survive.

"a"

Append a new record

Creates if absent and writes at the end. Useful for a deliberate log, not a substitute for validation.

Add + only when the same stream genuinely needs both reading and writing, then follow the standard's positioning/flush rules between direction changes. Add b for binary data. For this lesson, use text mode and write the format your program promises to read.

Text, binary, buffering, and position are separate concerns

Text mode represents text according to the host environment; binary mode treats the stream as bytes. On some platforms, text mode translates line endings, so a byte offset calculated in text mode is not a portable record address. Choose binary mode for a binary format, not merely because it feels lower level.

long file_size(FILE *input) {
  if (fseek(input, 0L, SEEK_END) != 0) return -1;
  long size = ftell(input);
  if (size < 0) return -1;
  if (fseek(input, 0L, SEEK_SET) != 0) return -1;
  return size;
}

fseek and ftell are useful for seekable streams such as regular files, but not every stream is seekable: a pipe and many terminal inputs move only forward. Also remember buffering: output may wait in a user-space buffer until it is flushed, closed, or requires a visible prompt. Call fflush(stdout) only when interactive behavior needs the prompt to appear before more input is read.

Read one character at a time without confusing EOF

fgetc returns either the next byte value converted to unsigned char, or the special EOF value. Store that result in int, not char: an int can hold every byte value and the extra negative sentinel.

int copy_stream(FILE *source, FILE *destination) {
  int character;

  while ((character = fgetc(source)) != EOF) {
    if (fputc(character, destination) == EOF) {
      return 0;
    }
  }

  if (ferror(source)) {
    return 0;
  }
  return 1;
}

The assignment must occur before the comparison. The loop body receives only a real byte. When the loop ends, feof is true only after a read attempt reaches end-of-file; it is not a condition to test before reading. Check ferror(source) if you need to distinguish a normal end from a read error.

Read lines with capacity-aware buffers

fgets accepts a character array and its capacity. It reads at most capacity - 1 characters, then writes a terminating \0 when it succeeds. A newline is kept if it fits. A long physical line can therefore arrive in several pieces.

char line[128];
int line_number = 0;

while (fgets(line, sizeof line, input) != NULL) {
  line_number += 1;
  printf("%d: %s", line_number, line);
}

if (ferror(input)) {
  perror("reading input");
  return 1;
}

Use sizeof line only where line is an actual array in that scope. When passed to a function, an array parameter becomes a pointer and no longer carries its capacity. Pass the capacity separately and reject a zero capacity before calling fgets.

Decide what a line means

If your file format requires one record per physical line, detect a missing newline and either reject an overlong record or continue accumulating it. Truncating silently changes data. Removing a final newline, when appropriate, is a parsing step—not something fgets does automatically.

Use formatted conversion only with checked results

fscanf resembles scanf, but the stream is a file. Its return value is the number of successful assignments. Treat that value as part of the file format contract.

int score;
int total = 0;
int count = 0;

while (fscanf(input, "%d", &score) == 1) {
  total += score;
  count += 1;
}

if (!feof(input)) {
  fprintf(stderr, "scores.txt contains a non-integer value
");
  return 1;
}

For user-created text formats, a line-oriented read followed by explicit parsing is often easier to diagnose than a long fscanf format. It gives your program the original line, a specific validation point, and a place to reject trailing unexpected characters.

Make a line parser own one complete record contract

A reliable file reader separates transport from meaning: fgets obtains one line fragment; a parser decides whether that line represents one valid record. The parser should accept an output pointer only after validating it and should reject trailing non-whitespace text.

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

int parse_score_line(const char line[], int *score) {
  char *end = NULL;
  errno = 0;
  long value = strtol(line, &end, 10);

  while (isspace((unsigned char)*end)) end += 1;
  if (line[0] == '\0' || *end != '\0' || errno == ERANGE ||
      value < 0 || value > 100) {
    return 0;
  }
  *score = (int)value;
  return 1;
}

The cast to unsigned char before isspace matters when char is signed and the input contains a byte above ASCII. A parser should report enough context at its call site—file path and line number—without mixing file I/O, error printing, and domain validation into one untestable loop.

Resource cleanup must survive every branch

As programs acquire more than one resource, early returns can accidentally skip cleanup. C does not have automatic destructors for ordinary FILE * objects. A focused goto cleanup is often clearer than duplicating cleanup in many branches, provided the label does only cleanup and the final status is preserved.

int transform_file(const char input_name[], const char output_name[]) {
  int status = 1;
  FILE *input = fopen(input_name, "r");
  FILE *output = NULL;
  if (input == NULL) goto cleanup;

  output = fopen(output_name, "w");
  if (output == NULL) goto cleanup;

  if (!copy_stream(input, output)) goto cleanup;
  status = 0;

cleanup:
  if (output != NULL && fclose(output) == EOF) status = 1;
  if (input != NULL && fclose(input) == EOF) status = 1;
  return status;
}

Do not use this pattern to jump into the middle of work. Use it to make ownership visible: initialize handles to NULL, acquire them in order, then release acquired handles in reverse order. In a program with only one file, an early return after a checked fclose remains perfectly readable.

Writing is also an operation that can fail

fprintf writes formatted text to a chosen stream. It returns a negative value on output failure. Check writing and closing when data matters: buffered output can report a late error when it is flushed by fclose.

FILE *output = fopen("report.txt", "w");
if (output == NULL) {
  perror("report.txt");
  return 1;
}

if (fprintf(output, "count=%d total=%d\n", count, total) < 0) {
  perror("writing report.txt");
  fclose(output);
  return 1;
}

if (fclose(output) == EOF) {
  perror("closing report.txt");
  return 1;
}

A program that should preserve an existing valuable file needs a stronger update strategy than opening it directly with "w": write a new temporary file, verify the result, then use a platform-appropriate replacement step. That design belongs to a later systems project, but the risk starts here.

argc counts argument strings; argv points to them

A hosted C program may use int main(int argc, char *argv[]). argc is the number of argument strings, and argv is an array of pointers to those strings. argv[0] conventionally identifies the invoked program; the first user argument is argv[1].

#include <stdio.h>

int main(int argc, char *argv[]) {
  printf("program: %s\n", argv[0]);

  for (int index = 1; index < argc; index++) {
    printf("argument %d: %s\n", index, argv[index]);
  }
  return 0;
}
  • 01
    Compile the program

    cc -Wall -Wextra -Wpedantic -std=c17 report.c -o report

  • 02
    Pass a file and option

    ./report scores.txt --verbose gives argc == 3.

  • 03
    Check count before indexing

    argv[2] is valid only when argc >= 3.

  • 04
    Interpret each string

    Arguments are text. Parse numbers, recognize options, and reject unsupported combinations.

  • The shell forms arguments before your C program starts

    Your C program does not split a command line itself. The shell expands quotes, whitespace, wildcards, variables, and redirections, then starts the program with already separated strings. That is why a filename with spaces must be quoted at the shell, but arrives as one argv entry.

    $ ./line_count "meeting notes.txt" --nonempty
    
    argc = 3
    argv[0] = "./line_count"
    argv[1] = "meeting notes.txt"
    argv[2] = "--nonempty"
    
    /* Never build a shell command from argv and pass it to system().
       Open argv[1] directly with fopen instead. */
    Do not turn an argument back into shell source

    An argument is data. Passing a constructed string to system invites shell-injection bugs and platform-specific quoting mistakes. Call C library functions with validated argument strings directly; if a later project needs to start another program, use an operating-system process API with a real argument vector.

    Make the command contract visible with a usage message

    int main(int argc, char *argv[]) {
      if (argc != 2) {
        fprintf(stderr, "Usage: %s INPUT_FILE\n", argv[0]);
        return 2;
      }
    
      FILE *input = fopen(argv[1], "r");
      if (input == NULL) {
        perror(argv[1]);
        return 1;
      }
    
      /* Process the input stream. */
      return fclose(input) == EOF ? 1 : 0;
    }

    Usage errors are different from a valid request that cannot be completed. This example returns 2 for invalid command syntax and 1 for an operating-system or file failure. The exact convention is yours to document; the important part is that scripts can distinguish success from failure.

    ARGUMENT CONTRACT WALKTHROUGH

    See what C receives after the shell splits a command

    Enter a command shape, predict argc and argv, then run the contract check. This is a teaching model; your terminal shell performs the actual quoting and expansion.

    Notice: argv[0] is modeled as ./line_count. The file name is never inherently special—it becomes input only because this program's usage contract assigns that meaning to argv[1].

    Parse numeric arguments, then validate the whole string

    Command-line arguments are never already integers. Avoid atoi when invalid input matters because it provides no reliable error signal. strtol reports where parsing stopped and makes a range check possible.

    #include <errno.h>
    #include <limits.h>
    #include <stdlib.h>
    
    int parse_count(const char text[], int *result) {
      char *end = NULL;
      errno = 0;
      long value = strtol(text, &end, 10);
    
      if (text[0] == '\0' || *end != '\0' || errno == ERANGE ||
          value < 0 || value > INT_MAX) {
        return 0;
      }
    
      *result = (int)value;
      return 1;
    }

    The parser rejects an empty string, text such as 12cats, a number that overflowed long, and a value outside the chosen int contract. A caller must still check that result is a valid non-NULL output pointer before passing it to a production version of this function.

    Treat flags and paths as a small language

    Even a tiny command has grammar. State it in the usage line, then parse from left to right. Reject unknown flags and ambiguous combinations rather than silently guessing. The simple loop below keeps an option parser visible before a later lesson introduces a dedicated option-parsing library.

    int nonempty_only = 0;
    const char *path = NULL;
    
    for (int index = 1; index < argc; index++) {
      if (strcmp(argv[index], "--nonempty") == 0) {
        nonempty_only = 1;
      } else if (path == NULL) {
        path = argv[index];
      } else {
        fprintf(stderr, "unexpected argument: %s\n", argv[index]);
        return 2;
      }
    }
    
    if (path == NULL) {
      fprintf(stderr, "Usage: %s FILE [--nonempty]\n", argv[0]);
      return 2;
    }

    Options that begin with - and paths that happen to begin with - eventually require a convention such as -- to mark the end of options. Document that convention before accepting arbitrary user paths. This is a design decision, not a syntax detail.

    Independent lab: a line-counting command

    Build line_count.c locally. It accepts one required filename and an optional --nonempty flag. It prints a stable, script-friendly summary.

    $ cc -Wall -Wextra -Wpedantic -std=c17 line_count.c -o line_count
    $ ./line_count notes.txt
    notes.txt: 42 lines
    $ ./line_count notes.txt --nonempty
    notes.txt: 37 non-empty lines
    01Define usageReject anything except two or three argument strings. Print usage to stderr.
    02Open safelyUse fopen(argv[1], "r"), test for NULL, and include the path in perror.
    03Count correctlyUse fgetc stored in int; decide and document whether a final unterminated line counts.
    04Close and reportCheck ferror, then fclose, before printing a success result.

    Worked design: a resilient score-report CLI

    Put the lesson together with a program that reads one integer score per line and writes a summary. Its boundaries are visible: arguments decide the file and passing mark, stream code reads lines, a parser validates each line, domain logic calculates the result, and output reports a stable summary.

    Usage: ./score_report SCORES_FILE PASSING_SCORE
    
    1. Check argc == 3 before reading argv[1] or argv[2].
    2. Parse PASSING_SCORE with strtol; require 0 through 100.
    3. Open SCORES_FILE with "r"; use perror(argv[1]) if it fails.
    4. For each fgets line:
       - reject an overlong line or a line that parse_score_line rejects;
       - increment count, sum, and passing count only after validation.
    5. Check ferror(input), then fclose(input).
    6. Print exactly: records=N average=X.XX passing=M
     to stdout.
    7. Return 0 only after every required operation succeeds.

    Test this tool with an empty file, one valid line without a final newline, a blank line, an out-of-range score, a non-number, a missing path, a directory path, and a file too long for your chosen buffer. The happy path shows that code runs; these cases show that its contract holds.

    Lesson review

    • fopen returns NULL on failure. Test it before every operation through the resulting FILE *.
    • Use "r" to read, "w" only when truncation is intended, and "a" for deliberate append-only output.
    • Store fgetc in int, compare it with EOF, and check ferror after the loop.
    • fgets needs a real capacity, retains a fitting newline, and can return a long line in pieces.
    • Check writes and the final fclose; a successful fopen requires cleanup on every success path.
    • Keep data on stdout and diagnostics on stderr so scripts can redirect and compose your tool safely.
    • Use line-oriented reads plus a parser when you need actionable validation, exact error location, and rejection of trailing junk.
    • Validate argc before indexing argv; every argument begins as text and needs an explicit parser and domain check.
    • The shell creates the argument strings before C starts. Quote paths in the shell, but never send an argument back through a shell with system.
    KNOWLEDGE CHECK

    Treat input and files as fallible boundaries

    Check whether you can protect a file handle, preserve EOF, and validate every command-line argument before using it.

    01What does fopen return when it cannot open a requested file?
    02Which mode creates or truncates a text file for writing?
    03Why must a loop store fgetc in an int instead of char?
    04What is argv[0] in a hosted C program?
    05Before accessing argv[2], which condition must be true?
    06Which function is a safer general choice for parsing a decimal command-line value?
    07Where should a command normally write machine-readable result data?
    08Why should a program avoid passing a constructed argument string to system()?
    PREVIOUS LESSONStructures, Enumerations, and Unions
    NEXT LESSONDynamic Memory Allocation
    ON THIS PAGEFiles and Command-Line ArgumentsExternal boundariesStandard streamsFILE handlesOpen modesText and binaryCharacter streamsLine buffersFormatted inputRecord parsingCleanup contractsWriting safelyargc and argvShell behaviorUsage contractsNumber parsingOptions and pathsIndependent labWorked designLesson reviewKnowledge check
    Course contents