Skip to main content
C advanced Lesson 20 of 23

System Programming in C

Learn fork/exec, signals, pipes, mmap, and socket basics for Unix/Linux systems programming in C.

fork and exec

fork and exec together are the Unix way to start new programs. fork creates an identical copy of the current process — same code, same memory, same file descriptors. exec replaces the current process image with a new program, keeping the same PID. Every shell command you run uses this pattern.

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

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

    pid_t pid = fork();   /* creates a copy of this process */

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

    if (pid == 0) {
        /* Child process — pid == 0 in the child */
        printf("Child PID: %d, parent: %d\n", getpid(), getppid());

        /* exec replaces the child's memory with a new program */
        char *args[] = {"/bin/ls", "-la", "/tmp", NULL};
        execv("/bin/ls", args);

        /* Only reached if execv fails — the new program never loaded */
        perror("execv");
        exit(1);
    } else {
        /* Parent process — pid is the child's PID */
        int status;
        waitpid(pid, &status, 0);   /* block until child exits */

        if (WIFEXITED(status)) {
            printf("Child exited with status %d\n", WEXITSTATUS(status));
        } else if (WIFSIGNALED(status)) {
            printf("Child killed by signal %d\n", WTERMSIG(status));
        }
    }

    return 0;
}

Running a Command and Capturing Output

popen combines fork, exec, and pipe into a single call, giving you a FILE * connected to a command’s stdout. It is the simplest way to capture the output of a shell command from C.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Run a shell command and return its stdout as a heap string (caller must free) */
char *run_command(const char *cmd) {
    FILE *fp = popen(cmd, "r");
    if (!fp) return NULL;

    size_t capacity = 256, len = 0;
    char *buf = malloc(capacity);
    if (!buf) { pclose(fp); return NULL; }

    int c;
    while ((c = fgetc(fp)) != EOF) {
        if (len + 1 >= capacity) {
            capacity *= 2;
            char *tmp = realloc(buf, capacity);
            if (!tmp) { free(buf); pclose(fp); return NULL; }
            buf = tmp;
        }
        buf[len++] = (char)c;
    }
    buf[len] = '\0';
    pclose(fp);
    return buf;
}

int main(void) {
    char *output = run_command("uname -a");
    if (output) {
        printf("System: %s", output);
        free(output);
    }
    return 0;
}

Signals

Signals are asynchronous notifications delivered to a process by the kernel or another process. They are the mechanism behind Ctrl+C, graceful shutdown requests, and child process notifications. Signal handlers run asynchronously — they interrupt normal execution at any point — so they must only call async-signal-safe functions.

#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdatomic.h>

/* volatile sig_atomic_t is the only type guaranteed safe to modify in a signal handler */
static volatile sig_atomic_t running = 1;
static volatile sig_atomic_t reload_config = 0;

void handle_sigint(int sig) {
    (void)sig;
    running = 0;   /* tell main loop to exit cleanly */
}

void handle_sighup(int sig) {
    (void)sig;
    reload_config = 1;  /* request config reload — main loop handles it */
}

int main(void) {
    /* Use sigaction (not signal) for reliable, portable signal handling */
    struct sigaction sa_int = {0};
    sa_int.sa_handler = handle_sigint;
    sigemptyset(&sa_int.sa_mask);
    sa_int.sa_flags = SA_RESTART;   /* restart interrupted system calls */
    sigaction(SIGINT, &sa_int, NULL);

    struct sigaction sa_hup = {0};
    sa_hup.sa_handler = handle_sighup;
    sigemptyset(&sa_hup.sa_mask);
    sigaction(SIGHUP, &sa_hup, NULL);

    /* Ignore SIGPIPE — common for network servers: prevents crash on broken connection */
    signal(SIGPIPE, SIG_IGN);

    printf("Running (PID %d). Press Ctrl+C to stop.\n", getpid());

    while (running) {
        if (reload_config) {
            reload_config = 0;
            printf("Reloading configuration...\n");
        }
        sleep(1);
        printf("tick\n");
    }

    printf("Shutting down cleanly.\n");
    return 0;
}

Pipes

A pipe is a unidirectional byte stream connecting two processes. The kernel provides a read end and a write end. Data written to the write end can be read from the read end — this is the | operator in shell scripts, implemented as a system call in C.

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

int main(void) {
    int pipefd[2];   /* pipefd[0] = read end, pipefd[1] = write end */

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

    pid_t pid = fork();
    if (pid == 0) {
        /* Child: reads from the pipe */
        close(pipefd[1]);   /* close write end — child only reads */

        char buf[256];
        ssize_t n;
        while ((n = read(pipefd[0], buf, sizeof(buf) - 1)) > 0) {
            buf[n] = '\0';
            printf("Child received: %s", buf);
        }
        close(pipefd[0]);
        _exit(0);
    } else {
        /* Parent: writes to the pipe */
        close(pipefd[0]);   /* close read end — parent only writes */

        const char *messages[] = {"Hello from parent\n", "Line 2\n", "Done\n"};
        for (int i = 0; i < 3; i++) {
            write(pipefd[1], messages[i], strlen(messages[i]));
        }
        close(pipefd[1]);   /* closing write end sends EOF to child */

        waitpid(pid, NULL, 0);
    }

    return 0;
}

