Skip to content

Processes and IPC in C

Time: 45 min | Prerequisites: Exploring Linux | Theory companion: Linux Fundamentals, Sections 1.3–1.4


Learning Objectives

By the end of this tutorial you will be able to:

  • Create child processes with fork() and collect exit status with waitpid()
  • Send data between processes using pipe()
  • Register signal handlers with sigaction() and communicate between processes using signals
  • Combine fork, pipe, and signals into an integrated sensor monitor
Before You Start

All exercises run on any Linux machine — your host laptop (Ubuntu, Fedora, Arch, etc.) or the Raspberry Pi via SSH. You need gcc installed:

gcc --version           # should print version info
mkdir -p ~/ipc
cd ~/ipc

The CPU temperature path /sys/class/thermal/thermal_zone0/temp exists on both Ubuntu desktops and Raspberry Pi, so even the sensor exercises work on the host. No RPi required for this tutorial.

This is your first user-space C tutorial. The system calls you learn here — fork(), pipe(), read(), write(), sigaction() — are the same calls that kernel drivers use internally. Mastering them in user space prepares you for kernel C in Lab 7.


1. fork() — Creating Processes

When the shell runs ./my_program, it uses fork() to create a copy of itself, then execve() to replace that copy with your program. Now you will use fork() directly.

Step 1: fork_demo.c

/* fork_demo.c — create a child process */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    printf("Parent PID: %d\n", getpid());

    pid_t pid = fork();

    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        /* Child process */
        printf("  Child PID: %d, parent: %d\n", getpid(), getppid());
        printf("  Child: doing work...\n");
        sleep(1);
        printf("  Child: exiting with status 42\n");
        _exit(42);
    }

    /* Parent process */
    printf("Parent: waiting for child %d...\n", pid);
    int status;
    waitpid(pid, &status, 0);

    if (WIFEXITED(status))
        printf("Parent: child exited with status %d\n", WEXITSTATUS(status));

    return 0;
}

Compile and run:

gcc -Wall -o fork_demo fork_demo.c
./fork_demo

You should see the parent create a child, the child report its PID, and the parent collect the exit status (42).

Note

Copy-on-Write (COW): fork() does not immediately copy all memory. The kernel marks parent and child pages as read-only and shares them. Only when one process writes to a page does the kernel create a private copy. This makes fork() fast even for large processes. See Theory 2 Section 1.1 for the fork/exec diagram.

Exercise: Observe a Zombie

A zombie is a child process that has exited but whose parent hasn't called waitpid() yet. The child's PID and exit status stay in the kernel's process table, waiting to be collected.

To see one, you need a program where the parent stays alive but never calls waitpid(). If the parent exits too, the zombie gets reparented to PID 1 (systemd), which reaps it instantly — so you'd never see it.

Create zombie_demo.c:

/* zombie_demo.c — create an observable zombie */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main(void)
{
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0) {
        /* Child: exit immediately */
        printf("Child %d: exiting now\n", getpid());
        _exit(0);
    }

    /* Parent: stay alive but NEVER call waitpid() */
    printf("Parent %d: sleeping 30s without calling waitpid()...\n", getpid());
    printf("In another terminal, run:  ps aux | grep Z\n");
    sleep(30);
    printf("Parent exiting (zombie will be reaped by init now)\n");

    return 0;
}
gcc -Wall -o zombie_demo zombie_demo.c
./zombie_demo &

In another terminal (within 30 seconds):

ps aux | grep Z
# You should see something like:
# user  12346  0.0  0.0  0  0  pts/0  Z+  10:00  0:00 [zombie_demo] <defunct>

The Z+ state and <defunct> label confirm the zombie. After 30 seconds the parent exits, the zombie gets reparented to PID 1, and systemd reaps it.

Note

Why zombies matter on embedded systems: Each zombie occupies a slot in the kernel's process table (PID). On a desktop with 32,768 PIDs this is harmless. On an embedded system running for months with fork() in a loop and no waitpid(), you can exhaust all PIDs — new fork() calls start failing with EAGAIN. Always collect your children.

