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:
fopenreturnsNULLwhen it fails (file not found, no permission, disk full). Everyfopenneeds a NULL check, exactly the way everymallocneeds one. Skipping it means the firstfread/fprintfon aNULLhandle 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:
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);
}
A few things worth knowing about the end of the loop:
NULLis 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
linestill holds its'\n'. If you want the text without the newline, overwrite it:line[strcspn(line, "\n")] = '\0';. perrorprints 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 failedfopenor 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.datin 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 withfread, 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:
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 atargv[1].argc("argument count") is how many strings are inargv, includingargv[0]. The array is NULL-terminated:argv[argc]is alwaysNULL.
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:
In-class exercise: Part A, Exercise A2 (pen and paper) - index
argvby 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.
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:
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):
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 withctypes.
10. A glance beyond today¶
The C-shared-library-behind-a-nicer-interface trick scales far past our example:
numpyis, at its heart, exactly this: fast array math written in C (and Fortran), wrapped in a Python API. Every time you writearr.sum()in numpy, you are doing what we just did by hand.dlopen/dlsymlet a C program itself load a.soat 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 forNULL), read/write through theFILE *, andfcloseit - onefcloseperfopen, the same discipline asmalloc/free. - Write text with
fprintf/fputs; read it with thewhile (fgets(...))line loop, which returnsNULLat 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 atargv[1],argv[argc]isNULL. Arguments are strings - convert numbers withstrtol, checkargc, 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 withctypes- declaringargtypesandrestypeso 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.