File I/O in Linux — Introduction & File Descriptors

File I/O in Linux — Introduction & File Descriptors
Understanding how Linux treats every resource as a file — and the tiny number that connects you to it
Topic
File Descriptors
Level
Beginner
Part
1 of 7

Keywords:

File Descriptor stdin stdout stderr Universal I/O POSIX System Calls Linux File System

What is File I/O?

When a program needs to save data, read a configuration, or send output to a screen, it performs I/O — Input and Output. In Linux (and all UNIX-based systems), almost everything is treated as a file — regular files on disk, your keyboard input, your terminal screen, network sockets, hardware devices. They all use the same basic operations. This is the power of the Universal I/O model.

This post covers the foundation: what a file descriptor is, the three default ones every program gets for free, and why this uniform model makes Linux so elegant and flexible.

The Big Idea — Everything is a File

Why Does Linux Treat Everything as a File?

Think about all the things a program might interact with:

  • A text file saved on your hard drive
  • Your keyboard (input device)
  • Your monitor or terminal (output device)
  • A network connection (socket)
  • A pipe between two programs

In older operating systems, each of these needed different functions and different code. Linux unifies them. No matter what you are reading from or writing to, you use the same four system calls: open(), read(), write(), and close().

This uniformity is called the Universal I/O Model. It is one of the defining features of the UNIX/Linux design philosophy.

Real-World Analogy 🏠

Imagine every type of communication in your house used the same method — speaking into a microphone. Whether you are calling your mom on the phone, sending a message to your neighbour, or recording a voice note, you always use a microphone. The destination changes, but your action is always the same. That is what the Universal I/O model does for Linux.

What is a File Descriptor?

File Descriptors — The Ticket Numbers of I/O

When you open a file (or any resource) in Linux, the operating system gives you back a small non-negative integer. This is called a file descriptor (often shortened to fd). It is just a number — typically 0, 1, 2, 3, 4, and so on.

Think of it like a token number at a bank. When you arrive at the bank, you get a number. The teller does not know your name or your account details upfront — they just call your number and look up everything from there. Similarly, when you open a file, Linux gives you a number (file descriptor), and all future operations on that file use that number.

Each process (running program) has its own personal set of file descriptors. Process A’s file descriptor 3 and Process B’s file descriptor 3 are completely independent — they can refer to totally different files.

How a Process’s File Descriptor Table Works
Process A’s File Descriptor Table ┌────────────────────────────────────────────────┐ │ fd 0 → Standard Input (keyboard) │ │ fd 1 → Standard Output (terminal screen) │ │ fd 2 → Standard Error (terminal screen) │ │ fd 3 → /home/user/data.txt (opened file) │ │ fd 4 → /var/log/app.log (opened file) │ └────────────────────────────────────────────────┘ Process B’s File Descriptor Table (completely separate) ┌────────────────────────────────────────────────┐ │ fd 0 → Standard Input (keyboard) │ │ fd 1 → Standard Output (terminal screen) │ │ fd 2 → Standard Error (terminal screen) │ │ fd 3 → /etc/config.conf (opened file) │ └────────────────────────────────────────────────┘

The Three Default File Descriptors

stdin, stdout, and stderr — The Three Free Gifts

Every single program that runs on Linux automatically gets three file descriptors already open before the program even starts doing anything. You did not ask for them — the operating system gives them to you. These are:

Number POSIX Name C Library Name What It Is
0 STDIN_FILENO stdin Standard Input — where your program reads keyboard input from
1 STDOUT_FILENO stdout Standard Output — where your program prints normal output
2 STDERR_FILENO stderr Standard Error — where your program prints error messages

Deep Dive: Standard Input (fd 0)

stdin is file descriptor 0. By default, it is connected to your keyboard. When your C program calls scanf() or when a shell script reads with read, it is reading from fd 0.

But here is the interesting part — stdin does not have to be your keyboard. You can redirect it. When you run:

./myprogram < input.txt

The shell makes fd 0 point to input.txt instead of the keyboard. Your program never knew the difference — it still just reads from fd 0. That is the beauty of the abstraction.

Deep Dive: Standard Output (fd 1)

stdout is file descriptor 1. By default, it is connected to your terminal screen. When your program does printf(), it is writing to fd 1.

Again, you can redirect it:

./myprogram > output.txt

Now fd 1 points to output.txt. The program still writes to fd 1. It does not know or care where that output actually ends up.

