4.2.32 · D2Operating Systems

Visual walkthrough — File operations — open, read, write, seek, close

2,175 words10 min readBack to topic

We will follow this exact chain, one picture per link:

open

read

write

lseek

past the end

close

Related vault stops on the way: System calls and the kernel boundary, File descriptor table, File system & inodes, Buffered I/O (fopen/fread).


Step 1 — Draw the file as a row of numbered boxes

WHAT. Before any code, picture the raw material. A file is nothing clever: it is a straight line of bytes, and a byte is just one little box that holds a number from 0 to 255. We number the boxes starting at 0. So a 20-byte file is boxes .

WHY this picture. Every single call below is defined by which box we are pointing at. If you don't first agree that boxes have addresses , phrases like "offset 16" or "seek to end" are meaningless. Everything hangs off this ruler.

PICTURE. Look at the row of boxes. The number written under each box is its address (its position). The very first byte is address , not — this off-by-one is where half of all file bugs live.


Step 2 — open: get a ticket, plant the arrow at 0

WHAT. Your program cannot touch the disk directly (that hardware belongs to the kernel — see System calls and the kernel boundary). So you ask:

Term by term:

  • — the name; the kernel follows it to the file's inode.
  • — the access mode, a promise about what you'll do.
  • — the returned file descriptor: the smallest unused small integer (0,1,2 are usually taken by stdin/stdout/stderr, so you often get 3). It indexes your File descriptor table.

WHY a returned number at all? You need a short handle to name "this open file" in every later call. The kernel also creates, off to the side, an open file description box that stores the offset — and it plants that arrow at .

PICTURE. The ticket fd = 3 on the left points into a kernel box holding offset = 0, and that arrow sits just left of byte 0.


Step 3 — read: copy bytes, then slide the arrow right

WHAT. We ask for up to 8 bytes:

The kernel copies bytes starting at the arrow into your buf, then does the crucial thing:

WHY the arrow moves by n, not by 8. You asked for 8 but got n. The arrow advances by what was actually delivered, so a second read continues exactly where the first left off — automatic sequential reading, no manual counting.

WHY can n < 8? The file might have fewer than 8 bytes left, or (for a pipe/socket) less data has arrived. So n is the truth; 8 was only a wish.

PICTURE. Before: arrow at 0. read copies bytes 0–7 into buf. After: the arrow has jumped to sit left of byte 8. Only the first n boxes of buf are valid — the tail is garbage.


Step 4 — write: same arrow, opposite direction

WHAT. Writing is the mirror of reading — bytes flow from your buf into the file at the arrow:

and again the arrow slides right by n:

WHY check n here too? A short write (n < count) can happen if the disk fills or a signal interrupts. So production code loops until everything is out:

ssize_t total = 0, n;
while (total < count) {
    n = write(fd, buf + total, count - total);
    if (n < 0) break;      // real error
    total += n;            // slide our own bookkeeping past what's done
}

PICTURE. The arrow starts at some box; write overwrites boxes rightward and drags the arrow the same distance. Compare the red arrow position to Step 3 — identical motion, bytes just go the other way.


Step 5 — lseek: teleport the arrow without touching a single byte

WHAT. Sometimes you don't want the next byte — you want byte 16, or the last 4 bytes. lseek moves the arrow by pure arithmetic, copying nothing:

