Systems programming with POSIX · about 60 min

File descriptors: open, read, write, close

Why this matters

Everything you have done with files so far went through <stdio.h>: fopen, fprintf, fgets. Underneath, stdio is a library that calls a handful of system calls, requests to the operating system kernel. Learning those calls shows you what a file really is to a process, explains puzzles like output appearing in the wrong order, and opens the door to everything stdio cannot do: pipes between processes, terminals, sockets, devices. On Unix, all of those are used through the same four calls this lesson teaches, which is what people mean by "everything is a file".

System calls

A process cannot touch the disk, the terminal or another process directly. The CPU runs your code in user mode, where such operations are forbidden. To get anything done, the program executes a special instruction (syscall on x86-64) that switches into the kernel, which checks the request, performs it, and returns a result. The C library wraps each system call in an ordinary-looking function: open, read, write, close, fork and so on. These are POSIX functions, declared in <unistd.h> and <fcntl.h>, not part of ISO C. To see them with -std=c17 you request POSIX explicitly by defining a feature test macro before any include:

#define _POSIX_C_SOURCE 200809L

Without it, gcc in strict C17 mode hides POSIX declarations, and you get errors such as implicit declaration of function 'fdopen'.

File descriptors

When a process opens a file, the kernel creates an open file description (which file, the current offset, the access mode) and records it in the process's file descriptor table. The program gets back the index into that table: a small non-negative int called a file descriptor (fd).

  process                              kernel
  fd table                             open file descriptions
  +---+-----------------+
  | 0 | o---------------+-------->  terminal (read)
  | 1 | o---------------+-------->  terminal (write)
  | 2 | o---------------+-------->  terminal (write)
  | 3 | o---------------+-------->  notes.txt, offset 0, write-only
  +---+-----------------+

Every process starts with three descriptors already open: 0 standard input, 1 standard output, 2 standard error (STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO in <unistd.h>). The shell sets them up before running your program, and redirections like > out.txt just make descriptor 1 point somewhere else. open always returns the lowest unused number, so the first file you open is normally 3. On Linux you can see the table of any process in /proc/PID/fd.

The four calls

int     open(const char *path, int flags, ... /* mode_t mode */);
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
int     close(int fd);

open takes flags combined with |, exactly the flag-set technique from the bit manipulation module. Exactly one access mode, plus any options:

flagmeaning
O_RDONLY, O_WRONLY, O_RDWRopen for reading, writing, or both
O_CREATcreate the file if it does not exist (then the mode argument is required)
O_TRUNCtruncate an existing file to length 0
O_APPENDevery write goes to the current end of the file
O_EXCLwith O_CREAT: fail if the file already exists

The third argument, the mode, gives a newly created file's permissions in octal: 0644 is rw-r--r--. The process's umask (usually 022) then removes bits from it. open returns the new descriptor, or -1 with errno set.

read asks for up to count bytes and returns how many it actually got: possibly fewer (a short read), 0 at end of file, or -1 on error. Fewer is normal: a terminal returns one line at a time, a pipe returns whatever is available, and the end of a file gives a partial last chunk.

write writes up to count bytes and returns how many it wrote. For regular files it almost always writes everything, but for pipes, sockets and when interrupted by a signal it may write fewer, so robust code loops.

close releases the descriptor. The number becomes free for reuse; the kernel tracks how many descriptors refer to each open file and releases it when the last one closes. When a process exits, the kernel closes all its descriptors.

Neither read nor write knows anything about strings or lines: they move raw bytes. There is no terminating '\0' in what read gives you, so use the returned count.

A worked example

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/* write all len bytes, retrying after short writes */
int write_all(int fd, const char *buf, size_t len)
{
    while (len > 0) {
        ssize_t n = write(fd, buf, len);
        if (n < 0) {
            if (errno == EINTR)
                continue;          /* interrupted by a signal: try again */
            return -1;
        }
        buf += n;
        len -= (size_t)n;
    }
    return 0;
}