Exercise: Memory Separation After fork

This exercise makes Copy-on-Write visible. Parent and child each modify the same global variable — but they see different values:

/* fork_memory.c — prove that fork creates separate memory */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int counter = 0;   /* global variable */

int main(void)
{
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }

    if (pid == 0) {
        /* Child: modify the counter */
        counter += 100;
        printf("Child:  counter = %d  (addr = %p)\n", counter, (void *)&counter);
        _exit(0);
    }

    /* Parent: also modify the counter */
    counter += 1;
    waitpid(pid, NULL, 0);
    printf("Parent: counter = %d  (addr = %p)\n", counter, (void *)&counter);

    return 0;
}
gcc -Wall -o fork_memory fork_memory.c
./fork_memory

Expected output:

Child:  counter = 100  (addr = 0x55a3b4001c)
Parent: counter = 1    (addr = 0x55a3b4001c)

Same virtual address, different values. The MMU maps each process to different physical pages. The child's += 100 is invisible to the parent. This is the fundamental difference between processes and threads — threads share memory, processes don't.

Note

Compare with threads: In thread_demo.c, two threads increment the same global counter and both see each other's changes (leading to race conditions). With fork(), changes are completely isolated — no race condition possible, but also no easy data sharing. This is the core trade-off.

Exercise: fork + exec — Replacing the Child Program

fork() alone creates a copy of the same program. The shell needs more: it forks, then the child calls exec() to replace itself with a different program entirely. This is how every command you type in bash actually runs.

/* exec_demo.c — fork + exec pattern (how the shell works) */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void)
{
    printf("Parent PID: %d\n", getpid());

    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        /* Child: replace ourselves with 'ls -la /' */
        printf("  Child PID %d: about to exec ls...\n", getpid());
        execl("/usr/bin/ls", "ls", "-la", "/", NULL);

        /* If exec returns, it failed */
        perror("exec");
        _exit(127);
    }

    /* Parent: wait for child */
    int status;
    waitpid(pid, &status, 0);

    if (WIFEXITED(status))
        printf("Parent: child exited with status %d\n", WEXITSTATUS(status));

    /* This line proves the parent is still the ORIGINAL program */
    printf("Parent: I am still exec_demo (PID %d)\n", getpid());

    return 0;
}
gcc -Wall -o exec_demo exec_demo.c
./exec_demo

Notice: the child prints the ls output and exits. The parent continues as exec_demo. The printf("Parent: I am still...") line after waitpid proves that exec() only replaced the child — the parent is unchanged.

Note

After exec() succeeds, the child's original code is gone. The perror("exec") line is only reached if exec fails (e.g., wrong path). This is why fork+exec is a two-step process: fork gives you a new PID, exec loads a new program into that PID.

Checkpoint 1

Question Your Answer
Child's PID and parent's PID
Exit status collected by parent
Did you observe a zombie when waitpid was removed?
What did exec_demo child output? Does the parent still print after exec?

2. pipe() — Parent-Child Communication

A pipe is a one-way byte stream between two processes. The parent creates the pipe before forking, and both processes inherit the file descriptors.

Step 2: pipe_sensor.c

/* pipe_sensor.c — child reads sensor, parent displays */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/wait.h>

