Systems programming with POSIX · about 65 min

Pipes and redirection

Why this matters

grep error log.txt | sort | uniq -c > report.txt connects four processes and a file with two characters, | and >. That composability, small programs that each do one thing, glued together with pipes, is the core of the Unix design, and it is built from three system calls you can now use yourself: pipe, dup2 and the fork/exec/wait trio from the previous lesson. Knowing how it works lets you run other programs and collect their output from C, build pipelines in your own tools, and diagnose the classic bug where a program using pipes hangs forever.

The pipe

pipe(fds) asks the kernel for a one-way byte channel with a buffer (64 KiB on Linux) and gives you two file descriptors:

               write(fds[1], ...)             read(fds[0], ...)
   writer  ----------------------->  [ kernel buffer ]  ----------------------->  reader
                 fds[1]: write end                          fds[0]: read end

Bytes written to fds[1] come out of fds[0] in the same order. Like a file, a pipe has no message boundaries: three writes of 10 bytes may arrive as one read of 30 or as reads of 7 and 23. The behavior that makes pipes work:

A pipe inside one process is not very useful. Its power comes from fork: the child inherits copies of both descriptors, so parent and child share the channel.

Closing what you do not use

After fork, both processes hold both ends. Each must close the ends it does not use:

  after fork:                              after closing unused ends:
  parent: fds[0] fds[1]                    parent:        fds[1]  (writes)
  child:  fds[0] fds[1]                    child:  fds[0]         (reads)

Forgetting this is the number one pipe bug. If the reader keeps its own copy of the write end open, its read never returns 0, because a writer (itself) still exists, and it hangs forever after consuming the data. In the exercises, such a hang shows up as "timed out".

dup2: rewiring descriptors

dup2(oldfd, newfd) makes newfd refer to the same open file as oldfd, closing whatever newfd referred to before. Since programs simply write to descriptor 1 and read from descriptor 0, rewiring those numbers before exec redirects a program without it knowing:

  dup2(fd, STDOUT_FILENO):

  before                               after
  1 -> terminal                        1 -> out.txt
  3 -> out.txt   (fd)                  3 -> out.txt   (then close(3))

This is the whole of shell redirection: > is open + dup2(fd, 1), < is open + dup2(fd, 0), 2>&1 is dup2(1, 2). Open file descriptors survive exec, so the new program inherits them.

Redirecting output to a file

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

int main(void)
{
    int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (fd < 0) {
        perror("open");
        return 1;
    }
    fflush(stdout);
    if (dup2(fd, STDOUT_FILENO) < 0) {
        perror("dup2");
        return 1;
    }
    close(fd);
    printf("this line goes to out.txt\n");
    fflush(stdout);
    execlp("date", "date", "+%Y", (char *)NULL);
    perror("exec");
    return 1;
}

Afterwards out.txt contains this line goes to out.txt followed by the year from date. Remove the second fflush and the printf line vanishes: stdout now points to a file, so stdio buffers it, and exec throws away the process's memory, buffer included, before it was ever written. (This example redirects its own stdout; real code usually does it in a child after fork, so the parent keeps its stdout.)

Building a pipeline

Here is printf 'b\na\nc\n' | sort done by hand: one pipe, two children, each with one end dup2'd onto stdout or stdin:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    int fds[2];
    if (pipe(fds) < 0) {
        perror("pipe");
        return 1;
    }

    pid_t writer = fork();
    if (writer == 0) {
        dup2(fds[1], STDOUT_FILENO);   /* stdout now goes into the pipe */
        close(fds[0]);
        close(fds[1]);
        execlp("printf", "printf", "b\\na\\nc\\n", (char *)NULL);
        perror("exec printf");
        _exit(127);
    }

    pid_t reader = fork();
    if (reader == 0) {
        dup2(fds[0], STDIN_FILENO);    /* stdin now comes from the pipe */
        close(fds[0]);
        close(fds[1]);
        execlp("sort", "sort", (char *)NULL);
        perror("exec sort");
        _exit(127);
    }

    close(fds[0]);                     /* the parent uses neither end */
    close(fds[1]);
    waitpid(writer, NULL, 0);
    waitpid(reader, NULL, 0);
    printf("pipeline finished\n");
    return 0;
}
a
b
c
pipeline finished

