Chapter 73: IPC: Unnamed Pipes (pipe()) for Parent-Child Communication

Chapter Objectives

By the end of this chapter, you will be able to:

  • Understand the fundamental principles of Inter-Process Communication (IPC) and the role of pipes.
  • Implement unnamed pipes in C using the pipe() system call to establish a communication channel.
  • Manage file descriptors and data flow between a parent and child process after a fork().
  • Develop robust applications that use pipes for unidirectional data transfer.
  • Debug common issues related to pipe implementation, such as deadlocks and improper file descriptor management.
  • Use pipes in conjunction with dup2() and exec() to build simple shell-like pipelines.

Introduction

In any modern multitasking operating system, processes rarely exist in complete isolation. They often need to cooperate, share data, and signal one another to accomplish complex tasks. This coordination is achieved through Inter-Process Communication (IPC), a set of mechanisms that allow processes to exchange information. IPC is the invisible plumbing that enables the sophisticated features we take for granted, from the simple act of copying and pasting text between applications to the complex data streams managed by web servers. In the world of embedded Linux, where resource-constrained systems must perform specialized tasks efficiently, robust IPC is not just a convenience—it is a necessity. It allows a developer to design modular, resilient systems where dedicated processes handle specific tasks like sensor data acquisition, network communication, or user interface management, all while communicating seamlessly.

mindmap
  root((Linux IPC))
    Unnamed Pipes
      ::icon(fa fa-water)
      Unidirectional
      For related processes (parent-child)
      Stream-oriented
      Kernel-managed
      (Chapter Focus)
    Named Pipes (FIFOs)
      ::icon(fa fa-folder-open)
      Have a filesystem entry
      For unrelated processes
      Also stream-oriented
    Shared Memory
      ::icon(fa fa-memory)
      Fastest method
      Processes map same memory region
      Requires synchronization (semaphores)
    Message Queues
      ::icon(fa fa-envelope)
      Structured messages
      Kernel manages queue
      Processes read/write messages
    Sockets
      ::icon(fa fa-network-wired)
      For network communication
      Can be used on same machine (UNIX domain)
      Flexible and powerful
    Signals
      ::icon(fa fa-bell)
      Simple notifications
      Asynchronous
      Limited data transfer

This chapter introduces one of the oldest and most fundamental IPC mechanisms in the UNIX and Linux world: the unnamed pipe. A pipe provides a simple, unidirectional channel for a stream of data to flow from one process to another. Its power lies in its simplicity and integration with the Linux file I/O model. When you type a command like ls -l | grep '.c' into your shell, you are using a pipe to direct the output of the ls command to become the input of the grep command. This elegant concept is made possible by the pipe() system call. We will explore how to create and manage these communication channels within the context of parent and child processes, a foundational pattern in Linux system programming. Using the Raspberry Pi 5 as our development platform, you will learn to build C programs that leverage pipes to create efficient, modular applications, mastering a skill that is essential for any serious embedded Linux developer.

Technical Background

At its core, an unnamed pipe is a kernel-managed buffer that connects two file descriptors. It is a “pipe” in the most literal sense: what is written into one end can be read from the other. This mechanism is inherently simple and secure because it exists only within the kernel’s memory space and is accessible only to the process that created it and its descendants. Because they have no name in the filesystem, they cannot be accessed by unrelated processes, which is why they are primarily used for communication between a parent and its child.

Creating a Pipe: The pipe() System Call

The journey begins with the pipe() system call, which is defined in <unistd.h>:

C
int pipe(int pipefd[2]);

This function asks the kernel to create a new pipe object. If successful, it returns 0 and populates a two-integer array, which we’ve called pipefd, with two new file descriptors. If an error occurs, it returns -1 and sets the errno variable. These two file descriptors are the endpoints of our communication channel:

  • pipefd[0] is the read end of the pipe. Any data written to the other end can be read from this file descriptor.
  • pipefd[1] is the write end of the pipe. Any data written to this file descriptor is buffered by the kernel and becomes available for reading at the other end.

