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:
| fd | Symbolic Name | C Macro | Purpose |
|---|---|---|---|
| 0 | standard input | STDIN_FILENO | keyboard / pipe |
| 1 | standard output | STDOUT_FILENO | terminal / pipe |
| 2 | standard error | STDERR_FILENO | terminal / pipe |
Driver Prep: In kernel modules you will work with
struct filedirectly. 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-1means failure, anderrnotells 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:
| Flag | Meaning |
|---|---|
O_RDONLY | Open for reading only |
O_WRONLY | Open for writing only |
O_RDWR | Open for reading and writing |
O_CREAT | Create the file if it does not exist |
O_TRUNC | Truncate existing file to zero length |
O_APPEND | Writes always go to end of file |
O_EXCL | Fail if file already exists (with O_CREAT) |
O_CLOEXEC | Close 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_CREATis undefined behavior. The compiler will not warn you becauseopen()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_allto also handleEAGAIN(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_fdisunsafebecause 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
nixcrate provides safe wrappers:nix::unistd::dup2(). For production code, prefernixover rawlibccalls.
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:
| errno | Meaning |
|---|---|
ENOENT | No such file or directory |
EACCES | Permission denied |
EEXIST | File already exists (with O_EXCL) |
EMFILE | Too many open files (per-process) |
ENFILE | Too many open files (system-wide) |
EINTR | Interrupted by signal |
EBADF | Bad 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 filedirectly. Thereleasecallback instruct file_operationsis 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
-
What file descriptor number does
open()return if stdin, stdout, and stderr are all open and no other files are open? -
What happens if you call
open()withO_CREATbut forget the third argument (the mode)? -
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 matchingclose(). In Rust,Drophandles 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 onEINTR. -
Forgetting
O_CLOEXEC. File descriptors leak acrossexec()by default. Always useO_CLOEXECunless you specifically want inheritance.