#define SENSOR_PATH "/sys/class/thermal/thermal_zone0/temp"

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

    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return 1;
    }

    if (pid == 0) {
        /* Child: read sensor, write to pipe */
        close(fd[0]);   /* Close unused read end */

        FILE *f = fopen(SENSOR_PATH, "r");
        if (!f) {
            perror("fopen sensor");
            _exit(1);
        }

        char buf[32];
        if (fgets(buf, sizeof(buf), f)) {
            /* Remove trailing newline */
            buf[strcspn(buf, "\n")] = '\0';
            write(fd[1], buf, strlen(buf));
        }
        fclose(f);
        close(fd[1]);
        _exit(0);
    }

    /* Parent: read from pipe, display result */
    close(fd[1]);   /* Close unused write end */

    char buf[64];
    int n = read(fd[0], buf, sizeof(buf) - 1);
    close(fd[0]);

    waitpid(pid, NULL, 0);

    if (n > 0) {
        buf[n] = '\0';
        int temp_mc = atoi(buf);
        printf("Temperature: %d.%d C\n", temp_mc / 1000, (temp_mc % 1000) / 100);
    }

    return 0;
}
gcc -Wall -o pipe_sensor pipe_sensor.c
./pipe_sensor
 Child process              Kernel               Parent process
 ┌──────────────┐     ┌───────────────┐     ┌──────────────┐
 │ read sensor  │     │               │     │              │
 │ write(fd[1]) ├────►│  pipe buffer  ├────►│ read(fd[0])  │
 │ close(fd[1]) │     │  (up to 64K)  │     │ display temp │
 └──────────────┘     └───────────────┘     └──────────────┘
Tip

This is what the shell does for cat /sys/class/thermal/thermal_zone0/temp | awk '{print $1/1000}' — now you see the C code behind it. The shell creates a pipe, forks twice, and wires up the file descriptors.

Checkpoint 2

Question Your Answer
Temperature reported by pipe_sensor
What happens if you forget to close(fd[1]) in the parent?
Which FD does the child write to?

3. Signals — Asynchronous Notification

A signal is a software interrupt delivered to a process. The kernel interrupts whatever the process is doing, runs the handler, then resumes normal execution.

Step 3: signal_demo.c

/* signal_demo.c — handle SIGINT and SIGUSR1 */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

volatile sig_atomic_t got_usr1 = 0;
volatile sig_atomic_t got_int = 0;

void handle_usr1(int sig)
{
    (void)sig;
    got_usr1 = 1;
}

void handle_int(int sig)
{
    (void)sig;
    got_int = 1;
}

int main(void)
{
    struct sigaction sa_usr1 = { .sa_handler = handle_usr1 };
    struct sigaction sa_int  = { .sa_handler = handle_int };

    sigaction(SIGUSR1, &sa_usr1, NULL);
    sigaction(SIGINT,  &sa_int,  NULL);

    printf("PID: %d — send signals with:\n", getpid());
    printf("  kill -SIGUSR1 %d\n", getpid());
    printf("  kill -SIGINT  %d   (or Ctrl+C)\n", getpid());

    int count = 0;
    while (!got_int) {
        if (got_usr1) {
            got_usr1 = 0;
            count++;
            printf("  SIGUSR1 received (#%d)\n", count);
        }
        pause();   /* Sleep until any signal arrives */
    }

    printf("\nSIGINT received — exiting after %d SIGUSR1 signals\n", count);
    return 0;
}
gcc -Wall -o signal_demo signal_demo.c
./signal_demo &

From another terminal (or the same one):

kill -SIGUSR1 $(pgrep signal_demo)
kill -SIGUSR1 $(pgrep signal_demo)
kill -SIGINT  $(pgrep signal_demo)

Signal Reference Table

Signal Number Default Action Catchable Embedded Use Case
SIGINT 2 Terminate Yes Ctrl+C — interactive stop
SIGTERM 15 Terminate Yes systemctl stop — clean shutdown
SIGKILL 9 Terminate No Force kill — cannot be caught or ignored
SIGUSR1 10 Terminate Yes Application-defined (reload config)
SIGUSR2 12 Terminate Yes Application-defined (rotate logs)
SIGCHLD 17 Ignore Yes Child process exited — trigger waitpid()
SIGSTOP 19 Stop No Freeze process (used by debuggers)
SIGCONT 18 Continue Yes Resume a stopped process
Warning

Async-signal-safety: Only set a volatile sig_atomic_t flag inside signal handlers. Do not call printf(), malloc(), or any function that takes a lock — this can cause deadlocks or memory corruption. The handler fires asynchronously, potentially in the middle of another printf() call.

