Unix I/O Model: File Descriptors, Open File Descriptions, and Inodes
A practical guide to I/O model in unix.
A useful way to understand Unix I/O is to separate three concepts:
- File descriptor (FD) — a process-local integer
- Open file description (OFD) — kernel state created by
open() - Inode — the filesystem object
This model explains how open(), read(), write(), lseek(), dup(), fork(), and fcntl() work.
The Basic Model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Process
┌──────────────────────────┐
│ FD table │
│ │
│ fd 0 ───────────────┐ │
│ fd 1 ───────────┐ │ │
│ fd 3 ─────────┐ │ │ │
└────────────────┼─┼───┼────┘
│ │ │
▼ ▼ ▼
Open File Description
┌───────────────────┐
│ file offset │
│ status flags │
│ access mode │
│ pointer to inode │
└─────────┬─────────┘
│
▼
Inode
┌───────────────────┐
│ type │
│ permissions │
│ owner │
│ size │
│ timestamps │
│ file data │
└───────────────────┘
The file descriptor is only an index in the process’s FD table. The actual open-file state is stored in the open file description. Linux calls the kernel object implementing an open file description a struct file.
1. open()
1
int fd = open("file.txt", O_RDWR);
open() does two important things:
1
2
3
4
5
open("file.txt")
│
├── creates an open file description
│
└── creates an FD referring to it
For example:
1
2
3
4
5
6
7
Process
FD table
fd 3 ─────────→ OFD #1 ─────────→ inode
│
├── offset = 0
└── flags = O_RDWR
Every successful open() creates a new open file description.
2. read()
1
read(fd, buffer, 100);
read() uses the offset stored in the open file description.
1
2
3
4
5
6
7
OFD
┌─────────────────┐
│ offset = 100 │
└────────┬────────┘
│
▼
inode/data
After reading 50 bytes:
1
offset = 150
So:
1
2
3
4
read()
│
├── reads data
└── advances OFD offset
The offset belongs to the open file description, not directly to the FD.
3. write()
1
write(fd, "hello", 5);
write() writes at the current offset and normally advances it.
1
2
3
4
5
6
7
8
9
10
11
Before:
OFD
offset = 100
write("hello", 5)
After:
OFD
offset = 105
If the write extends beyond the previous end of the file, the file size in the filesystem object increases.
4. lseek()
1
lseek(fd, 1000, SEEK_SET);
lseek() changes the offset in the open file description.
1
2
3
4
5
lseek()
│
▼
OFD
offset = 1000
It does not itself change the file size.
For example:
1
2
lseek(fd, 1000, SEEK_SET);
write(fd, "X", 1);
creates a file whose logical size is 1001 bytes, with a hole between the beginning and offset 999.
5. Two open() Calls
Consider:
1
2
int fd1 = open("file.txt", O_RDWR);
int fd2 = open("file.txt", O_RDWR);
The important point is that these create two different open file descriptions:
1
2
3
4
5
6
7
8
fd1 ─────→ OFD #1 ─────→ inode
│
└── offset = 0
fd2 ─────→ OFD #2 ─────→ inode
│
└── offset = 0
They refer to the same filesystem object, but have independent offsets.
For example:
1
read(fd1, buf, 10);
results in:
1
2
OFD #1 offset = 10
OFD #2 offset = 0
The two offsets are independent.
6. dup()
Now consider:
1
int fd2 = dup(fd1);
This is different from calling open() again.
1
2
3
4
5
6
7
fd1 ─────┐
│
▼
OFD #1 ─────→ inode
▲
│
fd2 ─────┘
Both FDs refer to the same open file description.
Therefore they share:
- file offset
- file status flags
For example:
1
read(fd1, buf, 10);
changes the offset seen through fd2 as well.
1
2
3
4
5
6
7
8
9
10
11
Before:
fd1 ──┐
├──→ OFD offset = 0
fd2 ──┘
read(fd1, 10)
After:
fd1 ──┐
├──→ OFD offset = 10
fd2 ──┘
This is why dup() is commonly used when redirecting standard input/output.
dup() creates a new FD, but both descriptors refer to the same OFD.
7. fork()
Consider:
1
2
3
int fd = open("file.txt", O_RDWR);
fork();
After fork(), the child gets a copy of the parent’s FD table.
But the corresponding FDs refer to the same open file description:
1
2
3
4
5
6
7
8
Parent Child
FD 3 ─────┐ FD 3 ─────┐
│ │
└────────→ OFD ←─────────┘
│
▼
inode
Therefore parent and child share:
- file offset
- file status flags
For example, if the child reads 100 bytes, the shared offset changes for the parent too.
Linux explicitly documents this sharing behavior for fork().
8. File Descriptor Flags vs File Status Flags
This distinction is important.
File descriptor flags
These belong to the FD itself.
Example:
1
2
FD 3
└── FD_CLOEXEC
They are manipulated with:
1
2
fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD);
File status flags
These belong to the open file description.
Examples:
1
2
3
O_APPEND
O_NONBLOCK
O_ASYNC
They are manipulated with:
1
2
fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags);
Therefore:
1
2
3
4
5
6
FD
└── FD flags
OFD
├── file offset
└── file status flags
Duplicated FDs share the OFD and therefore share its status flags, but their FD flags are separate.
O_APPEND and lseek()
When a file is opened with O_APPEND, every write() operation writes data at the end of the file, regardless of the current file offset.
1
2
3
4
int fd = open("file.txt", O_WRONLY | O_APPEND);
lseek(fd, 0, SEEK_SET);
write(fd, "HELLO", 5);
If the file initially contains:
1
ABCDEF
the result is:
1
ABCDEFHELLO
Why?
There are two separate concepts:
lseek()changes the current file offset stored in the open file description.O_APPENDchanges the behavior ofwrite()so that the kernel positions the write at EOF before performing it.
1
2
3
4
5
6
7
8
9
10
11
12
13
lseek()
│
▼
changes current offset
│
│
write() + O_APPEND
│
▼
ignore offset for write position
│
▼
write at EOF
Without O_APPEND:
1
2
3
4
5
6
lseek(fd, 0, SEEK_SET);
write(fd, "HELLO", 5);
ABCDEF
↓
HELLOF
With O_APPEND:
1
2
3
4
5
6
lseek(fd, 0, SEEK_SET);
write(fd, "HELLO", 5);
ABCDEF
↓
ABCDEFHELLO
Important rule
lseek()controls the file offset, butO_APPENDmakes eachwrite()occur at the end of the file.
O_APPEND is commonly used for log files, where multiple writers need to add data without overwriting existing contents.
On Linux, positioning the offset at EOF and performing the append write are performed as one atomic step, which is important when multiple processes write to the same file.
9. fcntl()
fcntl() performs various operations on an existing FD.
For example:
1
2
3
4
5
int flags = fcntl(fd, F_GETFL);
flags |= O_NONBLOCK;
fcntl(fd, F_SETFL, flags);
This modifies the file status flags of the open file description.
It can also manipulate the FD itself:
1
2
fcntl(fd, F_GETFD);
fcntl(fd, F_SETFD, FD_CLOEXEC);
So:
1
2
3
4
5
6
7
fcntl()
│
├── F_GETFD / F_SETFD
│ └── FD flags
│
└── F_GETFL / F_SETFL
└── OFD status flags
10. close()
1
close(fd);
close() removes the FD from the process’s FD table.
1
2
3
4
5
6
7
8
9
Before:
FD 3 ─────→ OFD
After:
FD 3 X
OFD
If other FDs still reference the OFD, the OFD remains alive.
For example:
1
2
3
fd1 ─────┐
├──→ OFD
fd2 ─────┘
After:
1
close(fd1);
we have:
1
fd2 ─────→ OFD
The OFD still exists.
11. stat()
stat() retrieves metadata about a filesystem object:
1
2
3
struct stat st;
stat("file.txt", &st);
For example:
1
2
3
4
5
6
7
inode
├── file type
├── permissions
├── owner
├── size
├── timestamps
└── inode number
This is different from the OFD’s state.
For example:
1
2
3
4
5
6
7
OFD:
offset = 500
O_APPEND
inode:
size = 4096
permissions = 0644
12. unlink()
1
unlink("file.txt");
unlink() removes the directory entry referring to the inode.
It does not necessarily immediately remove the underlying data.
For example:
1
2
3
4
5
6
directory
│
└── "file.txt" ─────→ inode
▲
│
OFD
After unlink():
1
2
3
4
5
6
7
8
directory
│
└── "file.txt" X
inode
▲
│
OFD
If a process still has the file open, it can continue using the file through its FD.
13. ioctl()
ioctl() performs operations that don’t fit the normal:
1
2
3
read()
write()
lseek()
model.
For example, getting terminal size:
1
2
3
struct winsize ws;
ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws);
Mental model:
1
2
3
4
5
read() → transfer data
write() → transfer data
lseek() → change offset
fcntl() → manipulate FD/OFD properties
ioctl() → resource/device-specific operation
This is why Unix can use the same FD abstraction for many different resources, while still providing specialized operations when necessary.
14. The Three Most Important Cases
Case 1: Two open() Calls
1
2
fd1 = open("file");
fd2 = open("file");
1
2
3
fd1 ──→ OFD #1 ──→ inode
↑
fd2 ──→ OFD #2 ──┘
Different OFDs → different offsets.
Case 2: dup()
1
fd2 = dup(fd1);
1
2
3
fd1 ──┐
├──→ OFD ──→ inode
fd2 ──┘
Same OFD → same offset.
Case 3: fork()
1
2
fd = open("file");
fork();
1
2
3
4
5
Parent FD ──┐
│
├──→ OFD ──→ inode
│
Child FD ───┘
Same OFD → shared offset.
Final Mental Model
Keep this picture in your head:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
PROCESS
┌─────────────────────┐
│ File Descriptor │
│ Table │
│ │
│ 0 ──────┐ │
│ 1 ───┐ │ │
│ 3 ─┐ │ │ │
└─────┼─┼──┼─────────┘
│ │ │
▼ ▼ ▼
Open File Descriptions
┌─────────────────────┐
│ offset │
│ status flags │
│ access mode │
│ │
│ pointer to inode ───┼─────┐
└─────────────────────┘ │
▼
INODE
┌────────────────┐
│ type │
│ permissions │
│ owner │
│ size │
│ timestamps │
│ data │
└────────────────┘
The key rule is:
1
2
3
4
5
6
7
8
FD
= process-local reference
OFD
= state of one open() instance
inode
= filesystem object
And the most important sharing rule is:
1
2
3
open() → new OFD
dup() → same OFD
fork() → inherited FD referring to same OFD
This model explains most of the behavior of Unix file I/O.
Redirection
Shell redirection is a direct application of the file descriptor model.
The key idea is:
The shell changes the process’s file descriptors before executing the program.
For example:
1
ls > output.txt
means that the shell makes ls’s standard output (FD 1) refer to output.txt.
Output Redirection
Normally:
1
2
3
4
5
6
7
8
Process
┌─────────────────────┐
│ FD table │
│ │
│ 0 ──→ terminal │
│ 1 ──→ terminal │
│ 2 ──→ terminal │
└─────────────────────┘
For:
1
ls > output.txt
the shell approximately does:
1
2
3
4
5
6
7
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
dup2(fd, STDOUT_FILENO);
close(fd);
execve(...);
If open() returns FD 3:
1
2
3
4
Before dup2():
FD 1 ──→ terminal
FD 3 ──→ OFD ──→ output.txt
After:
1
dup2(3, 1);
we have:
1
2
3
FD 1 ──┐
├──→ OFD ──→ output.txt
FD 3 ──┘
After close(3):
1
FD 1 ──→ OFD ──→ output.txt
The shell then executes ls.
ls simply writes to FD 1:
1
write(STDOUT_FILENO, buffer, size);
It does not need to know that FD 1 now refers to a file.
Input Redirection
For:
1
sort < input.txt
the shell approximately does:
1
2
3
4
5
6
7
int fd = open("input.txt", O_RDONLY);
dup2(fd, STDIN_FILENO);
close(fd);
execve(...);
The result is:
1
2
3
FD 0 ──→ OFD ──→ input.txt
FD 1 ──→ terminal
FD 2 ──→ terminal
sort simply reads from standard input:
1
read(STDIN_FILENO, buffer, size);
It doesn’t need to know whether the input comes from a terminal or a file.
Standard File Descriptors
Unix processes normally start with three standard file descriptors:
1
2
3
0 → stdin → standard input
1 → stdout → standard output
2 → stderr → standard error
Redirection changes where these descriptors point.
For example:
1
command > output.txt
results in:
1
2
3
FD 0 ──→ terminal
FD 1 ──→ output.txt
FD 2 ──→ terminal
While:
1
command 2> errors.txt
results in:
1
2
3
FD 0 ──→ terminal
FD 1 ──→ terminal
FD 2 ──→ errors.txt
2>&1
Consider:
1
command > output.txt 2>&1
Redirections are processed from left to right.
First:
1
> output.txt
gives:
1
2
FD 1 ──→ output.txt
FD 2 ──→ terminal
Then:
1
2>&1
makes FD 2 refer to the same open file description as FD 1:
1
2
3
FD 1 ──┐
├──→ OFD ──→ output.txt
FD 2 ──┘
Therefore both stdout and stderr go to output.txt.
The order matters:
1
command > output.txt 2>&1
is different from:
1
command 2>&1 > output.txt
because the shell processes the redirections in order.
Pipes
Pipes use the same FD model.
For:
1
ls | grep txt
the shell creates a pipe:
1
2
int pipefd[2];
pipe(pipefd);
Conceptually:
1
2
pipefd[0] → read end
pipefd[1] → write end
The shell then configures the two processes.
For ls:
1
dup2(pipefd[1], STDOUT_FILENO);
For grep:
1
dup2(pipefd[0], STDIN_FILENO);
The resulting setup is:
1
2
3
4
5
6
7
8
PIPE
┌──────────┐
ls │ │ grep
FD 1 ─────→ write │
│ │
│ read ────→ FD 0
│ │
└──────────┘
So:
1
2
3
4
5
6
7
8
9
ls
│
│ write(1, ...)
▼
pipe
│
│ read(0, ...)
▼
grep
Neither program needs special knowledge of the other program.
ls thinks it is writing to stdout.
grep thinks it is reading from stdin.
The shell and kernel connect them.
Redirection and exec()
A typical shell performs these steps:
1
2
3
4
5
6
7
8
9
10
11
12
Shell
│
├── open files / create pipes
│
├── configure FDs using dup2()
│
├── fork()
│
└── child: execve()
│
▼
Program
The important property of execve() is that it replaces the program’s code and memory, but open file descriptors normally remain open.
Therefore, the child starts with the FD configuration prepared by the shell.
Why Redirection Works
Programs don’t need to know where their input and output come from.
A program generally uses:
1
2
3
read(0, ...); // stdin
write(1, ...); // stdout
write(2, ...); // stderr
Those descriptors can refer to different kinds of resources:
1
2
3
4
5
6
7
8
File Descriptor
│
┌────────────┼────────────┐
▼ ▼ ▼
file pipe terminal
│ │ │
▼ ▼ ▼
disk kernel terminal
This is another consequence of the Unix “everything through file descriptors” design.
The shell determines the connections before the program runs.
The program only needs to use the standard file descriptors.
The Complete Mental Model
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
SHELL
│
┌───────────┴───────────┐
│ │
open() pipe()
│ │
└───────────┬───────────┘
▼
fork()
│
▼
dup2()
│
▼
execve()
│
▼
PROGRAM
┌────┼────┐
│ │ │
FD0 FD1 FD2
│ │ │
▼ ▼ ▼
input output errors
The important sequence to remember is:
1
2
3
4
5
6
7
8
9
open() / pipe()
↓
dup2()
↓
close()
↓
fork()
↓
execve()
This sequence is the foundation of how Unix shells implement redirection and pipelines.