It is a crucial and unchangeable rule that data flows in only one direction: from pipefd[1] to pipefd[0]. Think of it as a one-way water pipe; you can put water in at the source (pipefd[1]) and it will come out at the destination (pipefd[0]), but not the other way around.

The Pipe and the Fork: Establishing Communication

A pipe created within a single process is not particularly useful. Its true power is unlocked when combined with the fork() system call. When a process calls fork(), the child process inherits a complete copy of the parent’s file descriptor table. This means that after the fork, both the parent and the child process have file descriptors pointing to the same underlying pipe object in the kernel. Both processes now hold handles to both the read end and the write end.

This is where a critical design pattern emerges. To establish a clear, unidirectional flow of communication, each process must close the end of the pipe it does not intend to use. For example, if the parent wants to send data to the child:

  1. The Parent will write to the pipe, so it closes its read end (pipefd[0]). It will use pipefd[1] to send data.
  2. The Child will read from the pipe, so it closes its write end (pipefd[1]). It will use pipefd[0] to receive data.

By closing the unused ends, we create a clean, one-way channel from the parent’s pipefd[1] to the child’s pipefd[0]. This cleanup is not just good practice; it is essential for correct program behavior.

graph TD
    subgraph "Process A (Initial State)"
        A1(Start) --> A2["<b>pipe()</b> System Call"];
        style A1 fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
        style A2 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    end

    A2 --> A3{"Kernel creates pipe object<br>Returns pipefd[0] and pipefd[1]"};
    style A3 fill:#8b5cf6,stroke:#8b5cf6,stroke-width:1px,color:#ffffff
    
    A3 --> B1["<b>fork()</b> System Call"];
    style B1 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff

    subgraph "After fork()"
        direction LR
        B1 --> P1["<b>Parent Process</b><br>Inherits pipefd[0], pipefd[1]"];
        B1 --> C1["<b>Child Process</b><br>Inherits pipefd[0], pipefd[1]"];
        style P1 fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
        style C1 fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    end

    P1 --> P2{"Parent wants to <b>WRITE</b><br>Closes its READ end: <i>close(pipefd[0])</i>"};
    C1 --> C2{"Child wants to <b>READ</b><br>Closes its WRITE end: <i>close(pipefd[1])</i>"};
    style P2 fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
    style C2 fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff

    subgraph "Established Communication Channel"
        P3["Parent writes data using <b>write(pipefd[1], ...)</b>"]
        C3["Child reads data using <b>read(pipefd[0], ...)</b>"]
        style P3 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
        style C3 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    end

    P2 --> P3
    P3 --> KERNEL_PIPE
    KERNEL_PIPE --> C3
    C2 --> C3
    
    C3 --> E1(Data Received)
    style E1 fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff

    subgraph "Kernel Space"
        KERNEL_PIPE[("Pipe Buffer<br><i>(One-Way Data Flow)</i>")]
        style KERNEL_PIPE fill:#8b5cf6,stroke:#8b5cf6,stroke-width:4px,color:#ffffff,stroke-dasharray: 5 5
    end

Reading, Writing, and Blocking Behavior

Once the pipe is established, the processes can use the standard read() and write() system calls on the file descriptors, just as they would with a file. However, the behavior of these calls on a pipe has some special characteristics.

The pipe is backed by a kernel buffer of a fixed size. On modern Linux systems, this size is typically 65,536 bytes (64 KiB), but you can query the system-guaranteed minimum atomic write size using PIPE_BUF from <limits.h>.