Note

Signal handlers are the user-space equivalent of ISRs. Same principle: minimal work in the handler, defer the rest to the main loop. On an MCU, you set a flag in the ISR and check it in while(1). Here, you set a volatile sig_atomic_t flag in the handler and check it in while(!done). This pattern reappears in kernel interrupt handlers.

Checkpoint 3

Question Your Answer
PID of your signal_demo process
How many SIGUSR1 signals did you send?
What happens if you send SIGKILL instead of SIGINT?

4. Practical Integration — Sensor Monitor

Now combine fork, pipe, and signals into a realistic embedded pattern: a sensor monitoring daemon.

Step 4: sensor_monitor.c

/* sensor_monitor.c — fork + pipe + signals */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <time.h>

#define SENSOR_PATH "/sys/class/thermal/thermal_zone0/temp"
#define CSV_FILE    "sensor_log.csv"
#define INTERVAL    2

volatile sig_atomic_t running = 1;
volatile sig_atomic_t print_stats = 0;

void handle_term(int sig) { (void)sig; running = 0; }
void handle_usr1(int sig) { (void)sig; print_stats = 1; }

/* Child: read sensor in a loop, write to pipe */
static void child_loop(int write_fd)
{
    while (1) {
        FILE *f = fopen(SENSOR_PATH, "r");
        if (!f) {
            sleep(INTERVAL);
            continue;
        }
        char buf[32];
        if (fgets(buf, sizeof(buf), f)) {
            buf[strcspn(buf, "\n")] = '\0';
            write(write_fd, buf, strlen(buf));
            write(write_fd, "\n", 1);
        }
        fclose(f);
        sleep(INTERVAL);
    }
}

int main(void)
{
    /* Set up signal handlers */
    struct sigaction sa_term = { .sa_handler = handle_term };
    struct sigaction sa_usr1 = { .sa_handler = handle_usr1 };
    sigaction(SIGTERM, &sa_term, NULL);
    sigaction(SIGINT,  &sa_term, NULL);
    sigaction(SIGUSR1, &sa_usr1, NULL);

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

    pid_t child = fork();
    if (child < 0) { perror("fork"); return 1; }

    if (child == 0) {
        close(fd[0]);
        child_loop(fd[1]);
        _exit(0);
    }

    /* Parent: read pipe, log to CSV, handle signals */
    close(fd[1]);

    FILE *csv = fopen(CSV_FILE, "w");
    if (!csv) { perror("fopen csv"); kill(child, SIGTERM); return 1; }
    fprintf(csv, "timestamp,temp_mC,temp_C\n");
    fflush(csv);

    printf("Sensor monitor running (PID %d, child %d)\n", getpid(), child);
    printf("  kill -SIGUSR1 %d  → print stats\n", getpid());
    printf("  kill -SIGTERM %d  → graceful shutdown\n", getpid());

    FILE *pipe_fp = fdopen(fd[0], "r");
    int readings = 0;
    int temp_sum = 0;
    char line[64];

    while (running) {
        if (print_stats) {
            print_stats = 0;
            int avg = readings > 0 ? temp_sum / readings : 0;
            printf("[STATS] %d readings, avg: %d.%d C\n",
                   readings, avg / 1000, (avg % 1000) / 100);
        }

        if (fgets(line, sizeof(line), pipe_fp)) {
            int temp_mc = atoi(line);
            readings++;
            temp_sum += temp_mc;

            time_t now = time(NULL);
            struct tm *t = localtime(&now);
            char ts[32];
            strftime(ts, sizeof(ts), "%H:%M:%S", t);

            fprintf(csv, "%s,%d,%d\n", ts, temp_mc, temp_mc / 1000);
            fflush(csv);
        }
    }

    /* Graceful shutdown */
    printf("\nShutting down — %d readings logged to %s\n", readings, CSV_FILE);
    kill(child, SIGTERM);
    waitpid(child, NULL, 0);
    fclose(pipe_fp);
    fclose(csv);

    return 0;
}

