Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

POSIX File I/O

Introduction

File I/O is the backbone of Unix/Linux programming. The POSIX file I/O API provides a unified, portable interface for reading and writing not just files on disk, but also devices, pipes, sockets, and virtually everything else in the Linux “everything is a file” philosophy.

This chapter covers the core POSIX I/O functions—open, read, write, close, lseek—along with advanced topics like direct I/O, synchronous I/O, file descriptor tables, and the dup2 system call. Understanding these primitives is essential because every higher-level I/O abstraction (stdio, memory-mapped files, io_uring) builds on top of them.

The File Descriptor

A file descriptor (fd) is a small non-negative integer that the kernel uses to reference an open file. Every process has a file descriptor table maintained in the kernel’s task_struct:

flowchart TD
    subgraph "Process File Descriptor Table"
        FD0["0 (stdin)"] --> R1["struct file (terminal)"]
        FD1["1 (stdout)"] --> R2["struct file (terminal)"]
        FD2["2 (stderr)"] --> R3["struct file (terminal)"]
        FD3["3"] --> R4["struct file (/etc/passwd)"]
        FD4["4"] --> R5["struct file (socket)"]
    end
    R4 --> INODE["inode (disk)"]
    R5 --> SOCK["socket buffer (kernel)"]

Reserved descriptors:

FDNameDefault
0stdinStandard input
1stdoutStandard output
2stderrStandard error
# View file descriptors for a process
$ ls -la /proc/self/fd
lrwx------ 1 user user 64 Jul 21 12:00 0 -> /dev/pts/0
lrwx------ 1 user user 64 Jul 21 12:00 1 -> /dev/pts/0
lrwx------ 1 user user 64 Jul 21 12:00 2 -> /dev/pts/0
lr-x------ 1 user user 64 Jul 21 12:00 3 -> /proc/12345/fd

The Three-Level I/O Architecture

flowchart TB
    subgraph "User Space"
        APP["Application<br>read(fd, buf, n)"]
        LIBC["glibc wrapper<br>__GI___libc_read()"]
    end
    subgraph "Kernel"
        VFS["VFS Layer<br>vfs_read()"]
        FS["Filesystem<br>ext4_file_read_iter()"]
        BLOCK["Block Layer<br>submit_bio()"]
        DEVICE["Device Driver<br>NVMe/SCSI"]
    end
    APP --> LIBC --> VFS --> FS --> BLOCK --> DEVICE

open() and openat()

Function Signatures

#include <fcntl.h>
#include <sys/types.h>
#include <sys/stat.h>

/* Legacy open — deprecated in new code */
int open(const char *pathname, int flags, ... /* mode_t mode */);

/* Preferred: openat — relative to a directory fd */
int openat(int dirfd, const char *pathname, int flags, ... /* mode_t mode */);

Flags

/* Access mode (mutually exclusive, masked with O_ACCMODE) */
O_RDONLY    /* Read only */
O_WRONLY    /* Write only */
O_RDWR      /* Read and write */

/* File creation */
O_CREAT     /* Create if it doesn't exist (needs mode argument) */
O_EXCL      /* Fail if O_CREAT and file exists (atomic) */
O_TRUNC     /* Truncate to zero length if it exists */
O_APPEND    /* Append to end on every write */

/* I/O behavior */
O_NONBLOCK  /* Non-blocking I/O */
O_NOCTTY    /* Don't become controlling terminal */
O_DSYNC     /* Synchronized data writes (see below) */
O_RSYNC     /* Synchronized read (equivalent to O_SYNC on Linux) */
O_SYNC      /* Synchronized file integrity writes */
O_DIRECT    /* Bypass page cache (direct I/O) */
O_NOATIME   /* Don't update access time */
O_TMPFILE   /* Create unnamed temporary file */
O_CLOEXEC   /* Set close-on-exec flag */
O_DIRECTORY /* Fail if not a directory */
O_NOFOLLOW  /* Don't follow symlinks */
O_PATH      /* Obtain an fd for path operations only */

Examples

#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>