sequenceDiagram
    actor User
    participant P as Parent (ls -l)
    participant K as Kernel Pipe
    participant C as Child (wc -l)

    User->>P: Executes ./pipeline
    activate P
    P->>K: write("file1...")
    activate K
    
    Note over K,C: Child's read() is blocking,<br>waiting for data.
    
    K-->>C: Data available
    deactivate K
    activate C
    C->>C: Processes "file1..."
    
    P->>K: write("file2...")
    activate K

    K-->>C: Data available
    deactivate K
    C->>C: Processes "file2..."

    P->>P: Finishes listing files
    P->>K: close(write_end)
    note over P,K: Signals End-of-File (EOF)
    activate K

    K-->>C: read() returns 0 (EOF)
    deactivate K
    C->>User: Prints final count
    deactivate C
    
    P->>User: Parent exits
    deactivate P
  • Writing to a Pipe (write()): When a process calls write() on the write end of the pipe, the kernel copies the data into its internal buffer. If the write is small enough (less than or equal to PIPE_BUF), the kernel guarantees that the write operation is atomic. This means the data from this single write() call will not be interleaved with data from any other process writing to the same pipe. If the pipe’s buffer is full, the write() call will block; the process will be put to sleep until some other process reads enough data from the pipe to make space.
  • Reading from a Pipe (read()): When a process calls read() on the read end of the pipe, it retrieves data from the kernel’s buffer. If the buffer is empty, the read() call will block until some data is written to the pipe. If the write end of the pipe has been closed by all processes that hold it, a read() call on an empty pipe will not block. Instead, it will immediately return 0, which is the standard end-of-file (EOF) indication. This is why closing the write end in the reading process is so important—it’s the only way for the reader to know when there is no more data to come.

Pipe Lifecycle and the SIGPIPE Signal

The kernel is responsible for managing the pipe object’s lifecycle. It keeps a reference count of how many file descriptors (across all processes) point to the pipe. The pipe is only destroyed when this reference count drops to zero—that is, when every file descriptor connected to it has been closed.

This leads to an important and sometimes surprising behavior related to the SIGPIPE signal. What happens if a process tries to write to a pipe whose read end has been closed by all processes? For instance, if the child process, which was supposed to be reading the data, terminates or closes its read descriptor prematurely. In this case, there is no longer a reader. The kernel cannot simply buffer the data forever.

graph TD
    A["Process calls <b>write()</b> on a pipe"] --> B{"Is there a reader?<br>(Is pipe's read-end open by any process?)"};
    style A fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
    style B fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff

    B -- Yes --> C{Is the pipe buffer full?};
    style C fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff

    C -- No --> D[Write succeeds.<br>Data is copied to buffer.];
    style D fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff

    C -- Yes --> E[Process <b>BLOCKS</b>.<br>Waits for a reader to make space.];
    style E fill:#eab308,stroke:#eab308,stroke-width:1px,color:#1f2937

    B -- No --> F["<b>No Reader Exists!</b><br>All read-ends of the pipe are closed."];
    style F fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff

    F --> G{Is SIGPIPE handled or ignored by the process?};
    style G fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
    
    G -- No --> H["Kernel sends <b>SIGPIPE</b> signal.<br>Default action: Terminate Process."];
    style H fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff

    G -- Yes --> I["<b>write()</b> fails.<br>Returns -1, errno is set to EPIPE."];
    style I fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff

Instead of letting the write() call block indefinitely or return an error, the kernel sends the SIGPIPE signal to the writing process. The default action for SIGPIPE is to terminate the process. This is often the desired behavior; if the consumer of your data has disappeared, it usually indicates a fatal error condition. However, programs can choose to ignore this signal or install a custom signal handler for it. If SIGPIPE is handled or ignored, the write() call will fail, returning -1, and errno will be set to EPIPE.

Tip: The ls | head -n 1 command is a great example of SIGPIPE in action. The ls command might produce a long list of files, writing them all to the pipe. The head command, however, only reads the first line and then exits. When it exits, it closes its input file descriptor, which is the read end of the pipe. The next time ls tries to write to the pipe, it receives a SIGPIPE and is terminated by the kernel. This is an efficient way to stop producer processes once they are no longer needed.

Redirecting Standard I/O with dup2()

Pipes become even more powerful when combined with I/O redirection. The dup2() system call allows a process to duplicate a file descriptor, making the new descriptor number an exact copy of an old one.

C
int dup2(int oldfd, int newfd);