Deep Dive: Standard Error (fd 2)

stderr is file descriptor 2. It is also connected to the terminal by default. The reason we have a separate file descriptor for errors (instead of just using stdout) is so that you can redirect normal output to a file while still seeing error messages on your screen.

./myprogram > output.txt 2> errors.txt

Here, normal output goes to output.txt and error messages go to errors.txt — separately. This is extremely useful when debugging long-running programs or scripts.

Why Are File Descriptors Just Numbers?

The Kernel Does the Heavy Lifting

Your program only sees a small integer. All the complex information — the actual location of the file on disk, the current reading position, the file permissions, the buffer state — is stored inside the kernel. Your fd is just an index into a kernel-managed table.

This design has multiple benefits:

  • Security: Programs cannot directly manipulate kernel data structures. They must go through system calls.
  • Simplicity: Passing a number around is much easier than passing complex objects.
  • Flexibility: The kernel can change how a file is stored internally without breaking your program.

User Space vs Kernel Space — Who Holds What
╔══════════════════════════════════════════════════╗ ║ USER SPACE (Your Program) ║ ║ ║ ║ int fd = 3; // That’s all you hold ║ ║ read(fd, buf, 100); // Ask kernel to read ║ ╚══════════════════════════════╦═══════════════════╝ ║ System Call ╔══════════════════════════════╩═══════════════════╗ ║ KERNEL SPACE ║ ║ ║ ║ fd=3 → File Table Entry ║ ║ ├── File path: /home/user/data.txt ║ ║ ├── Current offset: 512 bytes ║ ║ ├── Access mode: READ_ONLY ║ ║ └── Reference count: 1 ║ ╚══════════════════════════════════════════════════╝

How Programs Inherit File Descriptors

The Shell Opens Them — Your Program Inherits Them

When you open a terminal and run commands, the shell (bash, zsh, etc.) itself keeps fd 0, 1, and 2 open and connected to the terminal. When the shell launches your program, your program is a child process — it automatically inherits copies of those three file descriptors.

This is why your C program can call printf() and see output on the screen without ever explicitly opening a terminal — fd 1 was already set up and inherited from the shell.

Important Note:

The stdin, stdout, and stderr names (from <stdio.h>) are C library wrappers around fd 0, 1, 2. If you use freopen() to redirect one of them, the underlying file descriptor might change. After calling freopen(stdout, ...), you should not assume fd 1 still points to the original output. Always use the high-level name after a freopen, not the raw number.

The Four Key System Calls — A Quick Preview

Your Toolkit for All File I/O

Every single file I/O operation in Linux ultimately boils down to these four system calls. We will cover each one in detail in the next posts, but here is the big picture:

open()

Opens a file and returns a file descriptor number. Like getting a token at the bank.

read()

Reads data from an open file descriptor into your program’s memory buffer.

write()

Writes data from your program’s buffer to an open file descriptor.

close()

Closes the file descriptor, releasing the resources held by the kernel.

Putting It Together — A Simple Example

Reading a File Step by Step

Here is the simplest possible walkthrough of what happens when your C program reads a file:

/* Step 1: Open the file — get back a file descriptor */
int fd = open("notes.txt", O_RDONLY);
/* fd is now something like 3 */

/* Step 2: Read from the file into a buffer */
char buffer[100];
int bytes_read = read(fd, buffer, 100);

/* Step 3: Do something with the data */
printf("Read %d bytes: %s\n", bytes_read, buffer);

/* Step 4: Close the file — release the fd */
close(fd);
/* fd 3 is now free for reuse */

This same pattern — open, read/write, close — works for regular files, devices, pipes, and sockets. The only thing that changes is what you pass to open().

Key Takeaways from This Post

  • Linux uses the Universal I/O Model — the same four calls work for all file types
  • A file descriptor is just a small integer — the kernel manages all the complex details
  • Every process automatically gets fd 0 (stdin), fd 1 (stdout), and fd 2 (stderr)
  • These three are inherited from the shell, which is why your program can print to screen without setup
  • File descriptors can be redirected — making the I/O destination flexible without changing program code

Up Next: The open() System Call

Now that you understand what file descriptors are, the next post dives deep into how you actually open a file and get one — using open() with all its flags and options.

Next Post: open() →

Leave a Reply

Your email address will not be published. Required fields are marked *