Skip to main content
C · lesson 22 of 25
C advanced Lesson 22 of 25

System Programming in C

Unix system programming in C: fork and exec, exit statuses, signals, pipes, mmap and a TCP round trip, all run for real on Linux.

By SkillByExample · Updated

Everything here is POSIX, so it runs on Linux and macOS but not native Windows (use WSL). All output comes from Linux with glibc.

Feature-Test Macros

In strict ISO mode (-std=c17), the system headers hide anything that isn’t part of ISO C, and that includes most of this lesson:

#include <signal.h>
#include <stdio.h>

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = SIG_IGN;
    sigaction(SIGINT, &sa, NULL);
    FILE *p = popen("true", "r");
    return p == NULL;
}
$ gcc -Wall -Wextra -std=c17 -o nodefine nodefine.c
nodefine.c: In function 'main':
nodefine.c:5:12: error: variable 'sa' has initializer but incomplete type
    5 |     struct sigaction sa = {0};
      |            ^~~~~~~~~
nodefine.c:5:28: warning: excess elements in struct initializer
    5 |     struct sigaction sa = {0};
      |                            ^
nodefine.c:5:28: note: (near initialization for 'sa')
nodefine.c:5:22: error: storage size of 'sa' isn't known
    5 |     struct sigaction sa = {0};
      |                      ^~
nodefine.c:7:5: error: implicit declaration of function 'sigaction' [-Wimplicit-function-declaration]
    7 |     sigaction(SIGINT, &sa, NULL);
      |     ^~~~~~~~~
nodefine.c:8:15: error: implicit declaration of function 'popen'; did you mean 'fopen'? [-Wimplicit-function-declaration]
    8 |     FILE *p = popen("true", "r");
      |               ^~~~~
      |               fopen
nodefine.c:8:15: error: initialization of 'FILE *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
nodefine.c:5:22: warning: unused variable 'sa' [-Wunused-variable]
    5 |     struct sigaction sa = {0};
      |                      ^~

The headers were included, but struct sigaction and popen weren’t exposed. The fix is one line before any #include: #define _POSIX_C_SOURCE 200809L asks for POSIX.1-2008. Every example below starts with it (or with _DEFAULT_SOURCE, which also unlocks glibc extras such as MAP_ANONYMOUS). -std=gnu17, GCC’s default mode, exposes everything without a define.

fork and exec

fork duplicates the calling process and returns twice: 0 in the new child, and the child’s PID in the parent. exec then swaps the child’s program for another, keeping its PID:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    printf("parent: pid %d\n", (int)getpid());
    fflush(stdout);                         /* don't let the child inherit unflushed output */

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

    if (pid == 0) {                         /* in the child */
        printf("child:  pid %d, parent %d\n", (int)getpid(), (int)getppid());
        fflush(stdout);
        execlp("echo", "echo", "hello from echo, same pid", (char *)NULL);
        perror("execlp");                   /* only reached if exec failed */
        _exit(127);
    }

    int status;
    waitpid(pid, &status, 0);               /* reap the child */
    if (WIFEXITED(status)) printf("parent: child %d exited with %d\n", (int)pid, WEXITSTATUS(status));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o forkexec forkexec.c
$ ./forkexec
parent: pid 2419
child:  pid 2420, parent 2419
hello from echo, same pid
parent: child 2420 exited with 0

The child’s getppid() matches the parent’s PID. After execlp, the child process 2420 is running echo instead of this program: nothing after a successful exec runs, because the old code is gone. waitpid both waits and reaps the child. Without it, the dead child lingers as a zombie in the process table. The fflush calls matter because fork copies the stdio buffer too, and Practice 1 shows what happens without them.

Decoding How a Child Ended

waitpid’s status encodes whether the child exited normally or was killed by a signal:

#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <unistd.h>

void report(pid_t pid) {
    int status;
    waitpid(pid, &status, 0);
    if (WIFEXITED(status))        printf("exited normally, status %d\n", WEXITSTATUS(status));
    else if (WIFSIGNALED(status)) printf("killed by signal %d\n", WTERMSIG(status));
}

