Linux System Programming · advanced · ~20 min

Mini shell project

- **Assemble** a working Read-Eval-Print Loop (REPL) from `fork`, `exec`, and `wait` — the four steps read, parse, run, reap. - **Tokenize** a raw input line into a NULL-terminated `argv` array that `exec` will accept. - **Distinguish** builtins that must run in the parent (like `cd`) from external programs that run in a forked child. - **Interpret** a child's exit status with `WIFEXITED` / `WEXITSTATUS` / `WIFSIGNALED`, and report it like a real shell. - **Recognise** and avoid the classic mini-shell bugs: missing `argv` sentinel, un-reaped children, and duplicated buffered output across a fork.

Overview

You already know the three primitives this project rests on: fork splits one process into a parent and a child, exec replaces a child's program image with a brand-new one, and wait/waitpid lets the parent collect the child's exit status (and you have seen pipes for wiring processes together). A mini shell is where these stop being isolated exercises and become one coherent program — the classic rite of passage for Linux systems programming.

This lesson builds a tiny but genuinely working command interpreter. The whole thing is a loop: read a line, split it into words, decide whether it is a builtin or an external command, run it, and wait for it to finish before prompting again. We deliberately keep the scope small — no pipes (|) and no redirection (>, <) yet — so the control flow of fork/exec/wait stays front and centre. Once the skeleton here is solid, adding those features is a matter of dup2 and a second fork, which builds directly on the pipes prerequisite.

Why it matters

Every command you type into bash, zsh, or sh flows through exactly this fork/exec/wait machinery — understanding it demystifies the single most-used program on a Unix system. It is also the foundation for anything that launches subprocesses: build systems, CI runners, container runtimes, system() in libc, and the process-spawning code in servers all use these same syscalls. From a security standpoint this is where a huge class of bugs lives: passing an unsanitised argument to execvp, or worse handing an attacker-controlled string to a shell via system(), is the root of command-injection vulnerabilities. Building the loop by hand teaches you exactly where the trust boundary sits — the argument vector — and why running execvp directly (never a shell) with a fixed, validated argv is the defensive default.

Core concepts

The four-step loop

A shell is nothing more than an infinite loop that performs the same four steps for every line you type. The name REPL — Read, Eval, Print, Loop — captures it, and for a shell the "Eval" step decomposes into parse, run, and reap:

        +------------------------------------------------+
        |                                                |
        v                                                |
   [1 READ ]  fgets() one line of input                  |
        |                                                |
        v                                                |
   [2 PARSE]  split line -> argv[] (NULL-terminated)     |
        |                                                |
        v                                                |
   [3 RUN  ]  builtin?  --yes--> run in THIS process     |
        |     |                                          |
        |     no                                         |
        |     v                                          |
        |   fork() --> child: execvp(argv[0], argv)      |
        |         \--> parent: waitpid(pid,&st,0)        |
        v                                                |
   [4 REAP ]  read exit status, report it ---------------+

When Read hits end-of-input (Ctrl-D at a terminal, or the end of a script), the loop must stop cleanly. That EOF handling is not optional polish — a loop that ignores EOF spins forever burning a CPU core.

Parsing a line into argv

exec does not take a string; it takes an argument vector: an array of char *, one per word, terminated by a NULL pointer. Turning "echo hello world" into that array is tokenizing. The NULL at the end is a hard requirement — exec walks the array until it finds NULL, so a missing sentinel makes it read past the end into whatever garbage is on the stack.

line:  "echo hello world"
        |
   strtok_r splits on spaces/tabs
        v
argv:  [ "echo" ] [ "hello" ] [ "world" ] [ NULL ]
          0          1           2          3   <-- sentinel

Note that strtok_r writes NUL bytes into the line buffer to terminate each token — the pointers in argv point back into that same buffer. That is why you must not free the line until you are done using argv, and why tokenizing a string literal (which lives in read-only memory) crashes.

Knowledge check: why must argv end with a NULL pointer rather than, say, an empty string ""?