int main(void)
{
    int fd = open("notes.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open notes.txt");
        return 1;
    }
    printf("opened notes.txt as fd %d\n", fd);
    const char *msg = "first line\nsecond line\n";
    if (write_all(fd, msg, strlen(msg)) != 0) {
        perror("write");
        close(fd);
        return 1;
    }
    close(fd);

    fd = open("notes.txt", O_RDONLY);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    char buf[8];
    ssize_t n;
    int chunks = 0;
    while ((n = read(fd, buf, sizeof buf)) > 0) {
        chunks++;
        write_all(STDOUT_FILENO, buf, (size_t)n);
    }
    if (n < 0)
        perror("read");
    close(fd);
    printf("read in %d chunks\n", chunks);

    if (open("/etc/new-file", O_WRONLY | O_CREAT, 0644) < 0)
        printf("open /etc/new-file: %s (errno %d)\n", strerror(errno), errno);
    if (open("missing.txt", O_RDONLY) < 0)
        perror("open missing.txt");
    return 0;
}

The 23-byte file is read with an 8-byte buffer in 3 chunks (8 + 8 + 7). In the sandbox the root file system is read-only, so creating a file in /etc fails. Run with output going through a pipe (as in the exercises), it prints:

open missing.txt: No such file or directory
first line
second line
opened notes.txt as fd 3
read in 3 chunks
open /etc/new-file: Read-only file system (errno 30)

That order looks wrong, and understanding it is the most practical lesson here:

So the printf lines all came out together at the end. On a terminal you would see the "natural" order. The rule: do not mix stdio and raw write on the same descriptor, or call fflush(stdout) before switching.

stdio versus system calls

FILE * streams are a buffer plus a file descriptor (fileno(f) gives you the descriptor). Each system call costs a switch into the kernel and back, maybe a few hundred nanoseconds; calling write once per character would be painfully slow, which is why stdio collects output and issues one write per few kilobytes. Use stdio for text and formatted I/O; use descriptors when you need precise control (no buffering, exact byte counts, special files) or when working with pipes and processes, as the next lessons do.

Errors

System calls report failure by returning -1 and setting errno, as you learned in the I/O module. Common values here: ENOENT (no such file), EACCES (permission denied), EEXIST (with O_EXCL), EROFS (read-only file system), EBADF (not an open descriptor), EINTR (interrupted by a signal; just retry).

Common mistakes

Recap

Going further: the sandbox has no section 2 man pages, so read open(2), read(2) and write(2) at man7.org. In the sandbox terminal, run ls -l /proc/self/fd and then ls -l /proc/self/fd < /etc/hostname to watch descriptor 0 change.

Exercises

Exercise 1 · quiz

Which number?

A program started normally from a shell calls open("data.txt", O_RDONLY) as the first thing it does, and the call succeeds. What does it return?

Answer
  • 3

open returns the lowest file descriptor number not currently in use. A process normally starts with 0 (stdin), 1 (stdout) and 2 (stderr) already open, so the first new descriptor is 3. If you close(0) first, the next open returns 0, which is exactly how shells implemented input redirection before dup2 existed. A descriptor is just a small int indexing the process's table of open files in the kernel; it is not a pointer.

Exercise 2 · quiz

Why did the order change?

A program calls printf("A\n");, then write(1, "B\n", 2);, then printf("C\n"); and exits. Run in a terminal it prints A, B, C. Run as ./prog | cat it prints B, A, C. Why?

Answer
  • printf goes through the stdio buffer, which is line buffered on a terminal but fully buffered on a pipe, so A and C stay in the buffer until exit while write(2) goes straight to the kernel

stdio keeps a user-space buffer in front of the file descriptor. On a terminal, stdout is line buffered, so every newline flushes. On a pipe or file it is fully buffered (typically 4096 bytes), so "A" and "C" are written together when the program exits and stdio flushes. write() bypasses the buffer entirely. Never mix stdio and raw write() on the same descriptor without fflush(stdout) first.

Exercise 3 · write code

Write cat with system calls

Write a small cat: for each file named on the command line, in order, copy its contents to standard output using open, read, write and close (from <fcntl.h> and <unistd.h>), with a buffer of 4096 bytes. Do not use fopen, fread, fgets, getc or printf for the copying. With no arguments, copy standard input (descriptor 0) instead.