This call forces newfd to refer to the same open file description as oldfd. If newfd was already open, it is silently closed first. This is the mechanism shells use to implement pipelines. To make a child process read its input from a pipe, you can use dup2() to copy the pipe’s read descriptor (pipefd[0]) onto the standard input descriptor (STDIN_FILENO, which is always 0). Similarly, to make a process write its output to a pipe, you can copy the pipe’s write descriptor (pipefd[1]) onto the standard output descriptor (STDOUT_FILENO, which is always 1).

After the dup2() call, any library function or system call that writes to standard output (like printf()) will now write directly into the pipe. This allows us to connect existing programs that know nothing about pipes, which is the essence of the UNIX philosophy of building small, single-purpose tools that work together. A process can re-wire its own standard streams and then use execvp() to load and run a new program, which will inherit these redirected streams, completely unaware that its input is coming from a pipe instead of a terminal.

Practical Examples

Let’s move from theory to practice. The following examples are designed to be compiled and run on your Raspberry Pi 5. You will need the GCC compiler, which is standard on Raspberry Pi OS.

Example 1: Simple Parent-to-Child Communication

This first example demonstrates the fundamental pattern. A parent process will create a pipe, fork a child, and then send a simple string message to the child, which reads the message and prints it to the console.

File: simple_pipe.c

C
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>

