JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Redirection and Pipes Between Descriptors

This is where the whole rung clicks into place. Once you truly believe a file descriptor is just a small integer the kernel hands you, you can rewire where it points and string two programs together — which is exactly the magic behind shell redirection and the pipe symbol you type every day.

Two facts you already own

You arrive at this last guide already carrying everything it needs. From guide 1 you know a file descriptor is nothing but a small non-negative integer — an index into a per-process table the kernel keeps, where each slot points at an open file, a pipe, a socket, anything. From guide 2 you know that read() and write() do not care what kind of thing sits behind the descriptor; they just move bytes to or from whatever integer you hand them. And from the standard streams you met earlier, you know three descriptors are open before your `main()` even runs: 0 is stdin, 1 is stdout, 2 is stderr.

Hold those two facts side by side and a door opens. If write() blindly sends bytes to whatever sits behind descriptor 1, and if descriptor 1 is just a table slot the kernel manages, then the bytes a program prints are not bound to the terminal at all. They go wherever slot 1 happens to point. Change what slot 1 points at, and every `printf()` in the program — without the program knowing or being recompiled — lands somewhere new: a file, a pipe, another program. That single observation is the whole of redirection, and it is why a Unix program need never contain a line of code about 'should I write to the screen or to a file'.

dup2: rewiring a slot in the table

The kernel gives you one precise tool for this rewiring, and it is the heart of redirection with dup. The call `dup2(oldfd, newfd)` makes newfd a copy of oldfd: after it returns, both integers refer to the very same open file behind the scenes — the same file, the same file offset, the same everything. If newfd was already open, dup2() quietly closes it first. So `dup2(fd, 1)` means: 'whatever fd points at, make descriptor 1 point there too.' From that instant, every write to 1 — every printf() — flows into fd's destination instead of the terminal.

Now you can hand-build what the shell does when you type `$ ./a.out > out.txt`. The shell first opens out.txt with open(), getting back some descriptor — say 4. It then calls `dup2(4, 1)` to point stdout at the file, and closes the now-redundant 4. Only then does it run your program. Your program prints to descriptor 1 exactly as always, utterly unaware that 1 no longer leads to a screen. The redirection happened entirely in the few instructions between opening the file and launching the program — and crucially, in the launching itself, which is the next piece.

Why does the redirection survive into your program at all? Because of fork then exec from the last rung. When the shell forks, the child inherits a copy of the parent's whole descriptor table — slot for slot. The shell does its dup2() surgery in that child, after the fork but before the exec, so the rewired table is already in place. Then exec() replaces the child's code and memory with your program — but exec() deliberately leaves the descriptor table untouched. Your fresh `main()` opens its eyes to a table that was rearranged just for it. That ordering, fork → dup2 → exec, is the skeleton of every redirection in Unix.

The pipe: a tube with two ends

Redirecting to a file is useful, but the real prize is connecting two running programs so one's output streams straight into the other's input. The kernel object that makes this possible is the anonymous pipe: an in-kernel buffer, a few kilobytes wide, with two descriptors attached. One end is read-only, the other write-only. Bytes written to the write end queue up inside the kernel and come out, in order, at the read end. It is a one-way tube made of pure descriptors — no file on disk, no name, nothing you can see in a directory.

You create one with `pipe(fds)`, passing an array of two ints. On success the kernel fills `fds[0]` with the read end and `fds[1]` with the write end — an easy pair to remember if you note that 0 is read like stdin's 0, and 1 is write like stdout's 1. Like every syscall in this rung, you check its return value: pipe() returns 0 on success and -1 on failure, and on -1 you inspect errno rather than charging ahead with two garbage descriptors. Once you hold the pair, you have a private channel; the next move is to give each end to a different process.

before fork:   parent holds  fds[0]=read   fds[1]=write

          fork()  ->  child inherits a COPY of both ends

  parent (writer)                 child (reader)
    close(fds[0])  read end          close(fds[1])  write end
    write(fds[1], ...) ----pipe----> read(fds[0], ...)

  each side closes the end it does not use, so EOF can arrive
A pipe shared across fork(). Each process keeps exactly one end and closes the other.

Joining pipe and fork into a real pipeline

Here is the beautiful part: a pipe alone connects nothing useful until you cross it with fork(). The trick is that you call pipe() first, then fork(). Because the child inherits a copy of the descriptor table, both parent and child now hold copies of both ends of the same pipe. The two processes share one in-kernel tube. From there each side keeps the one end it needs and closes the other: the writer keeps the write end and closes the read end; the reader keeps the read end and closes the write end. Now the pipe genuinely flows one way, from one process to the other.

  1. Call pipe(fds) and check it for -1, so you have a verified read end fds[0] and write end fds[1].
  2. Call fork() (checking for -1). Both processes now hold copies of both pipe ends.
  3. In the writer process: close(fds[0]) to drop the read end, then dup2(fds[1], 1) so its stdout becomes the pipe, then close(fds[1]) since slot 1 now carries it.
  4. In the reader process: close(fds[1]) to drop the write end, then dup2(fds[0], 0) so its stdin becomes the pipe, then close(fds[0]).
  5. exec() the two programs. Each now reads from stdin and writes to stdout as usual, oblivious that those streams are wired to each other.

Run those steps for two programs and you have reconstructed, by hand, exactly what the shell does for `$ ls | wc -l`. The shell pipeline is no more than this: one pipe(), one fork() per stage, a dup2() to splice each program's stdin or stdout onto a pipe end, and an exec() of the actual command. The `|` you type is a request to the shell to perform this little dance for you. There is no special 'pipe operator' inside ls or wc — they were written knowing nothing of each other, each just reading stdin and writing stdout, and the kernel quietly joined their streams.

Closing ends, EOF, and the broken pipe

The closing of unused ends is not tidiness — it is correctness, and skipping it is the single most common pipe bug. A read() on a pipe returns 0, the signal for end of file, only when every write end has been closed. If the reader process forgets to close its own copy of the write end, then a write end is still open — its own — so read() will block forever waiting for bytes that can never come, because the only descriptor that could send them is the one it is holding open by mistake. The deadlock looks baffling until you remember each process inherited both ends and must drop the one it does not use.

The mirror-image case is just as important and ties off this rung. What if the reader goes away first — ` ls | head -1` quits after one line — but the writer keeps writing? When a program writes to a pipe whose read end is fully closed, the kernel sends it the SIGPIPE signal, which by default kills the writer outright; this is the [[sigpipe-broken-pipe|broken pipe]] you may have glimpsed as 'Broken pipe' in a terminal. This is not a malfunction — it is the kernel's way of saying 'no one is listening, stop producing.' It is exactly why ` yes | head` ends promptly instead of spinning forever. A robust program either handles SIGPIPE or checks write()'s return for the EPIPE error rather than assuming every write lands.

Step back and see what this rung gave you. You started believing a file was an opaque thing your program held; you end knowing it is a numbered slot the kernel manages on your behalf, reachable by the same read()/write() whether it leads to a file, a terminal, or a pipe. Redirection is just rewiring a slot with dup2(); a pipeline is just two processes sharing a kernel tube, spliced onto their standard streams. The `>` and `|` you have typed casually for years are now things you could build yourself, from open(), pipe(), fork(), dup2(), exec(), and a careful eye on which descriptors stay open. From here the ladder turns to where these bytes and processes actually live in memory — but the everything-is-a-file lens you sharpened here will keep paying off.