episode 01

cat(1)

file I/O · read() · write() · the Unix streaming model

Why We Start Here

Most tutorials about Linux tools show you how to use them. Flags, examples, combinations with other commands. That kind of thing.

Programming tutorials do something similar. They teach concepts through small programs that work but feel disconnected from anything real.

This series is built around a different idea: the best way to understand Unix tools is to build them. Not to recreate them perfectly, but to go through the same reasoning the original authors went through. One tool, one concept, one blank file at a time.

No frameworks. No helper libraries. Just C and the operating system.

We start with cat because it looks trivial. That is exactly why it belongs first.

Rethinking cat

Ask someone what cat does and they'll say: it reads a file and prints it to the terminal. That's true. It also barely scratches what cat is doing.

When Unix was designed, one of its core ideas was composability: small programs that each do one thing well and connect together. cat is the purest expression of this philosophy.

cat is not really about files. It's about moving data from input to output. The input could be a file, a pipe from another program, or your keyboard. From cat's point of view, the distinction doesn't exist. All it sees is a stream of bytes.

That indifference to the source of data is what makes it so useful. And it's what we need to understand before writing a single line of C.

The C Concept: File Descriptors

Unix has a unifying idea that makes all of this possible: many things can be treated like files. A file on disk, your keyboard, the terminal, a pipe between two programs: all of them expose the same interface to the program reading or writing them.

The glue holding this together is the file descriptor. It's a small integer your program uses to refer to an open data stream. By convention, three file descriptors are always open when your program starts:

file descriptors: stdin / stdout / stderr
fd 0 stdin keyboard fd 1 stdout terminal fd 2 stderr terminal your program

The three file descriptors every Unix program starts with. read(fd, ...) and write(fd, ...) work identically on all of them.

From a program's perspective, reading from the keyboard and reading from a file use the same system call. That is why tools like cat can stay so small and still handle so many situations.

How Programs Talk to the Kernel

Your program never accesses a file directly. It asks the kernel to do it on its behalf through system calls. The two we need for cat are:

the only two syscalls we need
ssize_t read(int fd, void *buffer, size_t count);
ssize_t write(int fd, const void *buffer, size_t count);

read() asks the kernel: "give me up to count bytes from file descriptor fd, put them in buffer." It returns how many bytes it actually read, or zero at end-of-file, or -1 on error.

write() does the reverse: "take count bytes from buffer and send them to file descriptor fd." The reason these two calls work on files, terminals, pipes, and network sockets is that the kernel abstracts all of them behind the file descriptor interface.

What Redirection Actually Does

Redirection is the simplest version of this idea. When you run cat file.txt > output.txt, cat has no idea the output is going to a file. It still just writes bytes to fd 1. Before cat ever starts, the shell quietly points fd 1 at output.txt instead of the terminal. The program does not change. Only the connection does.

bash: the shell rewires fd 1
$ cat file.txt > output.txt   # same result as: cp file.txt output.txt
$ cat file.txt >> output.txt  # append instead of overwrite

That is why cat file.txt > output.txt produces the same result as cp file.txt output.txt, even though cat knows nothing about copying files. It wrote to fd 1, exactly as always. The shell handled the rest. Pipes apply the same trick, except the far end is a second program instead of a file.

pipes: two programs connected by the shell
cat writes to fd 1 pipe created by shell grep reads from fd 0 term inal

cat file.txt | grep error. The shell creates the pipe and wires it up; neither program knows the other exists.

When you write cat file.txt | grep error, neither program knows the other is running. cat writes bytes to fd 1. grep reads bytes from fd 0. The shell quietly connected those two ends with a pipe before either program started. This is the Unix composability model in action.

// useless use of cat

In the Unix community, cat file | grep error is known as a "useless use of cat" because grep error file does the same thing more directly. It's not wrong, just redundant. We use it here because it makes the data flow visible. Once that mental model is clear, use grep directly.

The Build

Step 1: The Minimal Loop

The simplest possible cat does one thing: read from stdin, write to stdout, repeat until EOF. Here's what that looks like:

cat.c, v1: stdin only
#include <unistd.h>

#define BUFFER_SIZE  1024

int     main(void)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;

    bytes_read = 1;
    while (bytes_read > 0)
    {
        bytes_read = read(0, buffer, BUFFER_SIZE);
        write(1, buffer, bytes_read);
    }
    return (0);
}

That's it. No stdio.h. No printf. Just read() and write(). When read() returns 0, the input has ended and the loop stops. Compile this and it works exactly like cat with no arguments.

There's one bug worth naming immediately: we're calling write() without checking whether bytes_read is negative (an error). We'll fix that in the final version. For now, the shape of the solution is the important thing.

read/write loop: animated

Bytes flow from the source through the kernel boundary into the buffer, then out to stdout. Every iteration of the while loop moves one chunk.

Step 2: Accepting File Arguments

The real cat reads from stdin when called with no arguments, and from named files when you give it some. The logic is: if we got filenames on the command line, open and stream each one. Otherwise, stream fd 0.

cat.c, v2: file arguments
#include <unistd.h>
#include <fcntl.h>

#define BUFFER_SIZE  1024

int     main(int argc, char **argv)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    int     fd;
    int     i;

    bytes_read = 1;
    if (argc == 1)
    {
        while (bytes_read > 0)
        {
            bytes_read = read(0, buffer, BUFFER_SIZE);
            write(1, buffer, bytes_read);
        }
        return (0);
    }
    i = 1;
    while (i < argc)
    {
        fd = open(argv[i], O_RDONLY);
        if (fd == -1)
        {
            write(2, "Error opening file\n", 19);
            i++;
            continue;
        }
        bytes_read = 1;
        while (bytes_read > 0)
        {
            bytes_read = read(fd, buffer, BUFFER_SIZE);
            write(1, buffer, bytes_read);
        }
        close(fd);
        i++;
    }
    return (0);
}

