15. Pipes and File Redirection
As Processes explains, processes normally have separate address spaces. One process cannot simply dereference a pointer into another process's ordinary memory. Inter-process communication, or IPC, provides controlled ways for processes to exchange data.
A pipe is one of the simplest IPC mechanisms: a kernel-managed, unidirectional stream of bytes. One side writes bytes, and the other side reads them in the same order.
A producer writes bytes into the pipe's write end. The kernel temporarily holds those bytes in the pipe buffer, and a consumer removes them through the read end. The course diagram below shows both descriptor ends and the kernel buffer together.
This is the mechanism behind a shell pipeline such as:
seq 1 10 | wcseq writes to its standard output. The shell arranges for that descriptor to refer to the pipe. wc reads its standard input, which the shell has arranged to refer to the other end.
Creating a Pipe#
#include <unistd.h>
int pipe(int pipefd[2]);On success:
pipefd[0]is the read end;pipefd[1]is the write end.
]
int pipefd[2];
if (pipe(pipefd) == -1) {
perror("pipe");
return 1;
}The descriptors can be passed to ordinary read() and write(). A pipe contains bytes, not messages. One write of 100 bytes might be returned by several reads, and one read might collect bytes supplied by several writes.
If the bytes represent integers, records or lines, the processes must agree on a protocol: rules for where each piece begins, how its length is known, and what marks the end. A newline-delimited protocol, for example, treats every \n as the end of one record. The pipe transports bytes; your protocol gives those bytes structure.
A Parent Sending Data to a Child#
The usual pattern is to create the pipe before fork(). Both resulting processes inherit both descriptors; each must then close the end it does not use.
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>
int main(void) {
int pipefd[2];
if (pipe(pipefd) == -1) {
perror("pipe");
return 1;
}
pid_t pid = fork();
if (pid == -1) {
perror("fork");
close(pipefd[0]);
close(pipefd[1]);
return 1;
}
if (pid == 0) {
close(pipefd[1]);
char buffer[128];
ssize_t n = read(pipefd[0], buffer, sizeof buffer);
if (n == -1) {
perror("read");
_exit(1);
}
printf("child received %zd bytes\n", n);
fflush(stdout);
close(pipefd[0]);
_exit(0);
}
close(pipefd[0]);
char message[] = "hello from the parent";
if (write(pipefd[1], message, sizeof message) == -1) {
perror("write");
}
close(pipefd[1]);
waitpid(pid, NULL, 0);
return 0;
}The shape is:
The complete sequence is:
- The parent closes its copy of the read end, while the child closes its copy of the write end.
- The parent writes bytes, then closes the write end when it has no more data.
- The child reads buffered bytes. Once the buffer is empty and no write descriptor remains open anywhere, its next
read()returns0for EOF.
This first example performs one small write and one read because it is demonstrating ownership of the two pipe ends. It does not establish a general “one write equals one read” rule. A program which needs the complete message must loop, count the bytes transferred and use its protocol to recognise completion. For example, a fixed 24-byte record is complete only after the reader has accumulated 24 bytes, even if that requires several read() calls.
Why Unused Ends Must Be Closed#
read() on an empty pipe does not immediately mean EOF. It blocks while any descriptor referring to the write end remains open, because another writer could still send bytes.
Suppose the child wants to read until EOF, but accidentally keeps its own inherited write end open. Even after the parent closes its writer, the kernel still sees one writer: the child's unused descriptor. The child waits forever for data which it could theoretically write to itself.
The rule is simple:
- every process closes every pipe end it will not use;
- writers close the write end after their final byte;
- readers treat
read() == 0as end-of-stream.
Closing is part of the communication protocol, not merely cleanup.
Worked EOF Trace#
After fork(), count open write-end descriptors rather than reasoning only from process names:
| Moment | Parent's write end | Child's inherited write end | Writers visible to kernel | Can an empty read return EOF? |
|---|---|---|---|---|
immediately after fork() |
open | open | 2 | no |
| child closes its unused write end | open | closed | 1 | no |
| parent writes, then closes | closed | closed | 0 | yes, after buffered bytes are read |
The child does not receive EOF at the instant the parent calls close(). It first receives any bytes already buffered in the pipe. Only a later read, after the buffer is empty and the writer count is zero, returns 0.
Important
- One forgotten copy
The parent closes its write end after sending all data, but the child forgot to close its inherited write end. The child reads until the pipe buffer is empty. What happens on its next read(), and why?
Note
- Reasoned answer
The read blocks rather than returning 0. The kernel still sees the child's inherited descriptor as an open writer, even though the program never intends to write through it. EOF depends on all write-end references being closed, not on the parent alone being finished.
Note
Checkpoint
pipe()creates a read end and a write end before the processes that need them diverge.- Every process must close every unused end; otherwise EOF and broken-pipe behaviour cannot occur correctly.
- A pipe is an ordered byte stream, so readers must handle partial transfers and define their own message format.
File Descriptor Duplication#
dup() and dup2() make another descriptor refer to the same open file description. This is the descriptor-to-open-file-description distinction introduced in File Systems:
int dup(int oldfd);
int dup2(int oldfd, int newfd);dup() selects the lowest available descriptor. dup2(oldfd, newfd) uses the requested number, atomically replacing newfd if necessary. If oldfd == newfd, a valid dup2() call changes nothing; it does not close the descriptor and reopen it.
In plain language, dup2(3, 0) means: “make descriptor 0 point wherever descriptor 3 currently points.” It does not copy the file's bytes and it does not move the current position. It changes a table entry. This is exactly why it can turn an ordinary opened file into standard input before execv() starts another program.
The duplicate descriptors share:
- the underlying file or pipe endpoint;
- the current file offset for a regular file;
- file status flags such as append mode.
Read these stages from top to bottom. The descriptor number is the name a process uses; the open-file description is the kernel object which remembers the file and current offset.
- After
open("input.txt", O_RDONLY)—fd 0still refers to keyboard input. The newly returnedfd 3refers to aninput.txtopen-file description whose offset begins at zero. - After
dup2(3, 0)— bothfd 0andfd 3refer to that same open-file description. Nothing was copied out of the file; descriptor 0 was simply changed to reach the same kernel object as descriptor 3. - After
close(3)andexecv(...)—fd 3is gone, butfd 0still refers toinput.txtand survives the program replacement. Reads performed by the new program advance the open-file description's offset.
A read through descriptor 0 advances the same shared offset in the underlying open file description. Because the open file description is preserved across execv(), the newly executed program reads seamlessly from input.txt whenever it reads from standard input (STDIN_FILENO).
Practice: Quick Checks#
- Which element of
int pipefd[2]is the read end and which is the write end? Does onewrite()necessarily correspond to oneread()? - What does
dup2(oldfd, STDOUT_FILENO)do when descriptor 1 is already open?
Note
- Quick Check Answers
- Index 0 is the read end and index 1 is the write end. No: a pipe is a byte stream, so read boundaries need not match write boundaries.
- If the descriptors differ, it atomically closes descriptor 1 if necessary and makes descriptor 1 refer to the same open file description as
oldfd. Ifoldfdis already descriptor 1,dup2()leaves it unchanged after validating it.
Redirecting Standard Input and Output#
A program such as tr does not need special "read from a file" logic. It reads descriptor 0. Another process can replace descriptor 0 before executing it:
int fd = open("input.txt", O_RDONLY);
if (fd == -1) {
perror("input.txt");
return 1;
}
if (dup2(fd, STDIN_FILENO) == -1) {
perror("dup2");
return 1;
}
close(fd);
char *argv[] = {"/usr/bin/tr", "a-z", "A-Z", NULL};
execv(argv[0], argv);
perror("execv");After dup2, both fd and 0 refer to the same open file description. We close the now-redundant original descriptor, then execv() replaces the program while descriptor 0 survives. The new program reads the file as though it were standard input.
Output redirection follows the same pattern:
int fd = open("message.txt", O_WRONLY | O_CREAT | O_TRUNC, 0666);
if (fd == -1) {
perror("message.txt");
return 1;
}
if (dup2(fd, STDOUT_FILENO) == -1) {
perror("dup2");
return 1;
}
close(fd);
char *argv[] = {"/bin/echo", "hello", "world", NULL};
execv(argv[0], argv);
perror("execv");This is equivalent in spirit to echo hello world > message.txt.
Practice: Tracing and Descriptor Lifetime#
- After
fork(), both parent and child hold both pipe ends. The parent closes its write end, but the child keeps its inherited write end while reading until EOF. What happens after the buffered data is consumed? - A process opens
input.txtas descriptor 3, callsdup2(3, 0), closes 3, then successfully callsexecv(). From where does the new program read descriptor 0, and is the underlying offset preserved?
Note
- Tracing Answers
3. The child blocks instead of receiving EOF because at least one write-end descriptor—the child's own copy—still exists.
4. Descriptor 0 still refers to the open file description for input.txt, including its current offset. Because 3 != 0, a successful dup2(3, 0) clears FD_CLOEXEC on the new descriptor 0, so it remains open across the later execv().
Joining Two Programs with a Pipe#
To implement producer | consumer:
- create a pipe;
- create both child processes;
- in the producer, duplicate the write end onto standard output;
- in the consumer, duplicate the read end onto standard input;
- close all original pipe descriptors in every process;
- execute the respective programs;
- let the parent wait for both.
| Participant | Standard descriptor after dup2() |
Responsibility |
|---|---|---|
| producer child | standard output → pipe write end | produce bytes, then close/exit |
| consumer child | standard input → pipe read end | consume bytes until EOF |
| parent | neither end needed after setup | close both copies, then wait for both children |
The producer and consumer should run concurrently. A pipe's kernel buffer has finite capacity. If the parent waits for the producer before starting the consumer, this sequence can occur:
- the producer writes until the pipe buffer becomes full;
- its next
write()blocks until some reader makes space; - the parent is waiting for that producer to finish;
- the consumer, which would read and make space, has not been started.
Every participant is waiting for an event which cannot now occur: a deadlock. Start both children first, close the parent's unused pipe ends, and only then wait for them.
Note
Checkpoint
dup2()changes what a descriptor number refers to; it does not copy file contents.- Redirection works by attaching standard input or output to a file or pipe before the new program starts.
- Pipeline stages must run concurrently, and the parent must eventually close its copies and wait for every child.
Practice: Writing and Debugging#
- For
producer | consumer, list the twodup2()operations and the unused pipe ends each child must close beforeexec(). - A parent creates the producer, immediately waits for it, and only afterwards creates the consumer. Explain the finite-buffer failure and state the correct ordering.
Note
- Writing and Debugging Answers
5. The producer duplicates the write end onto STDOUT_FILENO and closes both original pipe descriptors. The consumer duplicates the read end onto STDIN_FILENO and also closes both originals after duplication.
6. A producer which fills the pipe blocks because no consumer is draining it, while the parent waits for that blocked producer. Start both children, close the parent's pipe copies, then wait for both.
Using fdopen#
fdopen() associates a FILE * stream with an existing descriptor, bridging the low-level descriptor interface with the buffered streams from Files and Streams:
FILE *stream = fdopen(pipefd[0], "r");
if (stream == NULL) {
perror("fdopen");
}The mode must be compatible with the descriptor's access mode: for example, do not request "w" for a read-only pipe end. You may then use fgets() or fread(). Once the stream owns that descriptor, close it with fclose(stream). Do not separately close the same descriptor and later use the stream.
popen: Convenient but Risky#
FILE *popen(const char *command, const char *mode);
int pclose(FILE *stream);With mode "r", your process reads the command's standard output. With "w", your process writes to its standard input.
FILE *stream = popen("/bin/date --utc", "r");
if (stream == NULL) {
perror("popen");
return 1;
}
char line[256];
if (fgets(line, sizeof line, stream) != NULL) {
printf("captured: %s", line);
}
int status = pclose(stream);Close a popen() stream with pclose(), not fclose(), because pclose() also waits for the command. It returns -1 on failure; otherwise its result is an encoded wait status which should be interpreted with macros such as WIFEXITED and WEXITSTATUS, just like the status produced by waitpid().
Like system(), popen() runs a shell command string. It is brittle and vulnerable to command injection if any untrusted text is inserted. Prefer explicit process creation and argument arrays for reliable programs.
Pipes with posix_spawn#
posix_spawn_file_actions_t describes descriptor changes which occur in the child before its new program begins:
#include <errno.h>
#include <spawn.h>
posix_spawn_file_actions_t actions;
int error = posix_spawn_file_actions_init(&actions);
if (error != 0) {
errno = error;
perror("posix_spawn_file_actions_init");
return 1;
}
if ((error = posix_spawn_file_actions_adddup2(
&actions, pipefd[1], STDOUT_FILENO)) != 0
|| (error = posix_spawn_file_actions_addclose(
&actions, pipefd[0])) != 0
|| (error = posix_spawn_file_actions_addclose(
&actions, pipefd[1])) != 0) {
errno = error;
perror("posix_spawn file action");
posix_spawn_file_actions_destroy(&actions);
return 1;
}
error = posix_spawn(&pid, path, &actions, NULL, argv, environ);
int destroy_error = posix_spawn_file_actions_destroy(&actions);
if (error != 0) {
errno = error;
perror("posix_spawn");
return 1;
}
if (destroy_error != 0) {
errno = destroy_error;
perror("posix_spawn_file_actions_destroy");
return 1;
}The API is verbose, but it avoids constructing a shell command and expresses redirection directly. Always destroy the action object after the spawn attempt and close the parent's copies of descriptors it no longer needs.
Blocking and Broken Pipes#
Pipe behaviour follows the presence of readers and writers:
- reading from an empty pipe blocks while a writer remains;
- reading from an empty pipe returns
0once all write ends are closed; - writing may block when the pipe buffer is full;
- writing when no read end remains generates
SIGPIPEby default, or fails withEPIPEif the signal is handled or ignored.
This is why descriptor lifetime controls program lifetime. A forgotten writer prevents EOF; a forgotten reader can keep a producer from discovering that its output is unwanted.
Practice: Challenge#
- What normally happens when a process writes to a pipe with no remaining readers? How does the observable result change if the relevant signal is ignored or handled?
- Why must a stream returned by
popen()be closed withpclose()rather than merelyfclose()?
Note
- Challenge Answers
7. The default action of SIGPIPE terminates the process. If that signal is ignored or handled, write() fails with -1 and errno == EPIPE.
8. pclose() closes the stream and waits for the spawned command, returning its encoded termination status. fclose() alone does not perform that wait, so separate reaping would be required.
Note
- Further reading
The pipe and redirection section of the course's official Processes topic notes uses the expected course interfaces. The Linux manual pages for pipe() and dup()/dup2() describe the exact descriptor and blocking semantics.