Four verbs for one integer
From guide 1 you already hold the central object: a file descriptor is just a small non-negative integer — 0, 1, 2, then 3, 4, and up — that the kernel hands you to name one open file. The integer itself does nothing; it is a ticket, an index into a per-process table the kernel keeps on your behalf. This guide is about the four system calls that turn that ticket into actual work. They read almost like a sentence: open() to acquire a descriptor for a file, read() to pull bytes out through it, write() to push bytes in through it, and close() to hand the descriptor back when you are done. Four verbs, one integer, and essentially every program that touches a file does so through these four.
Hold on to one thing that the earlier rung on syscalls drove home: these four are system calls, not ordinary function calls. When you write read(fd, buf, n), control traps into the kernel — it crosses a real boundary, switches into kernel mode, the kernel does privileged work on a device or disk for you, and then returns. That boundary is why they can fail in ways pure arithmetic never does (a disk fills, a file vanishes), and it is why guide 3 will care so much about not crossing it more often than you need to. For now just keep the picture: each of these four is a trip across the user/kernel line and back.
open(): asking the kernel for a descriptor
You start by naming the file you want with a path and saying what you intend to do with it. That is open(). You hand it a pathname like "notes.txt" and a set of flags describing your intent, and it hands back a fresh descriptor — the lowest-numbered free integer in your table, which is why a program that has only the three standard streams open will usually get 3 from its first open(). The flags are the important half. O_RDONLY says you only want to read; O_WRONLY, only to write; O_RDWR, both. To those you OR in extra wishes: O_CREAT to create the file if it does not exist, O_TRUNC to chop an existing file down to zero length first, O_APPEND to make every write land at the end. You combine them with the bitwise OR, exactly the bit-flag pattern from the data-representation rung.
Two tiny examples make the shape concrete. To read an existing file, fd = open("notes.txt", O_RDONLY) hands back a descriptor or -1; you check it immediately with `if (fd == -1) { perror("open"); return 1; }`. To create or overwrite one for writing, out = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644) takes a third argument — a permission mode like 0644 — but here is a subtle point worth stating: that mode is consulted only when O_CREAT actually creates a brand-new file, and is ignored entirely when you open a file that already exists. So 0644 sets the permissions of the file you create, never of one you merely reopen.
read() and write(), and the hidden cursor between them
Both read() and write() take the same three arguments: a descriptor, the address of a buffer in your own memory, and a count of bytes. write(fd, buf, n) copies up to n bytes out of your buffer and into the file; read(fd, buf, n) copies up to n bytes into your buffer from the file. The buffer is just a region of your memory — a `char buf[4096]` on the stack, or a block from malloc() — and you are telling the kernel "move bytes between this memory and that descriptor." Nothing here knows or cares whether the descriptor leads to a file on disk, a terminal, or a pipe; that uniformity is the whole everything-is-a-file idea you met in guide 1 paying off — the same two calls move bytes regardless of what is on the other end.
But how does read() know where in the file to start, when you never told it? This is the quietly important piece. Behind your descriptor the kernel keeps a single number called the file offset — a cursor measured in bytes from the start of the file. When you open() a file it starts at 0. Each read() and write() begins at the current offset and, having moved k bytes, advances the offset by k, so the next call naturally picks up where the last one left off. You never pass a position; the offset is implicit, carried by the open file behind the descriptor. Read 100 bytes and the cursor sits at 100; write 50 more and it sits at 150. That hidden, advancing cursor is what makes a plain loop of read() calls walk straight through a file from front to back.
The honest part: short reads and partial writes
Here is the truth beginners are most often not told, and it causes real bugs: read() and write() may move fewer bytes than you asked. Look again at the return value. read() returns the number of bytes actually placed in your buffer this call — it may be less than n. Ask for 4096 and you might get 512, or 1, even though the file has far more left. This is a short read, and it is normal: a terminal hands you a line at a time, a network connection hands you whatever has arrived so far, a pipe hands you only what the other side has written. Likewise write() returns how many bytes it actually accepted, which can also be short — the disk filled partway, or a pipe's buffer was nearly full. The count is a report of what happened, never a guarantee that all n moved.
Because of this, you almost never write a single bare read() or write() and trust it. You write a loop that keeps going until the count is met or the call signals it is done. For writing, the pattern is to advance a pointer and shrink the remaining count by however many bytes were taken, calling write() again on the leftover until none remains. Skipping this is a classic and silent corruption: a program that calls write(fd, buf, n) once, sees it return n-100, and assumes the whole thing went out, has just dropped the last 100 bytes of its data without any crash to warn you. The loop is not pedantry; it is the only correct way to move a known amount of data through these calls.
- Keep two trackers: a pointer p into your data and a count left of bytes still to write.
- Call w = write(fd, p, left) once to push out as much as it will take this round.
- If w == -1, the write failed; check errno (retry on EINTR, otherwise report and stop).
- Otherwise advance p by w and subtract w from left, so the next call writes only the leftover.
- Repeat until left reaches 0 — now, and only now, are all your bytes truly written.
End of file, and a read loop done right
Reading needs one more signal: how do you know when the file is exhausted? read() answers this with its return value too, and the rule is precise. A return greater than 0 is the count of bytes you got. A return of exactly 0 means end of file — there is nothing left to read, the cursor is at the end, and further reads will keep returning 0. And a return of -1 is a genuine error, with errno saying why. Those three cases — positive, zero, negative — are the entire vocabulary of read(), and a correct loop branches on all three. Notice that 0 is not an error and not a short read; it is the clean, deliberate way the kernel says "that is everything." Confusing a 0 (real EOF) with a -1 (failure) is a common beginner slip.
char buf[4096];
ssize_t n;
while ((n = read(in, buf, sizeof buf)) > 0) {
/* n bytes are valid in buf[0..n-1]; may be fewer than 4096 */
write_all(out, buf, n); /* the loop-until-done writer */
}
if (n == -1) { /* read() error, not EOF */
perror("read");
/* handle: propagate or abort */
}
/* n == 0 here means clean end of file: every byte was read */Read that loop slowly, because it is the canonical shape of nearly all low-level I/O. It reads into a buffer, gets back however many bytes the kernel chose to give this time, processes exactly that many, and loops; only a return of 0 ends it cleanly, and a -1 is split out as an error. The same skeleton copies a file, drains a pipe, or pulls from a socket — only the descriptors change. Internalise it once here and you have the backbone of the next three guides, where the bytes come from buffered streams, from inodes on a real filesystem, and from pipes wired between two processes.
close(): giving the descriptor back, and why it matters
When you are finished with a file you call close(fd). This releases the descriptor: the entry in your file-descriptor table is freed, the integer becomes available again (the very next open() may well reuse that number), and the kernel lets go of the resources behind it. A descriptor is a finite resource — a process has a limit on how many it may hold open at once, often around 1024 by default — so a long-running program that opens files in a loop and never closes them will eventually run out and see open() start failing with EMFILE. That is a descriptor leak, the I/O cousin of the memory leak you met on the heap: a resource acquired and never released, slowly starving the program.
There is a second, sharper reason to close, and it ties back to the previous section. Buffered data and queued writes may only be fully committed when the descriptor is closed, and — honestly — even close() itself can fail and return -1. If the last bytes you wrote were still in flight to a full disk or a network that hung up, close() may be the call that finally reports it. So a careful program checks the return value of close() too, especially on a file it was writing. Closing is not a mere courtesy you can skip because the program is about to exit anyway; on a file you wrote, it can be the difference between your data being on disk and your data being silently lost.
Step back and you have the full life of one file: open() to acquire a descriptor with a clear intent, a loop of read() or write() that respects the count it gets back and the advancing offset, and close() to release the descriptor and flush the last bytes — each call checked, because each one crosses into the kernel and each one can fail. That is the raw machinery. Guide 3 layers a buffer over it so you are not paying for a kernel trip on every byte; guide 4 follows the path you open() down into directories and inodes; guide 5 wires two descriptors together with a pipe. But all of them rest on these four verbs, and on reading their return values honestly.