Processes, Signals, and Low-Level I/O
Below FILE * there is a file descriptor, and below system() there is fork and exec. This week opens the layer where a C program meets the operating system — the interface Unix was written in C to express, and the one every shell is built from.
- Use file descriptors directly and say how they relate to
FILE *. - Create a process with
fork, replace it withexec, and collect its status. - Avoid zombies and explain what one is.
- Install a signal handler and say what may legally be done inside it.
- Connect two processes with a pipe and redirect a descriptor.
1File descriptors
A file descriptor is a small non-negative integer indexing a per-process table of open files. Three are open when your program starts:
| Descriptor | Name | Stream |
|---|---|---|
| 0 | STDIN_FILENO | stdin |
| 1 | STDOUT_FILENO | stdout |
| 2 | STDERR_FILENO | stderr |
#include <fcntl.h>
#include <unistd.h>
int fd = open("data.txt", O_RDONLY);
if (fd < 0) { perror("open"); return EXIT_FAILURE; }
char buffer[4096];
ssize_t n = read(fd, buffer, sizeof buffer);
if (n < 0) { perror("read"); }
close(fd);FILE * (C standard) | File descriptor (POSIX) |
|---|---|
| Buffered | Unbuffered: every call is a system call |
| Portable to any C implementation | POSIX only |
| Formatted I/O | Bytes only |
| Cannot be passed to a child | Inherited across fork and exec |
Convert between them with fileno(FILE *) and fdopen(int, mode). Do not mix buffered and unbuffered access to the same file without flushing — the two layers have separate ideas of the position.
read and write may transfer less than you asked. A short read is normal at end of file, on a pipe, or on a socket; a short write is normal when a pipe is full. Code that assumes the full count silently loses data. Always loop until the total is satisfied or an error occurs, and treat EINTR — interrupted by a signal — as "retry", not as a failure.
2fork
#include <unistd.h>
pid_t pid = fork();
if (pid < 0) {
perror("fork"); /* failed */
} else if (pid == 0) {
/* child: fork returned 0 */
} else {
/* parent: fork returned the child's process id */
}fork duplicates the calling process. Both continue from the same point with the same memory contents, the same open descriptors, and the same variable values — and the only way to tell them apart is the return value.
The memory is copied lazily: pages are shared until one process writes, at which point that page is duplicated. So fork is cheap, and the two processes are nonetheless fully independent — a variable changed in the child is unchanged in the parent.
Descriptors, by contrast, are genuinely shared: both processes hold references to the same open file description, including the file position. That sharing is what makes pipes work.
3exec and waiting
exec replaces the current process image with a different program. It does not return on success — there is nothing to return to.
execlp("ls", "ls", "-l", (char *)NULL); /* searches PATH */
perror("execlp"); /* only reached on failure */
_exit(127); /* note _exit, not exit */The _exit matters: after a failed exec in a child, calling exit would run the parent's atexit handlers and flush the parent's buffered output a second time, producing duplicated text. Use _exit in a child that is abandoning ship.
#include <sys/wait.h>
int status;
pid_t done = waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("exited with %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("killed by signal %d\n", WTERMSIG(status));
}Zombies
When a child exits, the kernel keeps its exit status until the parent collects it. Until then the child is a zombie — no memory, no code, just a table entry. A parent that never waits accumulates them until the process table fills.
Three remedies: call waitpid for each child; reap them in a SIGCHLD handler; or set SIGCHLD to SIG_IGN, which tells the kernel you will never ask.
4Signals
A signal is an asynchronous notification. Your handler runs at an arbitrary point in whatever the program was doing.
| Signal | Cause | Default |
|---|---|---|
SIGINT | Ctrl-C | Terminate |
SIGTERM | kill | Terminate |
SIGSEGV | Invalid memory access | Terminate with a core dump |
SIGCHLD | A child changed state | Ignored |
SIGPIPE | Wrote to a pipe with no reader | Terminate |
SIGKILL, SIGSTOP | — | Cannot be caught |
#include <signal.h>
static volatile sig_atomic_t should_stop = 0;
static void on_interrupt(int sig)
{
(void)sig;
should_stop = 1; /* the only safe thing to do */
}
struct sigaction sa = { 0 };
sa.sa_handler = on_interrupt;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART; /* restart interrupted system calls */
sigaction(SIGINT, &sa, NULL);Use sigaction, not signal: the older interface has behavior that varies between systems, and sigaction lets you control the mask and the restart flag.
Async-signal safety
A handler can interrupt the program anywhere — including in the middle of malloc updating its data structures. Calling malloc from the handler then corrupts the heap. The same applies to printf, which has its own locks and buffers.
Only functions the standard lists as async-signal-safe may be called: write, _exit, signal, kill, and a few dozen others. printf, malloc, and free are not among them.
The practical discipline is to set a flag and return:
static volatile sig_atomic_t should_stop = 0;
while (!should_stop) {
do_work(); /* the real work happens here */
}
puts("shutting down cleanly"); /* printf is safe out here */volatile so the loop actually re-reads it — week 38's first legitimate use. sig_atomic_t because it is the only type guaranteed to be read and written atomically with respect to a signal.
5Pipes and redirection
int fds[2];
pipe(fds); /* fds[0] to read, fds[1] to write */A pipe is a one-way channel between two descriptors. After fork, both processes hold both ends, so each must close the one it does not use — otherwise the reader never sees end of file, because a writing end is still open in its own process.
dup2 makes one descriptor a copy of another, which is how redirection works:
dup2(fds[1], STDOUT_FILENO); /* the child's stdout becomes the pipe */
close(fds[1]);After this the child's printf writes into the pipe without knowing anything has changed. That is precisely what a shell does for ls | grep foo, and the reason descriptor numbers rather than FILE * are the operating system's currency.
6Worked example: a miniature shell
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdbool.h>
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/wait.h>
/* Set by the handler, read by the loop. Both qualifiers are required. */
static volatile sig_atomic_t interrupted = 0;
static void on_sigint(int sig)
{
(void)sig;
interrupted = 1;
/* Nothing else. write() would be safe; printf() would not. */
}
/* read and write may transfer less than requested; loop. */
static ssize_t write_all(int fd, const void *data, size_t n)
{
const char *p = data;
size_t written = 0;
while (written < n) {
ssize_t w = write(fd, p + written, n - written);
if (w < 0) {
if (errno == EINTR) continue; /* retry, not an error */
return -1;
}
written += (size_t)w;
}
return (ssize_t)written;
}
static void describe_status(const char *what, int status)
{
if (WIFEXITED(status)) {
printf(" %s exited with %d\n", what, WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf(" %s killed by signal %d\n", what, WTERMSIG(status));
} else {
printf(" %s ended in an unexpected way\n", what);
}
}
/* ---------- 1. fork and exec one command ---------- */
static int run_command(char *const argv[])
{
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return -1;
}
if (pid == 0) { /* child */
execvp(argv[0], argv);
perror(argv[0]); /* only on failure */
_exit(127); /* _exit, not exit */
}
int status;
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) { perror("waitpid"); return -1; }
}
return status;
}
/* ---------- 2. redirect output to a file ---------- */
static int run_redirected(char *const argv[], const char *path)
{
pid_t pid = fork();
if (pid < 0) { perror("fork"); return -1; }
if (pid == 0) {
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (fd < 0) { perror(path); _exit(126); }
if (dup2(fd, STDOUT_FILENO) < 0) { perror("dup2"); _exit(126); }
close(fd); /* the copy in fd 1 remains */
execvp(argv[0], argv);
perror(argv[0]);
_exit(127);
}
int status;
waitpid(pid, &status, 0);
return status;
}
/* ---------- 3. connect two commands with a pipe ---------- */
static int run_pipeline(char *const left[], char *const right[])
{
int fds[2];
if (pipe(fds) < 0) { perror("pipe"); return -1; }
pid_t first = fork();
if (first < 0) { perror("fork"); return -1; }
if (first == 0) {
close(fds[0]); /* not reading */
dup2(fds[1], STDOUT_FILENO);
close(fds[1]);
execvp(left[0], left);
perror(left[0]);
_exit(127);
}
pid_t second = fork();
if (second < 0) { perror("fork"); return -1; }
if (second == 0) {
close(fds[1]); /* not writing */
dup2(fds[0], STDIN_FILENO);
close(fds[0]);
execvp(right[0], right);
perror(right[0]);
_exit(127);
}
/* The parent must close BOTH ends, or the reader never sees EOF. */
close(fds[0]);
close(fds[1]);
int s1, s2;
waitpid(first, &s1, 0);
waitpid(second, &s2, 0);
describe_status("left", s1);
describe_status("right", s2);
return s2;
}
int main(void)
{
struct sigaction sa;
memset(&sa, 0, sizeof sa);
sa.sa_handler = on_sigint;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
if (sigaction(SIGINT, &sa, NULL) < 0) {
perror("sigaction");
return EXIT_FAILURE;
}
printf("parent pid %ld\n\n", (long)getpid());
puts("== 1. fork: two processes, one program ==");
pid_t pid = fork();
if (pid == 0) {
printf(" child : pid %ld, fork returned 0, parent is %ld\n",
(long)getpid(), (long)getppid());
_exit(3);
}
int status;
waitpid(pid, &status, 0);
printf(" parent: fork returned %ld\n", (long)pid);
describe_status("child", status);
puts("\n== 2. memory is copied, descriptors are shared ==");
int shared = 100;
pid = fork();
if (pid == 0) {
shared = 999;
printf(" child sees shared = %d\n", shared);
_exit(0);
}
waitpid(pid, NULL, 0);
printf(" parent sees shared = %d (the child's change did not reach us)\n",
shared);
puts("\n== 3. exec replaces the program ==");
char *echo_args[] = { "echo", " hello from execvp", NULL };
describe_status("echo", run_command(echo_args));
puts("\n== 4. a command that does not exist ==");
char *missing[] = { "definitely_not_a_command", NULL };
describe_status("missing", run_command(missing));
puts(" 127 is the conventional code for 'command not found'");
puts("\n== 5. redirection with dup2 ==");
char *date_args[] = { "date", "+%Y-%m-%d", NULL };
run_redirected(date_args, "shell_out.txt");
FILE *f = fopen("shell_out.txt", "r");
if (f != NULL) {
char line[128];
if (fgets(line, sizeof line, f)) printf(" file contains: %s", line);
fclose(f);
}
remove("shell_out.txt");
puts("\n== 6. a pipeline: seq 1 20 | grep 1 ==");
char *left[] = { "seq", "1", "20", NULL };
char *right[] = { "grep", "1", NULL };
run_pipeline(left, right);
puts("\n== 7. low-level write, looped ==");
const char *msg = " written with write(2), not printf\n";
write_all(STDOUT_FILENO, msg, strlen(msg));
puts("\n== 8. signals ==");
puts(" press Ctrl-C within three seconds to interrupt the loop");
for (int i = 0; i < 30 && !interrupted; i++) {
struct timespec ts = { .tv_sec = 0, .tv_nsec = 100000000L };
nanosleep(&ts, NULL);
}
if (interrupted) {
puts(" the handler set a flag; this message is printed out here,");
puts(" where printf is safe — inside a handler it would not be");
} else {
puts(" no interrupt arrived");
}
puts("\n== 9. zombies ==");
pid = fork();
if (pid == 0) _exit(0);
printf(" child %ld has exited but is not yet reaped: it is a zombie\n",
(long)pid);
puts(" run 'ps -el | grep defunct' in another terminal now");
sleep(1);
waitpid(pid, NULL, 0);
puts(" reaped; the process table entry is gone");
return EXIT_SUCCESS;
}gcc -std=c17 -D_POSIX_C_SOURCE=200809L -Wall -Wextra -g -o minishell minishell.c
./minishellThe -D_POSIX_C_SOURCE=200809L is needed because -std=c17 asks for strict standard C, which hides the POSIX declarations. Without it you get implicit-declaration errors for fork and friends — week 11's diagnostic, from an unexpected direction.
Four things the output demonstrates
Memory is copied, descriptors are shared. The child sets shared to 999 and the parent still sees 100 — separate address spaces. Yet both write to the same terminal, because descriptor 1 refers to the same open file description in both.
The parent must close both pipe ends. Comment out close(fds[1]) in the parent and run the pipeline again: grep never terminates, because a write end is still open in the parent, so the pipe never reaches end of file. This is the single most common pipe bug.
Exit code 127. The failed execvp child calls _exit(127), the conventional "command not found" code that shells use. Week 31's exit-code conventions, on the producing side.
The signal handler does almost nothing. It sets one flag of type volatile sig_atomic_t and returns. All reporting happens in the main loop, where the standard library is safe to call.
Break the signal handler on purpose
static void on_sigint(int sig)
{
(void)sig;
printf("caught it\n"); /* NOT async-signal-safe */
char *p = malloc(100); /* definitely not safe */
free(p);
interrupted = 1;
}This usually appears to work, which is why the rule is so often ignored. Press Ctrl-C repeatedly while the program is inside malloc and the heap can be corrupted, producing a crash somewhere unrelated later. Run it under -fsanitize=address and hammer the key; the failure mode is exactly the action-at-a-distance of week 20.
Watch the zombie
# in another terminal, while the program is in its sleep(1)
ps -el | grep defunctThe entry shows state Z. Remove the waitpid and loop the fork a thousand times to watch the process table fill — the reason a long-running server must reap its children.
Handle a short write
write_all loops because a single write may transfer fewer bytes than requested. Prove it by writing several megabytes to a pipe whose reader is slow: the first write returns as soon as the pipe buffer is full, typically after 64 KB. Code that ignores the return value loses the remainder silently.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Not closing unused pipe ends | The reader never sees end of file | Close both ends you do not use, in every process. |
Never calling waitpid | Zombies accumulate | Reap every child, or ignore SIGCHLD. |
printf or malloc in a signal handler | Heap corruption; deadlock | Set a volatile sig_atomic_t flag and return. |
exit instead of _exit in a child | Parent's buffers flushed twice | _exit after a failed exec. |
Assuming read/write transfer everything | Silent data loss | Loop until complete; retry on EINTR. |
Ignoring EINTR | Spurious failures once signals are in use | Retry, or set SA_RESTART. |
Using signal rather than sigaction | Behavior differs between systems | sigaction. |
Mixing FILE * and raw descriptors | Interleaved or lost output | Flush before switching layers. |
8Check yourself
How does a process tell whether it is the parent or the child after fork?
Only by the return value: 0 in the child, the child's process id in the parent, negative on failure. Both continue from the same instruction with identical memory contents, so nothing else distinguishes them. The memory is subsequently independent — a write in one is invisible to the other.
Why must both processes close the pipe ends they do not use?
Because the reader sees end of file only when every write end is closed. If the parent keeps its copy of the write end open, the child reading from the pipe blocks forever even after the writer exits. Each process must close both descriptors it does not need, including the parent after forking.
What is a zombie process and how do you avoid one?
A child that has exited but whose exit status the parent has not yet collected; the kernel keeps a process-table entry so the status can be reported. Call waitpid for each child, reap them in a SIGCHLD handler, or set SIGCHLD to SIG_IGN to tell the kernel you will never ask.
Why may a signal handler not call printf?
Because the handler can interrupt the program at any instruction, including inside printf or malloc while their internal state is inconsistent. Re-entering them then corrupts that state. Only functions the standard designates async-signal-safe may be called; the safe discipline is to set a volatile sig_atomic_t flag and do the work in the main loop.
Why does a child that fails to exec call _exit rather than exit?
Because the child inherited a copy of the parent's buffered output and its atexit handlers. exit would flush those buffers and run those handlers a second time, duplicating output and possibly repeating cleanup actions. _exit terminates immediately without either.
9Where this leads
Week 40 extends the descriptor model across a network: a socket is a file descriptor, read and write work on it, and the same short-transfer and EINTR rules apply. The forked-child-per-connection server built there is the direct descendant of this week's pipeline, and week 47 replaces it with an event loop.