File Descriptors

On Linux, everything is a file. A regular file, a terminal, a pipe, a network socket, even a device -- they are all accessed through the same interface: the file descriptor. This chapter shows you that interface from both sides of the C/Rust divide.

The File Descriptor Table

Every process has a small integer table managed by the kernel. Each entry points to an open file description (an in-kernel structure). When you call open(), the kernel picks the lowest available integer, fills the slot, and returns that integer to you.

Process File-Descriptor Table            Kernel Open-File Descriptions
+-----+------------------+          +----------------------------------+
|  0  | ──────────────────>─────>   | struct file  (terminal /dev/pts/0)|
+-----+------------------+          +----------------------------------+
|  1  | ──────────────────>─────>   | struct file  (terminal /dev/pts/0)|
+-----+------------------+          +----------------------------------+
|  2  | ──────────────────>─────>   | struct file  (terminal /dev/pts/0)|
+-----+------------------+          +----------------------------------+
|  3  | ──────────────────>─────>   | struct file  (/tmp/data.txt)      |
+-----+------------------+          +----------------------------------+
|  4  |      (unused)     |
+-----+------------------+
| ... |                   |
+-----+------------------+

File descriptors 0, 1, and 2 are pre-opened by the shell before your program starts:

fdSymbolic NameC MacroPurpose
0standard inputSTDIN_FILENOkeyboard / pipe
1standard outputSTDOUT_FILENOterminal / pipe
2standard errorSTDERR_FILENOterminal / pipe

Driver Prep: In kernel modules you will work with struct file directly. Understanding the user-space side now makes the kernel side feel familiar.

Opening a File in C

/* open_file.c -- open, write, read, close */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>

int main(void)
{
    const char *path = "/tmp/fd_demo.txt";

    /* O_WRONLY  -- write only
       O_CREAT   -- create if missing
       O_TRUNC   -- truncate to zero length if exists
       0644      -- rw-r--r-- permissions */
    int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) {
        perror("open");
        return 1;
    }
    printf("opened %s as fd %d\n", path, fd);

    const char *msg = "hello from file descriptor land\n";
    ssize_t nw = write(fd, msg, strlen(msg));
    if (nw == -1) {
        perror("write");
        close(fd);
        return 1;
    }
    printf("wrote %zd bytes\n", nw);

    if (close(fd) == -1) {
        perror("close");
        return 1;
    }

    /* Now reopen for reading */
    fd = open(path, O_RDONLY);
    if (fd == -1) {
        perror("open (read)");
        return 1;
    }

    char buf[128];
    ssize_t nr = read(fd, buf, sizeof(buf) - 1);
    if (nr == -1) {
        perror("read");
        close(fd);
        return 1;
    }
    buf[nr] = '\0';
    printf("read back: %s", buf);

    close(fd);
    return 0;
}

Compile and run:

$ gcc -Wall -o open_file open_file.c && ./open_file
opened /tmp/fd_demo.txt as fd 3
wrote 32 bytes
read back: hello from file descriptor land

Notice the fd is 3 -- the first slot after stdin/stdout/stderr.

Caution: Always check the return value of open(). A return of -1 means failure, and errno tells you why. Forgetting this check is the single most common file-handling bug in C.

The Open Flags

Here are the flags you will use constantly:

FlagMeaning
O_RDONLYOpen for reading only
O_WRONLYOpen for writing only
O_RDWROpen for reading and writing
O_CREATCreate the file if it does not exist
O_TRUNCTruncate existing file to zero length
O_APPENDWrites always go to end of file
O_EXCLFail if file already exists (with O_CREAT)
O_CLOEXECClose fd automatically on exec()

O_CREAT requires a third argument to open() specifying the permission bits. Without it the permissions are garbage -- whatever happened to be on the stack.

Caution: Forgetting the mode argument when using O_CREAT is undefined behavior. The compiler will not warn you because open() uses variadic arguments.

Partial Reads and Writes

read() and write() are not guaranteed to transfer the full amount you requested. A read() of 4096 bytes might return 17 if only 17 bytes are available. A write() on a non-blocking socket might write half your buffer.

A robust write loop:

/* write_all.c -- handle partial writes */
#include <unistd.h>
#include <errno.h>
#include <string.h>
#include <fcntl.h>
#include <stdio.h>

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;
        }
        p += n;
        remaining -= (size_t)n;
    }
    return (ssize_t)count;
}

