Skip to content

Lecture 13 - Files, Command-Line Arguments, and Shared Libraries

Every program we have written so far talks to the world through exactly two channels: it reads from stdin and writes to stdout, and the moment it returns from main, everything it computed is gone. Today we give a C program three new ways to reach outside itself. Files let data outlive the process that made it - write now, read back tomorrow. Command-line arguments let the shell hand a program its input before it even starts running - the filename in ./tool filename instead of a scanf prompt. And shared libraries let a C function be called by a completely different program - even one written in Python. That last one pays off a promise from Lecture 1, where we contrasted C's compiled, close-to-the-machine model with Python's interpreted convenience: today we stop choosing between them and use each for what it is best at.


1. Files and the FILE *

A file is a named sequence of bytes that lives on disk, outliving any one program run. In C the standard library gives you a handle to an open file called a FILE * (a pointer to an opaque FILE object - you never look inside it, you just pass it around). You have already used three FILE * values without knowing it: stdin, stdout, and stderr are all FILE *.

To work with a file of your own, you open it, use it, then close it:

#include <stdio.h>

FILE *f = fopen("scores.txt", "r");   /* open for reading */
if (f == NULL) {                      /* the open can fail! */
    perror("scores.txt");             /* prints: scores.txt: No such file... */
    return 1;
}
/* ... read from f ... */
fclose(f);                            /* release the file */

fopen takes a filename and a mode string that says what you intend to do. The mode determines whether the file must already exist, whether opening it destroys what was there, and where the read/write cursor starts:

"r"   read     file must exist; cursor at start;      fails if not found
"w"   write    create if needed; TRUNCATE to empty;   old contents destroyed
"a"   append   create if needed; cursor at END;       old contents kept
"r+"  read+write   file must exist; cursor at start;  does not truncate

Two rules to internalize now, because both cause real bugs:

  • fopen returns NULL when it fails (file not found, no permission, disk full). Every fopen needs a NULL check, exactly the way every malloc needs one. Skipping it means the first fread/fprintf on a NULL handle crashes.
  • "w" throws the file away. Opening an existing, important file with "w" truncates it to zero bytes before you have written a single thing. Use "a" to add to a file, "r+" to edit in place.