(The \\n in the C string passes a literal backslash-n to the printf command, which interprets it itself, just as the shell version does.) Note that each child closes both original pipe descriptors after dup2: the copy on 0 or 1 is all it needs. The parent closes both too. If the parent kept fds[1] open, sort would wait forever for more input, because sort cannot print anything until it has seen end of file.

A shell runs a | b | c the same way with two pipes and three children, all started before any is waited for, so they run concurrently, data streaming through the pipes.

popen: the shortcut

For the common case "run a command and read its output", stdio offers FILE *popen(const char *command, "r") and pclose, which do the pipe, fork, exec (through /bin/sh -c) and wait for you. It is convenient, but because it passes the command to a shell, never build that string from untrusted input: popen("grep " + user_input) is a command injection hole. Using fork and execvp with an argument array avoids the shell entirely.

Common mistakes

Recap

Going further: read pipe(7) in the sandbox (man 7 pipe, section 7 is installed) for the exact rules on capacity and atomic writes. Then extend the pipeline example to three commands, such as printf ... | sort | uniq -c.

Exercises

Exercise 1 · quiz

Why does the reader hang?

A parent creates a pipe, forks a child that writes some data into fds[1], closes it and exits. The parent then loops while ((n = read(fds[0], buf, sizeof buf)) > 0) to collect the output. It receives all the data, but then read blocks forever instead of returning 0. What is the most likely cause?

Answer
  • The parent never closed its own copy of the write end, fds[1], so the kernel thinks someone may still write

read on a pipe returns 0 (end of file) only when every descriptor referring to the write end is closed, in every process. After fork, the parent holds a copy of fds[1] too. As long as it is open, the parent could in principle still write, so the reader waits. Close the ends you do not use, in every process, immediately after fork: the parent closes fds[1] if it only reads, the child closes fds[0] if it only writes.

Exercise 2 · write code

Parent writes, child transforms

Create a pipe and fork. The parent copies everything from its standard input into the pipe (using read and write), closes the write end, and waits for the child. The child reads from the pipe until end of file and writes everything to its standard output converted to uppercase (use toupper from <ctype.h> on each byte, cast to unsigned char first). Only the child produces output. The parent exits with status 0 after the child has finished.

$ printf 'hello, pipe
second line
' | ./prog
HELLO, PIPE
SECOND LINE

Remember to close the unused ends in both processes, or the child will never see end of file.

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    int fds[2];
    if (pipe(fds) < 0) {
        perror("pipe");
        return 1;
    }
    /* fork; child: read fds[0], uppercase, write to stdout;
       parent: copy stdin into fds[1], close it, wait */
    return 0;
}

Example

input

hello, pipe
second line

expected output

HELLO, PIPE
SECOND LINE

Example

expected output

(nothing)
Hint 1

After fork, the child closes fds[1] (it only reads) and the parent closes fds[0] (it only writes).

Hint 2

Child: loop read(fds[0], ...) until it returns 0, uppercase the bytes, write them to STDOUT_FILENO, then _exit(0).

Hint 3

Parent: loop read(STDIN_FILENO, ...) and write each chunk into fds[1]; then close(fds[1]) so the child sees end of file, and waitpid.

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <ctype.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

static int write_all(int fd, const char *p, size_t n)
{
    while (n > 0) {
        ssize_t w = write(fd, p, n);
        if (w < 0)
            return -1;
        p += w;
        n -= (size_t)w;
    }
    return 0;
}

int main(void)
{
    int fds[2];
    if (pipe(fds) < 0) {
        perror("pipe");
        return 1;
    }
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    char buf[4096];
    ssize_t n;
    if (pid == 0) {
        close(fds[1]);
        while ((n = read(fds[0], buf, sizeof buf)) > 0) {
            for (ssize_t i = 0; i < n; i++)
                buf[i] = (char)toupper((unsigned char)buf[i]);
            if (write_all(STDOUT_FILENO, buf, (size_t)n) != 0)
                _exit(1);
        }
        close(fds[0]);
        _exit(0);
    }
    close(fds[0]);
    while ((n = read(STDIN_FILENO, buf, sizeof buf)) > 0)
        if (write_all(fds[1], buf, (size_t)n) != 0)
            break;
    close(fds[1]);
    waitpid(pid, NULL, 0);
    return 0;
}

Exercise 3 · write code