int main(void)
{
    int fd = open("/tmp/robust.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) { perror("open"); return 1; }

    const char *data = "robust write completed\n";
    if (write_all(fd, data, strlen(data)) == -1) {
        perror("write_all");
        close(fd);
        return 1;
    }

    close(fd);
    printf("done\n");
    return 0;
}

Try It: Modify write_all to also handle EAGAIN (would block on non-blocking descriptors). What should you do -- retry immediately or sleep?

The Rust Equivalent

Rust wraps file descriptors in std::fs::File. The Read and Write traits provide read() and write(). Dropping a File closes it automatically.

// open_file.rs -- open, write, read, close via drop
use std::fs::{File, OpenOptions};
use std::io::{Read, Write};

fn main() -> std::io::Result<()> {
    let path = "/tmp/fd_demo_rs.txt";

    // Create and write
    {
        let mut f = OpenOptions::new()
            .write(true)
            .create(true)
            .truncate(true)
            .open(path)?;

        let msg = b"hello from Rust file descriptor land\n";
        f.write_all(msg)?;
        println!("wrote {} bytes", msg.len());
    } // f is dropped here -- close() called automatically

    // Reopen and read
    {
        let mut f = File::open(path)?;
        let mut contents = String::new();
        f.read_to_string(&mut contents)?;
        print!("read back: {}", contents);
    }

    Ok(())
}

Compile and run:

$ rustc open_file.rs && ./open_file
wrote 37 bytes
read back: hello from Rust file descriptor land

Rust Note: write_all() already handles partial writes internally. You never need to write a retry loop in Rust -- the standard library does it for you. The ? operator propagates errors cleanly.

Accessing the Raw File Descriptor in Rust

Sometimes you need the raw integer, for example when calling a Linux-specific ioctl. Rust provides traits for this:

// raw_fd.rs -- access the underlying file descriptor number
use std::fs::File;
use std::os::unix::io::AsRawFd;

fn main() -> std::io::Result<()> {
    let f = File::open("/tmp/fd_demo_rs.txt")?;
    let raw: i32 = f.as_raw_fd();
    println!("raw fd = {}", raw);
    // f still owns the fd -- it will close on drop
    Ok(())
}
$ rustc raw_fd.rs && ./raw_fd
raw fd = 3

There is also FromRawFd for wrapping an existing fd, and IntoRawFd for giving up ownership. Use these when bridging C libraries.

#![allow(unused)]
fn main() {
use std::fs::File;
use std::os::unix::io::FromRawFd;

// SAFETY: fd must be a valid, open file descriptor that we now own.
let f = unsafe { File::from_raw_fd(raw_fd) };
}

Caution: from_raw_fd is unsafe because Rust cannot verify the fd is valid or that nobody else will close it. Double-close is undefined behavior at the OS level.

Duplicating File Descriptors: dup and dup2

dup(fd) duplicates a file descriptor, returning the lowest available number. dup2(oldfd, newfd) duplicates oldfd onto newfd, closing newfd first if it was open.

This is how shells implement redirection. ls > output.txt is roughly:

fd = open("output.txt", ...)
dup2(fd, STDOUT_FILENO)    // stdout now points to output.txt
close(fd)                   // don't need the extra fd
exec("ls", ...)
/* dup_demo.c -- redirect stdout to a file */
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

int main(void)
{
    int fd = open("/tmp/dup_out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) { perror("open"); return 1; }

    /* Save original stdout */
    int saved_stdout = dup(STDOUT_FILENO);
    if (saved_stdout == -1) { perror("dup"); return 1; }

    /* Redirect stdout to the file */
    if (dup2(fd, STDOUT_FILENO) == -1) { perror("dup2"); return 1; }
    close(fd);  /* fd is no longer needed; stdout points to the file */

    /* This printf goes to /tmp/dup_out.txt */
    printf("this line goes to the file\n");
    fflush(stdout);

    /* Restore stdout */
    dup2(saved_stdout, STDOUT_FILENO);
    close(saved_stdout);

    /* This printf goes to the terminal */
    printf("this line goes to the terminal\n");

    return 0;
}
$ gcc -Wall -o dup_demo dup_demo.c && ./dup_demo
this line goes to the terminal
$ cat /tmp/dup_out.txt
this line goes to the file
Before dup2:             After dup2(fd, 1):       After close(fd):
  fd 1 ──> terminal        fd 1 ──> file            fd 1 ──> file
  fd 3 ──> file            fd 3 ──> file            fd 3 ──  (closed)

dup2 in Rust

Rust has no safe wrapper for dup2 in the standard library. Use the libc crate or the nix crate:

// dup2_demo.rs -- redirect stdout using libc::dup2
// Cargo.toml needs: libc = "0.2"
use std::fs::OpenOptions;
use std::os::unix::io::AsRawFd;

fn main() -> std::io::Result<()> {
    let file = OpenOptions::new()
        .write(true).create(true).truncate(true)
        .open("/tmp/dup_out_rs.txt")?;

    let saved = unsafe { libc::dup(1) };
    if saved == -1 { return Err(std::io::Error::last_os_error()); }

    if unsafe { libc::dup2(file.as_raw_fd(), 1) } == -1 {
        return Err(std::io::Error::last_os_error());
    }

    println!("this line goes to the file");

    unsafe { libc::dup2(saved, 1); libc::close(saved); }
    println!("this line goes to the terminal");
    Ok(())
}

Rust Note: The nix crate provides safe wrappers: nix::unistd::dup2(). For production code, prefer nix over raw libc calls.

Error Handling at Every Syscall

In C, every system call can fail. The pattern is always the same:

int result = some_syscall(...);
if (result == -1) {
    perror("some_syscall");
    // handle error: cleanup, return, exit
}

The global variable errno is set on failure. perror() prints a human-readable message. strerror(errno) gives you the string directly.

Common errors:

errnoMeaning
ENOENTNo such file or directory
EACCESPermission denied
EEXISTFile already exists (with O_EXCL)
EMFILEToo many open files (per-process)
ENFILEToo many open files (system-wide)
EINTRInterrupted by signal
EBADFBad file descriptor

In Rust, std::io::Error wraps all of this. The kind() method maps to ErrorKind variants, and raw_os_error() gives you the raw errno.

use std::fs::File;
use std::io::ErrorKind;

fn main() {
    match File::open("/nonexistent/path") {
        Ok(_) => println!("opened"),
        Err(e) => {
            println!("error kind: {:?}", e.kind());
            println!("os error:   {:?}", e.raw_os_error());
            println!("message:    {}", e);

            if e.kind() == ErrorKind::NotFound {
                println!("file does not exist");
            }
        }
    }
}
$ rustc error_demo.rs && ./error_demo
error kind: NotFound
os error:   Some(2)
message:    No such file or directory (os error 2)
file does not exist

O_CLOEXEC and File Descriptor Leaks

When you fork() and then exec(), all open file descriptors are inherited by the child process unless they are marked close-on-exec. This is a common source of file descriptor leaks and security bugs.

/* cloexec.c -- demonstrate O_CLOEXEC */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

int main(void)
{
    /* Without O_CLOEXEC: fd leaks to child after exec */
    int fd_leak = open("/tmp/fd_demo.txt", O_RDONLY);

    /* With O_CLOEXEC: fd is automatically closed on exec */
    int fd_safe = open("/tmp/fd_demo.txt", O_RDONLY | O_CLOEXEC);

    printf("fd_leak = %d, fd_safe = %d\n", fd_leak, fd_safe);

    /* In a fork+exec scenario, fd_leak would be visible to the child
       process, but fd_safe would not. */

    close(fd_leak);
    close(fd_safe);
    return 0;
}

Driver Prep: Kernel drivers deal with struct file directly. The release callback in struct file_operations is called when the last fd referring to a file is closed. Understanding reference counting of descriptors here prepares you for that.

lseek: Moving the File Offset

Every open file description has a current offset. read() and write() advance it. lseek() repositions it.

/* lseek_demo.c -- seek within a file */
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>

int main(void)
{
    int fd = open("/tmp/seek_demo.txt", O_RDWR | O_CREAT | O_TRUNC, 0644);
    if (fd == -1) { perror("open"); return 1; }

    write(fd, "ABCDEFGHIJ", 10);

    /* Seek back to offset 3 */
    off_t pos = lseek(fd, 3, SEEK_SET);
    printf("position after lseek: %ld\n", (long)pos);

    /* Overwrite from position 3 */
    write(fd, "xyz", 3);

    /* Seek to beginning and read everything */
    lseek(fd, 0, SEEK_SET);
    char buf[32];
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    buf[n] = '\0';
    printf("contents: %s\n", buf);

    close(fd);
    return 0;
}
$ gcc -Wall -o lseek_demo lseek_demo.c && ./lseek_demo
position after lseek: 3
contents: ABCxyzGHIJ

SEEK_SET -- offset from beginning. SEEK_CUR -- offset from current position. SEEK_END -- offset from end of file.

In Rust, use Seek trait:

// seek_demo.rs
use std::fs::OpenOptions;
use std::io::{Read, Write, Seek, SeekFrom};

fn main() -> std::io::Result<()> {
    let mut f = OpenOptions::new()
        .read(true)
        .write(true)
        .create(true)
        .truncate(true)
        .open("/tmp/seek_demo_rs.txt")?;

    f.write_all(b"ABCDEFGHIJ")?;

    // Seek to offset 3
    let pos = f.seek(SeekFrom::Start(3))?;
    println!("position after seek: {}", pos);

    f.write_all(b"xyz")?;

    // Seek to start and read
    f.seek(SeekFrom::Start(0))?;
    let mut contents = String::new();
    f.read_to_string(&mut contents)?;
    println!("contents: {}", contents);

    Ok(())
}

Quick Knowledge Check

  1. What file descriptor number does open() return if stdin, stdout, and stderr are all open and no other files are open?

  2. What happens if you call open() with O_CREAT but forget the third argument (the mode)?

  3. After dup2(fd, STDOUT_FILENO), which file descriptor should you close -- fd, STDOUT_FILENO, or both?

Common Pitfalls

  • Forgetting to check return values. Every syscall can fail. Every one.

  • Forgetting the mode with O_CREAT. The file gets garbage permissions.

  • Not handling partial reads/writes. read() returning less than requested is normal, not an error.

  • Leaking file descriptors. Every open() must have a matching close(). In Rust, Drop handles this, but in C it is your job.

  • Using fd after close. Just like use-after-free, using a closed fd is a bug. The number might be reassigned to a different file.

  • Ignoring EINTR. Signals can interrupt blocking syscalls. Always retry on EINTR.

  • Forgetting O_CLOEXEC. File descriptors leak across exec() by default. Always use O_CLOEXEC unless you specifically want inheritance.