open() is the system call that turns a filename into a file descriptor. O_RDONLY is a flag saying we only want to read. If it fails, because the file doesn't exist or we lack permission, it returns -1, and we write a message to stderr (fd 2). Then we move on to the next file instead of crashing.

Step 3: The Final Version with -n

Let's add line numbering. It's a text-level feature. We intercept each byte as it flows through and check whether it's a newline. When we see one, a new line is starting. The harder part is printing the number without printf, which means writing our own integer-to-string converter.

cat.c, final: -n flag, proper error handling
#include <unistd.h>
#include <fcntl.h>

#define BUFFER_SIZE  1024

static void     write_number(int n)
{
    char    tmp[16];
    int     len;
    int     i;

    len = 0;
    do
    {
        tmp[len++] = '0' + (n % 10);
        n /= 10;
    } while (n > 0);
    i = len - 1;
    while (i >= 0)
    {
        write(1, &tmp[i], 1);
        i--;
    }
}

void    cat_fd(int fd, int number_lines)
{
    char    buffer[BUFFER_SIZE];
    ssize_t bytes_read;
    int     line_number;
    int     at_line_start;
    int     i;

    line_number   = 1;
    at_line_start = 1;
    while ((bytes_read = read(fd, buffer, BUFFER_SIZE)) > 0)
    {
        i = 0;
        while (i < bytes_read)
        {
            if (number_lines && at_line_start)
            {
                write_number(line_number++);
                write(1, "\t", 1);
                at_line_start = 0;
            }
            write(1, &buffer[i], 1);
            if (buffer[i] == '\n')
                at_line_start = 1;
            i++;
        }
    }
    if (bytes_read == -1)
        write(2, "cat: read error\n", 16);
}

int     main(int argc, char **argv)
{
    int     fd;
    int     number_lines;
    int     i;

    number_lines = 0;
    i = 1;
    if (argc > 1 && argv[1][0] == '-' && argv[1][1] == 'n')
    {
        number_lines = 1;
        i++;
    }
    if (i == argc)
    {
        cat_fd(0, number_lines);
        return (0);
    }
    while (i < argc)
    {
        fd = open(argv[i], O_RDONLY);
        if (fd == -1)
        {
            write(2, "cat: cannot open ", 17);
            while (argv[i][0])
                write(2, argv[i]++, 1);
            write(2, "\n", 1);
            i++;
            continue;
        }
        cat_fd(fd, number_lines);
        close(fd);
        i++;
    }
    return (0);
}

The real cat carries a few more flags, and every one of them fits the same loop. -E prints a $ at each line end. -A makes non-printing characters visible. Each is just another byte-level check sitting next to the '\n' test we already wrote. The core stays a streaming loop. Features bolt onto it without disturbing it, which is exactly how Unix tools grow.

// design note: binary-safe by accident

Notice what we never did. We never treated the data as text. We never searched for a terminating '\0', never assumed an encoding, never called a string function. read() hands us a count of raw bytes and write() sends exactly that many back out. That is why this cat copies a JPEG, a compiled binary, or a UTF-8 document just as faithfully as a plain log file. The -n path is the only text-level feature in the whole program, and even it only ever compares one byte against '\n'. Working in bytes instead of text is what keeps the tool universal.

The Test

Build it and run it next to the real cat:

bash: compile and compare
# compile
$ cc -Wall -Wextra -o mycat cat.c

# basic comparison
$ diff <(cat cat.c) <(./mycat cat.c)

# empty file
$ touch empty.txt && diff <(cat empty.txt) <(./mycat empty.txt)

# file with no trailing newline
$ printf "no newline" > nonl.txt
$ diff <(cat nonl.txt) <(./mycat nonl.txt)

# line numbering
$ diff <(cat -n cat.c) <(./mycat -n cat.c)

# binary input: every byte must survive untouched
$ diff <(cat /bin/ls) <(./mycat /bin/ls)

# stdin via pipe
$ echo "hello world" | ./mycat

If all diffs are empty, the output matches. The edge case worth watching is the file with no trailing newline. Make sure your loop doesn't discard that last fragment.

What We Actually Learned

The implementation is small. The ideas behind it aren't.

We learned that file descriptors unify files, keyboards, terminals, and pipes under a single interface. We learned that read() and write() are the actual mechanism behind every input/output operation, not convenience functions layered on top of something else. We saw that redirection and pipes are not magic. They're the shell rewiring file descriptors before a program starts.

Most importantly: cat doesn't care what its input is. That indifference is the design, not a limitation.

Don't just read the code. Rewrite it yourself from scratch. Then break it. Remove the close() call, run it on a directory, pipe a program into it that writes faster than it reads. The gaps between what you expected and what happens are where the understanding actually lives.

This is the real reason to rebuild a tool. Doing it yourself proves that cat was never magic. You read its bytes, you check its return values, you watch it match the original on every test, and one more piece of the system stops being a black box. Memorising a command never survives a change of context. Building it once does.

That habit outlasts any particular tool, including the ones that did not exist when this series was written. Engineers and architects once drew everything on paper. Software changed the medium, but the ability to reason about structure never stopped mattering. AI is the same kind of shift: a tool worth using well, and a poor replacement for understanding what runs underneath it. Tools change often. Fundamentals rarely do, and that is exactly why they repay the effort.

Next up: head(1). The stream stays the same. We just decide to stop early.