Talking directly to the kernel

Real-World Analogy

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 / Java (what you know)
# 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 — POSIX low-level (raw syscalls)
/* 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);
No buffering, no format strings

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.

Mental model

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 */
Headers needed: #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

FlagMeaningCombine with
O_RDONLYOpen for reading onlyCannot combine with WRONLY/RDWR
O_WRONLYOpen for writing onlyO_CREAT, O_TRUNC, O_APPEND
O_RDWROpen for reading and writingO_CREAT, O_TRUNC
O_CREATCreate file if it does not existRequires a third mode argument (e.g. 0644)
O_TRUNCTruncate file to zero length on openO_WRONLY or O_RDWR
O_APPENDAll writes go to end of fileO_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 */
}
Rule: Always save 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

Example 1 — open + read + close: read a file and print it Week 5 Lecture / Wk7A Tutorial
#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;
}
Output (if hello.txt contains "Hello, world!\n")
Hello, world!
Example 2 — write bytes to stdout with write() Week 5 Lecture — Page 33
#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;
}
Output
Hello from write()
Example 3 — lseek to read a file twice from the beginning Week 5 Lecture — Page 33 + man 2 lseek
#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;
}
Output (if data.txt contains "Hello")
First read: Hello
Second read: Hello
File size: 5 bytes
Example 4 — open with O_CREAT to create a new file and write to it Week 5 Lecture + Wk7A Tutorial
#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;
}
Terminal output + output.txt contents
Wrote to output.txt
[output.txt] Line 1: written with write()
[output.txt] Line 2: raw bytes, no printf

Practice problems with solutions

P1 — What are the values of stdin, stdout, and stderr file descriptors? Week 5 Lecture — Page 32

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?

0 = stdin, 1 = stdout, 2 = stderr. The first 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.
P2 — Spot the bugs in this open+read+close program Wk7A Tutorial + Week 5 Lecture

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;
}
Bug 1: 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").
P3 — Write a program that counts the bytes in a file using lseek Week 5 Lecture — man 2 lseek

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;
}
Key insight: After seeking to 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.
P4 — Why can read() return fewer bytes than requested? Week 5 Lecture — Page 34 + man 2 read

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;
}
Not an error: 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.
P5 — What is the difference between low-level I/O and stdio? Week 5 Lecture — Page 31

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?

Three key differences:

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

Card 1 of 10
Question — click to flip
Answer
Click card to flip • Use buttons to navigate

Test your understanding

Topic 19 Quiz — Low-level I/O Score: 0 / 6
1
What integer value represents stdout (standard output) as a file descriptor?LO2
multiple choice
2
What does read() return when it reaches end-of-file?LO2
multiple choice
3
Which flag combination opens a file for writing, creating it if it does not exist, and truncating it if it does?LO3
multiple choice
4
True or False: The stdio functions printf() and fwrite() are buffered, while write() is not.LO2
true / false
5
Fill in the blank: lseek(fd, 0, ______); repositions the file offset to the very beginning of the file.LO3
fill in the blank
6
After a syscall fails and returns -1, what should you call to print a human-readable error message to stderr?LO2
multiple choice
0/6
Quiz complete!