int main(void)
{
    int fd;

    /* Create a new file with mode 0644 */
    fd = open("example.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    const char *msg = "Hello, POSIX I/O!\n";
    write(fd, msg, strlen(msg));
    close(fd);

    /* Atomic create — fails if file exists */
    fd = open("example.txt", O_WRONLY | O_CREAT | O_EXCL, 0644);
    if (fd == -1 && errno == EEXIST) {
        printf("File already exists (good, that was atomic!)\n");
    }

    /* openat — relative to a directory */
    int dirfd = open("/tmp", O_RDONLY | O_DIRECTORY);
    if (dirfd != -1) {
        fd = openat(dirfd, "testfile.txt",
                    O_WRONLY | O_CREAT | O_TRUNC, 0600);
        write(fd, "via openat\n", 11);
        close(fd);
        close(dirfd);
    }

    return 0;
}

Why openat() over open():

  1. Race-free directory resolution: Avoid TOCTOU bugs with relative paths
  2. Consistent behavior: openat(AT_FDCWD, ...) is identical to open()
  3. Sandboxing: Combine with O_PATH and /proc/self/fd/ for secure path resolution

read() and write()

Function Signatures

#include <unistd.h>

ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);

Return values:

  • > 0: Number of bytes actually read/written
  • 0: End of file (read only)
  • -1: Error (check errno)

Critical: Short Reads and Writes

Always handle short reads and writes. A single read()/write() call is NOT guaranteed to transfer all requested bytes:

/* WRONG: ignoring short writes */
write(fd, buf, len);  /* Might write fewer than len bytes! */

/* CORRECT: loop until all bytes are written */
ssize_t write_all(int fd, const void *buf, size_t count)
{
    const char *p = buf;
    size_t remaining = count;

    while (remaining > 0) {
        ssize_t n = write(fd, p, remaining);
        if (n == -1) {
            if (errno == EINTR)
                continue;   /* Interrupted by signal, retry */
            return -1;      /* Real error */
        }
        remaining -= n;
        p += n;
    }
    return count;
}

/* CORRECT: loop for reads too */
ssize_t read_all(int fd, void *buf, size_t count)
{
    char *p = buf;
    size_t remaining = count;

    while (remaining > 0) {
        ssize_t n = read(fd, p, remaining);
        if (n == -1) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        if (n == 0)
            break;  /* EOF */
        remaining -= n;
        p += n;
    }
    return count - remaining;
}

Why short reads/writes happen:

  • read() from a pipe/socket may return fewer bytes than requested
  • write() to a regular file may be interrupted by a signal
  • Near the end of a file
  • Kernel buffer constraints

readv() and writev() — Scatter/Gather I/O

#include <sys/uio.h>

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

struct iovec {
    void  *iov_base;    /* Starting address */
    size_t iov_len;     /* Number of bytes */
};
/* Write a header + payload in a single syscall */
struct iovec iov[2];
iov[0].iov_base = header;
iov[0].iov_len = header_len;
iov[1].iov_base = payload;
iov[1].iov_len = payload_len;

writev(fd, iov, 2);  /* Single syscall, atomic for pipes */

close()

#include <unistd.h>
int close(int fd);

Key details:

  • Returns 0 on success, -1 on error
  • The fd number is freed and can be reused by subsequent open()/socket() calls
  • Errors on close are real! Especially for NFS and network filesystems where close() flushes data. Always check the return value.
  • close() is not idempotent: closing an already-closed fd returns EBADF
/* WRONG: ignoring close errors */
close(fd);

/* CORRECT */
if (close(fd) == -1) {
    perror("close");
    /* Handle error—data may not have been written! */
}

close-on-exec (CLOEXEC)

By default, file descriptors survive exec(). To prevent leaking fds to child processes:

/* At open time (preferred—atomic) */
int fd = open("file", O_RDONLY | O_CLOEXEC);

/* After the fact (has a race window) */
int fd = open("file", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);

lseek() — File Offset

#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
whenceMeaning
SEEK_SETSet offset to offset bytes from beginning
SEEK_CURSet offset to current + offset
SEEK_ENDSet offset to file size + offset
SEEK_DATANext data region at or after offset
SEEK_HOLENext hole (sparse file) at or after offset
/* Get current file offset */
off_t pos = lseek(fd, 0, SEEK_CUR);

/* Get file size */
off_t size = lseek(fd, 0, SEEK_END);

/* Seek to beginning */
lseek(fd, 0, SEEK_SET);

/* Sparse file operations */
lseek(fd, 1024*1024, SEEK_SET);  /* Create a 1MB "hole" */
write(fd, "X", 1);               /* Write 1 byte at 1MB offset */