\texttt{offset} & \texttt{whence = SEEK\_SET} \quad(\text{count from } 0)\\ \texttt{cur} + \texttt{offset} & \texttt{whence = SEEK\_CUR} \quad(\text{count from here})\\ \texttt{size} + \texttt{offset} & \texttt{whence = SEEK\_END} \quad(\text{count from the end}) \end{cases}$$ Term by term for the classic "last 4 bytes" call on a 20-byte file: - $\texttt{whence = SEEK\_END}$ → start measuring from $\texttt{size} = 20$. - $\texttt{offset} = -4$ → step **left** 4 boxes (negative = leftward). - $\texttt{newpos} = 20 + (-4) = 16$. Now a `read(fd, tail, 4)` covers boxes 16, 17, 18, 19 — exactly the last four. **WHY it exists.** Files allow ==random access==: you needn't read from the start every time. `lseek` is the tool that answers "put the arrow *there*." **PICTURE.** Three arrows for the three `whence` modes, all landing on box 16 from different starting points (from 0, from a middle "cur", and from the right-hand end). No bytes shaded — nothing moved but the arrow. > [!mistake] "seek is slow / moves the disk head." > The word *seek* sounds mechanical, but `lseek` just updates the **integer** offset in the kernel. > Zero bytes cross the boundary. It's effectively free. --- ## Step 6 — Edge case: push the arrow *past* the end, then write **WHAT.** What if we `lseek` to offset 25 on a 20-byte file (past the end) and then `write` one byte? The arrow is legal even beyond `size`. Writing there **stretches** the file: boxes 20–24 spring into existence as an implicit **hole of zero bytes**, and your byte lands at 25. **WHY this matters.** Those hole bytes read back as `0` but may take **no disk space at all** — the [[File system & inodes|filesystem]] just records "these are zeros." This is a ==sparse file==. **PICTURE.** The arrow sits at 25, far right of the old end (20). Boxes 20–24 are drawn as empty "phantom" zero boxes; only box 25 (red) holds real data. > [!recall]- What does a `read` over the hole return? > Reading boxes 20–24 returns bytes equal to `0` ::: yes, holes read back as zeros even though no disk block was allocated. --- ## Step 7 — Degenerate case: the arrow at the end → `read` returns 0 **WHAT.** Suppose the arrow already sits at `size` (box 20 on a 20-byte file). There are no more boxes to the right. Now `read(fd, buf, 8)` returns: $$\texttt{n} = 0$$ **WHY 0 and not −1.** Zero means "**nothing left, cleanly**" — this is ==End Of File (EOF)==, not an error. Errors return $-1$. This single fact is what makes copy loops terminate correctly: ```c while ((n = read(src, buf, sizeof buf)) > 0) // 0 stops us gracefully; -1 is checked separately /* write n bytes */; ``` **PICTURE.** The arrow parked at the right wall past box 19; the `read` box comes back empty with a big `n = 0` tag. > [!mistake] "read == 0 is an error." > `0` = clean EOF. `-1` = error. Treating EOF as an error makes a copy stop one call too early or > loop forever. --- ## Step 8 — `close`: hand the ticket back **WHAT.** Finally: $$\underbrace{\texttt{ret}}_{0 \text{ ok, } -1 \text{ error}} = \texttt{close}(\texttt{fd})$$ This releases the ticket back to your [[File descriptor table]] and drops the reference count on the open file description. When that count hits 0 the kernel **flushes** any pending data and throws the offset arrow away. **WHY you must.** Descriptors are a **limited** resource (see [[ulimit and resource limits]]). A long-running program that forgets to `close` slowly leaks tickets until a fresh `open` fails with `EMFILE`. (Note: `close` is about the *raw* fd; the [[Buffered I/O (fopen/fread)]] layer adds its own flush on top.) **PICTURE.** The ticket `3` returns to the table, the kernel's offset box vanishes, and slot 3 is now free for the next `open` to reuse. --- ## The one-picture summary One arrow, one file, the whole lifecycle on a single timeline: `open` plants it at 0, `read`/`write` slide it right by `n`, `lseek` teleports it (even past the end into a sparse hole), an end-parked `read` yields `0`, and `close` retires the ticket. > [!recall]- Feynman retelling — the whole walkthrough in plain words > A file is a row of numbered boxes starting at box 0. You can't touch the boxes yourself, so you ask > the OS: **open** the file. It gives you a numbered ticket and secretly plants a little red arrow at > box 0 — that arrow is the *offset*, meaning "the box I'll touch next." **Read** copies boxes from > the arrow into your bag and slides the arrow right by however many it actually gave you (maybe fewer > than you asked). **Write** does the mirror image, pouring your bytes into the boxes and sliding the > arrow the same way. Both share the *same* arrow, so they keep marching forward. **Seek** is pure > teleportation — it just re-parks the arrow by arithmetic ("from the start," "from here," or "from > the end"), moving zero bytes, so it's basically free. If you park the arrow past the last box and > write, the gap fills with invisible zeros — a sparse file. If the arrow is already at the very end, > a read gives you `0`, which means "all done," not "broken." Finally **close** hands the ticket back > so someone else can use it. Tickets are scarce; never forget to return them. > [!mnemonic] The arrow rule > **Every file call is "where is the arrow, and does it move?"** — `open` plants it, `read`/`write` > nudge it by `n`, `lseek` teleports it, `close` throws it away. --- ## Active recall By how much does the offset advance after a successful `read`/`write`? ::: By `n`, the actual bytes transferred (not the requested count). Compute `lseek(fd, -4, SEEK_END)` on a 20-byte file. ::: newpos = size + offset = 20 + (-4) = 16. The arrow sits at offset = size and you `read`. What comes back? ::: `n = 0`, meaning EOF (clean end), not an error. You `lseek` to 25 on a 20-byte file and write 1 byte. What are bytes 20–24? ::: A hole of implicit zero bytes — a sparse file; new EOF is 26. Does `lseek` transfer any bytes across the kernel boundary? ::: No — it only updates the integer offset; effectively free. Why must a robust `write` loop? ::: A single `write` may store fewer than `count` (short write); loop, advancing `total += n`, until done.