JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

File Descriptors: The Integer Behind the File

When your program opens a file, the kernel does not hand you the file — it hands you a small integer, like 3. This guide is about that integer: what it really points to, why nearly everything in a Unix system is reached through one, and why getting this idea in your bones makes the rest of files, pipes, and I/O suddenly make sense.

The kernel hands you a number, not a file

You already know the most important fact about this rung before we start: your program cannot touch the disk by itself. Reaching a file means asking the kernel across the system-call boundary you drew in the last rung. So picture the moment you ask it to open a file. What would you expect to get back — a chunk of the file's contents? A pointer into the disk? Neither. The kernel keeps the file, all its real machinery, entirely on its own side of the wall, and hands you back something almost insultingly small: a single non-negative integer. That integer is a file descriptor, and on a fresh program the first one you open is usually `3`.

Why a number, and not a pointer to some rich struct describing the file? Because the file's real state — where it lives on disk, how far you have read into it, whether you may write to it — is privileged information that must stay inside the kernel, where a buggy or hostile program cannot corrupt it. A pointer would let you reach in and tamper. A file descriptor is deliberately opaque: it is a name the kernel agreed to answer to, meaningful only when you hand it back across the boundary. You say "do this to descriptor 3" and the kernel, holding the real object, does it. You never get to see the object itself, and that is the whole point.

What the number actually indexes

So if 3 is not the file, what is it? It is an index. Every running process owns a small private array that the kernel maintains on its behalf: the file descriptor table. Slot 0, slot 1, slot 2, slot 3, and so on. When you open a file, the kernel sets up the real bookkeeping object for that open file on its own side, then writes a pointer to it into the lowest free slot of your table — and the number of that slot is the integer it returns to you. The descriptor `3` literally means "entry number 3 in this process's descriptor table". That is why the numbers start small and climb: the kernel always fills the lowest empty slot.

  YOUR PROCESS                          KERNEL SIDE
  ---------------------------           ------------------------------
  fd table (one per process)            open file objects
   index ->  points to
     0   ->  ------------------------>  [ terminal, for input  ]
     1   ->  ------------------------>  [ terminal, for output ]
     2   ->  ------------------------>  [ terminal, for errors ]
     3   ->  ------------------------>  [ notes.txt, offset=0   ]
     4   ->  (empty - next open() lands here)

  read(3, buf, 100)  =  "use slot 3"  ->  kernel reads notes.txt
The descriptor is the left-hand index; the real open-file state lives on the kernel side. read(3, ...) means "act on whatever slot 3 points to". The integer never leaves your side; the file never leaves the kernel's.

One subtlety worth holding onto: the table is per process. Your descriptor 3 and another program's descriptor 3 are entirely unrelated — same number, different tables, different files. This is also why a descriptor cannot simply be mailed to another program as an integer: a bare 3 means nothing in a table that is not yours. (There is a real way to hand a live descriptor across to another process, passing file descriptors, but it is a deliberate kernel-assisted act, precisely because the raw number alone is useless elsewhere. You will meet it in the IPC rung.)

Three doors already open: 0, 1, and 2

Now the small numbers explain themselves. If the first file you open is 3, what happened to 0, 1, and 2? They were already taken before your code ran a single line. By long-standing convention, the kernel and shell arrange for every program to start with three descriptors already in its table: 0 is standard input, 1 is standard output, and 2 is standard error. These are the standard streams you have been quietly using all along — printf() ultimately writes bytes to descriptor 1, and an error message from perror() goes to descriptor 2. They are not magic globals; they are just the first three entries in your descriptor table, occupied before main() begins.

This is what lets the shell quietly rewire a program without the program ever knowing. When you run `$ ./a.out > out.txt`, your program still just writes to descriptor 1 exactly as before — but the shell, before launching you, pointed slot 1 at the file out.txt instead of the terminal. The program asked for "descriptor 1" and got whatever slot 1 happens to point to. That indirection is the gift hiding inside the file descriptor idea: a program reads its input and writes its output through plain numbers, and someone else decides what those numbers connect to. We will pull that mechanism apart in the last guide of this rung, redirection, but you can already see its shape.

Everything is a file (well, almost)

Here is where the idea gets genuinely beautiful. A descriptor does not have to name a file on disk. Slots 0, 1, and 2 above were pointing at your terminal, which is not a file at all — yet you read from it and write to it with the very same descriptor mechanism. This is the famous Unix design principle that everything is a file: a regular disk file, a terminal, a network connection, a pipe to another program, even some kernel state — a huge variety of things are all presented to your program as descriptors you read() and write(). One uniform interface, many different kinds of thing behind it.

The payoff is enormous. Because a pipe, a socket, and a file all wear the same descriptor clothing, a program written to read from descriptor 0 does not care whether the bytes are coming from the keyboard, from a file the shell redirected in, or from another program's output flowing through a pipe. You write your logic once against "a stream of bytes named by a descriptor", and it works against everything that fits that shape. The everything-is-a-file idea is why so few, so small system calls — open, read, write, close — can drive such an enormous range of devices and connections.

The descriptor remembers where you are

There is one more piece of state riding along with a descriptor that you must know about now, because it explains why reading a file twice does not give you the same bytes. The open-file object the descriptor points to remembers a file offset: a single number, the position of the next byte to be read or written, which starts at 0 when you open the file. You do not pass a position to read() — you just say read(3, buf, 100). The kernel reads 100 bytes starting at the current offset, then advances the offset by however many it actually delivered. The descriptor carries a cursor, and that cursor moves as you go.

This is why two back-to-back read() calls on the same descriptor return different parts of the file — the first read left the offset partway in, and the second picks up from there. It is exactly what you want for streaming through a file top to bottom, and it is the reason you do not have to track "where am I?" yourself. The file offset is a tiny but load-bearing idea: it is why a descriptor is a position in an open file, not merely a name for the file. Hold this thought — the very next guide, on open, read, write, and close, leans on it in every example.

Step back and hold the whole shape. A file descriptor is a small integer; it indexes a per-process table; each entry points to a kernel-side open-file object that carries the real state, including the offset; the smallest three are wired to your standard streams before you start; and almost every kind of byte source or sink — files, terminals, pipes, sockets — is reached through this one uniform handle. Get this idea in your bones and the rest of the rung is detail. Next we put it to work: the open, read, write, close system calls that create a descriptor, move bytes through it, and let it go.