And the contract, which should feel familiar: every fopen needs exactly one fclose. An unclosed file leaks an operating-system resource, and - worse - data you "wrote" may still be sitting in a buffer and never actually reach the disk until fclose (or the program's clean exit) flushes it.


2. Writing and reading text: fprintf, fgets, fscanf

Once you hold a FILE *, writing to a file is just printf with a stream argument in front. In fact printf(...) is defined to be fprintf(stdout, ...). So everything you already know about format strings transfers directly:

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

fprintf(f, "ada 95\n");            /* write a formatted line */
fprintf(f, "%s %d\n", name, pts);  /* same format specifiers as printf */
fputs("grace 88\n", f);            /* fputs: write a plain string, no format */

fclose(f);

Reading text back has two common tools. fgets reads one line at a time into a buffer you provide, and is the safe workhorse because you tell it the buffer size so it can never overflow:

char line[256];
FILE *f = fopen("scores.txt", "r");
if (f == NULL) { perror("scores.txt"); return 1; }

fgets(line, sizeof line, f);   /* reads up to one line, incl. the '\n' */

fscanf reads formatted fields, the mirror of fprintf, and is convenient when a line has a known shape like name score:

char name[64];
int score;
fscanf(f, "%63s %d", name, &score);   /* read a word and an int */

fscanf is handy but fragile: one malformed line and it silently stops matching. For anything a human might have typed, prefer reading a whole line with fgets and then parsing the buffer yourself. That is the pattern we build on next.

In-class exercise: Part B, Exercise B2 (on the computer) - write a few lines to a file with fprintf, then reopen it and read them all back.


3. Reading a whole file, line by line

The single most useful file idiom in C is the loop that reads a file to the end, one line at a time. fgets returns the buffer on success and NULL at end-of-file (or on error), which makes it a natural loop condition:

char line[256];
while (fgets(line, sizeof line, f) != NULL) {
    /* process one line; line still contains its trailing '\n' */
    fputs(line, stdout);
}
scores.txt:            program output:
ada 95                 ada 95
grace 88     ---->      grace 88
alan 72                alan 72

A few things worth knowing about the end of the loop:

  • NULL is the end signal, and it means either "clean end of file" or "a read error happened." To tell them apart, ask afterward: feof(f) is true for a normal end, ferror(f) is true if something went wrong.
  • Each line still holds its '\n'. If you want the text without the newline, overwrite it: line[strcspn(line, "\n")] = '\0';.
  • perror prints your message followed by a human-readable reason for the most recent failing library call ("No such file or directory", "Permission denied"). It is the right way to report a failed fopen or read, far better than a bare "error".

In-class exercise: Part A, Exercise A1 (pen and paper) - reason about what "r", "w", "a", and "r+" each do to an existing file, and why the NULL check is not optional.


4. Binary I/O: fread and fwrite

Text files store everything as human-readable characters: the integer 95 is written as the two bytes '9' and '5'. That is portable and readable, but it costs space and a parse step on the way back in. When you just want to save memory to disk exactly as the machine holds it, use binary I/O: copy the raw bytes straight out and straight back.

fwrite and fread move a block of count items, each size bytes, between memory and a file. Open the file in binary mode by adding a b to the mode string ("wb", "rb"):

typedef struct {
    char name[16];
    int  score;
} Record;

Record table[3] = { {"ada", 95}, {"grace", 88}, {"alan", 72} };

/* write the whole array as raw bytes */
FILE *f = fopen("scores.dat", "wb");
if (f == NULL) { perror("scores.dat"); return 1; }
fwrite(table, sizeof(Record), 3, f);   /* ptr, item size, count, stream */
fclose(f);

/* read it straight back into another array */
Record loaded[3];
f = fopen("scores.dat", "rb");
if (f == NULL) { perror("scores.dat"); return 1; }
size_t n = fread(loaded, sizeof(Record), 3, f);  /* returns items read */
fclose(f);

Both return the number of items actually transferred, so fread's return value tells you how many records you really got (fewer than requested means end of file or a short/interrupted read).

memory (a Record)              scores.dat (raw bytes on disk)
+------------------+           +----+----+ ... +----+----+----+----+----+
| name[16] | score |  fwrite   | 'a'| 'd'| ... |  0 | 95 |  0 |  0 |  0 |
+------------------+  ----->    +----+----+ ... +----+----+----+----+----+
                                 \___ 16 bytes ___/ \___ 4-byte int ___/

Binary is compact and needs no parsing, but the trade-off is real:

  • The file is not human-readable - open scores.dat in an editor and you see garbage.
  • It is not portable across machines. The exact byte layout depends on integer size, struct padding, and byte order (endianness), so a file written on one architecture may not read correctly on another. Text (or a defined format) is the answer when a file must travel between systems.

Use binary I/O for a program's own scratch/save files where speed and size matter; use text when a human or another program must read it.

In-class exercise: Part B, Exercise B4 (on the computer) - dump an array of records to a binary file with fwrite, read it back with fread, and confirm every field survived the round trip.


5. Command-line arguments: argc and argv

Until now main took no parameters. Its full signature accepts the words the user typed after the program name on the command line:

int main(int argc, char *argv[]) {
    ...
}
  • argv ("argument vector") is an array of strings, one per word on the command line. argv[0] is the program's own name, and the real arguments start at argv[1].
  • argc ("argument count") is how many strings are in argv, including argv[0]. The array is NULL-terminated: argv[argc] is always NULL.

For the command ./report --top 3 scores.dat:

argc = 4

argv[0] -> "./report"
argv[1] -> "--top"
argv[2] -> "3"
argv[3] -> "scores.dat"
argv[4] -> NULL

Every argument arrives as a string, even numbers: argv[2] is the two-character string "3", not the integer 3. To get a number, convert it. Prefer strtol over atoi, because strtol can tell you whether the text was actually a valid number:

#include <stdlib.h>

char *end;
long n = strtol(argv[2], &end, 10);   /* base 10 */
if (*end != '\0') {                   /* leftover chars => not a clean number */
    fprintf(stderr, "not a number: %s\n", argv[2]);
    return 1;
}

A program that needs an argument should check argc first and, if it is missing, print a short usage message to stderr and return a nonzero exit code so the shell (and any script calling it) knows it failed:

if (argc < 2) {
    fprintf(stderr, "usage: %s <filename>\n", argv[0]);
    return 1;
}

In-class exercise: Part A, Exercise A2 (pen and paper) - index argv by hand for a given command line, and Part B, Exercise B1 (on the computer) - sum the integer arguments passed on the command line.


6. Putting it together: a tool named on the command line

Files and arguments combine into the shape of nearly every real command-line tool: take a filename as an argument, open it, process it, report a result. Here is a miniature wc - it counts the lines in the file you name:

#include <stdio.h>

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "usage: %s <filename>\n", argv[0]);
        return 1;                      /* no file named */
    }

    FILE *f = fopen(argv[1], "r");     /* the argument is the filename */
    if (f == NULL) {
        perror(argv[1]);               /* say why it could not open */
        return 1;
    }

    long lines = 0;
    char buf[256];
    while (fgets(buf, sizeof buf, f) != NULL) {
        lines++;
    }
    fclose(f);

    printf("%ld\n", lines);
    return 0;
}
$ ./lc scores.txt
3
$ ./lc nope.txt
nope.txt: No such file or directory
$ ./lc
usage: ./lc <filename>