If a file cannot be opened, print mycat: NAME: REASON to standard error (REASON from strerror(errno), for example No such file or directory), carry on with the next file, and exit with status 1 at the end. Otherwise exit 0. Handle short writes: write may write fewer bytes than asked, so loop until the whole chunk is written.

Put #define _POSIX_C_SOURCE 200809L before the includes.

$ ./prog a.txt missing.txt b.txt
alpha
mycat: missing.txt: No such file or directory
beta
$ echo $?
1

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/* copy everything from fd in to fd out; return 0 or -1 */
static int copy_fd(int in, int out)
{
    return 0;
}

int main(int argc, char *argv[])
{
    if (argc == 1)
        return copy_fd(STDIN_FILENO, STDOUT_FILENO) == 0 ? 0 : 1;
    int status = 0;
    for (int i = 1; i < argc; i++) {
        /* open argv[i], copy it, close it; report errors */
    }
    return status;
}

Example with arguments a.txt b.txt

expected output

alpha
beta

Example with arguments a.txt missing.txt b.txt

expected output

alpha
beta
Hint 1

read returns the number of bytes read, 0 at end of file, or -1 on error. Loop until it returns 0.

Hint 2

For each chunk of n bytes, call write in a loop, advancing a pointer by however many bytes each write actually wrote.

Hint 3

In main: fd = open(argv[i], O_RDONLY); if fd < 0, fprintf(stderr, "mycat: %s: %s\n", argv[i], strerror(errno)), set status = 1, continue.

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

/* copy everything from fd in to fd out; return 0 or -1 */
static int copy_fd(int in, int out)
{
    char buf[4096];
    ssize_t n;
    while ((n = read(in, buf, sizeof buf)) != 0) {
        if (n < 0) {
            if (errno == EINTR)
                continue;
            return -1;
        }
        char *p = buf;
        while (n > 0) {
            ssize_t w = write(out, p, (size_t)n);
            if (w < 0) {
                if (errno == EINTR)
                    continue;
                return -1;
            }
            p += w;
            n -= w;
        }
    }
    return 0;
}

int main(int argc, char *argv[])
{
    if (argc == 1)
        return copy_fd(STDIN_FILENO, STDOUT_FILENO) == 0 ? 0 : 1;
    int status = 0;
    for (int i = 1; i < argc; i++) {
        int fd = open(argv[i], O_RDONLY);
        if (fd < 0) {
            fprintf(stderr, "mycat: %s: %s\n", argv[i], strerror(errno));
            status = 1;
            continue;
        }
        if (copy_fd(fd, STDOUT_FILENO) != 0) {
            fprintf(stderr, "mycat: %s: %s\n", argv[i], strerror(errno));
            status = 1;
        }
        close(fd);
    }
    return status;
}

Exercise 4 · write code

Append to a log

Write a program that appends its first command-line argument, followed by a newline, to the file log.txt in the current directory, creating the file if needed with permissions 0600 (read and write for the owner only). Existing content must be kept. Use open with the right flags and a single write call for the line (build it in a buffer with snprintf).

If no argument is given, print usage: prog MESSAGE to standard error and exit 2. If open or write fails, report it with perror and exit 1. The program prints nothing on success.

$ ./prog "first"; ./prog "second"; cat log.txt
first
second
$ stat -c %a log.txt
600

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open log.txt");
        return 1;
    }
    write(fd, argv[1], strlen(argv[1]));
    close(fd);
    return 0;
}
Hint 1

O_TRUNC empties the file. O_APPEND makes every write go to the current end of the file.

Hint 2

The third argument of open is the permission mode for a newly created file: 0600 (an octal literal, note the leading 0).

Hint 3

Check argc before touching argv[1].

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "usage: prog MESSAGE\n");
        return 2;
    }
    char line[4096];
    int len = snprintf(line, sizeof line, "%s\n", argv[1]);
    if (len < 0 || (size_t)len >= sizeof line) {
        fprintf(stderr, "message too long\n");
        return 1;
    }
    int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0600);
    if (fd < 0) {
        perror("open log.txt");
        return 1;
    }
    if (write(fd, line, (size_t)len) != len) {
        perror("write log.txt");
        close(fd);
        return 1;
    }
    if (close(fd) != 0) {
        perror("close log.txt");
        return 1;
    }
    return 0;
}