Memory-Mapped Files (mmap)

mmap maps a file or anonymous memory directly into the process’s virtual address space. For large files, it can be faster than read/write because the kernel manages the actual I/O lazily — only loading pages that are actually accessed. It also enables zero-copy sharing between processes.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
#include <sys/stat.h>

/* Read a file using mmap — efficient for large files, no buffer management */
void process_with_mmap(const char *filename) {
    int fd = open(filename, O_RDONLY);
    if (fd == -1) { perror("open"); return; }

    struct stat st;
    fstat(fd, &st);
    size_t size = (size_t)st.st_size;

    /* Map the file into the address space — reads trigger page faults, not read() calls */
    void *data = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
    close(fd);   /* can close fd after mmap — the mapping persists */

    if (data == MAP_FAILED) { perror("mmap"); return; }

    /* Access the file as if it were an array of bytes in memory */
    const char *text = (const char *)data;
    int lines = 0;
    for (size_t i = 0; i < size; i++) {
        if (text[i] == '\n') lines++;
    }
    printf("File has %zu bytes and %d lines\n", size, lines);

    munmap(data, size);   /* unmap when done */
}

/* Anonymous mmap — allocate large memory blocks without malloc fragmentation */
void anon_mmap_example(void) {
    size_t size = 64 * 1024 * 1024;   /* 64 MB */
    void *buf = mmap(NULL, size,
                     PROT_READ | PROT_WRITE,
                     MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (buf == MAP_FAILED) { perror("mmap"); return; }

    memset(buf, 0, size);
    printf("Allocated %zu MB with mmap\n", size / (1024 * 1024));
    munmap(buf, size);
}

int main(void) {
    /* Create test file */
    FILE *fp = fopen("/tmp/test_mmap.txt", "w");
    fprintf(fp, "line 1\nline 2\nline 3\n");
    fclose(fp);

    process_with_mmap("/tmp/test_mmap.txt");
    anon_mmap_example();
    return 0;
}

TCP Socket Basics

Sockets are the Unix API for network communication. A TCP socket provides a reliable, ordered byte stream between two endpoints. The server binds to a port and listens; the client connects. Both sides then read and write as if working with a file.

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

/* Simple TCP echo server — sends back everything it receives */
void run_server(int port) {
    int server_fd = socket(AF_INET, SOCK_STREAM, 0);
    if (server_fd < 0) { perror("socket"); return; }

    /* SO_REUSEADDR lets the server restart immediately after a crash */
    int opt = 1;
    setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

    struct sockaddr_in addr = {0};
    addr.sin_family      = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;   /* listen on all interfaces */
    addr.sin_port        = htons(port);  /* htons converts to network byte order */

    if (bind(server_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
        perror("bind"); close(server_fd); return;
    }
    listen(server_fd, 5);   /* backlog of 5 pending connections */
    printf("Server listening on port %d\n", port);

    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_len);
    if (client_fd < 0) { perror("accept"); close(server_fd); return; }

    printf("Client connected: %s\n", inet_ntoa(client_addr.sin_addr));

    char buf[1024];
    ssize_t n;
    while ((n = recv(client_fd, buf, sizeof(buf) - 1, 0)) > 0) {
        buf[n] = '\0';
        printf("Received: %s", buf);
        send(client_fd, buf, n, 0);   /* echo back */
    }

    close(client_fd);
    close(server_fd);
}

int main(void) {
    /* Uncomment to run the server — test with: nc localhost 8080 */
    /* run_server(8080); */
    printf("Socket example — uncomment run_server() to test\n");
    return 0;
}

Compile with no extra flags on Linux: gcc -Wall -std=c11 -o server server.c On older systems you may need -lsocket -lnsl.

Frequently Asked Questions

What is the difference between fork and exec?
fork creates a copy of the current process. exec replaces the current process image with a new program. Together, fork+exec is the standard Unix way to launch a child process: fork creates the child, then exec loads the new program into it.
What are signals in Unix?
Signals are asynchronous notifications sent to processes. SIGINT (Ctrl+C), SIGTERM (graceful termination), SIGKILL (force kill), SIGSEGV (segfault), SIGCHLD (child exited) are common examples. Signal handlers must be async-signal-safe — only call a small set of functions from them.
What is mmap?
mmap maps a file or anonymous memory into the process's address space. It can be faster than read/write for large files, allows shared memory between processes, and is how dynamic linkers load shared libraries.