exec has no separate length argument; it discovers where the vector ends by scanning for a NULL pointer. An empty string "" is still a valid, non-NULL pointer, so exec would treat it as a real (empty) argument and keep scanning into memory past your array — undefined behaviour. Only a genuine NULL marks the end.

Builtins vs external commands

Most commands (ls, echo, grep) are separate programs on disk: the shell forks a child and execs them. But a few commands must run inside the shell process itself, because they change the shell's own state. The prime example is cd: if you forked a child to run chdir, the child would change its directory and then exit, leaving the parent — your shell — exactly where it started. So cd, exit, export, and similar are builtins handled directly, with no fork.

Command Kind How it runs Why
ls, echo, grep external fork + execvp in child separate program on disk ($PATH)
cd builtin chdir() in the parent must change the shell's own cwd
exit builtin exit() in the parent must terminate the shell itself
export VAR=x builtin setenv() in the parent env change must persist in the shell

The rule of thumb: if a command needs to outlive the child by changing the shell's environment, it has to be a builtin.

Reaping the child and reading its status

After fork, the parent calls waitpid(pid, &status, 0) to block until that specific child finishes. This does two jobs at once: it synchronises (the shell waits before prompting again) and it reaps (it removes the finished child from the kernel's process table). A child that has exited but not been waited-for becomes a zombie — it holds a PID slot until reaped. A shell that never waits leaks zombies until the process table fills.

status is not the exit code directly; it is a packed integer you decode with macros:

Macro Question it answers Value it yields
WIFEXITED(status) Did it exit normally (via return/exit)? true/false
WEXITSTATUS(status) If so, what code? (0–255) the exit code
WIFSIGNALED(status) Was it killed by a signal? true/false
WTERMSIG(status) If so, which signal? signal number

By convention exit code 0 means success, non-zero means failure, and 127 specifically means "command not found" — which is why the demo's child returns 127 when exec fails.

Syntax notes

pid_t fork(void);

Returns twice: 0 in the child, the child's PID (a positive number) in the parent, or -1 on error (check errno). Both processes continue from the same line; you branch on the return value.

int execvp(const char *file, char *const argv[]);

Replaces the current process image with program file, searched on $PATH (the p); argv is the NULL-terminated argument vector, and by convention argv[0] is the program name. On success it never returns — the old code is gone. It returns -1 (and sets errno) only on failure, so any line after execvp runs only in the error case.

pid_t waitpid(pid_t pid, int *wstatus, int options);

Blocks until child pid changes state (with options == 0, until it terminates). Writes a packed status into *wstatus; returns the reaped PID, or -1 on error. Decode *wstatus with WIFEXITED / WEXITSTATUS / WIFSIGNALED / WTERMSIG.

int chdir(const char *path);

Changes the calling process's working directory. Returns 0 on success, -1 on error. Must run in the shell process itself for cd to have any lasting effect.

char *strtok_r(char *str, const char *delim, char **saveptr);

Reentrant tokenizer. First call passes the buffer, later calls pass NULL; saveptr holds the position between calls. Modifies the input buffer in place (writes NUL terminators), so never call it on a string literal. Returns the next token, or NULL when exhausted.

Memory/resource contract: strdup returns heap memory you must free. fork duplicates open file descriptors and buffered stdio — fflush(stdout) before forking so buffered text is not emitted twice. Use _exit (not exit) in a child after a failed exec to skip re-flushing the inherited buffers.

Lesson

The shell loop

A shell repeats the same four steps over and over:

  1. Read a line of input.
  2. Parse that line into argv (an array of argument strings).
  3. fork + exec to launch the requested program.
  4. wait for that program to finish.

A usable subset fits in about 60 lines of C. "Subset" here means the basics only: no pipes (|) and no redirections (>, <).

Why build one

Writing a mini shell is the classic rite of passage for Linux systems programming. It ties together three core ideas:

  • fork — the split into a parent process and a child process.
  • exec — replacing the child's program image with a new one.
  • wait / exit — the contract by which a parent collects a child's exit status.

Code examples

/* mini-shell: a tiny Read-Eval-Print Loop built from fork + exec + wait.
 *
 * To keep the demo hermetic (no interactive typing, reproducible output) we
 * feed the loop a fixed script of command lines instead of reading a terminal.
 * Swap `next_line` for fgets(line, sizeof line, stdin) to make it interactive.
 */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>

#define MAX_ARGS 64

/* The canned "session". A real shell reads these from the keyboard. */
static const char *script[] = {
    "echo hello from the child",
    "true",
    "false",
    "cd /tmp",     /* builtin: handled by the parent, no fork */
    "pwd",
    NULL           /* NULL marks end-of-input, just like Ctrl-D (EOF) */
};

/* Pull the next scripted line, or NULL at EOF. Returns a heap copy the caller
 * must free, because strtok() writes into the buffer it tokenises. */
static char *next_line(void) {
    static size_t i = 0;
    if (script[i] == NULL) return NULL;
    return strdup(script[i++]);
}

/* Split `line` in place into a NULL-terminated argv. Returns the token count. */
static int tokenize(char *line, char *argv[MAX_ARGS]) {
    int argc = 0;
    char *save = NULL;
    for (char *tok = strtok_r(line, " \t\n", &save);
         tok && argc < MAX_ARGS - 1;
         tok = strtok_r(NULL, " \t\n", &save)) {
        argv[argc++] = tok;
    }
    argv[argc] = NULL;            /* exec REQUIRES this sentinel */
    return argc;
}

/* Returns 1 if the command was a builtin (already handled), else 0. */
static int run_builtin(int argc, char *argv[]) {
    if (strcmp(argv[0], "cd") == 0) {
        const char *dir = (argc > 1) ? argv[1] : "/";
        if (chdir(dir) != 0) perror("cd");   /* cd must run in the PARENT */
        return 1;
    }
    if (strcmp(argv[0], "exit") == 0) {
        exit(0);
    }
    return 0;
}

/* fork + exec + wait for an external program. */
static void run_external(char *argv[]) {
    fflush(stdout);              /* flush BEFORE fork or the child inherits
                                    a copy of the buffer and output duplicates */
    pid_t pid = fork();
    if (pid < 0) {
        perror("fork");
        return;
    }
    if (pid == 0) {
        /* --- child --- */
        execvp(argv[0], argv);
        /* Only reached if exec FAILED (bad path, no permission, ...). */
        perror(argv[0]);
        _exit(127);              /* 127 == "command not found", shell convention */
    }
    /* --- parent --- */
    int status = 0;
    if (waitpid(pid, &status, 0) < 0) {
        perror("waitpid");
        return;
    }
    if (WIFEXITED(status))
        printf("  [%s exited with code %d]\n", argv[0], WEXITSTATUS(status));
    else if (WIFSIGNALED(status))
        printf("  [%s killed by signal %d]\n", argv[0], WTERMSIG(status));
}

int main(void) {
    char *line;
    while ((line = next_line()) != NULL) {     /* READ */
        printf("mini$ %s\n", line);
        fflush(stdout);                        /* keep prompt ahead of child output */
        char *argv[MAX_ARGS];
        int argc = tokenize(line, argv);       /* PARSE */
        if (argc == 0) { free(line); continue; }   /* blank line */
        if (!run_builtin(argc, argv))          /* EVAL: builtin or... */
            run_external(argv);                /* ...fork+exec+wait */
        free(line);                            /* LOOP */
    }
    printf("mini$ (EOF)\n");
    return 0;
}

Line by line

  • Lines 1–6 (header comment): explains the one concession to a hermetic demo — instead of reading a live terminal we replay a fixed script[]. The loop logic is identical; only the source of each line changes.
  • Lines 17–24 (script[]): the canned session. The trailing NULL plays the role of EOF (Ctrl-D): when next_line reaches it, the loop ends.
  • Lines 28–32 (next_line): returns a heap copy (strdup) of the next line. This matters because the next step tokenizes the buffer in place, and you must never write into a string literal.
  • Lines 35–45 (tokenize): strtok_r walks the line, carving it on spaces/tabs/newlines. Each token pointer is stored in argv; the guard argc < MAX_ARGS - 1 always leaves room for the sentinel. Line 43 writes argv[argc] = NULL — the single most important line for exec correctness.
  • Lines 48–58 (run_builtin): checked before forking. cd calls chdir in the parent (line 51) so the change persists; exit terminates the shell itself. Returning 1 tells main "already handled, do not fork."
  • Line 62 (fflush(stdout)): flushes the parent's buffered output before fork, so the child does not inherit a copy of the buffer and print it a second time.
  • Line 64 (fork): the split. Everything after this runs in both processes until they branch.
  • Lines 69–75 (child branch): execvp replaces the child with the requested program. If it returns at all, it failed — so perror reports why and _exit(127) ends the child with the "command not found" code. _exit (not exit) avoids re-flushing inherited stdio buffers.
  • Lines 76–85 (parent branch): waitpid blocks until this child finishes, reaping it. The WIF* macros decode the packed status into a human-readable report.
  • Lines 88–101 (main): the REPL itself. next_line is READ, tokenize is PARSE, the builtin/external decision plus fork/exec is RUN, and the status report is REAP. free(line) on line 98 releases the strdup each iteration. The while condition exits cleanly on EOF, and line 100 prints the final (EOF) marker.

Common mistakes

1. Forgetting the NULL sentinel on argv.

char *argv[2];
argv[0] = "ls";
execvp(argv[0], argv);   /* argv[1] is uninitialised garbage */

Why it breaks: exec scans argv until it hits a NULL pointer. Without one it reads past your array into random stack memory — sometimes a crash, sometimes garbage passed as arguments (a security hazard).

char *argv[3];
argv[0] = "ls";
argv[1] = "-l";
argv[2] = NULL;          /* explicit terminator */
execvp(argv[0], argv);

2. Running cd in a forked child.

if (fork() == 0) { chdir(argv[1]); _exit(0); }   /* WRONG */

Why it breaks: the child changes its own directory and immediately dies; the parent shell never moves. Every subsequent command still runs in the old directory.

if (strcmp(argv[0], "cd") == 0) { chdir(argv[1]); }  /* parent, no fork */

3. Never reaping children (zombie leak).

if (fork() == 0) execvp(argv[0], argv);
/* parent loops back immediately, never waits */

Why it breaks: each finished child stays a zombie in the process table until reaped; a busy shell exhausts PIDs.

pid_t pid = fork();
if (pid == 0) execvp(argv[0], argv);
else waitpid(pid, &status, 0);   /* reap it */

4. Not flushing stdio before fork, so output duplicates.

printf("done");        /* buffered, not yet written */
fork();                /* both processes now hold the buffer -> "done" twice */

Why it breaks: printf to a pipe or file is block-buffered; fork copies the un-flushed buffer into the child, and both flush it at exit.

printf("done");
fflush(stdout);        /* empty the buffer before splitting */
fork();

Debugging tips

  • strace -f ./mini is the single best tool here: the -f follows children across fork, and you will see the exact execve, wait4, and chdir syscalls with their arguments and return values. A failing command shows up as execve(... ) = -1 ENOENT.
  • Verify the argv you built before exec: temporarily loop and fprintf(stderr, "[%d]=%s\n", i, argv[i]) for each entry, and confirm the last one is (null). Most exec failures are a malformed vector.
  • Zombies are visible with ps aux | grep defunct (they show <defunct>); if you see them accumulate, your waitpid path is not being reached for every child.
  • gdb across a fork: set follow-fork-mode child to step into the child and watch it reach (or skip) execvp. Remember that after a successful exec the debugger is now in a different program.
  • valgrind catches the strdup/free leaks and any read of uninitialised argv slots (Conditional jump depends on uninitialised value), which is exactly what a missing sentinel produces.
  • echo $? in your own shell after running a command mirrors WEXITSTATUS; compare it to what your mini-shell reports to confirm your status decoding is correct.

Memory safety

  • The argv sentinel is a memory-safety issue, not just correctness. A missing trailing NULL makes exec read out-of-bounds; with attacker-influenced stack contents this can pass unintended arguments to the program. Always initialise the whole vector and set the terminator explicitly.
  • Dangling pointers from tokenizing. strtok_r returns pointers into your line buffer, so every entry in argv aliases that buffer. Freeing (or overwriting) the line while argv is still in use leaves dangling pointers. Free the line only after the command has fully run.
  • Never tokenize a string literal. strtok_r writes NUL bytes into its input; passing a const char * literal is a write to read-only memory and a guaranteed crash. Always strdup first.
  • Buffered stdio and fork. A fork duplicates the process's memory, including un-flushed stdio buffers. Flush before forking (or the child re-emits your output), and in the child after a failed exec use _exit, which skips the atexit/flush machinery, rather than exit, which would flush the inherited buffers a second time.
  • Fixed-size argv needs a bound. Cap the token count (argc < MAX_ARGS - 1) so a pathological line with thousands of words cannot overflow the array — an unbounded write would be a classic stack buffer overflow.
  • Always check fork and execvp return values. Ignoring a -1 from fork and then calling waitpid on a bogus PID, or continuing after execvp "succeeds" (it never returns on success), leads to control-flow bugs that are hard to spot.

Real-world uses

  • The shells you use daily — bash, zsh, dash, fish — are elaborate versions of this exact loop, with job control, pipes, redirection, globbing, and scripting layered on top of the same fork/exec/wait core.
  • system() and popen() in libc are thin wrappers that fork a shell to run a command string. Because they invoke /bin/sh -c, they are a notorious command-injection vector — best practice is to avoid them for untrusted input and instead fork+execvp a fixed argv yourself, exactly as this lesson does, so no shell metacharacters are ever interpreted.
  • Build tools and task runners (make, ninja, npm scripts, CI runners) spawn compilers and test binaries via fork/exec and inspect exit codes to decide pass/fail — the WIFEXITED/WEXITSTATUS logic here is what "the build failed" actually means.
  • Container runtimes and process supervisors (runc, systemd, tini, s6) are fundamentally fork/exec/wait engines: the "PID 1 must reap zombies" rule you just learned is why minimal containers ship a tiny init like tini.
  • Best practice: prefer the exec*p* family with a validated argument vector over anything that routes through a shell; always reap children (or handle SIGCHLD); and treat the boundary between your string parsing and the argv you hand to exec as the security-critical trust boundary.

Practice tasks

  1. Interactive mode. Replace next_line's scripted source with fgets(buf, sizeof buf, stdin) so the shell reads real keyboard input, and confirm that Ctrl-D (EOF) exits the loop cleanly and a blank line just re-prompts.
  2. Add a pwd-free status line. Extend the prompt to show the exit code of the previous command (like $?), e.g. mini[0]$ after a success and mini[1]$ after a failure — you will need to remember the last WEXITSTATUS across iterations.
  3. More builtins. Implement exit N (exit with a chosen code) and export NAME=VALUE (using setenv) as parent-side builtins, and verify with env that the exported variable is visible to subsequently launched external commands.
  4. Redirection (>). Detect a > token during parsing, remove it and the filename from argv, and in the child open() the file and dup2() it onto STDOUT_FILENO before execvp. Test with echo hi > out.txt.
  5. A single pipe (|). Split the line at one | into two commands, create a pipe(), fork both children, wire the write end to the first child's stdout and the read end to the second child's stdin with dup2, close the unused ends in every process, and waitpid for both. Test with ls | wc -l.

Summary

  • A shell is a REPL: read a line, parse it into a NULL-terminated argv, run it, and reap the child — then loop, stopping cleanly on EOF.
  • argv must end in NULL; the pointers alias the (mutable, heap-allocated) line buffer, so never tokenize a literal and never free the line while argv is live.
  • Builtins like cd/exit run in the parent (they change the shell's own state); everything else is fork + execvp in a child.
  • execvp never returns on success — code after it runs only on failure; use _exit(127) there.
  • Always waitpid to synchronise and reap (avoid zombies), and decode status with WIFEXITED/WEXITSTATUS/WIFSIGNALED.
  • fflush(stdout) before fork so buffered output is not duplicated.
  • Security: run execvp with a fixed, validated argument vector — never route untrusted input through a shell via system().

Practice with these exercises