int main() {
    int pipefd[2];
    pid_t cpid;
    char buf;
    const char *message = "Hello from your parent!";

    // Create the pipe.
    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    // Fork a child process.
    cpid = fork();
    if (cpid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (cpid == 0) {    // Child process reads from pipe
        printf("Child: I am the child process.\n");

        // The child will only read, so it closes the write end of the pipe.
        // This is a critical step!
        close(pipefd[1]); 

        printf("Child: Waiting to read from the pipe...\n");
        // Read from the pipe one character at a time and print to stdout.
        while (read(pipefd[0], &buf, 1) > 0) {
            write(STDOUT_FILENO, &buf, 1);
        }
        write(STDOUT_FILENO, "\n", 1);
        printf("Child: Reached EOF. Closing read end and exiting.\n");

        // Close the read end of the pipe.
        close(pipefd[0]);
        exit(EXIT_SUCCESS);

    } else {            // Parent process writes to pipe
        printf("Parent: I am the parent process.\n");

        // The parent will only write, so it closes the read end of the pipe.
        close(pipefd[0]); 

        printf("Parent: Writing message to the pipe...\n");
        // Write the message to the pipe.
        write(pipefd[1], message, strlen(message));

        // After writing, close the write end. This sends an EOF to the reader.
        printf("Parent: Closing write end of the pipe.\n");
        close(pipefd[1]);

        // Wait for the child process to terminate.
        wait(NULL);
        printf("Parent: Child has terminated. Exiting.\n");
        exit(EXIT_SUCCESS);
    }
}

Build and Run Steps:

1. Save the code above as simple_pipe.c on your Raspberry Pi 5.

2. Open a terminal and compile the program:

Bash
gcc -o simple_pipe simple_pipe.c -Wall


The -Wall flag enables all compiler warnings, which is a good practice.

3. Run the executable:

Bash
./simple_pipe

Expected Output:

The exact order of the first few lines may vary due to the scheduler, but the overall flow will be consistent.

Plaintext
Parent: I am the parent process.
Parent: Writing message to the pipe...
Parent: Closing write end of the pipe.
Child: I am the child process.
Child: Waiting to read from the pipe...
Hello from your parent!
Child: Reached EOF. Closing read end and exiting.
Parent: Child has terminated. Exiting.

Code Explanation:

  • pipe(pipefd): We create the pipe. pipefd[0] is for reading, pipefd[1] for writing.
  • fork(): We create the child process. The code following the if/else block executes in two separate processes.
  • Child Process (cpid == 0):
    • close(pipefd[1]): The child’s first action is to close the write end. It will only be a reader. This is crucial because it ensures that when the parent closes its write end, the child will see an EOF. If the child kept pipefd[1] open, it would never detect the end of the data stream.
    • while (read(pipefd[0], &buf, 1) > 0): The child enters a loop, reading one byte at a time from the pipe. read() will block until data is available. It returns the number of bytes read, or 0 on EOF, or -1 on error.
    • close(pipefd[0]): Once the loop finishes (on EOF), the child closes its read descriptor and exits.
  • Parent Process (cpid > 0):
    • close(pipefd[0]): The parent closes its read end, as it will only be a writer.
    • write(pipefd[1], ...): The parent writes the entire message string into the pipe.
    • close(pipefd[1]): This is the most important step for the parent. By closing the write end, it signals “end of file” to the reading process. The child’s read() call, which was blocking, will now return 0, causing its loop to terminate.
    • wait(NULL): The parent waits for the child to finish, which is good practice to prevent zombie processes.

Example 2: Emulating a Shell Pipeline (ls | wc -l)

This more advanced example demonstrates how to redirect standard I/O to create a pipeline, just like the shell does. The parent process will execute ls -l, and its standard output will be piped to the child process, which will execute wc -l on that input.

graph TD
    subgraph "Main Process"
        A1(Start) --> A2["Create Pipe: <b>pipe()</b>"];
        A2 --> A3["Create Child: <b>fork()</b>"];
        style A1 fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
        style A2 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
        style A3 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    end

    A3 --> PARENT;
    A3 --> CHILD;

    subgraph "Parent Process (becomes ls -l)"
        PARENT[Parent] --> P1["Close unused READ end<br><i>close(pipefd[0])</i>"];
        P1 --> P2["Redirect STDOUT to pipe's WRITE end<br><b>dup2(pipefd[1], STDOUT_FILENO)</b>"];
        P2 --> P3["Close original WRITE end<br><i>close(pipefd[1])</i>"];
        P3 --> P4["Replace process with 'ls -l'<br><b>execlp('ls', ...)</b>"];
        P4 --> P_OUT(["ls -l output"]);
        style PARENT fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
        style P1 fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
        style P2 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
        style P3 fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
        style P4 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    end

    subgraph "Child Process (becomes wc -l)"
        CHILD[Child] --> C1["Close unused WRITE end<br><i>close(pipefd[1])</i>"];
        C1 --> C2["Redirect STDIN to pipe's READ end<br><b>dup2(pipefd[0], STDIN_FILENO)</b>"];
        C2 --> C3["Close original READ end<br><i>close(pipefd[0])</i>"];
        C3 --> C4["Replace process with 'wc -l'<br><b>execlp('wc', ...)</b>"];
        C_IN(["wc -l input"]) --> C4;
        style CHILD fill:#1e3a8a,stroke:#1e3a8a,stroke-width:2px,color:#ffffff
        style C1 fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
        style C2 fill:#f59e0b,stroke:#f59e0b,stroke-width:1px,color:#ffffff
        style C3 fill:#ef4444,stroke:#ef4444,stroke-width:1px,color:#ffffff
        style C4 fill:#0d9488,stroke:#0d9488,stroke-width:1px,color:#ffffff
    end
    
    subgraph "Kernel Pipe"
        PIPE[("Data Stream")]
        style PIPE fill:#8b5cf6,stroke:#8b5cf6,stroke-width:4px,color:#ffffff,stroke-dasharray: 5 5
    end

    P_OUT --> PIPE --> C_IN;

    C4 --> FINAL_OUTPUT(Final Output to Console);
    style FINAL_OUTPUT fill:#10b981,stroke:#10b981,stroke-width:2px,color:#ffffff

File: pipeline.c

C
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sys/types.h>

int main() {
    int pipefd[2];
    pid_t cpid;

    if (pipe(pipefd) == -1) {
        perror("pipe");
        exit(EXIT_FAILURE);
    }

    cpid = fork();
    if (cpid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    }

    if (cpid == 0) { // Child process: executes "wc -l"
        // Close the write end of the pipe, as it's not needed.
        close(pipefd[1]);

        // Redirect standard input to be the read end of the pipe.
        // After this, any read from STDIN_FILENO will read from the pipe.
        if (dup2(pipefd[0], STDIN_FILENO) == -1) {
            perror("dup2");
            exit(EXIT_FAILURE);
        }

        // The original pipe descriptor is no longer needed.
        close(pipefd[0]);

        // Execute "wc -l". This program reads from its standard input.
        execlp("wc", "wc", "-l", NULL);

        // execlp only returns on error.
        perror("execlp wc");
        exit(EXIT_FAILURE);

    } else { // Parent process: executes "ls -l"
        // Close the read end of the pipe, as it's not needed.
        close(pipefd[0]);

        // Redirect standard output to be the write end of the pipe.
        // After this, any write to STDOUT_FILENO will write to the pipe.
        if (dup2(pipefd[1], STDOUT_FILENO) == -1) {
            perror("dup2");
            exit(EXIT_FAILURE);
        }

        // The original pipe descriptor is no longer needed.
        close(pipefd[1]);

        // Execute "ls -l". Its output now goes into the pipe.
        execlp("ls", "ls", "-l", NULL);

        // execlp only returns on error.
        perror("execlp ls");
        exit(EXIT_FAILURE);
    }

    // The parent doesn't reach here unless execlp fails.
    // In a more robust shell, you'd wait for the child here.
    return 0;
}

Build and Run Steps:

1. Save the code as pipeline.c.

2. Compile it: 

Bash
gcc -o pipeline pipeline.c -Wall

3. Run it: 

Bash
./pipeline

Expected Output:

The program will print a single number, which is the number of files and directories in your current working directory (including . and ..), followed by a newline. For example:

Plaintext
15

This is the exact same output you would get from running ls -l | wc -l in your shell.

Code Explanation:

  • Child Process (wc -l):
    • close(pipefd[1]): Closes the unused write end.
    • dup2(pipefd[0], STDIN_FILENO): This is the key step. It duplicates the pipe’s read descriptor (pipefd[0]) onto the standard input descriptor (STDIN_FILENO). Now, file descriptor 0 points to the read end of the pipe.
    • close(pipefd[0]): After dup2, we have two descriptors pointing to the read end ( pipefd[0] and STDIN_FILENO). We no longer need the original, so we close it.
    • execlp("wc", "wc", "-l", NULL): We replace the child process’s image with the wc program. wc naturally reads from its standard input, which we have just cleverly rewired to be our pipe. It has no idea it’s not reading from a terminal.
  • Parent Process (ls -l):
    • close(pipefd[0]): Closes the unused read end.
    • dup2(pipefd[1], STDOUT_FILENO): Duplicates the pipe’s write descriptor (pipefd[1]) onto the standard output descriptor (STDOUT_FILENO). Now, file descriptor 1 points to the write end of the pipe.
    • close(pipefd[1]): Closes the original write descriptor.
    • execlp("ls", "ls", "-l", NULL): Replaces the parent process with ls -lls writes its output to standard output, which we have rewired to our pipe. Its output flows directly to the waiting wc process.

Warning: In this simplified example, the parent process calls exec. A real shell would fork twice—once for each command in the pipeline—and the main shell process would wait for both children to complete.

Common Mistakes & Troubleshooting

Implementing pipes seems straightforward, but small logical errors can lead to baffling behavior. Here are some of the most common pitfalls.

Mistake / Issue Symptom(s) Troubleshooting / Solution
Forgot to Close Unused Pipe Ends Program hangs or deadlocks. The reading process waits forever for data that never arrives. Always close the unused end of the pipe in each process immediately after fork().
– The writer process must close(pipefd[0]).
– The reader process must close(pipefd[1]).
This is critical for the reader to detect EOF when the writer is done.
Deadlock with Two Pipes Both parent and child processes block, waiting for each other to send data. The entire application freezes. Design a strict communication protocol.
Ensure one process is designated to write first while the other reads. For example, parent writes, child reads, then child writes, then parent reads. Avoid simultaneous read() calls on empty pipes.
Reading/Writing to Wrong Descriptor System calls like read() or write() fail, returning -1. errno is set to EBADF (Bad file descriptor). Remember the rule: pipefd[0] is for reading, pipefd[1] is for writing.
read(pipefd[0], ...)
write(pipefd[1], ...)
Double-check your system calls.
Parent Doesn’t Wait for Child The child process becomes an “orphan” adopted by PID 1. The parent may exit prematurely. “Zombie” processes can be created if the child terminates and the parent doesn’t reap it. The parent process should almost always call wait(NULL) or waitpid().
This ensures the parent waits for the child to finish, allows for a clean shutdown, and prevents zombies.
Ignoring read() / write() Return Values Data corruption, incomplete data transfer, or infinite loops. A single write() call is not guaranteed to send all data at once. Always check the return values and loop if necessary.
– For write(), loop until all bytes are written.
– For read(), remember that a return of 0 means End-of-File (EOF), not an error.

Exercises

  1. Child Data Transformation: Modify the simple_pipe.c example. The parent should send a lowercase string to the child. The child process should read the string, convert it to uppercase, and then print it to the console. This reinforces the concept of the child processing data received from the pipe.
  2. Two-Way Communication: Create a program that uses two pipes to establish bidirectional communication. The parent should send a number to the child. The child should read the number, multiply it by 5, and send the result back to the parent on the second pipe. The parent should then read the result and print it. Be careful to avoid the deadlock scenario described above.
  3. File Transfer and Checksum: Write a program where the parent process reads a text file (e.g., /etc/hostname) and sends its contents through a pipe to a child process. The child process should read all the data from the pipe and calculate a simple checksum (e.g., the sum of all byte values). The child should then print the calculated checksum to the console.
  4. Pipeline with grep: Implement a program that emulates the pipeline cat /etc/os-release | grep PRETTY_NAME. The parent process should execute cat with the specified file, piping its output to the child. The child process should execute grep with the specified pattern, reading from the pipe. This exercise solidifies the use of dup2 and execlp.
  5. Three-Stage Pipeline: As a challenge, create a three-stage pipeline that emulates ls -l | grep '^-' | wc -l (which counts only the regular files in a directory). This will require the main parent process to fork two children. The first child (ls -l) will pipe its output to the second child (grep '^-'), which will in turn pipe its output to a third process (which could be the original parent or a third child) that runs wc -l. This requires careful management of two separate pipes and multiple processes.

Summary

  • Unnamed Pipes provide a simple, unidirectional IPC channel for a stream of bytes between related processes.
  • The pipe() system call creates a pipe and returns two file descriptors: a read end and a write end.
  • Pipes are most commonly used with fork(), where the parent and child processes inherit the pipe’s file descriptors.
  • Crucial Cleanup: To establish a working channel, the writing process must close its read descriptor, and the reading process must close its write descriptor.
  • Closing the write end of a pipe signals an End-of-File (EOF) to the reader. The read() call will then return 0.
  • Writing to a pipe whose read end has been closed triggers the SIGPIPE signal, which by default terminates the writing process.
  • The dup2() system call is a powerful tool for redirecting a process’s standard input or output to a pipe, enabling the creation of shell-like pipelines with exec().

Further Reading

  1. Linux man pages: The definitive source. On your terminal, run man 2 pipeman 2 forkman 2 dup2, and man 2 wait.
  2. Advanced Programming in the UNIX Environment by W. Richard Stevens and Stephen A. Rago. Chapters on process control and IPC are the gold standard for this topic.
  3. The Linux Programming Interface by Michael Kerrisk. An exhaustive and modern reference for Linux system programming, with excellent chapters on pipes and process creation.
  4. Beej’s Guide to Unix Interprocess Communication: An accessible, friendly guide that covers pipes and other IPC mechanisms. (https://beej.us/guide/bgipc/)
  5. LWN.net: An excellent source for in-depth articles on kernel development and system programming topics. Search their archives for articles on pipes and IPC. (https://lwn.net/)
  6. GNU C Library (glibc) Manual: The official documentation for the C library functions, including detailed descriptions of pipe-related functions and their behavior. (https://www.gnu.org/software/libc/manual/)

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top