Low-level I/O (File Descriptors)
POSIX system calls for I/O — open, read, write, close, lseek, and file descriptor numbers.
Talking directly to the kernel
Think of a file descriptor as a numbered ticket at a deli counter. When your process opens a file, the kernel hands you a small integer (the ticket number). Every time you want to read or write, you present that ticket — the kernel looks up what file it refers to and performs the operation on your behalf. The ticket itself contains no data; it is just a handle. When you are done, you hand the ticket back with close().
In Python you use open("file.txt") which returns a file object with methods like .read() and .write(). In Java you use FileInputStream or BufferedReader. Both of these are high-level wrappers that internally use the same POSIX system calls you will use directly in C: open(), read(), write(), close().
What is a file descriptor? It is simply a non-negative integer. When your process starts, three are already open: 0 = stdin, 1 = stdout, 2 = stderr. When you call open(), the kernel returns the lowest available integer — typically starting at 3.
Why use low-level I/O instead of stdio? The stdio functions (printf, fread) add buffering on top of the kernel calls. That buffering is useful for regular files but wrong for pipes, network sockets, and device files — where you need to control exactly when data is sent and received. Low-level I/O gives you that control. It is also the only interface available when working directly with the kernel.
errno and perror: When a system call fails, it returns -1 and sets the global variable errno to a code describing the error. perror("msg") prints your message followed by a colon and the human-readable description of the current errno. Always check return values and use perror when debugging.
# Python — buffered, high-level
with open("file.txt", "r") as f:
data = f.read()
# Java — also buffered, high-level
FileReader fr = new FileReader("file.txt");
BufferedReader br = new BufferedReader(fr);
String line = br.readLine();
/* C — raw file descriptor */
#include <fcntl.h>
#include <unistd.h>
int fd = open("file.txt", O_RDONLY);
char buf[100];
ssize_t n = read(fd, buf, sizeof(buf));
/* process n bytes in buf */
close(fd);
Low-level I/O deals in raw bytes only. There are no format specifiers like %d, no automatic newlines, and no buffering. read() returns the raw bytes from the file — it is your responsibility to interpret them. You cannot use read() and then call printf expecting them to share a buffer.
fd is a ticket number. open() gets you a ticket. read()/write() use the ticket. close() returns the ticket. Everything on a Unix system — files, pipes, sockets, devices — is accessed through file descriptors.
open, read, write, close, lseek
/* open — open or create a file, returns fd or -1 */ /* #include <fcntl.h> */ int open(const char *path, int flags); /* ^ ^ | └── O_RDONLY, O_WRONLY, O_RDWR, O_CREAT|O_TRUNC... └── file path string */ /* read — read up to n bytes from fd into buf */ /* #include <unistd.h> */ ssize_t read(int fd, void *buf, size_t n); /* ^ ^ ^ | | └── max bytes to read | └── buffer to write into └── returns: bytes read, 0 on EOF, -1 on error */ /* write — write n bytes from buf to fd */ ssize_t write(int fd, const void *buf, size_t n); /* returns bytes written, or -1 on error */ /* close — release the file descriptor */ int close(int fd); /* returns 0 on success, -1 on error */ /* lseek — reposition file offset */ off_t lseek(int fd, off_t offset, int whence); /* whence: SEEK_SET (from start), SEEK_CUR (from current), SEEK_END (from end) */ /* returns new offset in bytes from start, or -1 on error */
#include <fcntl.h> for open() and the O_* flags. #include <unistd.h> for read(), write(), close(), lseek(). Always check return values — every syscall can fail.
open() flags reference
| Flag | Meaning | Combine with |
|---|---|---|
O_RDONLY | Open for reading only | Cannot combine with WRONLY/RDWR |
O_WRONLY | Open for writing only | O_CREAT, O_TRUNC, O_APPEND |
O_RDWR | Open for reading and writing | O_CREAT, O_TRUNC |
O_CREAT | Create file if it does not exist | Requires a third mode argument (e.g. 0644) |
O_TRUNC | Truncate file to zero length on open | O_WRONLY or O_RDWR |
O_APPEND | All writes go to end of file | O_WRONLY |
errno and perror
#include <errno.h> /* errno variable */ #include <stdio.h> /* perror() */ #include <string.h> /* strerror() */ int fd = open("no_such_file.txt", O_RDONLY); if (fd == -1) { perror("open"); /* prints: "open: No such file or directory" */ /* OR: printf("%s\n", strerror(errno)); same message, manual */ }
errno immediately after the failing call — subsequent function calls may overwrite it. Initialize errno = 0 before any call you want to inspect.
Complete programs you can compile and run
#include <fcntl.h> /* open(), O_RDONLY */
#include <unistd.h> /* read(), write(), close() */
#include <stdio.h> /* perror() */
int main(void) {
/* open() returns a file descriptor (integer) or -1 on error */
int fd = open("hello.txt", O_RDONLY);
if (fd == -1) {
perror("open"); /* prints error to stderr */
return 1;
}
char buf[256];
ssize_t n;
/* read in a loop — read() may return less than asked */
while ((n = read(fd, buf, sizeof(buf))) > 0) {
/* write raw bytes to stdout (fd 1) */
write(1, buf, n);
}
if (n == -1) {
perror("read");
}
close(fd); /* always close — releases kernel resources */
return 0;
}
#include <unistd.h>
#include <string.h> /* strlen() */
int main(void) {
const char *msg = "Hello from write()\n";
/* write(fd, buffer, count) — fd=1 is stdout */
/* Unlike printf, write() does NOT interpret format specifiers */
/* It sends raw bytes — here the ASCII bytes of the string */
ssize_t written = write(1, msg, strlen(msg));
if (written == -1) {
/* write to stderr (fd=2) for error messages */
const char *err = "write failed\n";
write(2, err, strlen(err));
return 1;
}
/* written == number of bytes actually sent */
return 0;
}
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = open("data.txt", O_RDONLY);
if (fd == -1) { perror("open"); return 1; }
char buf[64];
ssize_t n;
/* First read — reads from current position (start) */
n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("First read: %s\n", buf);
}
/* lseek back to the beginning (offset=0 from SEEK_SET) */
off_t pos = lseek(fd, 0, SEEK_SET);
if (pos == -1) { perror("lseek"); close(fd); return 1; }
/* Second read — reads same content again */
n = read(fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("Second read: %s\n", buf);
}
/* Get file size: seek to end, result is size */
off_t size = lseek(fd, 0, SEEK_END);
printf("File size: %ld bytes\n", (long)size);
close(fd);
return 0;
}
Second read: Hello
File size: 5 bytes
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
int main(void) {
/* O_WRONLY | O_CREAT | O_TRUNC: open for write, create if missing, truncate if exists */
/* 0644: permissions — owner rw, group r, others r */
int fd = open("output.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd == -1) { perror("open"); return 1; }
const char *line1 = "Line 1: written with write()\n";
const char *line2 = "Line 2: raw bytes, no printf\n";
write(fd, line1, strlen(line1));
write(fd, line2, strlen(line2));
close(fd);
printf("Wrote to output.txt\n");
return 0;
}
[output.txt] Line 1: written with write()
[output.txt] Line 2: raw bytes, no printf
Practice problems with solutions
Fill in the blanks: When a process starts, fd ___ = stdin, fd ___ = stdout, fd ___ = stderr. When you call open() for the first time, what fd number will it return?
open() call returns 3 — the kernel always assigns the lowest available integer. If you close fd 1 (stdout) and then call open(), it will return 1. This is the basis of I/O redirection: close stdout, then open a file — the file gets fd 1 and all printf output goes into the file.
The code below has three bugs. Identify and fix each one.
#include <fcntl.h>
#include <unistd.h>
int main(void) {
int fd = open("data.txt", O_RDONLY);
char buf[100];
ssize_t n = read(fd, buf, 100);
buf[100] = '\0'; /* Bug 1 */
write(1, buf, 100); /* Bug 2 */
/* Bug 3: missing something */
return 0;
}
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(void) {
int fd = open("data.txt", O_RDONLY);
if (fd == -1) { perror("open"); return 1; } /* added error check */
char buf[100];
ssize_t n = read(fd, buf, sizeof(buf));
if (n > 0) {
buf[n] = '\0'; /* Fix 1: null-terminate at n, not 100 — buf[100] is out of bounds */
write(1, buf, n); /* Fix 2: write n bytes, not 100 — only n bytes were actually read */
}
close(fd); /* Fix 3: always close the file descriptor */
return 0;
}
buf[100] is out-of-bounds. The buffer has indices 0–99. If n < 100, writing '\0' at position n is correct; writing at 100 is always a buffer overflow.Bug 2:
write(1, buf, 100) writes 100 bytes even if only n bytes were read — sends garbage bytes beyond the actual data.Bug 3: No
close(fd). Every opened fd must be closed to release the kernel resource. Leaking fds eventually causes open() to fail with EMFILE ("too many open files").
Without reading any data, determine the size of a file in bytes by using lseek() with SEEK_END. Print the size and return 0 on success.
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
int main(int argc, char *argv[]) {
if (argc != 2) { fprintf(stderr, "Usage: %s <file>\n", argv[0]); return 1; }
int fd = open(argv[1], O_RDONLY);
if (fd == -1) { perror("open"); return 1; }
/* Seek to end — lseek returns new offset = file size in bytes */
off_t size = lseek(fd, 0, SEEK_END);
if (size == -1) { perror("lseek"); close(fd); return 1; }
printf("%s: %ld bytes\n", argv[1], (long)size);
close(fd);
return 0;
}
SEEK_END with offset 0, the return value of lseek() is the number of bytes from the beginning of the file to the end — which is the file size. No data needs to be read. Cast to long before printing with %ld because off_t is a signed integer type whose size varies by platform.
You call read(fd, buf, 1024) but read() returns 46. Is this an error? Explain when read() returns less than asked and write a correct loop that reads exactly n bytes or reaches EOF.
/* read_exactly: reads exactly n bytes, returns total read, or -1 on error */
ssize_t read_exactly(int fd, void *buf, size_t n) {
size_t total = 0;
char *p = buf;
while (total < n) {
ssize_t r = read(fd, p + total, n - total);
if (r == 0) break; /* EOF */
if (r == -1) return -1; /* error */
total += r;
}
return (ssize_t)total;
}
read() returning fewer bytes than requested is normal. It happens when: (a) the file has fewer bytes remaining than requested, (b) the call was interrupted by a signal (EINTR), (c) reading from a pipe or socket where data arrives in chunks, or (d) the kernel decided to return a partial buffer. A return of 46 means exactly 46 bytes were placed in buf. Always loop when you need an exact number of bytes, especially on pipes and sockets.
List three concrete differences between the low-level POSIX I/O functions (open, read, write) and the stdio library functions (fopen, fread, fwrite). When should you prefer one over the other?
1. Buffering: stdio maintains an in-process buffer — data is accumulated and flushed in large chunks for efficiency. POSIX I/O makes a kernel call for every
read()/write(). No internal buffer.2. Handle type: stdio uses
FILE* (a C library struct). POSIX I/O uses int file descriptors. File descriptors exist at the kernel level; FILE* is a user-space wrapper.3. Format support: stdio provides
fprintf, fscanf, fgets — text formatting built-in. POSIX I/O deals only in raw bytes; you format yourself.Use stdio for regular files where buffering is an optimization. Use POSIX I/O for pipes, sockets, device files, and any situation where you need to control exactly when kernel I/O happens (e.g., interleaved with
select()/poll()).
Key concepts to memorize
Test your understanding
read() return when it reaches end-of-file?LO2printf() and fwrite() are buffered, while write() is not.LO2lseek(fd, 0, ______); repositions the file offset to the very beginning of the file.LO3