Abstractions 2: Files and I/O
Source: UC Berkeley CS162, Fall 2020/2021 - Prof. John Kubiatowicz, Lecture 4
Every program reads and writes: files, the terminal, the network. This lecture is
about the abstraction that makes all of those look the same - the file - and
the two levels of API you use to touch it: high-level buffered streams
(fopen/fread) and low-level file descriptors (open/read). The payoff
is the POSIX idea that "everything is a file".
The I/O and storage layers
I/O is a stack of layers. Your program calls a high-level library (streams), which calls a low-level interface (file descriptors), which traps into the kernel via a system call, which goes through the file system and an I/O driver down to the physical device.

Files and the working directory
A file is named by a path. Every process carries a current working directory (CWD), so relative paths are interpreted relative to it; absolute paths ignore it. The CWD is per-process state the kernel tracks on your behalf.

- Absolute path (starts with
/) - resolved from the root, ignores the CWD. - Relative path - resolved against the process's current working directory.
- The CWD is per-process state;
chdir()changes it and affects only that process.
High-level file I/O: C streams
The high-level API works on streams - a FILE * opened with fopen, carrying
an internal position and a user-space buffer. You read and write through it and
close it with fclose; an error is reported by a NULL pointer.

The stream API is rich: character, line, block, and formatted transfers.

A typical copy loop reads a block, writes a block, and repeats until end of file.

You can also move the position explicitly.

System programming is where sloppy error handling bites. fopen can return
NULL; fread/fwrite can transfer fewer elements than asked; a syscall can
fail and set errno. Real code checks every one of these. Examples (including
some on these slides) skip the checks for brevity - production code must not.
Low-level file I/O: file descriptors
Underneath streams is the raw system-call interface. Here a file is an integer
file descriptor returned by open, used by read/write, and released by
close - no user-space buffering, one syscall per call.

Three descriptors are open before your program starts.


- A file descriptor is a small non-negative integer naming an open file within a process.
- 0/1/2 are stdin/stdout/stderr, open before
mainruns. - Low-level calls (
open/read/write/close) are unbuffered - each is a system call.
Why two levels? Buffering
If the low-level calls do everything, why have streams at all? Buffering. A system call is far more expensive than a function call, so batching many small reads/writes into a few large syscalls (in a user-space buffer) is a big win.

- A system call is about 25x more expensive than a plain function call.
- That overhead is on the order of 100 ns per call - negligible once, ruinous if you do it per byte.
- Streams exist to turn thousands of tiny transfers into a handful of large syscalls.
What a file descriptor really points to
A descriptor is not the file itself - it's an index into the process's descriptor
table, which points to a kernel open file description (holding the file and
the current position). fork copies the descriptor table, so parent and child
share the same open file description - and thus the same position.

This shared-description design is what makes redirection and pipes work: point a process's descriptor 1 at something and everything it writes to stdout goes there.

Everything is a file
That is the whole idea. Files, devices, pipes, and sockets are all reached through
the same open/read/write/close descriptors, so a program written for files
works on any of them.

Recap
- I/O is layered: streams on top of file descriptors on top of the syscall boundary, the file system, and drivers.
- Every process has a current working directory; relative paths resolve against it.
- High-level streams (
fopen/fread/fwrite, buffered) trade a little overhead for far fewer syscalls; low-level descriptors (open/read/write, unbuffered) are the raw interface underneath. - A descriptor is an index into a per-process table pointing at a shared kernel
open file description;
forkshares descriptions, which is how redirection and pipes work. - Everything is a file - files, devices, pipes and sockets share one interface.
- Next: using that interface for communication between processes - IPC, pipes and sockets.