Notes:

  • lseek() does not work on pipes, sockets, or FIFOs (ESPIPE)
  • The file offset can be positioned past the end of the file (creates a sparse “hole” on most filesystems)

O_DIRECT — Bypassing the Page Cache

O_DIRECT sends I/O directly between user buffers and the block device, bypassing the kernel’s page cache. This is critical for databases and applications that manage their own caching.

Requirements

/* Buffer must be aligned to filesystem block size (typically 512 or 4096) */
#define ALIGNMENT 4096

void *buf;
posix_memalign(&buf, ALIGNMENT, 4096);

int fd = open("data.db", O_RDWR | O_DIRECT);
read(fd, buf, 4096);  /* Must be multiple of block size */
/* Full O_DIRECT example */
#include <fcntl.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>

#define BLOCK_SIZE 4096

int main(void)
{
    void *buf;
    int fd;

    /* Aligned buffer allocation */
    if (posix_memalign(&buf, BLOCK_SIZE, BLOCK_SIZE) != 0) {
        perror("posix_memalign");
        return 1;
    }

    fd = open("direct_test.dat", O_RDWR | O_CREAT | O_DIRECT, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    /* Write — must be aligned in size too */
    memset(buf, 'A', BLOCK_SIZE);
    ssize_t n = write(fd, buf, BLOCK_SIZE);
    printf("Wrote %zd bytes (direct I/O)\n", n);

    /* Read back */
    lseek(fd, 0, SEEK_SET);
    memset(buf, 0, BLOCK_SIZE);
    n = read(fd, buf, BLOCK_SIZE);
    printf("Read %zd bytes: first char = '%c'\n", n, ((char *)buf)[0]);

    close(fd);
    free(buf);
    return 0;
}

When to use O_DIRECT:

  • Databases (PostgreSQL, MySQL/InnoDB) with their own buffer pools
  • Avoiding double-buffering (page cache + application cache)
  • Streaming workloads where data is accessed once

When NOT to use O_DIRECT:

  • Small random reads (page cache is better)
  • Sequential access patterns (readahead helps)
  • When you want POSIX semantics

O_SYNC — Synchronous I/O

flowchart LR
    subgraph "write() returns when..."
        W1["O_SYNC"] -->|data + metadata| D1["Disk"]
        W2["O_DSYNC"] -->|data only| D2["Disk"]
        W3["O_RSYNC + O_SYNC"] -->|data + metadata| D3["Disk for reads too"]
        W4["Normal write()"] -->|data| P1["Page Cache"]
    end
Flagwrite() completes afterread() behavior
NoneData in page cacheFrom page cache
O_SYNCData + metadata on diskFrom page cache
O_DSYNCData on diskFrom page cache
O_SYNC|O_RSYNCData + metadata on diskSynchronized
# Compare write performance
$ dd if=/dev/zero of=/tmp/test_nosync bs=4k count=10000
$ dd if=/dev/zero of=/tmp/test_sync bs=4k count=10000 oflag=sync

The File Descriptor Table

Process-Level FD Table

Each process has its own file descriptor table, stored in task_struct->files:

flowchart TD
    subgraph "Process A"
        FDT_A["files_struct<br>fdtable"]
        FD0_A["fd[0] → file #1"]
        FD1_A["fd[1] → file #1"]
        FD2_A["fd[2] → file #2"]
    end
    subgraph "Process B (child of A)"
        FDT_B["files_struct<br>fdtable"]
        FD0_B["fd[0] → file #1"]
        FD1_B["fd[1] → file #3"]
    end
    FDT_A --> FD0_A --> FILE1["struct file<br>refcount=3"]
    FDT_A --> FD1_A --> FILE1
    FDT_A --> FD2_A --> FILE2["struct file<br>refcount=1"]
    FDT_B --> FD0_B --> FILE1
    FDT_B --> FD1_B --> FILE3["struct file<br>refcount=1"]

Three-level structure:

  1. Process fd table: Array in files_struct mapping fd numbers to struct file *
  2. Open file table: struct file instances with current offset, flags, refcount
  3. Inode/vnode: File metadata, data on disk

Key insight: dup() and fork() create new fd table entries pointing to the same struct file. This means:

  • Two fds sharing a struct file share the same file offset
  • Two fds with separate struct file instances have independent offsets

dup2() — Duplicating File Descriptors

#include <unistd.h>

int dup(int oldfd);              /* Lowest available fd */
int dup2(int oldfd, int newfd);  /* Specific fd number */
int dup3(int oldfd, int newfd, int flags);  /* dup2 + flags */

Classic Use Case: I/O Redirection

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>

int main(void)
{
    /* Redirect stdout to a file */
    int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }

    /* Now fd 1 (stdout) points to output.txt */
    dup2(fd, STDOUT_FILENO);
    close(fd);  /* Close original—stdout still works */

    /* This goes to output.txt, not the terminal */
    printf("Hello from redirected stdout!\n");

    /* Restore stdout (if needed) would require saving original fd */
    return 0;
}

