Linux File I/O Fundamentals: Descriptors, System Calls, and Positional Control
Linux treats all I/O resources uniformly through the file abstraction—regular files, pipes, devices, and sockets are all accessed using the same interface. At the core of this model lies the file descriptor: a small non-negative integer that serves as an index into a per-process table of open files maintained by the kernel.
File Descriptor Basics
Each process starts with three predefined descriptors:
0: Standard input (stdin)1: Standard output (stdout)2: Standard error (stderr)
Subsequent calls to open() return the lowest available descriptor starting from 3. The maximum number of concurrently open files per process is constrained by the system limit, viewable via ulimit -n. By default, this value is often 1024, but it can be adjusted.
Descriptors are inherited across fork() and shared across threads unless explicitly closed. Closing a descriptor releases it for reuse by future open() calls.
Core System Calls
The fundamental file I/O operations rely on four system calls:
open()— opens or creates a file and returns a descriptorread()— reads data from a descriptor into a bufferwrite()— writes data from a buffer to a descriptorclose()— releases the descriptor and flushes pending writes
Opening Files
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
int fd = open("data.bin", O_RDONLY);
if (fd == -1) {
perror("open failed");
return -1;
}
The flags argument specifies access mode (O_RDONLY, O_WRONLY, O_RDWR) and optional behaviors (O_CREAT, O_TRUNC, O_APPEND, etc.). When O_CREAT is used, a third mode parameter defines permissions (e.g., 0644).
Reading and Writing
Both read() and write() operate relative to the current file offset, which advances automatically after each call:
ssize_t n = read(fd, buffer, sizeof(buffer));
if (n > 0) {
// n bytes read successfully
} else if (n == 0) {
// end-of-file reached
} else {
// error occurred
}
ssize_t w = write(fd_out, buffer, n);
if (w != n) {
// partial or failed write
}
A successful read() returns the number of bytes actually transferred, which may be less than requested (e.g., at EOF or due to signal interruption). Similarly, write() may transfer fewer bytes than requested (e.g., disk full), though full writes are typical for regular files.
Controlling File Position
The lseek() system call modifies the current offset without reading or writing:
off_t pos = lseek(fd, 1024, SEEK_SET); // absolute: byte 1024 from start
pos = lseek(fd, -512, SEEK_CUR); // relative: move back 512 bytes
pos = lseek(fd, 0, SEEK_END); // set offset to end of file
It returns the new absolute position from the beginning of the file, or -1 on failure.
Practical Example: Selective Copy with Offset Adjustment
This program copies exactly 1024 bytes starting from byte 500 in the source file to the beginning of the destination file:
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main() {
char buf[1024];
int src_fd = open("src_file", O_RDONLY);
if (src_fd == -1) {
perror("cannot open source");
return 1;
}
int dst_fd = open("dest_file", O_WRONLY | O_CREAT | O_EXCL, 0644);
if (dst_fd == -1) {
perror("cannot create destination");
close(src_fd);
return 1;
}
// Skip first 500 bytes in source
if (lseek(src_fd, 500, SEEK_SET) == -1) {
perror("seek failed in source");
goto cleanup;
}
ssize_t n = read(src_fd, buf, sizeof(buf));
if (n <= 0) {
perror(n == 0 ? "unexpected EOF" : "read error");
goto cleanup;
}
// Reset destination to start
if (lseek(dst_fd, 0, SEEK_SET) == -1) {
perror("seek failed in destination");
goto cleanup;
}
if (write(dst_fd, buf, n) != n) {
perror("write failed");
goto cleanup;
}
printf("Copied %zd bytes successfully\n", n);
cleanup:
close(dst_fd);
close(src_fd);
return 0;
}
Note the use of O_EXCL with O_CREAT to ensure the destination file does not already exist—preventing accidental overwrites. Error handling follows POSIX conventions: check return values, use perror() for diagnostics, and clean up resources before exiting.