Notice the three exits: success (0), a file that would not open, and a missing argument. A well-behaved tool distinguishes them with its exit code, which is exactly what lets tools be chained together in the shell.

In-class exercise: Part B, Exercise B3 (on the computer) - extend this into a line/word/character counter that takes its filename from the command line.


7. Python vs C: two languages, two trade-offs

Step back and compare the two languages you now know. Neither is "better" - they sit at opposite ends of a trade-off, and knowing which end you are on is the point.

                     Python                     C
-------------------  -------------------------  ---------------------------
typing               dynamic (checked at run)   static (checked at compile)
memory               garbage-collected          manual (malloc/free)
speed                interpreted, slower loops   compiled, near the hardware
writing it           fast, few lines            verbose, explicit
safety               hard to corrupt memory     you can, and will, crash
ecosystem            enormous, batteries incl.   smaller standard library

Python is wonderful for getting something working fast and gluing systems together. C is what you reach for when a piece of code has to be fast or close to the hardware - a tight numeric loop, a device driver, the core of a data structure. The classic pain point is a hot loop in Python: summing ten million numbers in a Python for loop is dramatically slower than the same loop in C, because every iteration does interpreter bookkeeping.

Here is the key realization: you do not have to choose. The dominant pattern in real scientific and systems software is to write the 5% of the code that is performance-critical in C, and drive it from Python for everything else. To make that work, we need a way for a Python program to call a C function - and that is exactly what a shared library provides.


8. Static vs shared libraries

Back in Lecture 2 you compiled several .c files into .o object files and linked them into one executable. That is static linking: the machine code for every function is copied into the final program, and the .o files are no longer needed once the executable exists. Bundle several .o files for reuse and you have a static library, a .a archive - still copied into each program that uses it.

A shared library (a .so file on Linux, "shared object") is the alternative. Its code is not copied into the programs that use it. Instead it lives in one file on disk, and programs load it at run time and share that single copy:

static (.a):   prog1 [ + copy of libfoo ]     each program carries its own copy
               prog2 [ + copy of libfoo ]

shared (.so):  prog1 --\
                        >--- libfoo.so         one copy, shared at run time
               prog2 --/

Shared libraries are how one compiled chunk of C can be used by many programs - including programs written in another language entirely. To build one, compile with two extra flags:

  • -fPIC - "position-independent code," so the library works no matter where in memory it gets loaded.
  • -shared - produce a shared object instead of an executable.
clang -Wall -Wextra -std=c17 -fPIC -shared array_sum.c -o libarraysum.so

There is no main in a shared library - it is a bag of functions for someone else to call. That "someone else" is who we turn to next.


9. Calling C from Python with ctypes

Python ships with a module called ctypes that can load a shared library and call its C functions directly - no glue code, no recompiling Python. Let us give Python a genuinely fast array sum.