Implementing a Shell Pipe

/* Simulate: ls | grep .c */
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    int pipefd[2];
    pipe(pipefd);  /* pipefd[0]=read, pipefd[1]=write */

    if (fork() == 0) {
        /* Child 1: ls */
        dup2(pipefd[1], STDOUT_FILENO);  /* stdout → pipe write */
        close(pipefd[0]);
        close(pipefd[1]);
        execlp("ls", "ls", NULL);
    }

    if (fork() == 0) {
        /* Child 2: grep */
        dup2(pipefd[0], STDIN_FILENO);   /* stdin ← pipe read */
        close(pipefd[0]);
        close(pipefd[1]);
        execlp("grep", "grep", ".c", NULL);
    }

    close(pipefd[0]);
    close(pipefd[1]);
    wait(NULL);
    wait(NULL);
    return 0;
}

dup3 with CLOEXEC

/* Atomically set close-on-exec */
int newfd = dup3(oldfd, targetfd, O_CLOEXEC);

pread() and pwrite() — Positioned I/O

#include <unistd.h>

ssize_t pread(int fd, void *buf, size_t count, off_t offset);
ssize_t pwrite(int fd, const void *buf, size_t count, off_t offset);

These combine lseek() + read()/write() atomically:

/* Thread-safe: no race on file offset */
pread(fd, buf1, 100, 0);     /* Thread A reads bytes 0-99 */
pread(fd, buf2, 100, 100);   /* Thread B reads bytes 100-199 */
/* Both work concurrently without locking */

Scatter/Gather with preadv/pwritev

/* Combines positioned I/O with scatter/gather */
ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);

/* Linux-specific extensions with flags */
ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt,
                off_t offset, int flags);
ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt,
                 off_t offset, int flags);

File I/O Performance Patterns

Sequential Read Ahead

/* The kernel automatically readaheads for sequential access */
int fd = open("largefile", O_RDONLY);
char buf[4096];
while (read(fd, buf, sizeof(buf)) > 0) {
    process(buf);
}
close(fd);
/* Kernel detects sequential pattern and prefetches */

posix_fadvise()

#include <fcntl.h>

int posix_fadvise(int fd, off_t offset, off_t len, int advice);
AdviceMeaning
POSIX_FADV_NORMALDefault behavior
POSIX_FADV_SEQUENTIALExpect sequential access (aggressive readahead)
POSIX_FADV_RANDOMExpect random access (disable readahead)
POSIX_FADV_WILLNEEDWill need these pages soon (prefetch)
POSIX_FADV_DONTNEEDWon’t need these pages (drop from cache)
POSIX_FADV_NOREUSEUse each page once
/* Tell kernel we'll read sequentially */
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL);

/* Prefetch a region */
posix_fadvise(fd, offset, length, POSIX_FADV_WILLNEED);

/* Drop data from cache when done */
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);

fallocate() — Preallocating Space

#include <fcntl.h>

int fallocate(int fd, int mode, off_t offset, off_t len);
/* Allocate 1GB without writing zeros */
fallocate(fd, 0, 0, 1024ULL * 1024 * 1024);

/* Punch a hole (create sparse region) */
fallocate(fd, FALLOC_FL_PUNCH_HOLE | FALLOC_FL_KEEP_SIZE,
          1024*1024, 4096);

References

  • System Calls — How read() and write() reach the kernel
  • io_uring — Asynchronous I/O alternative
  • epoll — Efficient readiness notification for non-blocking I/O
  • Process Controlfork(), exec(), and fd inheritance
  • Pipes — Pipe I/O with read()/write()
  • ELF Format — How binaries use file I/O to load segments