int main(void) {
    pid_t a = fork();
    if (a == 0) _exit(3);                               /* child 1: plain exit */
    report(a);

    pid_t b = fork();
    if (b == 0) { execlp("nonexistent-cmd", "nonexistent-cmd", (char *)NULL); _exit(127); }
    report(b);

    pid_t c = fork();
    if (c == 0) { int *p = NULL; return *p; }          /* child 3: segfault */
    report(c);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o status status.c
$ ./status
exited normally, status 3
exited normally, status 127
killed by signal 11

The segfaulting child didn’t take the parent down with it: separate processes have separate memory. It was killed by signal 11 (SIGSEGV), which a shell would report as exit status 139 (128 + 11). 127 is the conventional status for “command not found”, exactly what a shell prints for a missing program, and it’s what the child chose to exit with when execlp failed.

Running a Command with popen

popen does the fork, exec and pipe for you and hands back a FILE * connected to the command’s output:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <string.h>

int main(void) {
    FILE *p = popen("printf 'banana\\napple\\ncherry\\n' | sort", "r");
    if (!p) { perror("popen"); return 1; }
    char line[64];
    int n = 0;
    while (fgets(line, sizeof line, p)) {
        line[strcspn(line, "\n")] = '\0';
        printf("line %d: %s\n", ++n, line);
    }
    int status = pclose(p);                 /* waits for the command */
    printf("command exit status: %d\n", status);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o popen popen.c
$ ./popen
line 1: apple
line 2: banana
line 3: cherry
command exit status: 0

The string is run by /bin/sh, so pipes like | sort work. That also means never building a popen command from untrusted input: a filename such as x; rm -rf ~ would run as a second command. pclose waits for the command and returns its waitpid status.

Signals

A signal handler interrupts the program at an arbitrary point, so the safe pattern is for the handler to set a flag and let the main loop act on it:

#define _POSIX_C_SOURCE 200809L
#include <signal.h>
#include <stdio.h>
#include <time.h>
#include <unistd.h>

static volatile sig_atomic_t running = 1;
static volatile sig_atomic_t reload = 0;

void on_int(int sig) { (void)sig; running = 0; }
void on_hup(int sig) { (void)sig; reload = 1; }

int main(void) {
    struct sigaction sa = {0};
    sigemptyset(&sa.sa_mask);
    sa.sa_handler = on_int;
    sigaction(SIGINT, &sa, NULL);
    sa.sa_handler = on_hup;
    sigaction(SIGHUP, &sa, NULL);

    printf("running as pid %d\n", (int)getpid());
    fflush(stdout);
    while (running) {
        if (reload) { reload = 0; printf("SIGHUP: reloading config\n"); fflush(stdout); }
        nanosleep(&(struct timespec){0, 100000000}, NULL);   /* 100 ms */
    }
    printf("SIGINT: shutting down cleanly\n");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o sig sig.c
$ ./sig &
running as pid 2444
$ kill -HUP 2444
SIGHUP: reloading config
$ kill -INT 2444
SIGINT: shutting down cleanly
$ wait 2444; echo $?
0
$ ./sig &
running as pid 2447
$ kill -TERM 2447
$ wait 2447; echo $?
143

SIGHUP and SIGINT were caught, and the program reloaded, then shut down on its own terms with exit status 0. Nothing handled SIGTERM, so its default action killed the process outright, giving 143 = 128 + 15, and the clean-shutdown message never printed. A real server installs a SIGTERM handler for exactly this reason: it’s what kill, systemd and Docker send first. The handlers only set flags. Calling printf inside a handler is unsafe, because the signal might arrive while the main code is already inside printf.

Pipes

pipe creates a one-way byte channel with a read end and a write end. Combined with fork, dup2 and exec, it’s how a shell builds producer | consumer:

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>

/* Equivalent of:  (parent output) | wc -l */
int main(void) {
    int fd[2];                                /* fd[0] read end, fd[1] write end */
    if (pipe(fd) == -1) { perror("pipe"); return 1; }

    pid_t pid = fork();
    if (pid == 0) {
        dup2(fd[0], STDIN_FILENO);            /* the pipe becomes wc's stdin */
        close(fd[0]);
        close(fd[1]);                         /* essential: see below */
        execlp("wc", "wc", "-l", (char *)NULL);
        _exit(127);
    }
    close(fd[0]);
    const char *lines = "one\ntwo\nthree\n";
    write(fd[1], lines, strlen(lines));
    close(fd[1]);                             /* EOF for wc */
    waitpid(pid, NULL, 0);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o pipe pipe.c
$ ./pipe
3
$ gcc -Wall -Wextra -std=c17 -o pipe_hang pipe_hang.c
$ timeout 3 ./pipe_hang
$ echo $?
124

wc counted the parent’s three lines. pipe_hang.c is the same program minus the child’s close(fd[1]), and it hung until timeout killed it. A reader sees end-of-file only when every copy of the write end is closed. After fork, the child held its own copy, and wc itself was that child, so wc waited forever for more input from a pipe it was keeping open. Close every pipe end a process doesn’t use.

Memory-Mapped Files

mmap maps a file into memory, so its contents can be read like an array:

#define _POSIX_C_SOURCE 200809L
#include <fcntl.h>
#include <stdio.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char *argv[]) {
    const char *name = argc > 1 ? argv[1] : "notes.txt";
    int fd = open(name, O_RDONLY);
    if (fd == -1) { perror(name); return 1; }
    struct stat st;
    if (fstat(fd, &st) == -1 || st.st_size == 0) { close(fd); return 1; }

    const char *text = mmap(NULL, (size_t)st.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);                                /* the mapping stays valid */
    if (text == MAP_FAILED) { perror("mmap"); return 1; }

    long lines = 0;
    for (off_t i = 0; i < st.st_size; i++) lines += text[i] == '\n';
    printf("%s: %lld bytes, %ld lines, first char '%c'\n",
           name, (long long)st.st_size, lines, text[0]);
    munmap((void *)text, (size_t)st.st_size);
    return 0;
}
$ printf "alpha\nbeta\ngamma\n" > notes.txt
$ gcc -Wall -Wextra -std=c17 -o mmapfile mmapfile.c
$ ./mmapfile notes.txt
notes.txt: 17 bytes, 3 lines, first char 'a'

There’s no read call and no buffer: the kernel loads each page of the file the first time it’s touched. The file descriptor can be closed immediately, because the mapping keeps its own reference. Empty files are rejected up front, since mmap with a length of 0 fails.

Memory Shared Across fork

After fork, ordinary memory is copied (lazily, copy-on-write), but a MAP_SHARED mapping is the same physical memory in both processes:

#define _DEFAULT_SOURCE                      /* for MAP_ANONYMOUS */
#include <stdio.h>
#include <stdlib.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int *heap = malloc(sizeof *heap);         /* copied into the child */
    int *shared = mmap(NULL, sizeof *shared, PROT_READ | PROT_WRITE,
                       MAP_SHARED | MAP_ANONYMOUS, -1, 0);   /* truly shared */
    if (!heap || shared == MAP_FAILED) return 1;
    *heap = 0;
    *shared = 0;

    pid_t pid = fork();
    if (pid == 0) {
        *heap = 42;
        *shared = 42;
        _exit(0);
    }
    waitpid(pid, NULL, 0);
    printf("after child wrote 42: heap = %d, shared = %d\n", *heap, *shared);
    munmap(shared, sizeof *shared);
    free(heap);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o shared shared.c
$ ./shared
after child wrote 42: heap = 0, shared = 42

The child wrote 42 to both. The parent’s heap value is still 0, because the child had its own private copy of that page. shared changed for both processes, which makes it a simple form of inter-process communication. If both processes wrote to it at the same time, it would need the same synchronization as threads.

A TCP Echo Round Trip

Sockets use the same file-descriptor model as pipes, but across a network. This program is its own server and client: the parent listens on a free loopback port, and a forked child connects to it:

#define _POSIX_C_SOURCE 200809L
#include <arpa/inet.h>
#include <netinet/in.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    int srv = socket(AF_INET, SOCK_STREAM, 0);
    struct sockaddr_in addr = {.sin_family = AF_INET, .sin_port = 0,   /* 0: any free port */
                               .sin_addr.s_addr = htonl(INADDR_LOOPBACK)};
    socklen_t len = sizeof addr;
    if (srv < 0 || bind(srv, (struct sockaddr *)&addr, len) < 0 || listen(srv, 1) < 0) {
        perror("server setup"); return 1;
    }
    getsockname(srv, (struct sockaddr *)&addr, &len);      /* which port did we get? */

    if (fork() == 0) {                                     /* child: the client */
        int c = socket(AF_INET, SOCK_STREAM, 0);
        if (connect(c, (struct sockaddr *)&addr, sizeof addr) < 0) { perror("connect"); _exit(1); }
        const char *msg = "hello over TCP";
        send(c, msg, strlen(msg), 0);
        char reply[64] = {0};
        recv(c, reply, sizeof reply - 1, 0);
        printf("client got back: \"%s\"\n", reply);
        fflush(stdout);                                    /* _exit skips stdio flushing */
        close(c);
        _exit(0);
    }

    int client = accept(srv, NULL, NULL);                  /* parent: the server */
    char buf[64];
    ssize_t n = recv(client, buf, sizeof buf, 0);
    printf("server received %zd bytes, echoing\n", n);
    fflush(stdout);
    send(client, buf, (size_t)n, 0);
    close(client);
    wait(NULL);
    close(srv);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o echo echo.c
$ ./echo
server received 14 bytes, echoing
client got back: "hello over TCP"

Binding to port 0 lets the kernel pick any free port, and getsockname reports which one it chose, so the program can’t collide with anything already running. The fflush before _exit is essential. Without it, ./echo prints both lines on a terminal but loses the client’s line whenever the output goes to a pipe (./echo | cat). _exit ends the process immediately without flushing stdio buffers, and output to a pipe is fully buffered, so the line was still sitting in the buffer. Real code also has to loop on send and recv, since TCP is a byte stream and one call may move only part of the data.

Practice

  1. Predict what this prints, then run it both on the terminal and through | cat:
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    printf("before fork ");            /* no newline, no flush */
    pid_t pid = fork();
    if (pid == 0) { printf("[child]\n"); return 0; }
    waitpid(pid, NULL, 0);
    printf("[parent]\n");
    return 0;
}
Solution
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
before fork [child]
before fork [parent]
$ ./p1 | cat
before fork [child]
before fork [parent]

before fork was printed once but appears twice. With no newline, it was still sitting in stdout’s buffer when fork copied the whole process, buffer included, so each process flushed its own copy on exit. Calling fflush(stdout) before fork (as forkexec.c does) prevents the duplication.

  1. Fork three children that exit with status 10, 20 and 30. Reap them all with wait in a loop and add up their exit codes.
Solution
#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void) {
    for (int i = 1; i <= 3; i++) {
        if (fork() == 0) _exit(i * 10);
    }
    int status, total = 0;
    pid_t pid;
    while ((pid = wait(&status)) > 0) {
        if (WIFEXITED(status)) total += WEXITSTATUS(status);
    }
    printf("all children reaped, exit codes summed to %d\n", total);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
all children reaped, exit codes summed to 60

wait returns whichever child finishes first, then returns -1 (with errno set to ECHILD) once none are left. That’s what ends the loop. Exit codes only carry 8 bits, so a child can report 0-255 and nothing more.

  1. Start ./sig in the background and send it kill -TERM. Then add a SIGTERM handler that does the same as SIGINT, and send it again.
Solution

Before the change, SIGTERM kills the process with status 143, as shown above. Add sigaction(SIGTERM, &sa, NULL); right after the SIGINT registration (while sa.sa_handler is still on_int), rebuild, and repeat:

$ gcc -Wall -Wextra -std=c17 -o sig sig.c
$ ./sig &
running as pid 2557
$ kill -TERM 2557
SIGINT: shutting down cleanly
$ wait 2557; echo $?
0

The process now shuts down through its normal path and exits 0. The message still says SIGINT because both signals share one handler. Rename it, or record which signal arrived, for clearer logs.

Frequently Asked Questions

What is the difference between fork and exec?
fork creates a new process that is a copy of the current one; it returns twice, giving 0 in the child and the child's PID in the parent. exec replaces the current process's program with a different one while keeping the same PID. A shell runs every command by forking a child and having the child exec the command.
What are signals in Unix?
Signals are asynchronous notifications delivered to a process: SIGINT (Ctrl+C), SIGTERM (polite termination request), SIGKILL (cannot be caught), SIGSEGV (invalid memory access), SIGHUP (often used to request a config reload) and SIGCHLD (a child exited). A handler can run at any moment, so it should only set a volatile sig_atomic_t flag or call async-signal-safe functions.
What is mmap?
mmap maps a file or anonymous memory directly into a process's address space, so file contents can be read like an array without read() calls. With MAP_SHARED, the same memory is visible to every process that maps it, including children after fork. Dynamic linkers use it to load shared libraries.
Why won't my POSIX functions compile with -std=c17?
Strict ISO modes like -std=c17 hide everything that isn't ISO C, including POSIX functions such as popen, sigaction and fork's helpers. Define a feature-test macro before any #include, typically #define _POSIX_C_SOURCE 200809L (or _DEFAULT_SOURCE for glibc extras like MAP_ANONYMOUS), or compile with -std=gnu17, GCC's default.