First, the C side. A plain function that takes a pointer to an array of long and its length, and returns the total:

/* array_sum.c */
long array_sum(const long *a, int n) {
    long total = 0;
    for (int i = 0; i < n; i++) {
        total += a[i];
    }
    return total;
}

Build it into a shared library:

clang -Wall -Wextra -std=c17 -fPIC -shared array_sum.c -o libarraysum.so

Now the Python side loads that .so and calls array_sum as if it were a Python function:

# sum.py
import ctypes

lib = ctypes.CDLL("./libarraysum.so")      # load the shared library

# tell ctypes the C function's types - it assumes int otherwise
lib.array_sum.argtypes = [ctypes.POINTER(ctypes.c_long), ctypes.c_int]
lib.array_sum.restype  = ctypes.c_long

n = 10_000_000
arr = (ctypes.c_long * n)(*range(n))       # a C array of n longs

print(lib.array_sum(arr, n))               # calls into compiled C

The one step you must not skip is declaring argtypes (the parameter types) and restype (the return type). ctypes has no way to see the C function's signature, so by default it assumes every argument and the return value are plain int. Passing a pointer or returning a long without saying so gives silent garbage. Spell the types out and it just works.

Time the same ten-million-element sum both ways, timing only the call itself (not the setup):

pure Python:   sum(range(10_000_000))        ~ 0.1-0.4 s
C via ctypes:  lib.array_sum(arr, n)          ~ 0.01 s

Be careful what you measure. sum(range(n)) looks like "pure Python," but range and sum are both implemented in C inside CPython, so this isn't testing interpreted Python at all - it is already fast for reasons that have nothing to do with our C function. The real cost on the ctypes side is building arr = (ctypes.c_long * n)(*range(n)): that line constructs a ten-million-element ctypes array one Python-level step at a time, and if you include it in your timing it can easily dominate and erase the advantage of calling into C. Time lib.array_sum(arr, n) by itself, after arr is already built, to see the real payoff: a hand-written C loop over the same data is still many times faster than an equivalent hand-written Python for loop (e.g. total = 0; for x in arr: total += x), which is the fairer comparison. This - a fast C core behind a friendly Python interface - is not a toy: it is the shape of the tools you already use.

In-class exercise: Part B, Exercise B5 (on the computer) - write and build your own .so, then call it from a short Python script with ctypes.


10. A glance beyond today

The C-shared-library-behind-a-nicer-interface trick scales far past our example:

  • numpy is, at its heart, exactly this: fast array math written in C (and Fortran), wrapped in a Python API. Every time you write arr.sum() in numpy, you are doing what we just did by hand.
  • dlopen/dlsym let a C program itself load a .so at run time and look up a function by name - the mechanism behind plugins, where a program loads code it was not compiled with.
  • The reason any of this works across languages is the C ABI (application binary interface): a stable, agreed-upon convention for how functions pass arguments and return values in machine code. C's ABI is the common tongue that lets Python, Rust, Go, and others all call into the same compiled library.

None of these are on today's menu to build, but they are the same idea one step larger, and worth recognizing by name.


11. Wrap-up

  • A file is bytes on disk that outlive the program. Open it with fopen (checking for NULL), read/write through the FILE *, and fclose it - one fclose per fopen, the same discipline as malloc/free.
  • Write text with fprintf/fputs; read it with the while (fgets(...)) line loop, which returns NULL at end of file. Text is portable and readable; binary (fwrite/fread) is compact and fast but machine- and layout-specific.
  • main(int argc, char *argv[]) receives the command line: argv[0] is the program name, real arguments start at argv[1], argv[argc] is NULL. Arguments are strings - convert numbers with strtol, check argc, and return a nonzero exit code on misuse.
  • Python and C trade off convenience against speed and control. You do not have to pick one: write the hot code in C, compile it into a shared library (-fPIC -shared, a .so), and call it from Python with ctypes - declaring argtypes and restype so the types line up.
  • This closes our single-program C arc. You can now persist data, take input from the shell, and hand your fast C code to the wider world. Next we turn to algorithms and graphs.