Build and Test

gcc -Wall -o sensor_monitor sensor_monitor.c
./sensor_monitor &
sleep 6                                    # Let it collect 3 readings
kill -SIGUSR1 $(pgrep sensor_monitor)      # Print stats
sleep 4                                    # A couple more readings
kill -SIGTERM $(pgrep sensor_monitor)      # Graceful shutdown
cat sensor_log.csv
Tip

Compare this to temp_logger_safe.sh from the Shell Scripting tutorial. Same architecture — continuous sensor read, CSV logging, signal-based shutdown — but in C with direct file descriptor control, separate processes for reading and logging, and no shell overhead.

Checkpoint 4

Question Your Answer
Readings logged after 10 seconds
Average temperature from SIGUSR1 stats
Did the CSV file have a proper header and data?

What Just Happened?

You wrote user-space C that uses the same system calls underlying every Linux program:

System Call What You Did Shell Equivalent Later in Course
fork() Created child processes Every command the shell runs Driver probe() creates kernel threads
exec() Replaced child with a new program How the shell runs ls, cat, etc. modprobe loads kernel modules the same way
pipe() Sent data between processes cmd1 \| cmd2 Device read()/write() uses the same FD model
sigaction() Handled asynchronous events trap in bash scripts Kernel interrupt handlers (ISRs)
waitpid() Collected child exit status $? in bash Preventing zombie processes in daemons

Forward references:

  • fork() → In Lab 7, driver probe() functions initialize resources much like a child process sets up its work
  • Signals → Kernel interrupt handlers follow the same "set flag, defer work" pattern
  • Pipe read/write → Your kernel driver will implement read() and write() file operations for /dev/ entries

Challenges

Tip

Challenge 1: Named Pipe (FIFO)

Two separate programs (not parent-child) communicating through a named pipe:

  • fifo_writer.c: creates a FIFO with mkfifo("/tmp/sensor_fifo", 0666), opens it, and writes temperature readings in a loop
  • fifo_reader.c: opens the same FIFO and reads/displays data

Run them in separate terminals. This demonstrates IPC between unrelated processes — no fork() needed.

Hint: mkfifo(), then use regular open()/read()/write().

Tip

Challenge 2: Multi-Child Pipeline

Create a three-stage pipeline in a single program:

  1. Child 1: reads temperature sensor, writes raw value to pipe A
  2. Child 2: reads pipe A, converts millidegrees to Celsius, writes to pipe B
  3. Parent: reads pipe B, displays formatted output

This mirrors what cat /sys/.../temp | awk '{print $1/1000}' | head -1 does — three processes connected by two pipes.

Tip

Challenge 3: POSIX Shared Memory

Use shm_open() + mmap() for high-speed data sharing between two processes:

  • Writer process: maps a shared memory region, writes sensor data at 100 Hz
  • Reader process: maps the same region, reads and displays
  • Use a pthread_mutex_t (with PTHREAD_PROCESS_SHARED attribute) for synchronization

Compile with gcc -lrt -lpthread. This is the fastest IPC mechanism — no kernel copy — but requires careful synchronization.


Deliverable

  • [ ] fork_demo.c compiles and runs — parent creates child and collects exit status
  • [ ] exec_demo.c compiles and runs — child execs ls, parent continues as original program
  • [ ] pipe_sensor.c compiles and runs — child reads temperature, parent displays via pipe
  • [ ] signal_demo.c compiles and runs — handles SIGUSR1 and SIGINT correctly
  • [ ] sensor_monitor.c compiles and runs — logs to CSV, responds to SIGUSR1/SIGTERM
  • [ ] fork_memory.c compiles and runs — parent and child show different counter values at the same address
  • [ ] (Optional) At least one advanced challenge completed

Next Steps

Continue with threads and concurrent programming in Tutorial: Threads and Synchronization, then network communication in Tutorial: Network Sockets.


Back to Course Overview