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 withwaitpid() - 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:
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:
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;
}
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;
}
Expected output:
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;
}
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;
}
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;
}
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, driverprobe()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()andwrite()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 withmkfifo("/tmp/sensor_fifo", 0666), opens it, and writes temperature readings in a loopfifo_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:
- Child 1: reads temperature sensor, writes raw value to pipe A
- Child 2: reads pipe A, converts millidegrees to Celsius, writes to pipe B
- 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(withPTHREAD_PROCESS_SHAREDattribute) for synchronization
Compile with gcc -lrt -lpthread. This is the fastest IPC mechanism — no kernel copy — but requires careful synchronization.
Deliverable
- [ ]
fork_demo.ccompiles and runs — parent creates child and collects exit status - [ ]
exec_demo.ccompiles and runs — child execsls, parent continues as original program - [ ]
pipe_sensor.ccompiles and runs — child reads temperature, parent displays via pipe - [ ]
signal_demo.ccompiles and runs — handles SIGUSR1 and SIGINT correctly - [ ]
sensor_monitor.ccompiles and runs — logs to CSV, responds to SIGUSR1/SIGTERM - [ ]
fork_memory.ccompiles 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.