Capture a command's output

Write a program that runs the command in its arguments (like run from the previous lesson) with the child's standard output connected to a pipe. The parent reads everything the command writes, counting bytes and newline characters, waits for the child, and prints exactly:

N bytes, M lines

where M is the number of '\n' bytes. The command's output itself is not shown. Exit with status 0.

$ ./prog echo hello
6 bytes, 1 lines
$ ./prog seq 1 10000
48894 bytes, 10000 lines

The second example produces far more than one read returns, and it only finishes if the parent closes its copy of the write end before reading.

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "usage: capture COMMAND [ARGS...]\n");
        return 2;
    }
    long bytes = 0, lines = 0;
    /* pipe, fork, dup2 in the child, exec; read and count in the parent */
    printf("%ld bytes, %ld lines\n", bytes, lines);
    return 0;
}

Example with arguments echo hello

expected output

6 bytes, 1 lines

Example with arguments printf a b c

expected output

5 bytes, 2 lines
Hint 1

Child: dup2(fds[1], STDOUT_FILENO), close both original pipe descriptors, then execvp(argv[1], &argv[1]).

Hint 2

Parent: close(fds[1]) first, then read from fds[0] until read returns 0, counting.

Hint 3

If the program hangs ("timed out"), some process still holds the write end open.

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 2) {
        fprintf(stderr, "usage: capture COMMAND [ARGS...]\n");
        return 2;
    }
    int fds[2];
    if (pipe(fds) < 0) {
        perror("pipe");
        return 1;
    }
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        dup2(fds[1], STDOUT_FILENO);
        close(fds[0]);
        close(fds[1]);
        execvp(argv[1], &argv[1]);
        perror("execvp");
        _exit(127);
    }
    close(fds[1]);
    long bytes = 0, lines = 0;
    char buf[4096];
    ssize_t n;
    while ((n = read(fds[0], buf, sizeof buf)) > 0) {
        bytes += n;
        for (ssize_t i = 0; i < n; i++)
            if (buf[i] == '\n')
                lines++;
    }
    close(fds[0]);
    waitpid(pid, NULL, 0);
    printf("%ld bytes, %ld lines\n", bytes, lines);
    return 0;
}

Exercise 4 · write code

Redirect to a file

Implement the shell's > redirection. ./prog FILE COMMAND [ARGS...] runs COMMAND with its standard output redirected to FILE (created with mode 0644 if needed, truncated if it exists), waits for it, and exits with the command's exit status (or 1 if it did not exit normally). The program itself prints nothing on standard output. If FILE cannot be opened, report it with perror and exit 1. With fewer than two arguments print usage: tofile FILE COMMAND [ARGS...] to stderr and exit 2.

Do the open and dup2 in the child, after fork, so the parent's own stdout is untouched.

$ ./prog out.txt echo hello; cat out.txt
hello
$ ./prog out.txt sh -c 'echo replaced; exit 4'; echo $?; cat out.txt
4
replaced

File: main.c

Starting point

#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 3) {
        fprintf(stderr, "usage: tofile FILE COMMAND [ARGS...]\n");
        return 2;
    }
    execvp(argv[2], &argv[2]);
    perror("execvp");
    return 127;
}
Hint 1

In the child: fd = open(argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0644); dup2(fd, STDOUT_FILENO); close(fd); execvp(argv[2], &argv[2]).

Hint 2

In the parent: waitpid and return WEXITSTATUS(status) if WIFEXITED(status).

Reference solution
#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(int argc, char *argv[])
{
    if (argc < 3) {
        fprintf(stderr, "usage: tofile FILE COMMAND [ARGS...]\n");
        return 2;
    }
    fflush(stdout);
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        int fd = open(argv[1], O_WRONLY | O_CREAT | O_TRUNC, 0644);
        if (fd < 0) {
            perror(argv[1]);
            _exit(1);
        }
        if (dup2(fd, STDOUT_FILENO) < 0) {
            perror("dup2");
            _exit(1);
        }
        close(fd);
        execvp(argv[2], &argv[2]);
        perror(argv[2]);
        _exit(127);
    }
    int status;
    if (waitpid(pid, &status, 0) < 0) {
        perror("waitpid");
        return 1;
    }
    return WIFEXITED(status) ? WEXITSTATUS(status) : 1;
}